阿里面试官让我手写 Vue 2.0核心原理,我都整理好了!

目录

  • 为什么使用 Vue?
  • 你会学到什么?
  • 带着问题去学习
  • Vue2.0整体概括
    • 1、传入实例参数
    • 2、设置数据劫持(Object.defineProperty)
    • 3、模板编译(Compile)
    • 4、虚拟DOM(Virtual DOM)
    • 5、对比新老虚拟DOM(Patch)
    • 6、更新视图(Update View)
  • 实现一个input双向绑定(v-model)
    • 一、响应式原理
      • 1、初始化
      • 2、数据劫持
      • 3、监听对象
      • 4、监听数组
      • 5、computed实现
      • 6、methods实现
      • 7、vm.$data代理
    • 二、依赖收集
      • 1、为什么进行依赖收集
      • 2、观察者 Watcher
      • 3、订阅者 Dep
      • 4、依赖收集
    • 三、编译模板
      • 1、将 DOM 拿到内存
      • 2、数据替换
      • 3、塞回页面

为什么使用 Vue?

从前端这么些年的发展史来看,从网页设计年代到了现在大前端时代的来临,各种各样的技术层出不穷。尤其是在前端性能优化方面,为了避免页面的回流和重绘,前辈们总结出了各种解决优化方案,基本都是尽量的减少 DOM 操作。

Vue 的诞生,是一个很大的优化方案,直接用虚拟 DOM 映射真实 DOM,来进行更新,避免了直接操作真实 DOM 带来的性能缺陷。

为了好理解呢,我们换个通俗一点的说法,当页面涉及到操作 DOM 的时候,我们不直接进行操作,因为这样降低了前端页面的性能。而是将 DOM 拿到内存中去,在内存中更改页面的 DOM ,这时候我们操作 DOM 不会导致每次操作 DOM 就会造成不必要的回流和重绘。更新完所有 DOM 之后,我们将更新完的 DOM 再插入到页面中,这样大大提高了页面的性能。

虽然这样讲有些欠妥或者不标准,其实 Vue 的虚拟 DOM 的作用可以这样去理解,也是为了照顾到一些刚刚接触到 Vue 的初学者。本篇写作的目的不是去写一高大上的术语,而是能将分享到的内容让大部分看明白,就已经足够了。

你会学到什么?

本篇主要仅供个人 Vue 源码学习记录,主要以 Vue2.0 为主(如果你是个 Vue 大神,就不必浪费时间看本人的学习笔记了)

主要分享整个 Vue2.0 源码的核心功能,会将一下几个功能通过删减,通过代码对核心原理部分展开分享,一些用到的变量和函数方法可能与源码中不相同,由于时间和精力有限,只分享核心内容部分。主要包括以下几个核心部分:

  • 响应式原理(MVVM)
  • 模板编译 (Compile)
  • 依赖追踪
  • 虚拟 DOM (VDOM)
  • patch
  • diff 算法

带着问题去学习

有问题才有学习的动力和激情,如果毫无目的的只扒源码,显然是非常枯燥的,前期在挖源码的时候,小鹿是带着一下几个疑问去探索原理的,这样更具有目的性。

1、双向绑定是怎么实现的?

2、vue 标签中的指令内部又是如何解析的?

3、什么是虚拟 DOM,它比传统的真实 DOM 有什么优势?

4、当数据更新时,虚拟 DOM 如果对比新老节点更新真实 DOM 的?

5、页面多个地方操作 DOM,内部如何实现优化的?

以上几个个问题,前期给我带来了探索源码的动力。当看了源码一个月过去之后,这个期间通过动手实践和总结,发现这些东西都是在最原本的事物基础上进行改进和优化,尤其是对基本功(JS、数据结构与算法)的重要性,越是简单的东西,越是新事物的组成部分。简单,简而不单,单而不简。能让你创新出新的事物,万物皆如此。

正文

距离上次更新,已经有三个月过去了,这三个月身边发生了很多事情,真是计划不如变化。三个月时间,对之前更新的内容又做了很多的补充,2020年 5 月25日晚,发布了第一版《大前端面试小册》电子书共 74 页。

60 天呕心沥血,我写完这本电子书

电子版主要增加了一下加点:

知识点内容上,都是从知其然,知其所以然开始写起的。后续为了将知识体系化,增加了知识线,阿里面试官让我手写 Vue 2.0核心原理,我都整理好了!_第1张图片
每个知识点,增加了大厂面试笔试题解析。

阿里面试官让我手写 Vue 2.0核心原理,我都整理好了!_第2张图片

增加了不少图解。

阿里面试官让我手写 Vue 2.0核心原理,我都整理好了!_第3张图片

《大前端面试小册》电子书获取方式,公众号:「小鹿动画学编程」后台回复「前端PDF」获取第一>版本。

Vue2.0 整体概括

在分享各个 Vue 源码中几个核心内容之前,小鹿先把整体的核心原理实现概括一遍,也就是大体说说 Vue 是如何工作的,这为了在分享前更好的建立起一个知识体系。把握整体,各个击破!

Vue 核心原理

初始化 Vue 实例 ==> 挂载实例(mount) ==> 设置数据劫持(Object.defineProperty) ==> 模板编译(compile) ==> 渲染(render function) ==> 转化为虚拟 DOMObject> 对比新老虚拟DOMpatch、diff> 更新视图

1、传入实例参数

当我们开始写 Vue 项目时,首先初始化一个 Vue 实例,传入一个对象参数,参数中包括一下几个重要属性:

{
    el: '#app',
    data: {
        student: {
            name: '公众号:小鹿动画学编程',
            age: 20,
        }
    }
    computed:{
    	...
    }
    ...
}
  • el:将渲染好的 DOM 挂载到页面中(可以传入一个 id,也可以传入一个 dom 节点)。
  • data:页面所需要的数据(对象类型,至于为什么,会在数据劫持内容说明)。
  • computed:计算属性,随着 data 中的数据变化,来更新页面关联的计算属性。
  • methods:实例所用到的方法集合。

除此之外,还有一些生命周期钩子函数等其他内容。

2、设置数据劫持(Object.defineProperty)

所谓的数据劫持,当 Vue 实例上的 data 中的数据改变时,对应的视图所用到的 data 中数据也会在页面改变。所以我们需要给 data 中的所有数据设置一个监听器,监听 data 的改变和获取,一旦数据改变,监听器会触发,通知页面,要改变数据了。

 Object.defineProperty(obj, key, {
     get() {
         return value;
     },
     set: newValue => {
         console.log(---------------更新视图--------------------)
     }
 }

数据劫持的实现就是给每一个 data绑定 Object.defineProperty()。对于 Object.defineProperty()的用法,自己详细看 MDN (Object.defineProperty()),这也是 MVVM的核心实现 API,下遍很多东西都是围绕着它转。

3、模板编译(compile)

1、拿到传入 dom 对象和 data 数据了,如果将这些 data 渲染到 HTML 所对应的 {{student.age}}v-model="student.name" 等标签中,这个过程就是模板编译的过程之一,主要解析模板中的指令、class、style等等数据。

2、标记静态结点。为了在更新视图时,也就是下一篇文章要分享到的 patch 过程,跳过这些静态结点,也就是这些没有变化的结点,这样更新起来性能更佳。

3、最后,将其转化为 render 字符串。

// 把当前节点放到内存中去(因为频繁渲染造成回流和重绘)
let fragment = this.nodefragment(this.el);

// 把节点在内存中替换(编译模板,数据编译)
this.compile(fragment);

// 把内容塞回页面
this.el.appendChild(fragment);

我们通过 el 拿到 dom 对象,然后将这个当前的 dom 节点拿到内存中去,然后将数据和 dom 节点进行替换合并,然后再把结果塞会到页面中。下面会根据代码实现,具体展开分享。

4、虚拟 DOM(Virtual DOM)

所谓虚拟 DOM,其实就是一个 javascript对象,说白了就是对真实 DOM 的一个描述对象,和真实 dom做一个映射。

// 真实 DOM
<div>
    <span>HelloWord</span>
</div>


// 虚拟 DOM —— 以上的真实 DOM 被虚拟 DOM 表示如下:
{
    children:(1) [{}]  // 子元素
    domElement: div		// 对应的真实 dom	
    key: undefined      // key 值
    props: {}           // 标签对应的属性
    text: undefined     // 文本内容
    type: "div"         // 节点类型
    ...
}

一旦页面数据有变化,我们不直接操作更新真实 DOM,而是更新虚拟 DOM,又因为虚拟 DOM和真实 DOM有映射关系,所有真实 DOM也被更新,避免了回流和重绘造成性能上的损失。

对于虚拟 DOM,主要核心涉及到 diff算法,新老虚拟结点如何检查差异的,然后又是如何进行更新的,后边会展开一点点讲。

5、对比新老虚拟 DOM(patch)

patch 主要是对更新后的新节点和更新前的节点进行比对,比对的核心算法就是 diff 算法,比如新节点的属性值不同,新节点又增加了一个子元素等变化,都需要通过这个过程,将最后新的虚拟 DOM 更新到视图上,呈现最新的变化,这个过程是一个核心部分,面试也是经常问到的。

6、更新视图(update view)

当第一次加载 Vue 实例的时候,我们将渲染好的数据挂载到页面中。当我们已经将实例挂载到了真实 dom 上,我们更新数据时,新老节点对比完成,拿到对比的最新数据状态,然后更新到视图上去。

注意:以下代码并非原封不动的源代码,为了能够清晰易懂,只是将一些核心原理进行抽离,通过自己实现的代码来展开分享,为了避免不必要的争议,请自行翻看源代码。

实现一个 input 双向绑定(v-model)

一、响应式原理

我们都用过 Vue 中的 v-model 实现输入框和数据的双向绑定,其实就是 MVVM框架的核心原理实现。

如果刚接触 MVVM,可以看小鹿之前在公众号分享的一篇文章:动画:浅谈后台 MVC 模型与 MVVM 双向绑定模型

下面我们动手来实现一个 MVVM 双向绑定。

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>

<body>
  <div id="app">
    <input type="text" v-model="student.name">
    {{student.age}}
  </div>
  <script src="./node_modules/vue/dist/vue.min.js"></script> 
  <script>
    let vm = new Vue({
      el: '#app',
      data: {
        student: {
          name: '公众号:小鹿动画学编程',
          age: 20,
        }
      }
    })
  </script>
</body>

</html>

1、初始化

初始化 Vue 实例,这个过程会做很多事情,比如初始化生命周期、datacomputedMethod 等。我们将实例中传入的数据,进行在构造函数中接收。

class Vue {
  // 传参接收
  constructor(options) {
    this.$el = options.el;
    this.$data = options.data;
    let computed = options.computed;
    let methods = options.methods;

    // 判断 $el 根元素是否存在
    if (this.$el) {
      // 1、数据劫持
      new Observer(this.$data);
		
      // 2、computed 实现
      this.relatedComputed(computed);
		
      // 3、methods 实现
      this.relatedMethods(methods);
        
      // 4、编译模板
      new Compile(this.$el, this);
        
      // ....
      
    }
  }
}

以上代码中,判断当前 $el 是否存在,如果存在,就开始初始化响应式系统以及 computedmethods的实现,最后编译模板,显示在视图上。

2、数据劫持

响应式的原理就是通过 Object.defineProperty 数据劫持来实现的,也就上述代码中的 new Observer(this.$data)过程,这个过程发生了什么?以及如何对 data 中各种类型数据进行监听的,下面直接看核心实现原理部分。

先看整体的实现代码,然后分别进行拆分讲解:

class Observer {
  constructor(data) {
    this.observer(data);
  }

  // 观察者(监听对象的响应式)
  observer(obj) {
    // 判断是否为对象
    if (typeof obj !== "object" || obj == null) return obj;

    // 实时响应数组中对象的变化
    if (Array.isArray(obj)) {
      Object.setPrototypeOf(obj, proto);
      this.observerArray(obj);
    } else {
      // 遍历对象 key value 监听值的变化
      for (let key in obj) {
        this.defineReactive(obj, key, obj[key]);
      }
    }
  }

  defineReactive(obj, key, value) {
    // value 可能是对象,需要进行递归
    this.observer(value);
    Object.defineProperty(obj, key, {
      get() {
        return value;
      },
      set: newValue => {
        if (newValue !== value) {
          // 传入的可能也是对象,需要递归
          this.observer(value);
          value = newValue;
          console.log('-------------------------视图更新-----------------------------')
        }
      }
    });
  }
  • 首先,声明一个 Observer 类,接收传入 data 中要给页面渲染的数据。
class Observer {
  constructor(data) {
    this.observer(data);
  } 
}

调用 this.observer(data) 方法,遍历 data 中的每个数据进,都通过 Object.defineProperty() 方法设置上监听。

3、监听对象

  • observer() 方法实现主要用于实时响应数组中对象的变化。
observer(obj) {
  // 判断是否为对象
  if (typeof obj !== "object" || obj == null) return obj;

  // 遍历对象 key value 监听值的变化
  for (let key in obj) {
      this.defineReactive(obj, key, obj[key]);
  }
}

defineReactive(obj, key, value) {
  // 递归创建 响应式数据,性能不好
  this.observer(value);  // 递归
  Object.defineProperty(obj, key, {
    get() {
      return value;
    },
    set: newValue => {
      if (newValue !== value) {
        // 设置某个 key 的时候,可能是一个对象
        this.observer(value);   // 递归
        value = newValue;
        console.log('-------------------------视图更新-----------------------------')
      }
    }
  });

data 是一个对象,我们对 data 数据对象进行遍历,通过调用 defineReactive 方法,给每个属性分别设置监听(setget 方法)。

我们对属性设置的监听,只是第一层设置了监听,如果属性值是个对象,我们也要进行监听。或者我们在给 Vue 实例 vmdata 赋值的时候,也可能是个对象,如下情况:

data: {
  student: {
    name: '小鹿',
    age: 20,
    address:{   // address 也是一个对象类型的值,需要对 address 中的属性值进行监听
        country:'china'
        province:'shandong',
    }
  }
},

所以我们要进行递归,也给其设置响应式。

...

defineReactive(obj, key, value) {
  // 递归创建 响应式数据,性能不好
  this.observer(value);  // 递归
  ...
}
...

...
set: newValue => {
      if (newValue !== value) {
        // 设置某个 key 的时候,可能是一个对象
        this.observer(value);   // 递归
        value = newValue;
        console.log('-------------------------视图更新-----------------------------')
      }
    }
。。。

设置好之后,当我们运行程序,给 vm 设置某一值的时候,会触发视图的更新。

阿里面试官让我手写 Vue 2.0核心原理,我都整理好了!_第4张图片

4、监听数组

上述我们只对对象的属性进行监听,但是我们希望监听的是个数组,对于数组,用Object.defineProperty() 来设置是不起作用的(具体原因见 MDN),所以不能用此方法。

如果数组中存放的是对象,我们也应该监听属性的变化,比如监听数组中 name 的变化。

{
  d: [1, 2, 3, { name: "小鹿" }]
};

首先,我们判断当前传入的如果是数组类型,我们就调用 observerArray 方法。

// 判断传入的参数如果是数组,则执行 observerArray 方法
if (Array.isArray(obj)) {
   this.observerArray(obj);
}

observerArray 方法的具体实现如下:

// 遍历数组中的对象,并设置监听
observerArray(obj) {
  for (let i = 0; i < obj.length; i++) {
    let item = obj[i];
    this.observer(item);    // 如果数组中是对象会被 defineReactive 监听
  }
}

当我们进行下方更改值时,视图被触发更新。

// 初始化 data 中的值
{
  d: [1, 2, 3, { name: "小鹿" }]
}

// 更改数组中的对象属性的值
vm.$data.d[3].name = "11";  // 此时视图会更新

还有一点就是,当我们给当前的数组添加元素时,也要触发视图进行更新,比如通过下方的方式更改数组。

// 通过 push 向 data 中的数组中添加一个值
vm.$data.d.push({ age: "15" });

除此之外,数组中添加数据的 APIpushunshiftsplice ,我们可以通过重写这三个原生方法,对其调用时,进行触发视图更新。

let arrProto = Array.prototype; // 数组原型上的方法
let proto = Object.create(arrProto); // 复制原型上的方法

// 重写数组的三个方法
[`push`, `unshift`, `splice`].forEach(method => {
  proto[method] = function(...args) {
    // 这个数组传入的对象也应该进行监控
    let inserted; // 默认没有插入新的数据
    switch (method) {
      case `push`:
      case `unshift`:
        inserted = args;
        break;
      case `splice`:
        inserted = args.slice(2); // 截出传入的数据
        break;
      default:
        break;
    }
    console.log("---------------视图更新-----------------");
    observerArray(inserted); // 如果数组增加的值是对象类型,需要对其设置监听
    arrProto[method].call(this, ...args);
  };
});

// 实时响应数组中对象的变化
if (Array.isArray(obj)) {
    Object.setPrototypeOf(obj, proto);  // 如果是数组,就设置重写的数组原型对象
    this.observerArray(obj);
} else {
    // 遍历对象 key value 监听值的变化
    for (let key in obj) {
        this.defineReactive(obj, key, obj[key]);
    }
}

好了,我们来测试一下,数组被监听到,视图已更新。

阿里面试官让我手写 Vue 2.0核心原理,我都整理好了!_第5张图片

5、computed 原理实现

computed主要是计算属性,每当我们计算属性所依赖的 data 属性发生变化时,通过计算,也要更新视图上的数据。如下实例,如果我们动态改变 this.student.name 属性值,页面中的 getNewName 也会发生改变。

let vm = new Vue({
    el: '#app',
    data: {
        student: {
            name: '小鹿',
            age: 20,
        },
    },
    computed: {
        getNewName() {
            return this.student.name + ‘公众号:小鹿动画学编程’;
        }
    },
})

其实内部的原理做法就是让 computed 内的计算属性也依赖于 data 数据,data 变,computed 依赖的数据也变。

relatedComputed(computed) {
    for (let key in computed) {
        Object.defineProperty(this.$data, key, {
            get: () => {
                return computed[key].call(this);
            }
        });
    }
}

6、methods 原理实现

我们通常调用方法是通过 vm 实例来调用方法的,所以我们要把 methods 挂载到 vm 实例上。

// methods
relatedMethods(methods) {
  for (let key in methods) {
    Object.defineProperty(this, key, {
      get: () => {
        return methods[key];
      }
    })
  }
}

7、vm.$data 代理到 vm 实例上

我们一般可以通过 vm.$data.student.name = ‘小鹿’ ,但是还可以使用 vm.student.name = ‘小鹿’。我们可以通过代理,将 vm.$data 代理到 vm 上。

// 代理 vm.$data
proxyVm(data) {
    for (let key in data) {
        // 绑定到 vm 上
        Object.defineProperty(this, key, {
            get() {
                return data[key];
            },
            set(newValue) {
                data[key] = newValue;
            }
        });
    }
}

二、依赖收集

1、为什么进行依赖收集

我们 data 中的数据,有时候我们在页面不同地方需要使用,所以当我们动态改变 data 数据的时候,如下:

<div>{{student.name}}</div>
<ul>
	<li>1</li>
	<li>{{student.name}}</li>
</ul>

vm.$data.student.name = 'xiaolu '

我们对视图中,所有依赖 data 属性中的值进行更新,那么我们需要对依赖的数据的视图进行数据依赖收集,当数据变化的时候,就对所依赖数据的视图更新。对于依赖收集,需要使用观察者-订阅者模式。

2、观察者 Watcher

观察中的 get() 主要用于获取当前表达式(如:student.name)的 未更新之前的值,当数据更新时,我们就调用 update 方法,就拿出新值和老值对比,如果有变化,我们就更新相对应的视图。

// 观察者
class Watcher {
  /**
   * @param {*} vm 当前实例
   * @param {*} expr 观察的值表达式
   * @param {*} cb 回调函数
   */
  constructor(vm, expr, cb) {
    this.vm = vm;
    this.expr = expr;
    this.cb = cb;
    // 默认存放一个老值(取出当前表达式的值)
    this.oldValue = this.get();
  }

  get() {
    Dep.target = this;
    let value = CompileUtil.getValue(this.vm, this.expr);// 根据视图中的表达式,取 data 中的值
    Dep.target = null; // 不取消任何值取值 都会添加 water
    return value;
  }

  // -> 数据变化后,会调用观察者的 update 方法
  update() {
    let newValue = CompileUtil.getValue(this.vm, this.expr);// 根据视图中的表达式,取 data 中的值
    if (newValue !== this.oldValue) {
      this.cb(newValue);
    }
  }
}

3、订阅者

订阅者中主要通过 addSub 方法增加观察者,通过 notify 通知观察者,调用观察者的 update 进行更新相应的视图。

// 订阅者
class Dep {
  constructor() {
    this.subs = []; // 存放所有的 watcher
  }

  // 订阅
  addSub(watcher) {
    this.subs.push(watcher);
  }

  // 通知
  notify() {
    this.subs.forEach(watcher => watcher.update());
  }
}

4、依赖收集

在我们更新视图的时候进行依赖收集,给每个属性创建一个发布订阅的功能,当我们的值在 set 中改变时,我们就触发订阅者的通知,让各个依赖该数据的视图进行更新。

defineReactive(obj, key, value) {
  // 递归创建 响应式数据,性能不好
  this.observer(value);
  let dep = new Dep(); // 给每一个属性都加上一个具有发布订阅的功能
  Object.defineProperty(obj, key, {
    get() {
      // 创建 watcher 时,会取到响应内容,并且把 watcher 放到了全局上
      Dep.target && dep.addSub(Dep.target);  // 增加观察者
      return value;
    },
    set: newValue => {
      if (newValue !== value) {
        // 设置某个 key 的时候,可能是一个对象
        this.observer(value);
        value = newValue;
        console.log('-------------------------视图更新-----------------------------')
        dep.notify(); // 通知
      }
    }
  });

剩下的就是我们调用 new Watcher 地方了,这个过程在编译模板里边。

三、编译模板

对于模板的编译,我们首先需要判断传入的 el 类型,然后拿到页面的结点到内存中去,把节点上有数据编译的地方,比如:v-modelv-on{{student.name}} 进行数据的替换,然后再塞回页面,就完成的页面的显示。

// 编译类
class Compile {
  constructor(el, vm) {
    // 判断 el 传入的类型
    this.el = this.isElementNode(el) ? el : document.querySelector(el);
    this.vm = vm;

    // 把当前节点放到内存中去 —— 之所以塞到内存中,是因为频繁渲染造成回流和重绘
    let fragment = this.nodefragment(this.el);

    // 把节点在内存中将表达式和命令等进行数据替换
    this.compile(fragment);

    // 把内容塞回页面
    this.el.appendChild(fragment);
  }
}

1、将 DOM 拿到内存

首先我们之前已经声明好 data 了,如下:

 let vm = new Vue({
     el: '#app',
     data: {
         student: {
             name: '小鹿',
             age: 20,
         },
     }
 })

然后我们需要拿到页面的模板,将页面中的一些指令(v-model="student.name")或者表达{{student.name}} 的结点替换成我们对应的属性值。

我们需要通过传入的 el 属性值先拿到页面的 dom 到内存中。

/**
   * 将 DOM 拿到内存中
   * @param {*} node DOM
   */
nodefragment(node) {
    let fragment = document.createDocumentFragment();
    let firstChild;
    while ((firstChild = node.firstChild)) {
        fragment.appendChild(firstChild);
    }
    return fragment;
}

2、数据替换

我们下一步需要将页面中的这些表达式,替换成相对应的 data 中的属性值,那么页面就将完成的呈现出带有数据的视图来。

<div id="app">
    <input type="text" v-model="student.name">
    {{student.age}}
</div>

通过上边的方法,已经将所有的页面结点循环遍历拿到。下一步开始进行一层层的遍历,将数据在内存中进行替换。

/**
 * 核心编译方法
 * 编译内存中的 DOM 节点
 * @param {*} node
 */
compile(node) {
  let childNodes = node.childNodes;
  [...childNodes].forEach(child => {
    // 判断当前的是元素还是文本节点
    if (this.isElementNode(child)) {
      this.compileElement(child);
      // 如果是元素的话,需要把自己传进去,再去遍历子节点
      this.compile(child);
    } else {
      this.compileText(child); // 文本节点有 {{student.age}}
    }
  });
}

/**
 * 判断当前传入的节点是不是元素节点
 * @param {*} node 节点
 */
isElementNode(node) {
  return node.nodeType == 1; // 1 代表元素节点
}

2.1 this.isElementNode(child)

页面是由很多的 node 结点构成,在上边的页面中,v-model="student.name" 主要存在与元素节点中,{{student.age}} 表达式的值存在于文本节点中,所以我们需要通过 this.isElementNode(child) 进行判断当前是否为元素节点,然后对当前节点进行不同的处理。

对于元素节点,我们调用 compileElement(child)方法,当然,元素节点中可能存在子节点的情况,所以我们需要递归判断元素节点里是否还有子节点,再次调用 this.compile(child); 方法。

我们以解析 v-model 指令为例,开始对节点进行解析判断赋值。

<input type="text" v-model="student.name">
/**
 * 编译元素节点 —— 判断是否存在 v- 指令
 * @param {*} node
 */
compileElement(node) {
  let attributes = node.attributes; 
  [...attributes].forEach(attr => {
    // type = "text" v-model="student.name"
    let { name, value: expr } = attr; // name:v-model  expr:"student.name"
    // 判断当前是否存在属性为 v- 的指令
    if (this.isDirective(name)) {
      // v-html  v-bind  v-model
      let [, directive] = name.split("-");
      let [directiveName, eventName] = directive.split(":"); // v-on:click
      // 调用不同的指令来处理
      CompileUtil[directiveName](node, expr, this.vm, eventName);
    }
  });
}

/**
 * 判断是够是 v- 开头的指令
 * @param {*} attrName
 */
isDirective(attrName) {
  return attrName.startsWith("v-");
}

同时我们还有一个工具类 CompileUtil,主要用于把对应的 data 数据插入到对应节点中。

上一步中,我们通过 let [directiveName, eventName] = directive.split(":") 解析出了 directiveName= v-modeleventName = student.name

然后我们将两个参数 directiveNameeventName 传入工具类对象中。

// node: 当前节点  expr:当前表达式(student.name) vm:当前 vue 实例
CompileUtil[directiveName](node, expr, this.vm, eventName);

通过调用不同的指令进行不同的处理

/**
 * 工具类(把数据插入到 DOM 中)
 * expr: 指令的值(v-model="student.name" 中的 student.name)
 */
let CompileUtil = {
  // ---------------------- 匹配指令或者表达式的函数 ----------------------
  // 匹配 v-model
  model(node, expr, vm) {
    let fn = this.updater["modelUpdater"];
    new Watcher(vm, expr, newValue => {
      // 给输入框添加一个观察者,如果数据更新了,会触发此方法,将新值付给 input
      fn(node, newValue);
    });
    // 给 input 绑定事件
    node.addEventListener("input", e => {
      let value = e.target.value; // 获取用户输入的内容
      this.setValue(vm, expr, value);
    });
    let value = this.getValue(vm, expr);
    fn(node, value);
  },
  
  // ---------------- 其他用到的工具函数 -------------------
  // $data取值 [student, name]
  getValue(vm, expr) {
    return expr.split(".").reduce((data, current) => {
      return data[current];
    }, vm.$data);
  },

  // 给 vm.$data 中数据赋值
  setValue(vm, expr, value) {
    expr.split(".").reduce((data, current, index, arr) => {
      // 如果遍历取到最后一个,我就给赋值
      if (index == arr.length - 1) {
        return (data[current] = value);
      }
      return data[current];
    }, vm.$data);
  },

  // -------------- 给对应的 dom 进行赋值 -------------------
  updater: {
    modelUpdater(node, value) {
      // 处理指令结点 v-model
      node.value = value;
    }
  }
};

以上就会触发这个函数:

// 匹配 v-model
model(node, expr, vm) {
    let fn = this.updater["modelUpdater"];
    new Watcher(vm, expr, newValue => {
        // 给输入框添加一个观察者,如果数据更新了,会触发此方法,将新值付给 input
        fn(node, newValue);
    });
    // 给 input 绑定事件
    node.addEventListener("input", e => {
        let value = e.target.value; // 获取用户输入的内容
        this.setValue(vm, expr, value);
    });
    let value = this.getValue(vm, expr);
    fn(node, value);
},

同时我们看到了 new Watch 对该属性创建一个观察者,用于以后数据更新时,通知视图进行相应的更新的。

new Watcher(vm, expr, newValue => {
    // 给输入框添加一个观察者,如果数据更新了,会触发此方法,将新值付给 input
    fn(node, newValue);
});

同时又给 input 绑定了一个事件,用于实现对 input 框的监听,相对应的 data 也要更新,这就实现了v-model输入框的双向绑定功能。

// 给 input 绑定事件
node.addEventListener("input", e => {
    let value = e.target.value; // 获取用户输入的内容
    this.setValue(vm, expr, value);
});

每当 data 数据被改变,我们就触发 this.updater 中的视图更新函数。

let fn = this.updater["textUpdater"];
fn(node, value);
// 给 dom 文本结点赋值数据
updater: {
  modelUpdater(node, value) {
    // 处理指令结点 v-model
    node.value = value;
  }
}

对于文本节点,调用 this.compileText(child) 方法和以上同样的实现方法。这一部分的整体实现代码如下:

**
 * 工具类(把数据插入到 DOM)
 * expr: 指令的值(v-model="school.name" 中的 school.name)
 */
let CompileUtil = {
  // $data取值 [school, name]
  getValue(vm, expr) {
    return expr.split(".").reduce((data, current) => {
      return data[current];
    }, vm.$data);
  },

  // 给 vm.$data 中数据赋值
  setValue(vm, expr, value) {
    expr.split(".").reduce((data, current, index, arr) => {
      // 如果遍历取到最后一个,我就给赋值
      if (index == arr.length - 1) {
        return (data[current] = value);
      }
      return data[current];
    }, vm.$data);
  },

  // 匹配 v-model
  model(node, expr, vm) {
    let fn = this.updater["modelUpdater"];
    new Watcher(vm, expr, newValue => {
      // 给输入框添加一个观察者,如果数据更新了,会触发此方法,将新值付给 input
      fn(node, newValue);
    });
    // 给 input 绑定事件
    node.addEventListener("input", e => {
      let value = e.target.value; // 获取用户输入的内容
      this.setValue(vm, expr, value);
    });
    let value = this.getValue(vm, expr);
    fn(node, value);
  },

  html(node, expr, vm) {
    //xss
    let fn = this.updater["htmlUpdater"];
    new Watcher(vm, expr, newValue => {
      console.log(newValue);
      fn(node, newValue);
    });
    let value = this.getValue(vm, expr);
    fn(node, value);
  },

  // 获取 {{a}} 中的值
  getContentValue(vm, expr) {
    // 遍历表达式 将内容 重新特换成一个完整的内容 返还出去
    return expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
      return this.getValue(vm, args[1]);
    });
  },

  // v-on:click="change"
  on(node, expr, vm, eventName) {
    node.addEventListener(eventName, e => {
      vm[expr].call(vm, e);
    });
  },

  // 可能存在 {{a}} {{b}} 多个样式
  text(node, expr, vm) {
    let fn = this.updater["textUpdater"];
    let content = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
      // 给表达式 {{}} 中的值添加一个观察者,如果数据更新了,会触发此方法
      new Watcher(vm, args[1], () => {
        fn(node, this.getContentValue(vm, expr)); // 返回一个全新的字符串
      });
      return this.getValue(vm, args[1]);
    });
    fn(node, content);
  },

// 给 dom 文本结点赋值数据
updater: {
  modelUpdater(node, value) {
    // 处理指令结点 v-model
    node.value = value;
  },
  textUpdater(node, value) {
    // 处理文本结点 {{}}
    node.textContent = value;
  },
  htmlUpdater(node, value) {
    // 处理指令结点 v-html
    node.innerHTML = value;
  }
}
};

3、塞回页面

此时,我们将渲染好的 fragment 塞回到真实 DOM中就可以正常显示了。

this.el.appendChild(fragment);

当我们在输入框中输入数据时,相对应的视图上 {{student.name}} 的地方进行实时的更新;
当我们通过 vm.$data.student.name 改变数据时,输入框内的数据也会发生改变。

效果图如下:

阿里面试官让我手写 Vue 2.0核心原理,我都整理好了!_第6张图片

从头到尾我们实现了一个双向绑定。

小结

上述代码中,大多数都是通过动手实践模拟原理的过程,而非单纯枯燥的知识点。通过手动去写一写原理的实现,发现并不是很简单,但是这些原理都是建立在基础知识之上的东西。

但是,当我们翻看源码的时候,就很难找到头绪,源码中,有各种参数判断、条件判断。此篇文章当做自己的随手笔记,里边的内容并非全都准确无误,如有理解不当,错误的地方,欢迎指出!

后续

后续会将《手写 Vue 2.0 核心原理(下)》整理出来,而且这部分会进行部分的优化和更改,将这部分全部写入《大前端面试小册》中,这个过程就是一个不断发现错误,反馈错误,不断优化的过程!

最后,我是小鹿,一个互联网业余写作分享爱好者,只有变秃,才能变强,欢迎来 Get !

最后

原创不易,拒绝白嫖不良习惯,点赞就完事了!

你可能感兴趣的:(大前端吊打面试官系列)