手撕 vue2

217 阅读34分钟

本文初衷是为了彻底分析 vue2 原理,不会深入 roolup 配置

配置 rollup

Rollup 是一个 Javascript 模块打包器,可以将小块代码编译成打快复杂代码,rollup.js 更专注于 Javascript 类库打包(开发应用时使用 webpack,开发类库一般使用 Rollup)

新建 vue2-project,初始化 yarn 并安装 rollup 相关包

npm init -y

// @babel/core: 调用 transform 方法去转换 js 源代码。
// @babel/preset-env:转化规则,把标准的语法转换为低级的语法。
// rollup-plugin-babel:关联 rollup 和 @babel/core

npm i rollup @babel/core @babel/preset-env rollup-plugin-babel -D

// rollup-plugin-node-resolve:按照 node 包引用来解析 比如包引用可以省略 index.js
cnpm i rollup-plugin-node-resolve -D

新建 src/index.js 打包入口文件

function Vue() {

}

export default Vue;

根目录新增 rollup.config.js 配置

rollup.config.js
import babel from 'rollup-plugin-babel'; // 让rollup打包的时候可以采用babel

export default {
  input: 'src/index.js', // 打包入口
  output: {
    file: 'dist/vue.js', // 打包出口
    format: 'umd', // 常见格式 IIFE ESM CJS UMD
    name: 'Vue', // umd 模块需要配置 name, 会将导出的模块挂到 window 上
    sourcemap: true
  },
  plugins: [
    resolve(), // 按 node 包的引用规则来解析模块
    babel({
      exclude: 'node_modules/**', // glob 写法, 忽略 node_modules 下所有包
    })
  ]
}

package.json 中配置启动命令

 "scripts": {
    "dev": "rollup -c -w"
  }

npm run dev,会发现符合 umd 规范的 dist/vue.js 已经打出来了

新建 examples/1.observe.html

code
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>vue2</title>
</head>
<body>
  
</body>
<script src="./vue.js"></script>
<script>
  console.log(Vue); // ƒ Vue() {}
</script>
</html>

可以看到 es6 语法已经转成 es5 了,接下来愉快的编码吧~

Vue 的初始化流程

  1. new Vue 会调用 _init 方法进行初始化操作(实际上组件的初始化也会调 _init 方法),并把用户的配置项作为一个属性 $options 挂载到 vm (实例)上
  2. 如果有 data,需要初始化数据
  3. 如果有 el,需要挂载到页面上

修改 1.observe.html 文件

code
<div id="app">{{ msg }}</div>

<script src="./vue.js"></script>
<script>
  new Vue({
    el: '#app',
    data: {
      msg: '杨帅加油'
    }
  });
</script>

修改 src/index.js 代码

src/index.js
import { initMixin } from './initMixin'; 

function Vue(options) {
  // 开始初始化
  this._init(options);
}

// 在 Vue 原型链上扩展方法 
initMixin(Vue);

export default Vue;

新建 initMixin.js,用来在 Vue 原型上扩展方法,只是做一个抽离封装

src/initMixin.js
import { initState } from "./state";

export function initMixin(Vue) {
  // 后续组件化开发的时候,Vue.extend 可以创造一个子组件,子组件也可以调用 _init 方法
  Vue.prototype._init = function(options) {
    const vm = this;
    
    // 注意调用的时候是 实例._init, 所以这里的 this 指的是实例本身 
    // 把用户的配置放到实例上, 这样在其他方法中都可以共享 options 了
    vm.$options = options;

    // 因为数据的来源有很多种,比如 data、props、computed 等,我们要做一个统一的数据的
    // 初始化『数据劫持』
    initState(vm);

    if (vm.$options.el) {
      // 要将数据挂载到页面上『模板解析』
      console.log('页面要挂载');
    } 
  }
}

新建 src/state.js,增加初始化数据的方法 initState

src/state.js
// 统一的数据的初始化分发
export function initState(vm) {
  const options = vm.$options;

  if (options.data) {
    initData(vm);
  }
}

function initData(vm) {
  // 数据的初始化
  console.log('数据的初始化操作');
}

初始化数据(数据劫持)

整体流程如下:

  1. 如果配置项中包含 data 属性,判断 data 是不是一个函数,如果是,取函数返回值「注意修正 this」,如果不是,直接取 data
  2. observer 去观测 data 中的数据,如果 data 不是对象或者后续不是对象(递归调用),则停止对其子属性劫持(其本身作为父级的属性已经被劫持过了),否则,递归调用 defineReactive「该函数不会被释放,每次调用形成新的闭包」 去递归劫持数据
// 这也是 vue2 性能瓶颈的一个主要原因,这里引出几条 vue2 的性能优化原则
//   @1 不要把所有的数据都放在 data 中,闭包过多会损耗性能
//   @2 尽量扁平化数据,不要嵌套多次,递归影响性能
//   @3 不要频繁获取数据,会触发 get 方法,走 get 方法中的全部逻辑
//   @4 如果数据某属性不需要响应式,可以使用 Object.freeze 冻结属性
//     「源码里会跳过 defineReactive」

初始化嵌套对象

index.html
<script>
  new Vue({
    el: '#app',
    data: {
      msg: '杨帅加油',
      info: {
        age: 18
      },
      hobby: ['eat', 'sing']
    }
  });
</script>

修改数据管理中心 state.js

src/state.js
import { observe } from "./observer";
import { isFunction } from "./utils";

// 统一的数据的初始化分发
export function initState(vm) {
  const options = vm.$options;

  if (options.data) {
    initData(vm);
  }
}

function initData(vm) {
  // 数据的初始化

  // 注意 data 可能是个对象 也可能是个函数
  //   + 对象:根实例
  //   + 函数:页面组件互相之间的数据隔离
  let data = vm.$options.data;

  // 注意修正 this,防止函数执行 this 指向 window
  data = isFunction(data) ? data.call(vm) : data;

  // 数据劫持
  observe(data);

  console.log(data);
}

新增数据劫持方法 observer/index.js

observer/index.js
import { isObject } from "../utils";

class Observer {
  constructor(data) {
    this.walk(data); // 遍历属性进行劫持
  }

  walk(data) {
    Object.keys(data).forEach(key => {
      // 使用 defineProperty 重新定义
      defineReactive(data, key, data[key]);
    })
  }
}

// vue2 慢的一个原因
// 这里引出几条 vue2 的性能优化原则
//   @1 不要把所有的数据都放在 data 中,闭包过多会损耗性能
//   @2 尽量扁平化数据,不要嵌套多次,递归影响性能
//   @3 不要频繁获取数据,会触发 get 方法,走 get 方法中的全部逻辑
//   @4 如果数据某属性不需要响应式,可以使用 Object.freeze 冻结属性
//     「源码里会跳过 defineReactive」
function defineReactive(obj, key, value) {
  observe(value); // 递归进行劫持
  Object.defineProperty(obj, key, {
    get() {
      // 这里就形成一个闭包,每次执行 defineReactive 上下文都不会被释放
      // 所以这就是 vue2 的性能瓶颈
      return value;
    },
    set(newValue) {
      if (newValue == value) return;

      // 设置新值重新劫持
      observe(newValue); 
      value = newValue;
    }
  });
}

export function observe(data) {
  // 如果不是对象,直接返回「非对象类型不再递归劫持子属性」
  if (!isObject(data)) return;

  // 如果一个数据被劫持过了,就不要重复劫持了,这里用类来实现
  // 递归劫持 data 子属性
  return new Observer(data);
}

新增工具方法 src/utils.js

src/utils.js
export function isFunction(val) {
  return typeof val === 'function';
}

export function isObject(val) {
  return typeof val !== 'null' && typeof val == 'object';;
}

此时输出 data,发现已经劫持了「添加了 get、set 方法」

为什么 this 能获取 data 数据

为什么 this.info 能获取到 this.$options.data().info 呢「实际二者还不一样,一个是劫持过的,一个是未劫持的」

  1. 解析到 data 后,把 data 作为 一个属性 _data 挂在到 vm 实例上
  2. 劫持完 data 后,遍历 data,劫持 data 内部的属性名,当从实例上获取这些属性名时,从 vm._data 中取劫持后的 data 数据

修改 src/state.js

src/state.js
// ...

// 代理 -> 使用 vm.info 取到 vm.data.info「劫持过的 info」
function proxyFn(vm, key, source) {
  Object.defineProperty(vm, key, {
    get() {
      return vm[source][key]
    },
    set(newVal) {
      vm[source][key] = newVal;
    }
  });
}

function initData(vm) {
  // 数据的初始化

  // 注意 data 可能是个对象 也可能是个函数
  //   + 对象:根实例
  //   + 函数:页面组件互相之间的数据隔离
  let data = vm.$options.data;

  // 注意修正 this,防止 data 函数执行 this 指向 window
  // 把 data 赋值给 vm 实例一个变量上,这里取名 _data,作为实例的属性数据源
  data = vm._data = isFunction(data) ? data.call(vm) : data;

  // 数据劫持
  observe(data);

  // 取 data.info => vm._data.info 做了一层代理获取到劫持后的 info
  // 这也是 vue 中我们可以用 this 访问和修改 data 中数据的原因
  for (let key in data) {
    proxyFn(vm, key, '_data');
  }

  console.log(vm.info);
}

此时,在页面通过 Vue 实例访问属性,就能获取到劫持后的 data 中的数据啦。

不要劫持数组「而是进行原型方法重写」

  1. 如果即将劫持的 val 是数组类型,则停止劫持,修改其原型链上的七个方法,并递归劫持其子元素
// 细节补充
//   + 如果子元素不是对象或数组,则不再进行劫持「数组内某元素如果是值类型,则不对该元素劫持,
//     其修改导致更新,依赖的是数组本身的方法重写」
//   + 如果子元素是数组,修改数组原型指向,增加一次方法代理 
//     arr.__proto => { 'push': fn, 'pop': fn ... } => Array.prototype
//   + 如果子元素是对象,走对象的递归劫持逻辑「defineReactive」

//   比如 vm.hobby = [{ a: 1 },'eat', 'sing'];
vm.hobby[1] = 'say'; // 数组内值类型不会被劫持,不会触发更新
vm.hobby[0].a = 2; // 数组内对象被循环劫持,触发更新
vm.hobby[0].b = 2; // 修改不存在的值,不触发更新

vm.hobby.push({ b: 2 }); // 数组新增对象类型要重新劫持
vm.hobby[3].b = 200; // 要触发更新
  1. 数组的新增元素的方法(splice, push, unshift),要递归劫持增加的元素,递归规则同上

前面我们发现,数组也被劫持了,说明 object.defineProperty 对数组也有效,对象深层被劫持的话,其实影响不大,我们一般不会有特别深层的对象,不过数组可能有 1000 项「每一项中还可能是个对象」,就要被递归劫持 1000 次,这对性能是个极大的损耗。

所以vue2 中,不会对数组进行劫持「除了数组本身所在的堆内存地址」,vue3 中为了兼容 proxy,内部对数组用的 就是 defineProperty,不过只是个补丁而已

那么 vue2 中如何对数组修改做出监听呢?其实很简单,哪个方法能修改原数组,就重写方法「而且要递归重写,因为可能数组套数组」,通知 vue 更新就完了

push pop splice shift unshift reverse sort

需要注意的是,除了这七个方法外,改索引和改长度是不能引发 vue 更新视图的,因为没有监听

怎么做到不对数组劫持呢,我们首先修改 observer/index.js

observer/index.js
import { isArray, isObject } from "../utils";
import arrayMethod from "./array";

// 观察者类
class Observer {
  constructor(val) {
    // 给对象和数组添加一个自定义属性,用于数组新增元素时的再次劫持
    // 不过切记 __ob__ 属性不能被枚举,不然如果 val 是个对象的话,就会把 __ob__ 也劫持
    // 这也是我们看到所有的 Vue 变量都有 __ob__ 属性的原因
    Object.defineProperty(val, '__ob__', {
      value: this,
      enumerable: false
    });

    if (isArray(val)) {
      // 如果是 val 数组,修改原型方法 这里就不考虑兼容 ie 了
      // 这里只针对 data 中的数组,没有重写 Array.prototype 上的方法
      val.__proto__ = arrayMethod;
      this.observeArray(val);
    } else {
      this.walk(val); // 遍历属性进行劫持
    }
  }

  // 递归遍历数组,对数组内部的对象再次重写 比如 [{}]  [[]]
  // 需要注意的是: 数组套对象的话,修改对象属性,也会触发更新,比如:
  // vm.arr[0].a = 100 ----> 触发更新
  // vm.arr[0] = 100   ----> 不会触发更新
  observeArray(val) {
    // 调用 observe 方法,数组内元素如果不是对象,则不会劫持
    val.forEach(itm => observe(itm));
  }

  // 遍历对象
  walk(data) {
    Object.keys(data).forEach(key => {
      // 使用 defineProperty 重新定义
      defineReactive(data, key, data[key]);
    })
  }
}

function defineReactive(obj, key, value) {
  observe(value); // 递归进行劫持
  Object.defineProperty(obj, key, {
    get() {
      // 这里就形成一个闭包,每次执行 defineReactive 上下文都不会被释放
      // 所以这就是 vue2 的性能瓶颈
      return value;
    },
    set(newValue) {
      if (newValue == value) return;
      console.log('触发set方法');
      // 设置新值重新劫持 
      observe(newValue); 
      value = newValue;
    }
  });
}

export function observe(data) {
  // 如果不是对象,直接返回「非对象类型不进行递归劫持」
  if (!isObject(data)) return;

  // 如果被劫持过,就不再进行劫持了
  if (data.__ob__) return;

  // 如果一个数据被劫持过了,就不要重复劫持了,这里用类来实现
  // 劫持过的对象 把类的实例挂在到 data.__ob__
  return new Observer(data);
}

新增 observer/array.js,用来重写数组七种方法

observer/array.js
// 这里不对数组原型做操作,只针对 data 中的数组,增加了一层方法拦截
let oldArrayPrototype = Array.prototype; 
// 挂载老的原型对象到 arrayMethod 上,arrayMethod.__proto__ 能拿到 oldArrayPrototype
let arrayMethod = Object.create(oldArrayPrototype); 

// 只有这七个方法修改了原数组 所以要重写
let methods = ['push', 'pop', 'splice', 'shift', 'unshift', 'reverse', 'sort'];

methods.forEach(method => {
  // 先找自己身上,找不到去原型对象上找「arrayMethod 的原型对象是 oldArrayPrototype」
  // 比如 push 方法可以传多个参数,所以这里通过扩展运算符拿到参数列表
  arrayMethod[method] = function(...args) {
    console.log('数组的方法进行重写');
    // 调用原有数组方法
    oldArrayPrototype[method].call(this, ...args);

    let inserted = null; // 新插入的元素

    // 对新增的元素进行重新劫持,新增数组元素的方法只有 splice、push、unshift
    switch(method) {
      case 'splice':
        // splice 第二个参数后,就是新增的元素
        inserted = args.slice(2);
      case 'push':
      case 'unshift':
        // push 和 unshift 传入的元素即为新增元素
        inserted = args;
        break;
    }

    // 遍历 inserted,需要劫持的增加数据劫持,但是数据劫持的方法在 Observer 类上
    // 我们取巧的把 Observer 类的实例挂载到当前操作的数组上 叫 __ob__,具体见 Observer 中实现
    let ob = this.__ob__;

    // 接着劫持 本身是个数组
    if (inserted) ob.observeArray(inserted);
  }
});

export default arrayMethod;

src/utils.js 新增数组判断方法

src/utils.js
// ...
export function isArray(val) {
  return Object.prototype.toString.call(val) === '[object Array]';
}

修改 dist/index.html

index.html
<script>
  let vm = new Vue({
    el: '#app',
    data: function() {
      return {
        msg: '杨帅加油',
        info: {
          desp: {
            age: 'ys'
          } 
        },
        hobby: [{ a: 1 },'eat', 'sing']
      }
    }
  });

  // vm.msg = { a: 200 }; // set 新值重新进行劫持
  // vm.msg.b = 100; // 修改不存在的属性 则不会更新

  // vm.hobby[1] = 'say'; // 数组的下标和长度修改不会触发更新
  // vm.hobby[0].a = 2; // 触发更新

  vm.hobby.push({ b: 2 }); // 新增的 { b: 2 } 被劫持
  vm.hobby[3].b = 200; // 触发更新
</script>

运行代码

其实这就是 Vue 中数据劫持的过程,不过还有很多细节没有去实现,具体后面再说,数据劫持完毕后,就开始进行模板编译了

模板编译原理(模板渲染)

整体流程如下: 在 vue2 中,使用的是更符合前端思维的 template 而不是更灵活的 JSX,值的一提的是,vue3 template 写起来性能会高一些,内部做了很多优化,所以在 vue3 里面尽量不要使用 jsx。

vue2 在数据变化需要更新视图的时候,采用了 diff 算法,如果 diff 算法通过匹配字符串去匹配差异的话,性能指定不高,所以这里它采用了将模板转 ast 语法树的方式。

首次渲染: 
template -> ast 语法树 -> render 函数 -> 生成虚拟 dom -> 生成真实 dom

更新节点: 
template -> ast 语法树 -> render 函数 -> 新的虚拟 dom -> diff 算法 -> 更新真实 dom

参考 vue2 模板转 AST 工具,这里能看到 template 转成的 render 方法~

<div id="app">{{ msg }}</div>

会被转为 一个 render 函数

// @1 _c 是创建 dom 节点的方法
// @2 _v 是创建文本的方法
// @3 _s 是转字符串的方法,如果 msg 是个对象,则会转成字符串输出
function render() {
  with(this) {
    return _c('div', {
      attrs: {
        "id": "app"
      }
    }, [_v(_s(msg))])
  }
}

根节点注册($mount)

  1. 使用 options 内的 el 或者 mount方法传入el,进行根节点的注册,实际上走的都是mount 方法传入 el,进行根节点的注册,实际上走的都是 mount 方法。该方法除了根据指定 dom 节点生成 render 函数外,也会把节点本身挂载到 vm 上(vm.$el)
根节点选择的优先级:
// 注意,生成 render 方法,实际传入的是 el.outerHTML 
// 为该元素和其所有子元素序列化后的字符串标签
1. 初始化 vue 时,options 传入 render,则不进行模板编译了,使用传入的 render 即可。
2. 否则,按照优先级 options.temlpate > options.el 进行根节点的选择,使用序列化的根节点去生成 render 方法。

修改 src/initMixin.js 方法

src/initMixin.js
import { initState } from "./state";

export function initMixin(Vue) {
  Vue.prototype._init = function(options) {
    // ...

    if (vm.$options.el) {
      vm.$mount(vm.$options.el);
    } 
  }

  // 挂载节点的方法,比如 new Vue({}).$mount(el) 这种写法
  Vue.prototype.$mount = function(el) {
    const vm = this;
    const opts = vm.$options;

    el = document.querySelector(el); // 获取真实节点
    vm.$el = el; // 真实元素挂载到实例上

    if (!opts.render) {
      // 模板编译
      let template = opts.template;

      if (!template && el) {
        // outerHTML 取得是该元素和其所有子元素序列化后的字符串标签
        template = el.outerHTML;
      }

      // compileToFunction 为模板编译的方法
      // 模板 -> js 对象 -> ast -> render code -> 生成 render 函数
      let render = compileToFunction(template); // el 节点本身

      opts.render = render;
    }
  } 
}

可以看出,compileToFunction 方法就是我们实际模板编译的入口

根模板 -> 正则分词解析为对象 -> ast

  1. 通过正则去做词法分析,生成一个表示标签和属性名的 js 对象。
  2. 通过一个栈结构,巧妙的把 js 对象组装成 AST 树。

新建 src/compiler/index.js

src/compiler/index.js
import { parserHTML } from "./parser";

// 模板编译原理
export function compileToFunctions(html) {
  // 1. 将模板转成 ast 语法树
  let astRoot = parserHTML(html);

  console.log(astRoot, '.....-...');
}

新建 src/compiler/parser.js 文件,内部进行正则分词,生成 ast

src/compiler/parser.js
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`; //
const qnameCapture = `((?:${ncname}\\:)?${ncname})`; //  match匹配的是标签名
const startTagOpen = new RegExp(`^<${qnameCapture}`); // 标签开头的正则 捕获的内容是标签名
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`); // 匹配标签结尾的 
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/; // 匹配属性的 分组里放的就是 "b",'b' ,b  => (b) 3 | 4 | 5


// a = "b"   a = 'b'   a = b
const startTagClose = /^\s*(\/?)>/; // 匹配标签结束的 <br/>   <div> 
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g // {{  asdasd  }}


export function parserHTML(html) {
    function advance(len) {
        html = html.substring(len);
    }

    function parseStartTag() {
        const start = html.match(startTagOpen);
        if (start) {
            const match = {
                tagName: start[1],
                attrs: []
            }
            advance(start[0].length);
            let attr;
            let end;
            while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
                match.attrs.push({ name: attr[1], value: attr[3] || attr[4] || attr[5] });
                advance(attr[0].length);
            }
            advance(end[0].length);
            return match;
        }
        return false;

    }
    // 生成一颗树  <div id="app" a=1 b=2>hello{{age}} <span>{{name}}</p>111</div>
    // [div,span]
    // 文本 -》 我的父亲是div
    // span => 我的父亲是div
    // {{name}} => 我的父亲是span
    // 遇到结束标签 就做pop操作 [div]
    // 111 -> 我的父亲是div
    //  就做pop操作
    let root = null;
    let stack = [];
    let parent = null;
    function createAstElement(tag, attrs) {
        return {
            tag,
            type: 1,
            attrs,
            children: [],
            parent: null
        }
    }
    function start(tagName, attrs) { // 匹配到了开始的标签
        let element = createAstElement(tagName, attrs);
        if (!root) {
            root = element
        }
        let parent = stack[stack.length - 1];
        if (parent) {
            element.parent = parent; // 当放入span的时候 我就知道div是他的父亲
            parent.children.push(element);
        }
        stack.push(element);
    }
    function chars(text) { // 匹配到了开始的标签
        let parent = stack[stack.length - 1];
        text = text.replace(/\s/g,''); // 遇到空格就删除掉
        if(text){
            parent.children.push({
                text,
                type:3
            });
        }
    }
    function end(tagName) {
        stack.pop(); // 每次出去就在栈中删除当前这一项, 这里你可以判断标签是否出错
    }
    while (html) { // html只能由一个根节点
        let textEnd = html.indexOf('<');
        if (textEnd == 0) { // 如果遇到< 说明可能是开始标签或者结束标签 <!DOC
            const startTagMatch = parseStartTag();
            // console.log(startTagMatch)
            if (startTagMatch) { // 匹配到了开始标签
                start(startTagMatch.tagName, startTagMatch.attrs);
                continue
            }
            // 如果代码走到这里了 说明是结束标签
            const endTagMatch = html.match(endTag);
            if (endTagMatch) {
                end(endTagMatch[1]);
                advance(endTagMatch[0].length);
            }
        }
        let text;
        if (textEnd > 0) {
            text = html.substring(0, textEnd)
        }
        if (text) {
            chars(text);
            advance(text.length);
        }
    }
    return root;
}

// 虚拟dom是描述dom的对象
{ /*  <span>{{name}}</span></div> */ }

// ast 抽象语法树 ,描述html语法本身的

// {
//     tag:'div',
//     type:1,
//     children:[{text:'hello {{age}}',type:3,parent:'div对象'},{ type:'span',type:1,attrs:[],parent:'div对象'}]
//     attrs:[{name:'id':value:'app'}],
//     parent:null
// }

ast -> render 代码片段

使用 ast 生成 render 内部使用的代码片段

修改 src/compiler/index.js

src/compiler/index.js
import { generate } from "./generate";
import { parserHTML } from "./parser";

// 模板编译原理
export function compileToFunctions(html) {
  // 1. 将模板转成 ast 语法树
  let astRoot = parserHTML(html);

  // 2. 代码生成(根据树,生成 render 方法内部代码)
  //     比如 _c('div', { id: 'app', a: 1 }, _v('hello'))
  let code = generate(astRoot);

  console.warn(code);
}

新建 src/compiler/generate.js, 把 ast 生成 render 内部使用的代码片段

src/compiler/generate.js
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g; // 匹配双花括号 {{}}

// 数组转对象 [{ name: 'aaa', value: 'bbb' }] => { aaa: 'bbb' }
function genProps(attrs) {
  let str = '';

  for (let i = 0; i < attrs.length; i++) {
    let attr = attrs[i];

    if (attr.name === 'style') { // style: "color: red; background: blue;"
      let styleObj = {};

      attr.value.replace(/([^;:]+)\:([^;:]+)/g, function() {
        styleObj[arguments[1]] = arguments[2];
      });

      attr.value = styleObj;
    }

    str += `${ attr.name }: ${ JSON.stringify(attr.value) },`
  }

  return `{${ str.slice(0, -1) }}` // 删掉多余的逗号
}

// 检查子节点 递归生成 code
function gen(node) {
  if (node.type == 1) {
    // 节点类型
    return generate(node);
  } else {
    let text = node.text;

    if (!defaultTagRE.test(text)) {
      // 文本内容不包含双花括号
      return `_v("${ text }")`;
    } else {
      // 双花括号内的当做变量进行拼接 -> hello {{ msg }} 你好呀
      let tokens = [];
      let match;
      // {{}} 结束索引 也是下一次遍历的开始索引 
      // defaultTagRE.lastIndex 也重置为 0,因为正则全局匹配和 exec 有冲突
      // 每次匹配完要重置下标
      let lastIdx = defaultTagRE.lastIndex = 0; 

      while(match = defaultTagRE.exec(text)) {
        // 匹配到了 {{}}
        let startIdx = match.index; // 开始索引

        if (startIdx > lastIdx) {
          tokens.push(JSON.stringify(text.slice(lastIdx, startIdx)));
        }

        // 花括号内变量 考虑换行需要 trim
        // 花括号内可能是个对象,为了避免隐式转为 [object Object]
        // 使用 _s 包装,_s 其实就是调用了 JSON.stringify()
        tokens.push(`_s(${ match[1].trim() })`);
        
        lastIdx = startIdx + match[0].length; // 结束索引
      }

      // 截取最好一段 比如 "hello {{ msg }} 你好呀" 中的 "你好呀"
      if (lastIdx < text.length) {
        tokens.push(JSON.stringify(text.slice(lastIdx)));
      }

      return `_v(${ tokens.join('+') })`;
    }
  }
}

// 获取 ast 节点的儿子
function genChildren(node) {
  let children = node.children; // 获取儿子

  if (children) {
    // 递归拼接
    return children.map(c => gen(c)).join(',');
  }

  return false;
}


// 生成类似 _c('div', { id: 'app', a: 1 }, _v('hello')) 这样的代码片段 也有可能是下面这种哦
// _c('div', { id: 'app', a: 1 }, _c('span', { id: 'span'}, _v('套娃子节点')))
// _c 表示创建元素的虚拟节点方法 _v 表示创建文本的虚拟节点方法
export function generate(astRoot) { 
  console.log(astRoot);
  let children = genChildren(astRoot);

  // 遍历 ast 树,将树拼接成字符串
  let code = `_c("${ astRoot.tag }", ${
    astRoot.attrs.length ? genProps(astRoot.attrs) : 'undefined'
  }${
    children ? `,${ children }` : '' 
  })`;

  return code;
}

修改 dist/index.html 文件

index.html
<!DOCTYPE html>
<html lang="en">
...
<body>
  <div id="app" a="1" style="color:red; background:blue;">
    快乐coding
    <span>hello {{ msg }} 你好呀</span>
  </div>
</body>
<script src="./vue.js"></script>
<script>
  let vm = new Vue({
    el: '#app',
    data: function() {
      return {
        msg: '杨帅加油',
        info: {
          desp: {
            age: 'ys'
          } 
        },
        hobby: [{ a: 1 },'eat', 'sing']
      }
    },
    // template: '<div>1</div>'
  });
</script>
</html>

执行,打印生成的 code 片段:

render 代码片段 -> render 函数

  1. 使用 new Function,将代码片段转为可执行的 render 函数
  2. 函数内部使用 with(this) {} 包裹,保证变量正常访问。

修改 src/compiler/index.js

src/compiler/index.js
import { generate } from "./generate";
import { parserHTML } from "./parser";

// 模板编译原理
export function compileToFunctions(html) {
  // 1. 将模板转成 ast 语法树
  let astRoot = parserHTML(html);
  console.log(astRoot);
  // 2. 代码生成(根据树,生成 render 方法内部代码)
  //     比如 _c('div', { id: 'app', a: 1 }, _v("hello"+_s(msg)+"你好呀"))
  let code = generate(astRoot);

  // 3. 把生成的代码包装成 render (敲黑板)
  //    使用 Function 把字符串变成函数
  //    使用 with 保证模板上的变量取自 this,后续调用 render.call(vm) 即可
  let render = new Function(`with(this) { ${ code } }`); 

  return render;
}

组件挂载 -> (render + vm) -> vdom

render 函数已经生成完毕,那接下来就是组件挂载(模板变量解析)了,组件挂载分为两步。

  1. Vue 原型链挂载 _render, _c(创建虚拟 dom 节点), _v(创建虚拟文本节点), _s(对象转字符串) 方法
  2. mountComponent 方法开始进行组件挂载流程,也就是结合 vm._render 方法和 vm 实例去生成虚拟 dom

src/index.js 入口文件引入 renderMixin

src/index.js
import { initMixin } from './initMixin'; 
import { renderMixin } from './render';

// ...

// 原型链挂载 _render -> 生成虚拟 dom 方法
renderMixin(Vue);

export default Vue;

新建 src/render.js 原型链挂载 _render, _c, _v, _s 函数逻辑

src/render.js
import { createElement, createTextElement } from "./vdom";

export function renderMixin(Vue) {
  // 创建 render 函数需要的三个方法
  Vue.prototype._c = function(tagName, data, ...children) { // createElement
    const vm = this;

    return createElement(vm, tagName, data, children);
  }

  Vue.prototype._v = function(text) { // createTextElement
    const vm = this;

    return createTextElement(vm, text);
  }

  // 转字符串
  Vue.prototype._s = function(val) {
    if (typeof val === 'object') return JSON.stringify(val);
    return val;
  }

  // 把 vm.$options 上的 render 方法 挂载到原型链
  Vue.prototype._render = function() {
    const vm = this;

    // 就是我们通过 ast 生成的 render 方法(或者原本就传了 render 方法)
    let render = vm.$options.render;
    
    let vnode = render.call(vm);
    
    return vnode;
  }
}

修改 src/initMixin.js,render 方法生成后,执行组件挂载逻辑,调用 lifecycle.js

src/initMixin.js

import { compileToFunctions } from "./compiler";
import { mountComponent } from "./lifecycle.js";
import { initState } from "./state";

export function initMixin(Vue) {
// 后续组件化开发的时候,Vue.extend 可以创造一个子组件,子组件也可以调用 _init 方法
Vue.prototype._init = function(options) {
  // ...
}

// 挂载节点的方法(如果 options 中不传 el 的话)
// 兼容 new Vue({}).$mount(el) 这种写法
Vue.prototype.$mount = function(el) {
  // ... 

  // opts.render 就是渲染函数
  // console.warn(opts.render, 'opts.render');

  // 开始组件挂载流程(生命周期方法),也就是模板解析(变量渲染到dom上)
  mountComponent(vm, el);
}
}

修改 src/lifecycle.js,调用 render 方法

src/lifecycle.js
export function mountComponent(vm, el) {
// 更新函数 数据变化后 会再次调用此函数
let updataComponent = () => {
  // 调用 render 函数,生成虚拟 dom,用虚拟 dom 生成真实 dom
  vm._update(vm._render());
}

updataComponent();
}

新建 src/vdom/index.js 此文件生成 虚拟dom

src/vdom/index.js
// 创建虚拟 dom 节点 也就是 render 方法中  _c
export function createElement(vm, tag, data = {}, children) {
  // console.log('createElement 方法', tag, data, children, vm);
  return vnode(vm, tag, data, data.key, children, undefined); // key 挂在了虚拟dom上哦
}

// 创建虚拟文本节点 也就是 render 方法中  _v
export function createTextElement(vm, text) {
  // console.log('createTextElement 方法', text, vm);
  return vnode(vm, undefined, undefined, undefined, undefined, text);
}

// 虚拟dom (比 ast 数更自由,随意组合属性,哪怕不合法的属性)
function vnode(vm, tag, data, key, children, text) {
  return {
    vm, 
    tag, 
    data, 
    key, 
    children,
    text
  }
}

vdom -> patch -> 真实 dom

1. Vue 原型挂载 vm._update 方法,该方法调用 patch 2. patch 方法进行 dom diff 比对(这里暂未实现),根据虚拟 dom 生成真实 dom 节点,并将 dom 真实节点挂载到 vnode.elm 上。

修改 src/lifecycle.js

src/lifecycle.js
import { patch } from "./vdom/patch";

export function lifecycleMixin(Vue) {
  // 生成真实 dom 的方法
  Vue.prototype._update = function(vdom) {
    const vm = this;
    // console.log(vm.$el, '_updata', vdom);
    // 既有初始化,又有更新

    // 老节点被干掉了 使用新节点
    vm.$el = patch(vm.$el, vdom); // diff 来啦
  }
}

export function mountComponent(vm, el) {
  // 更新函数 数据变化后 会再次调用此函数
  let updataComponent = () => {
    // 调用 render 函数,生成虚拟 dom,用虚拟 dom 生成真实 dom
    vm._update(vm._render());
  }

  updataComponent();
}

修改 src/index.js 入口文件引入 lifecycleMixin

src/index.js
import { initMixin } from './initMixin'; 
import { lifecycleMixin } from './lifecycle.js';
import { renderMixin } from './render';

// ...

// 原型链挂载 _update -> 生成真实 dom 方法
lifecycleMixin(Vue);

export default Vue;

新增 src/vdom/patch.js,该方法生成真实 dom

src/vdom/patch.js
/**
 * @description dom diff & 生成虚拟 dom 方法
 * @param { Element | Object } el 根 dom 节点
 * @param { Object } vnode 虚拟 dom 对象 
 */
export function patch(curElm, vnode) {
  // 把 vnode 生成真实 dom,挂载节点整个替换
  if (curElm.nodeType == 1) {
    const parentElm = curElm.parentNode; // 找到挂载节点的父节点
    const newElm = createElm(vnode); // 根据虚拟节点 创建真实节点

    parentElm.insertBefore(newElm, curElm.nextSibling); // 放在挂载节点的下一个元素
    parentElm.removeChild(curElm); // 删除掉挂载节点

    return newElm; // 新的根节点返还 重新挂载到 vm.$el 上
  }
}

// 创建真实 dom,并插入到页面(父节点)
export function createElm(vnode) {
  let { tag, data, children, text, vm } = vnode;

  if (typeof tag === 'string') { 
    // 元素节点
    // 把当前真实节点挂载到虚拟节点的 elm 属性上,方便下层使用
    vnode.elm = document.createElement(tag); 

    // 处理子节点(树的深度遍历)
    children.forEach(child => {
      // 插入到父真实节点上
      // console.log(child);
      vnode.elm.appendChild(createElm(child));
    });
  } else {
    // 文本节点
    // console.log('------text-------', text, vnode);
    vnode.elm = document.createTextNode(text);
  }

  console.log(vnode.elm);
  return vnode.elm;
}

响应式实现

修改 index.html,干掉 style 相关代码,这里我们暂时不对样式做处理,这个留到后面 diff 算法的时候去实现,我们希望数据改变影响视图,也就是数据和页面渲染关联起来。

index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>vue2</title>
</head>
<body>
  <div id="app">
    <span>{{ msg }}</span>
    <span>{{ name }}</span>
  </div>
</body>
<script src="./vue.js"></script>
<script>
  let vm = new Vue({
    el: '#app',
    data: function() {
      return {
        msg: '杨帅加油',
        name: 18
      }
    }
  });
</script>
</html>

数据变化我们能监听到(劫持时候的 set 方法),那我们怎么触发页面渲染呢,还记得 src/lifecycle.js.js 文件中有一句代码么。

// render 方法会重新生成虚拟 dom
// update 方法会调用 patch 方法生成真实 dom(目前还没有实现 diff 算法,后面实现)
vm._update(vm._render());

来试一下

  let vm = new Vue({
    el: '#app',
    data: function() {
      return {
        msg: '杨帅加油',
        name: 18
      }
    }
  });

  vm.msg = '杨帅不加油';
  vm._update(vm._render()); 

刷新页面,确实更新了,但是我们肯定不希望用户每次更改数据都手动调用,而是希望数据一改变,自动调用更新方法,这里我们想到了观察者模式,被观察者只要改变,观察者自动调用更新方法。

所以我们要修改原有页面渲染的方法,把 vm._update(vm._render()) 抛给一个观察者去执行。

修改 vdom/lifecycle.js 原有更新方法

vdom/lifecycle.js
// 后续每个组件渲染的时候都会有一个 watcher
export function mountComponent(vm, el) {
  // 更新函数 数据变化后 会再次调用此函数
  let updataComponent = () => {
    // 调用 render 函数,生成虚拟 dom,用虚拟 dom 生成真实 dom
    vm._update(vm._render());
  }

  // updataComponent();

  // 传入 true 标识着他是一个渲染 watcher,后续还会有其他 watcher,这里做个标识
  new Watcher(vm, updataComponent, () => {
    console.log('更新视图啦');
  }, true); 
}

新建 Dep 类(观察者收集器)

注意以下 dep 和 watcher 大小写,小写代表实例哦

  1. 同一个属性如果在 100 个组件中使用,该属性闭包中的 dep 会绑定 100 个 watcher 实例(dep 上挂载多个 watcher),注意在同一组件中,不同的属性对应的只有一个 watcher,也就是说,更改多个属性多次,调用的是同一个 watcher 进行更新,我们只需要在更新的时候取做队列异步更新即可,把多次更新合并成一个。
  2. 每个模板中使用到的属性上需要挂载一个 wacher 收集器 dep (defineReactive 方法顶部闭包方式保存), 用来收集自己的 wachers「模板渲染取值时调用 dep.depend(Dep.target)」
  3. 每个 dep 有自己的标识(id) 和 subs 存放 watcher
  4. depend 方法,该方法为 defineReactive 中 get 劫持到属性读取时的方法(仅限 render 方法使用到的属性,也就是模板内绑定的属性),此方法调用 Dep.target 的 addDep,watcher 的 addDep 内部除了在 watcher 上收集了 dep(这个以后用到),还调用了 dep.addSub(watcher) 去收集 watcher,简单点说就是 dep.depend -> watcher.addDep(收集 dep) -> dea.addSub(收集 watcher)
  5. notify 方法,该方法为 defineReactive 中 set 劫持到属性更改时调用(该属性闭包内的 dep 上的 notify),会依次调用 wacher 的 update 方法去做更新,其实这里在会先加入更新序列,使用调度器去判断什么时候执行更新操作(render + patch)。
observer/dep.js
// 观察者收集器
// 每个属性上需要挂载一个 wacher 收集器 dep (defineReactive 方法顶部闭包方式保存), 用来收集自己的 wachers,因为一个属性如果在多个组件用,是要多个 watcher
let id = 0; // 为了保证唯一性,也给加个序号

class Dep {
  constructor() {
    this.id = id++;
    this.subs = []; // 存放 watcher
  }

  depend() {
    // Dep.target  dep 里要存放这个 watcher,watcher 要存放 dep,多对多的关系
    if (Dep.target) {
      // 这一步最终结果是 dep 和 wacher 是互相存
      Dep.target.addDep(this);
    }
  }

  addSub(watcher) {
    this.subs.push(watcher);
  }
  
  // 通知更新
  notify() {
    // 属性一改可能会更新 n 次,所以这里要做出任务队列,异步更新
    this.subs.forEach(watcher => watcher.update());
  }
}

Dep.target = null; // 静态属性
export default Dep;

新建 Wather 类(观察者)

  1. 同一个 watcher 实例可能对应 n 多属性,比如 A 组件内有 100 变量,该 watcher 会收集 100 个 dep 供后续使用(watcher 上挂载多个 dep)
  2. 每个 watcher 有自己的 id,每个组件初始化生成一个新的 watcher,相同 ID 只执行一次,且后面覆盖前面。
  3. getter 方法,watcher 内部传入的 updataComponent 方法(也就是 render + patch,虚拟 dom 到真实 dom 的方法)
  4. get 方法,初始化的时候会调用一次 get 方法(首次渲染),此时利用 js 单线程的特点,动态挂载当前 watcher 到 Dep.target 上,供更新操作触发 defineReactive 中 get 方法时取 watcher,更新完毕后删除 Dep 上的 watcher 实例。
  5. addDep 方法,除了在 watcher 上收集了 dep(这个以后用到),还调用了 dep.addSub(watcher) 去在 dep 中收集 watcher。
  6. update 方法,当模板绑定属性的值改变时调用该属性所属闭包内的 dep.notify (defineReactive 中 set 方法劫持), dep.notify 方法依次调用该属性 watcher.update,update 方法会收集要更新的 watcher,在合适的时机更新(调度器策略)
  7. run 方法,调度器最后更新,调用的就是 run 方法啦,它内部其实调用的也是 Dep 中的 get 方法。
observer/watcher.js
// Dep 上挂载 watcher 的方式,之后会改
import { popTarget, pushTarget } from "./dep"; 
import { queueWatcher } from "./scheduler";

let id = 0; // 每 new 一次 watcher,id++

// 观察者类
class Watcher {
  // exprOrFn: 可能是个表达式(计算属性)或者更新的函数(vm._update(vm._render()))
  constructor(vm, exprOrFn, cb, options) {
    this.vm = vm;
    this.exprOrFn = exprOrFn;
    this.cb = cb;
    this.options = options;
    this.id = id++; // watcher 类实例化计数
    this.deps = [];
    // 防止一个模板绑定两次相同的值 存两个 dep <span> {{ msg + msg }}}</span>
    this.depsId = new Set(); 

    // render 方法会去 vm 上重新取值,生成虚拟 dom,我们这里把它重命名为 getter
    this.getter = exprOrFn;

    // 更新函数默认执行一次,首次渲染页面(生成虚拟 dom -> diff -> 真实 dom)
    this.get();
  }

  // 重新取值并渲染,取值会调用 defineProperty.get 方法,我们让每个属性都能收集自己的 watcher(多对多的关系)
  // 每个组件的渲染都会初始化一个 wacher,组件内属性跟 watcher 做绑定
  // 每个属性可能有多个 watcher(全局的属性 msg 100 个组件使用,就会声明 100 个 wacher 实例跟 msg 做绑定)
  // 同一个 watcher 实例可能对应 n 多属性,比如 A 组件内有 100 变量,该 watcher 会收集 100 个 dep 供后续使用
  // 为了收集以上关系,我们声明一个 Dep 类
  get() {
    // console.log('dom 渲染');
    // 注意,这里代码只有要更新页面时(getter 就是更新页面方法),才会走
    // 模板内绑定的变量,取值之前这里设置 Dep.target -> wacher
    // 注意,普通的 vm.msg 取值不会走该方法,也就是普通的读取变量 Dep.target -> null
    pushTarget(this); 
    // 只有取值的时候,把当前 watcher 收集到当前属性的收集器 Dep 上,并渲染页面
    // 也就是说,当前变量在模板中使用到了,才会去收集 watcher (没用到不取)
    this.getter();
    // 取值之后 立马清空挂载的 wacher 实例
    popTarget(); 
  } 

  // 双向收集
  addDep(dep) {
    let id = dep.id;

    // 如果没存过这个 dep, 再存
    if (!this.depsId.has(id)) {
      this.depsId.add(id);
      this.deps.push(dep);
      dep.addSub(this);
    }
  }

  // 存起来要更新的操作,交给调度器
  update() {
    // 缓存 wacher,多次调用先缓存,等会儿去重一起更新。
    // 这就是为什么 vue 的数据更新是异步的
    queueWatcher(this);
  }

  // 调度器更新实际调用的方法
  run() {
    // 渲染操作的更新方法,后续还有其他更新
    this.get();
  }
}

export default Watcher;

修改 observer/index.js 中对象劫持的方法

observer/index.js
function defineReactive(obj, key, value) {
  observe(value); // 递归进行劫持
  let dep = new Dep(); // 给当前变量声明一个 Dep(闭包中保存),并收集 watcher

  Object.defineProperty(obj, key, {
    get() {
      // 这里就形成一个闭包,每次执行 defineReactive 上下文都不会被释放
      // 所以这就是 vue2 的性能瓶颈
      console.log(key, 'get 取值啦');
      if (Dep.target) {
        // 说明是解析模板内变量 也就是 render 执行时候用到的 vm 中变量取值
        // 模板内变量的 Dep 里收集 watcher
        dep.depend();
      }
      
      return value;
    },
    set(newValue) {
      if (newValue !== value) {
        // 设置新值重新劫持 
        observe(newValue); 
        value = newValue;
        // 每个属性的闭包中的 dep 实例
        dep.notify(); // 告诉当前属性存放的 watcher 执行
      }
    }
  });
}

新建调度器 observer/scheduler.js

在更改属性时,我们调用了该属性闭包中的 dep.notify 方法,该方法会调用 watcher 上的 update 方法,我们提到,update 方法不会立即更新,而是维护了一个要更新的观察者的队列,watcher.update 方法调用了 queueWatcher 方法,并把 this (当前观察者)传入进去,queueWatcher 方法就是 wacher 调度器提供的,它的作用主要是把多次更新合并成一次。

  1. 接收 watcher 对象,相同 ID 只记录一次
  2. 更新操作我们希望是异步的(同步收集完成后,收集多个一起更新),又希望尽早更新,所以不能使用 setTimeout,尽量使用微任务,这里需要实现 nextTick 方法,注意此方法就是 Vue.prototype.$nextTick
observer/scheduler.js
import { nextTick } from "../utils";

// wactcher 调度 
let queue = [];
let has = {}; // 存放 wacher ID,防止相同 watcher 更新多次
let pending = false; 

// 清空 wacher 队列,每一个 watcher 都是一次更新操作(render + patch)
function flushSchedulerQueue() {
  console.log(queue);
  for (let i = 0; i < queue.length; i++) {
    queue[i].run(); // 更新
  }

  queue = [];
  has = {};
  pending = false;
}

export function queueWatcher(watcher) {
  let id = watcher.id;

  if (has[id] == null) {
    queue.push(watcher);
    has[id] = true;

    // 开启一次异步批处理更新(防抖)
    if (!pending) {
       console.log('dom 更新 run');
       
       // 不能使用延时器,不然我们想拿到更新后的 dom 节点,只能通过 setTimeout 去拿
       // 而且,我们希望尽早更新,同步代码执行完毕会先执行微任务,而不想等 setTimeout
      // setTimeout(flushSchedulerQueue, 0);

      nextTick(flushSchedulerQueue);
      pending = true;
    }
  }
}

修改 src/util.js 新增 nextTick 方法

  1. timer 方法,根据浏览器支持的微任务操作,逐步降级
  2. 维护微任务回调队列,注意,这些回调有可能是更新 dom 的操作,有可能是用户调用 this.$nextTick 的回调,内部实现原理,经典的异步更新流程,收集 -> 加锁 -> 异步更新 -> 继续执行同步代码收集任务 -> 同步执行完毕后开始整微任务队列 -> 微任务队列就一个任务!nextTick -> 同步方式清空收集到的任务列表 -> 开锁,注意,不管 callbacks 收集了多少任务,也只是在外面包了一个异步任务,该任务执行后去同步的方式依次清空 callbacks。
src/util.js
// ......

const callbacks = [];
let wating = false; // 防抖

// 依次执行 nextTick 队列中的 callback
function flushCallbacks() {
  callbacks.forEach(cb => cb());
  wating = false;
}

// 降级策略
function timer(cb) {
  let timerFn = () => {};

  if (Promise) {
    timerFn = () => {
      Promise.resolve().then(cb);
    };
  } else if (MutationObserver) { // 微任务 监听节点变化的 api
    let textNode = document.createTextNode(1); // 随便创建个文本节点来监听
    let observe = new MutationObserver(cb); // 注册个回调

    observe.observe(textNode, { // 监控文本节点变化 characterData 代表文本内容
      characterData: true
    });

    timerFn = () => {
      textNode.textContent = 2;
    }
  } else if (setImmediate) { // ie 才认的 api,性能略高于 setTimeout  
    timerFn = () => {
      setImmediate(cb);
    }
  } else {
    // 再不支持 只能延时器了
    timerFn = () => {
      setTimeout(cb);
    }
  }
  
  timerFn();
}

// 源码中的调度器会优先调用 nextTick 方法(批量更新就调用)
// 所以更新 dom 的操作会先入 callbacks 队列
export function nextTick(cb) {
  callbacks.push(cb);

  if (!wating) {
    // vue3 不考虑兼容,这里直接 Promise.resolve.then(flushCallbacks)
    // vue2 中考虑兼容性问题,有个降级策略
    timer(flushCallbacks);
    wating = true;
  }
}

修改 vdom/lifecycle.js

别忘了 Vue 上挂载 $nextTick 方法哦

vdom/lifecycle.js
// ...
import { nextTick } from "./utils";

export function lifecycleMixin(Vue) {
  // 生成真实 dom 的方法
  Vue.prototype._update = function(vdom) {
    const vm = this;

    // 既有初始化,又有更新
    // 老节点被干掉了 使用新节点
    vm.$el = patch(vm.$el, vdom); // diff 来啦
  }

  // nextTick 方法
  Vue.prototype.$nextTick = nextTick;
}

// ...

修改 index.html 进行测试

index.html
<body>
  <div id="app">
    <span>{{ msg }}</span>
    <span>{{ name }}</span>
  </div>
</body>
<script src="./vue.js"></script>
<script>
  let vm = new Vue({
    el: '#app',
    data: function() {
      return {
        msg: '杨帅加油',
        age: 'amosyang'
      }
    }
  });

  setTimeout(() => {
    vm.msg = 1;
    vm.msg = 2;
    vm.msg = 3;
    vm.name = '哈哈';

    // setTimeout(() => {
    //   console.log(vm.$el);
    // }, 0);
      
    vm.$nextTick(() => {
      console.log(vm.$el);
    });
  }, 1000);
</script>

数组的响应式原理

我们上面对对象劫持的方法进行了改造,使得它支持了双向绑定,那么数组怎么办呢,数组是方法重写,而不是递归劫持。

这里我们有一个思路,改写了数组七种方法的时候,我们把 Observer 对象挂到了数组身上,我们也可以挂一个 Dep 呀,用它来收集 watcher。

修改 observer/index

  1. 在每个属性对应的 observer 上保存 dep 实例,而 observer 又挂在每一个数组和对象身上,那么数组调用七种方法时候,只需要执行 arr.ob.dep.notify 即可完成更新, 注意此时 val 可能是对象也可能是数组,数组我们是需要加 dep 的,不过对象加上也不影响,因为对象加属性是不会触发更新的(没有劫持),而且后面实现对象 $set 需要使用到这个 dep
  2. defineReactive 调用 observe 方法递归劫持时,我们让每次劫持的 Observer 对象返回回来(非对象返回值为 undefined),命名为 childOb,可以判断属性对应的 childOb 对象是否存在,在触发属性取值 get 时去做依赖收集
  3. 数组需要考虑多层嵌套,因为多维数组只会触发一次 get,所以使用 dependArray 方法,在 get 内部递归收集
observer/index
import { isArray, isObject } from "../utils";
import arrayMethod from "./array"; // 数组结束(方法重写)
import Dep from "./dep";

// 观察者类
// 如果给对象新增一个属性不会触发视图更新(给对象本身也增加一个 dep,dep 中存 watcher,如果增加一个属性后,我就手动的触发 watcher 的更新)
class Observer {
  constructor(val) {
    // 给 Observer 实例增加一个 dep 属性,也就是 val.__ob__.dep
    // 注意此时 val 可能是对象也可能是数组,数组我们是需要加的,用于调用七种方法是调用 dep.notify
    // 对象加上也不影响,因为对象加属性是不会触发更新的(没有劫持),而且后面实现对象 $set 需要使用到这个 dep
    this.dep = new Dep(); 

    // 给对象和数组添加一个自定义属性,用于数组新增元素时的再次劫持
    // 不过切记 __ob__ 属性不能被枚举,不然如果 val 是个对象的话,就会把 __ob__ 也劫持
    // 这也是我们看到所有的 Vue 变量都有 __ob__ 属性的原因
    Object.defineProperty(val, '__ob__', {
      value: this,
      enumerable: false
    });

    if (isArray(val)) { // 我希望数组的变化可以触发视图更新
      // 如果是 val 数组,修改原型方法 这里就不考虑兼容 ie 了
      // 这里只针对 data 中的数组,没有重写 Array.prototype 上的方法
      val.__proto__ = arrayMethod;
      this.observeArray(val); 
    } else {
      this.walk(val); // 遍历属性进行劫持
    }
  }

  // 递归遍历数组,对数组内部的对象再次重写 比如 [{}]  [[]]
  // 需要注意的是: 数组套对象的话,修改对象属性,也会触发更新,比如:
  // vm.arr[0].a = 100 ----> 触发更新
  // vm.arr[0] = 100   ----> 不会触发更新
  observeArray(val) {
    // 调用 observe 方法,数组内元素如果不是对象,则不会劫持
    val.forEach(itm => observe(itm));
  }

  // 遍历对象
  walk(data) {
    Object.keys(data).forEach(key => {
      // 使用 defineProperty 重新定义
      defineReactive(data, key, data[key]);
    })
  }
}

// 对数组里面的数组进行依赖收集
function dependArray(val) {
  for (let i = 0; i < val.length; i++) {
    let current = val[i]; // current 是数组里面的数组

    current.__ob__ && current.__ob__.dep.depend(); 

    // 递归收集,使闭包内的 Dep 保存 wacher
    if (Array.isArray(current)) {
      dependArray(current);
    }
  }
}

// vue2 慢的一个原因
// 这里引出几条 vue2 的性能优化原则
//   @1 不要把所有的数据都放在 data 中,闭包过多会损耗性能
//   @2 尽量扁平化数据,不要嵌套多次,递归影响性能
//   @3 不要频繁获取数据,会触发 get 方法,走 get 方法中的全部逻辑
//   @4 如果数据某属性不需要响应式,可以使用 Object.freeze 冻结属性「源码里会跳过 defineReactive」
function defineReactive(obj, key, value) {
  // 进行劫持(递归) 对象或数组返回 Observer 实例,普通值返回 undefined
  let childOb = observe(value); 
  let dep = new Dep(); // 给当前变量声明一个 Dep(闭包中保存),并收集 watcher

  // console.log(childOb, '就是当前数据 Observer 实例');

  Object.defineProperty(obj, key, {
    get() {
      // 这里就形成一个闭包,每次执行 defineReactive 上下文都不会被释放
      // 所以这就是 vue2 的性能瓶颈
      // console.log(key, 'get 取值啦');
      if (Dep.target) {
        // 说明是解析模板内变量 也就是 render 执行时候用到的 vm 中变量取值
        // console.log('渲染模板内属性', key);
        // 模板内变量的 Dep 里收集 watcher
        dep.depend();

        // 数组本身添加 Dep 收集 watcher 去更新(对象也会被添加 Dep,但是对象的 Dep 暂时没有使用哦)
        // 如果 value 是个普通值,则 childOb 为 undefined
        if (childOb) { 
          // 让数组和对象也记录 Dep,收集 watcher
          childOb.dep.depend();

          // 如果当前 value 个数组,需要考虑二维数组的情况,因为某个字段是多维数组,取值只触发最外层数组的一次 get
          // 比如 hobbys -> [[1, 2, 3]],我想让 hobbys[0].push('4') 也能更新
          // 取外层数组要对里面的 item 也行依赖收集 
          if (Array.isArray(value)) {
            console.log(value);
            dependArray(value);
          }
        }
      }
      
      return value;
    },
    set(newValue) {
      if (newValue !== value) {
        // console.log('触发set方法');
        // 设置新值重新劫持 
        observe(newValue); 
        value = newValue;
        // 每个属性的闭包中的 dep 实例
        dep.notify(); // 告诉当前属性存放的 watcher 执行
      }
    }
  });
}

export function observe(data) {
  // 如果不是对象,直接返回「非对象类型不进行递归劫持」
  if (!isObject(data)) return;

  // 如果被劫持过,就不再进行劫持了
  // 劫持过的话,把上次劫持的 Oberver 对象返回
  if (data.__ob__) {
    return data.__ob__;
  };

  // 如果一个数据被劫持过了,就不要重复劫持了,这里用类来实现
  // 劫持过的对象 把类的实例挂在到 data.__ob__
  return new Observer(data);
}

修改 observer/array.js

  1. 调用数组 7 个方法数组之后,手动调用 ob.dep.notify 方法
observer/array.js
// ...
methods.forEach(method => {

    // ...
    let ob = this.__ob__;

    // 调用数组的 observer.dep 属性,触发更新
    ob.dep.notify();
  }
);

watch 的使用和实现

其实除了渲染 watcher 以外,vue2 中还有计算属性和 watch 这两种 watcher。

watch 的使用

我们来看下 watch 的基础用法~

// 写法一
const vm = new Vue({
  el: '#app',
  data: {
    name: 'ys',
    lover: '张三',
    speak: '学习使我快乐',
    ojbk: { val: '嗷嗷嗷' },
    age: { n: 100 }
  },
  methods: {
    fn() {
      console.log('快乐个p啦');
    }
  },
  watch: {
    name(newValue, oldVal) { // name 改变走这里
     console.log('name 改变了');
    },
    lover: [ // love 改变走这里
      function(newVal, oldVal) {
        console.log('处理函数一');
      },
      function(newVal, oldVal) {
        console.log('处理函数二');
      }
    ],
    ojbk: {
      function(newValue, oldVal) {
        console.log('ojbk 改变了');
      },
      immediate: true, // 立即执行一次
      deep: true // 递归监听
    },
    speak: 'fn', // speak 改变会调用 methods fn 方法
    'age.n'(newValue, oldVal) { // age.n 改变走这里
      console.log('age.n 改变了');
    },
  }
})

// 写法二
vm.$watch('name', function(newValue, oldVal) {
  console.log('name 改变了');
})

可以看出,watch 对象内部 key 对应的 val 可以是函数、数组、对象或者字符串,我们这里的实现先不考虑字符串,我们新建 dist/watch.html 来实现它。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>vue2 watch 实现</title>
</head>
<body>
  <div id="app">
    <span>{{ speak }}</span>
  </div>
</body>
<script src="./vue.js"></script>
<script>
  // ...
</script>
</html>

watcher 实现

那我们来实现它。

在 src/state.js 中处理 watch 选项 1. 如果 wach options 中,属性对应的 handler 为数组,则遍历数组,针对每一个处理函数去调用 vm.watcher方法,该方法内部会newWatcher,并把Watcher实例上挂上user=true,以标识这是一个用户自定义的watcher2.如果wachoptions中,属性对应的handler为函数,则针对该函数调用vm.watcher 方法,该方法内部会 new Watcher,并把 Watcher 实例上挂上 user = true,以标识这是一个用户自定义的 watcher。** **2. 如果 wach options 中,属性对应的 handler 为函数,则针对该函数调用 vm.watcher 方法,只创建一个 watcher 实例。 我们没对更复杂的 watch 配置进行扩展,比如 handler 为对象或字符串类型,是因为我觉得面试没用 hahaha..

src/state.js
export function initState(vm) {
  const options = vm.$options;

  if (options.data) {
    initData(vm);
  }

  // 处理 watch
  if (options.watch) {
    initWatch(vm, options.watch);
  }
}

// ...

function initWatch(vm, watch) {
  for (let key in watch) {
    let handler = watch[key];

    if (Array.isArray(handler)) { // handler 为数组
      for (let i = 0; i < handler.length; i++) { 
        // 创建个 watcher 去监听数据变化
        createWatcher(vm, key, handler[i]);
      }
    } else { // handler 为函数
      createWatcher(vm, key, handler);
    }
  }
}

function createWatcher(vm, key, handler) {
  // 所以无论用户是 optionsAPI 传入还是手动调用 vm.$watch,实际执行的都是 vm.$watch
  return vm.$watch(key, handler); 
}

// 把 $watch 扩展到原型上
export function stateMixin(Vue) {
  Vue.prototype.$watch = function(key, handler, options={}) {
    options.user = true; // 标识用户自己写的 watcher, 跟渲染 watcher 区分开
    new Watcher(this, key, handler, options); // 创建 watch 的 watcher
  }
}

在 src/index.js 中对 Vue 原型链扩展 $watch 方法

src/index.js
import { stateMixin } from './state';

// ...
// 扩展原型 $watcher 方法
stateMixin(Vue);

在 src/observer/watcher.js 中增加 watch 的 watcher 收集逻辑 1. 为每个 watcher 增加 user 标识,user 为 true 表示为用户自定义 watcher,目前我们认为是 watch watcher。 2. 如果传入 的 exprOrFn 是个字符串的话,那它是个 watch watcher,我们把 getter 更改成一个取值的方法,用于下面调用时进行取值,触发属性的 get 方法去为该属性的 dep 收集当前 watcher。 3. 用户更新属性,会依次调用收集到的 watcher,包括我们的 watch watcher,并调用更新函数 watcher.run,该方法内部执行 watcher.get 去更新页面,当 watcher 为 watch watcher 时,我们把用户为属性注册的监听回调执行,并传回给回调两个参数,它们就是 oldValue 和 newValue。

src/observer/watcher.js
// 观察者类
class Watcher {
  // exprOrFn: 可能是个表达式(计算属性)或者更新的函数(vm._update(vm._render()))或字符串(watch 创建的 watcher,第二个参数是 key)
  // options:为 true 标识它是个渲染 watcher
  constructor(vm, exprOrFn, cb, options) {
    this.vm = vm;
    this.exprOrFn = exprOrFn;
    this.cb = cb; // 用户回调绑定到实例上
    this.user = !!options.user; // 是不是用户 watcher, 取布尔值
    this.options = options;
    this.id = id++; 
    this.deps = [];
    this.depsId = new Set(); 

    if (typeof exprOrFn == 'string') { // watch api 的 watcher
      // 如果是 watch: { name: handler } 走到这里,此时 exprOrFn 就是 'name'
      // 走到下面 this.get() 方法内部时,执行 pushTarget 函数会把 Dep.target  
      // 更新为本次 watch 的 watcher,然后执行 this.getter 方法,我们 下面方法中
      // for 循环取值,这会触发取值被走 get 方法,get 方法回去收集当前 Dep.target 
      // 也就是 watch API 创建的 watcher,至此完成 watch wacter 收集
      this.getter = function() {
        // watcher: { "age.n": handler } 取值需要改成 vm['age']['n']
        let path = exprOrFn.split('.'); // [age, n]
        let obj = vm;

        for (let i = 0; i < path.length; i++) {
          obj = obj[path[i]]
        } 

        return obj
      }
    } else {
      // 渲染 watcher
      this.getter = exprOrFn;
    }

    // 更新函数默认执行一次,首次渲染页面(生成虚拟 dom -> diff -> 真实 dom)
    // this.value 代表初始的 value,也就是记录 oldValue
    // watch api 的 watcher 会返回当前 key 对应的值,渲染 watcher 返回 undefined
    this.value = this.get();
  }

  get() {
    // Dep.target -> null
    pushTarget(this);  
    // 当为 watch watcher 时候,可以收集到新值哦
    const value = this.getter();
    // 取值之后 立马清空挂载的 wacher 实例
    popTarget(); 

    return value;
  } 

  addDep(dep) {
    let id = dep.id;

    if (!this.depsId.has(id)) {
      this.depsId.add(id);
      this.deps.push(dep);
      dep.addSub(this);
    }
  }

  // 存起来要更新的操作,交给调度器
  update() {
    // 缓存 wacher,多次调用先缓存,等会儿去重一起更新。
    // 这就是为什么 vue 的数据更新是异步的
    queueWatcher(this);
  }

  // 调度器更新实际调用的方法
  run() {
    // 渲染操作的更新方法,后续还有其他更新
    // 更新后拿到新值
    let newValue = this.get();
    let oldValue = this.value;

    this.value = newValue; // 更新 oldValue

    if (this.user) { 
      // 用户自定义的 watcher 的话,调用 cb 方法,传入 oldValue 和 newValue
      // 这个 cb 就是 watch: { name: handler } 中的 handler 啦
      this.cb.call(this.vm, oldValue, newValue);
    }
  }
}

export default Watcher;

mixin 的使用和实现

mixin 的使用

我们新建 dist/mixin.html,我期望在两个 vue 应用注册后的 beforeCreate 方法中打印 hello ys,就可以使用 mixin 进行配置混入(所有配置都能混入)。

<!DOCTYPE html>
<html lang="en">
<body>
  <div id="app"></div>
</body>
<!-- 引用官方 vue 的包 -->
<script src="../node_modules/vue/dist/vue.js"></script> 
<script>
  Vue.mixin({ // 无侵入式增加应用公共能力
    data() {
      return {
        a: 1
      }
    },
    beforeCreate() {
      console.log('hello ys 1');
    }
  });

  Vue.mixin({ // 多次进行 mixin
    beforeCreate() {
      console.log('hello ys 2');
    }
  });

  console.log(Vue.options); // 公共配置

  // 每个 vue 应用执行之前都想对我打印一句 hello ys
  let vm = new Vue({
    beforeCreate() {
     
    }
  });

  let vm2 = new Vue({
    beforeCreate() {
     
    }
  });
</script>
</html>

以上代码输出两次 hello ys 1 和 两次 hello ys 2,打印的公共配置中,beforeCreate 被合并成一个数组~

mixin 的优缺点

优点:

  1. 无侵入式增加组件的公共能力 缺点:
  2. 命名冲突,混入的属性或者方法名称可能被组件内的同名变量替换掉
  3. 数据来源不清晰,莫名其妙应用 2 中多了一个 a 属性。

实现 mixin

1. src/index.js 新增初始化 Vue 静态方法的函数

code
import { initGlobalAPI } from './initGlobalAPI';

function Vue(options) {
  this._init(options);
}

// vue 增加静态方法,mixin 之类的
initGlobalAPI(Vue); 

// 在 Vue 原型链上扩展初始化方法(init, $mount 等)
initMixin(Vue);

// 原型链挂载 _render -> 生成虚拟 dom 方法
renderMixin(Vue);

// 原型链挂载 _update -> 生成真实 dom 方法
lifecycleMixin(Vue);

// 扩展原型 $watcher 方法
stateMixin(Vue);

export default Vue;


2. 新建 src/initGlobalAPI/index.js,增加 mixin 方法。

code
import { mergeOptions } from "../utils";

// 为 Vue 增加静态方法
export function initGlobalAPI(Vue) {
  Vue.options = {}; // 所有的全局属性都会放到这个变量上

  Vue.mixin = function(mixinOptions) {
    // this 代表 Vue,静态属性 Vue.options
    this.options = mergeOptions(this.options, mixinOptions);
  }
}


3. 工具模块中新增核心方法 mergeOptions,用于得到合并后的配置,赋值给 Vue.options

code
// ...


// 策略模式,针对不同的 key 去做合并
const strats = {};

// 针对不同 key 的策略,这里写四个生命周期的合并为栗,method 等合并同理
['beforeCreate', 'created', 'beforeMount', 'mounted'].forEach(method => {
  strats[method] = function(curVal, mixinVal) {
    if (mixinVal) {
      // Vue.options 默认值为空对象,所以原始配置中 key 对应的生命周期函数这里可能是空的
      // 首次是这样的 
      //   Vue.options = {}
      //   mixinOptions = { a, beforeCreate: function() {} }
      // 第二次是这样的
      //   Vue.options = { a, beforeCreate: [fn] }
      //   mixinOptions = { b, beforeCreate: function() {} }
      if (curVal) { 
        // 函数数组进行合并
        return curVal.concat(mixinVal);
      } else {
        // 公共配置没有生命周期,混入配置有,要把这些生命周期函数变为数组保存
        return [mixinVal]
      }
    } else {
      // 如果混入的 key 对应的值为空,直接使用原来的值
      return curVal; 
    }
  }
});

export function mergeOptions(curOptions, mixinOptions) {
  const res = {};

  // 先遍历 Vue.options,如果混入 options 中该属性也存在,使用混入的变量替换
  for (let key in curOptions) {
    mergeField(key);
  }

  // 再遍历混入 options,如果某属性 Vue.options 没有,拷贝过来
  for (let key in mixinOptions) {
    if (!curOptions.hasOwnProperty(key)) {
      mergeField(key);
    }
  }

  // 合并配置
  function mergeField(key) {
    // 策略模式,针对不同的 key 进行合并(这里拿生命周期函数做示例)
    if (strats[key]) {
      res[key] = strats[key](curOptions[key], mixinOptions[key]);
    } else {
      // 优先使用新传递的属性区替换公共属性
      res[key] = mixinOptions[key] || curOptions[key];
    }
  }

  return res;
}


4. src/ininMixin.js 方法中进行数据初始化前的配置合并,beforeCreate 声明周期方法调用

code
import { compileToFunctions } from "./compiler";
import { mountComponent } from "./lifecycle";
import { initState } from "./state";
import { mergeOptions } from "./utils";

function callHook(vm, hook) {
  const handlers = vm.$options[hook];

  if (handlers) {
    // 依次执行生命周期收集的钩子函数
    for (let i = 0; i < handlers.length; i++) {
      // 生命周期的 this 也指向当前实例,这就是为什么 vue2 中生命周期能拿到当前组件实例
      handlers[i].call(vm); 
    }
  } 
}

export function initMixin(Vue) {
  // 后续组件化开发的时候,Vue.extend 可以创造一个子组件,子组件也可以调用 _init 方法
  Vue.prototype._init = function(options) {
    const vm = this;
    
    // 注意调用的时候是 实例._init, 所以这里的 this 指的是实例本身 
    // 把用户的配置放到实例上, 这样在其他方法中都可以共享 options 了
    // 同混入的 Vue.options 做合并(mixin)
    vm.$options = mergeOptions(this.constructor.options, options);

    callHook(vm, 'beforeCreate'); // beforeCreate 在 initState 前执行

    // 因为数据的来源有很多种,比如 data、props、computed 等,我们要做一个统一的数据的初始化『数据劫持』
    initState(vm);

    if (vm.$options.el) {
      vm.$mount(vm.$options.el);
    } 
  }

// ...


OK,这就是 mixin 实现的流程啦。

意外事件 😭

因为代码没搞 git 仓库,导致代码丢失.. 现在又重新整理了一份,大概内容是一样的,只是文件重新做了规划,贴到下面,如果备注不明了,可以翻看前面的备注,实在不好意思..

- examples  # 测试用例的模板的文件夹
  - 1.observe.html
  - 2.update.html
  - 3.update.html
  - 4.mixin.html
  - 5.watch.html
  - 6.diff.html
- src
  - compile
    - generate.js  # 将 ast 生成 render 内部使用的代码片段
    - index.js     # 提供 compileToFunction,模板编译为 render 函数
    - parser.js    # 模板 -> ast 语法树
  - initGlobalAPI
    - index.js     # 初始化全局属性 Vue.options 和 Vue 静态方法
  - observer
    - array.js     # 重写数组方法
    - dep.js       # 观察者收集器
    - index.js     # 提供 Observer 类,进行变量劫持
    - watcher.js   # 观察者类和异步更新的逻辑
  - vdom   
    - index.js
    - patch.js     # dom diff,虚拟 dom -> 真实节点
.babelrc
package-lock.json
package.json
rollup.config.js

代码仓库,其中,master 代码会随着本文一起更新,v-20210809 分支代码为这版的起始代码,也就是进度到这里的代码。

实现 diff 算法

新建 dist/6.diff.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>diff</title>
</head>
<body>
  <div id="app"></div>
  <script src="../dist/vue.js"></script>
</body>
</html>

为了方便,我们在 src/index.js 尾部手动渲染模板

// 上面代码略.. 尾部追加就好

const template1 = `<ul>
<li style="background: red" key="A">A</li>
<li style="background: yellow"key="B">B</li>
<li style="background: green" key="C">C</li>
<li style="background: purple"key="D">D</li>
</ul>`

// 1. 模板转 render
const render1 = compileToFunctions(template1);
// 2. 创建 Vue 实例
const vm1 = new Vue({ data: {} }); 
// 3. 调用 render 生成虚拟节点
const oldVnode = render1.call(vm1);
// 4. 创建真实节点(和 patch 的区别是把整体生成的 dom 替换页面相应 dom)
const el1 = createElm(oldVnode);
// 5. 生成的 dom 节点插入到 body
document.body.appendChild(el1);


const template2 = `<ul>
<li style="background: purple"key="D">D</li>
<li style="background: green" key="C">C</li>
<li style="background: yellow"key="B">B</li>
<li style="background: red" key="A">A</li>
</ul>`

// 1. 模板重新转 render
const render2 = compileToFunctions(template2);
// 2. 调用 render 重新生成虚拟节点
const newVnode = render2.call(vm1);

// 3. 1s 后,新旧节点替换
setTimeout(() => {
  // 4. 创建真实节点(和 patch 的区别是把整体生成的 dom 替换页面相应 dom)
  const el2 = createElm(newVnode);
  
  document.body.removeChild(el1);
  document.body.appendChild(el2);
}, 1000);

此时刷新页面,先渲染 A, B, C, D, 1s 后渲染 D, C, B, A,我们注意到这里是全量替换的,并没有进行节点复用(因为暂时没有处理属性,所以 style 的背景色不显示)。

现在我不希望这么玩了,我希望对新旧 vnode 做 diff 算法,能复用的复用一下,更新部分节点

setTimeout(() => {
  // 进行 dom diff
  patch(oldVnode, newVnode);
}, 1000);

ok,来改写下 patch 方法啦。

// oldVnode 第一次为真实的根dom节点 & oldVnode(更新时为虚拟 dom)
export function patch(oldVnode, vnode) {
  if (oldVnode.nodeType == 1) { 
    // 初次渲染

    return elm;
  } else {
    // 说明新旧 vnode 都为虚拟 dom,此处需要对比 vnode,再按需生成真实 dom
    // dom diff
    // 比较虚拟节点的差异,而且会递归下探到子节点
    patchVnode(oldVnode, vnode);

    // 最终返回新的 el 元素
    return vnode.el;
  }
}

function patchVnode(oldVnode, vnode) {

}

接下来我们就看下 patchVnode 方法内部的逻辑

节点改变(直接替换dom,不进行 diff 对比)

如果新旧节点的 vnode 不等(vnode 中标签名或 key不等),不进行 diff 比对,全量替换。

修改 src/vdom/index.js

- 暴露对比节点是否相同的方法

// 检验两个元素是否一致,看新旧 vnode 中 tagName 和 key 是否完全相等
export function isSameVnode(oldVnode, newVnode) {
  return oldVnode.tag == newVnode.tag && oldVnode.key === newVnode.key;
}

修改 src/vdom/patch.js

- 新旧节点不等,全量替换,不进行 diff 比对。

// 检验两个元素是否一致,看新旧 vnode 中 tagName 和 key 是否完全相等
function patchVnode(oldVnode, vnode) {
  // 1. 节点改变直接替换
  if (!isSameVnode(oldVnode, vnode)) { 
    // 如果节点不能复用,不用进行 diff 算法比对,newVnode -> newDom -> 替换旧 DOM
    return oldVnode.el.parentNode.replaceChild(
      createElm(vnode), 
      oldVnode.el
    )
  } 

  // 复用旧的 dom 节点
  let el = vnode.el = oldVnode.el; 
}

所以,如果某节点直接改变,比如从 p 标签换成了 div,那么该节点会被重新生成、替换,该节点的所有子节点都不参与 diff 比对。

测试代码 src/index.js
const template1 = `<ul>
<li style="background: red" key="A">A</li>
<li style="background: yellow"key="B">B</li>
<li style="background: green" key="C">C</li>
<li style="background: purple"key="D">D</li>
</ul>`

// 1. 模板转 render
const render1 = compileToFunctions(template1);
// 2. 创建 Vue 实例
const vm1 = new Vue({ data: {} }); 
// 3. 调用 render 生成虚拟节点
const oldVnode = render1.call(vm1);
// 4. 创建真实节点(和 patch 的区别是把整体生成的 dom 替换页面相应 dom)
const el1 = createElm(oldVnode);
// 5. 生成的 dom 节点插入到 body
document.body.appendChild(el1);


const template2 = `<div>
<li style="background: purple"key="D">D</li>
<li style="background: green" key="C">C</li>
<li style="background: yellow"key="B">B</li>
<li style="background: red" key="A">A</li>
</div>`

// 1. 模板重新转 render
const render2 = compileToFunctions(template2);
// 2. 调用 render 重新生成虚拟节点
const newVnode = render2.call(vm1);

setTimeout(() => {
  // dom diff
  patch(oldVnode, newVnode);
}, 1000);


文本节点直接内容替换

没有 tag 属性(标签名)的就是文本。

修改 src/vdom/patch.js

- 新旧节点都是文本,对比文本值,不一样则直接内容替换。

function patchVnode(oldVnode, vnode) {
  // ...
  // 2. 文本节点直接替换(tag 为 undefined)
  if (!oldVnode.tag) {
    // 因为走到这里说明新旧节点相等(一个为 undefined,另一个也是),不然在 1 就被替换啦,
    // 所以这里判断一个即可
    if (oldVnode.text !== vnode.text) {
      return oldVnode.el.textContent = vnode.text;
    }
  }
}

相同节点(标签名和 key 相同)

相同节点要进行属性比对(事件先不考虑),更新属性,然后开始处理子节点。

  • 老节点有儿子,新节点没儿子,给复用的节点干掉子节点
  • 新节点有儿子,老节点没儿子, 给复用的节点创建儿子
  • 两方都有儿子(可能是文本哦),开始进行儿子节点间的比对(updateChildren),vue diff 算法的核心
// 新旧 vnode 的 diff 比对
function patchVnode(oldVnode, vnode) {
    // ...
    // 3. 相同节点,对比 & 更新属性
    updateProperties(vnode, oldVnode.props);

    // 比对完外部标签后,该进行儿子的比对了
    //  @1 两方都有儿子
    //  @2 一方有儿子,一方没儿子
    //  @3 两方都是文本的
    let oldChildren = oldVnode.children || [];
    let newChildren = vnode.children || [];

    if (oldChildren.length > 0 && newChildren.length > 0) {
      // 两方都有儿子(可能是文本哦),开始进行 diff 比对
      updateChildren(el, oldChildren, newChildren);
    } else if (oldChildren.length > 0) {
      // 老节点有儿子,新节点没儿子,给复用的节点干掉子节点
      el.innerHTML = '';
    } else if (newChildren.length > 0) {
      // 新节点有儿子,老节点没儿子, 给复用的节点创建儿子
      newChildren.forEach(child => el.appendChild(createElm(child)));
    }
}

属性处理

比较前后属性是否一致,并更新新的 dom 节点

  • 老的有,新的没有,将老的删除掉,
  • 如果新的有,老的也有,以新的为准
  • 如果新的有,老的没有,直接替换成新的
// 相同节点,对比 & 更新属性,oldProps 首次渲染不存在
function updateProperties(vnode, oldProps = {}) { // oldProps 可能不存在,如果存在就表示更新
  let newProps = vnode.props || {}; // 获取新的属性
  let el = vnode.el;
  // 比较前后属性是否一致 老的有新的没有,将老的删除掉,
  // 如果新的有 老的 也有,以新的为准
  // 如果新的有老的没有,直接替换成新的
  let oldStyle = oldProps.style || {}; // 如果前后都是样式
  let newStyle = newProps.style || {};
  for (let key in oldStyle) {
    if (!(key in newStyle)) { // 老的有的属性 但是新的没有,我就将他移除掉 
      el.style[key] = ''
    }
  }
  for (let key in oldProps) {
    if (!(key in newProps)) { // 老的有的属性 但是新的没有,我就将他移除掉 
      el.removeAttribute(key)
    }
  }
  for (let key in newProps) { // 以新的为准
    if (key == 'style') {
      for (let styleName in newStyle) {
        el.style[styleName] = newStyle[styleName]; // 对样式的特殊处理
      }
    } else {
      el.setAttribute(key, newProps[key]);
    }
  }
}

export function createElm(vnode) {
  const { tag, props, children, text } = vnode;
  if (typeof tag == 'string') {
    vnode.el = document.createElement(tag); // 把创建的真实dom和虚拟dom映射在一起方便后续更新和复用
    updateProperties(vnode); // 处理样式
    children && children.forEach(child => {
      vnode.el.appendChild(createElm(child))
    });
    // 样式稍后处理  diff算法的时候需要比较新老的属性进行更新??????
  } else {
    vnode.el = document.createTextNode(text);
  }
  return vnode.el;
}

儿子节点之间的比对,核心 diff 算法

vue2 中 dom diff 使用了双指针比对的方式,存在四种场景的优化,为了方便理解,这里用画图的方式~

新旧节点相同

若当前新旧头节点相同(比如尾部插入,就是头节点一直相同)

新旧节点不同,尾结点相同

若当前新旧头节点不同,但是尾节点相同(比如头部插入,就是尾节点一直相同)

新旧头尾节点不同,老头和新尾相同

若当前新旧节点头尾都不相同,但是老节点列表的头和新节点列表的尾相同(对角线比对)

新旧头尾节点、老头新尾不同,新头老尾相同

若当前新旧节点头尾都不相同,老头和新尾也不相同,新头和老尾相同(对角线比对),尾移头(头指的是起始下标元素的前面,不一定是首位)

乱序比对

如果上面四种优化都没有捕获到,就会走到这里进行乱序比对~ 这也是 vue2 dom diff 比较绕的一个点,这里会收集老 children 节点做一个一个 map,然后遍历新的 vnode 节点列表去做对比。

可以看出,B 和 D 即使相对位置没有发生改变,匹配到后,DOM 还是会分别向前移动一次,而 Vue3 中利用最长递增子序列进行计算,可以保证 B, D 不需要动,从而提升性能~

代码
整体 diff 代码
import { isSameVnode } from ".";

export function patch(oldVnode, vnode) {
  if (oldVnode.nodeType === 1) {
    // 初始化渲染操作
    // ...
  } else {
    // oldVnode 是虚拟节点,代表要做新旧 vnode 的 diff
    // -------------- diff 来啦 -----------------
    // 比较虚拟节点的差异,而且会递归下探到子节点
    patchVnode(oldVnode, vnode);

    // 最终返回新的 el 元素
    return vnode.el;
  }
}


// 新旧 vnode 的 diff 比对
function patchVnode(oldVnode, vnode) {
  // 1. 节点改变直接替换
  if (!isSameVnode(oldVnode, vnode)) {
    // 如果顶层节点不能复用,不用进行 diff 算法比对,newVnode -> newDom -> 替换旧 DOM
    return oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el)
  }

  // 复用 el
  let el = vnode.el = oldVnode.el;

  // 2. 文本节点直接替换(tag 为 undefined)
  if (!oldVnode.tag) {
    // 因为走到这里说明新旧节点相等(一个为 undefined,另一个也是),不然在 1 就被替换啦,所以这里判断一个即可
    if (oldVnode.text !== vnode.text) {
      return oldVnode.el.textContent = vnode.text;
    }
  }

  // 3. 相同节点,对比 & 更新属性
  updateProperties(vnode, oldVnode.props);

  // 比对完外部标签后,该进行儿子的比对了
  //  @1 两方都有儿子
  //  @2 一方有儿子,一方没儿子
  //  @3 两方都是文本的
  let oldChildren = oldVnode.children || [];
  let newChildren = vnode.children || [];

  if (oldChildren.length > 0 && newChildren.length > 0) {
    // 两方都有儿子(可能是文本哦),开始进行 diff 比对
    updateChildren(el, oldChildren, newChildren);
  } else if (oldChildren.length > 0) {
    // 老节点有儿子,新节点没儿子,给复用的节点干掉子节点
    el.innerHTML = '';
  } else if (newChildren.length > 0) {
    // 新节点有儿子,老节点没儿子, 给复用的节点创建儿子
    newChildren.forEach(child => el.appendChild(createElm(child)));
  }
}

// 比较儿子节点,diff 算法的核心
function updateChildren(el, oldChildren, newChildren) {
  // vue2 对常见dom的操作做了一些优化
  // push shift unshift pop reserver sort api经常被用到,我们就考虑对这些特殊的情况做一些优化
  // 内部采用了双指针的方式
  let oldStartIndex = 0;
  let newStartIndex = 0;
  let oldEndIndex = oldChildren.length - 1;
  let newEndIndex = newChildren.length - 1; // 索引 
  // 当前虚拟节点
  let oldStartVnode = oldChildren[oldStartIndex];
  let newStartVnode = newChildren[newStartIndex];
  let oldEndVnode = oldChildren[oldEndIndex];
  let newEndVnode = newChildren[newEndIndex] // 虚拟节点

  function makeIndexByKey(oldChildren) {
    let map = {};

    oldChildren.forEach((item, index) => {
      map[item.key] = index;
    });

    return map;
  }

  // 将旧 children 做出映射表, ABCD -> {A: 0, B: 1, C: 2, D: 3}
  let map = makeIndexByKey(oldChildren);

  // 从头部开始比,比对结束后移动指针,一方遍历结束 O(n) 的遍历
  while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    if (!oldStartVnode) {
      // 防止指针在移动的时候 oldChildren 中的那一项已经被移动走了(重置为 null),则直接跳过 
      // 比如 ABCDEF -> BDEMP,当遍历到新列表中 D 时候,就把 ABCDEF -> ABC null EF 了。
      // 那么老列表遍历到 null 要直接跳到下一个!
      // 同样后面元素往前移动也需要跳过去(比如移动到某节点前),如果该节点为null,不处理
      oldStartVnode = oldChildren[++oldStartIndex]
    } else if (!oldEndVnode) {
      // 防止指针在移动的时候 oldChildren 中的那一项已经被移动走了(重置为 null),则直接跳过
      oldEndVnode = oldChildren[--oldEndIndex]
    } else if (isSameVnode(oldStartVnode, newStartVnode)) {
      // 旧 children 头节点相同,继续比头节点

      // 相同节点直接递归 diff 比对 + 节点更新,标签一致比属性,属性比完比儿子
      patchVnode(oldStartVnode, newStartVnode);

      // 移动指针,继续遍历
      oldStartVnode = oldChildren[++oldStartIndex];
      newStartVnode = newChildren[++newStartIndex];
    } else if (isSameVnode(oldEndVnode, newEndVnode)) {
      // 新旧 children 头节点不同,尾结点相同

      // 节点 diff
      patchVnode(oldEndVnode, newEndVnode);

      // 修改下标,继续遍历
      oldEndVnode = oldChildren[--oldEndIndex];
      newEndVnode = newChildren[--newEndIndex];
    } else if (isSameVnode(oldStartVnode, newEndVnode)) {
      // 头和头,尾和尾都不同,旧 children 头和新 children 尾相同,头真实 dom 移到尾部

      // 节点 diff
      patchVnode(oldStartVnode, newEndVnode);

      // 元素移位,移动到旧 children 末尾的后面
      el.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling);

      // 更新旧 children 的开始节点为下一个,且进行指针移动
      oldStartVnode = oldChildren[++oldStartIndex];
      // 更新新 children 的尾节点为前一个,且进行指针移动
      newEndVnode = newChildren[--newEndIndex];
    } else if (isSameVnode(oldEndVnode, newStartVnode)) {
      // 头和头,尾和尾都不同,旧 children 尾和新 children 头相同,尾移头

      // 节点 diff
      patchVnode(oldEndVnode, newStartVnode);

      // 元素移位,移动到旧 children 开始节点的前面
      el.insertBefore(oldEndVnode.el, oldStartVnode.el);

      // 更新旧 children 的尾节点为前一个,且进行指针移动
      oldEndVnode = oldChildren[--oldEndIndex];
      // 更新新 children 的尾节点为前一个,且进行指针移动
      newStartVnode = newChildren[++newStartIndex];
    } else { // 搞完四种特殊场景的优化后,我们需要来最复杂的乱序比对啦
      // 乱序比对是指,以上四种情况都不满足,头头,头尾互相都不等

      // 拿到当前新 children 中节点的 key 去 map 中寻找,找到代表需要进行真实 dom 移位
      // 当然,列表上也要移位,原位置补 null,为了保下标
      let moveIndex = map[newStartVnode.key];

      if (moveIndex === undefined) { // 老列表不存在,创建 dom 节点,插入到 oldStartIndex 前面
        el.insertBefore(createElm(newStartVnode), oldStartVnode.el);
      } else {  // 比较并移动
        let moveVnode = oldChildren[moveIndex]; // 获取要移动的虚拟节点

        // 能复用就要比对,更新属性和子节点等~
        patchVnode(moveVnode, newStartVnode);
        el.insertBefore(moveVnode.el, oldStartVnode.el); // 将更新过的真实节点移动出来
        oldChildren[moveIndex] = null; // 列表上把移走的元素置 null
      }

      // 移动到下一个节点做比对,可以理解成这里面不停遍历新列表
      newStartVnode = newChildren[++newStartIndex];
    }
  }

  // 乱序比对完成,把剩余没操作的元素干掉
  if (oldStartIndex <= oldEndIndex) {
    for (let i = oldStartIndex; i <= oldEndIndex; i++) {
      let child = oldChildren[i];

      if (child !== null) {
        el.removeChild(child.el); // 移除老的中心的不需要的元素
      }
    }
  }

  // 针对节点不同的更新情况~
  // 如果是后面或前面追加元素
  //   @1 比如 ABCD -> ABCDEF,需要把尾部新增元素插入
  //   @2 比如 EABCD -> ABCD,需要把头部新增元素插入
  // 具体做法是取尾指针的下一个元素
  //   @2 如果没值,说明尾指针在末尾(后追加元素),对整个列表进行元素追加即可
  //   @1 如果有值,说明尾指针在前面了(前追加元素),做下个节点的前插入
  if (newStartIndex <= newEndIndex) {
    for (let i = newStartIndex; i <= newEndIndex; i++) {
      // 尾指针的下一个元素!也是参考物,有就是插入,没有就是追加
      let anchor = newChildren[newEndIndex + 1] == null ? null : newChildren[newEndIndex + 1].el;

      el.insertBefore(createElm(newChildren[i]), anchor);
    }
  }
}

// 相同节点,对比 & 更新属性,oldProps 首次渲染不存在
function updateProperties(vnode, oldProps = {}) { // oldProps 可能不存在,如果存在就表示更新
  let newProps = vnode.props || {}; // 获取新的属性
  let el = vnode.el;
  // 比较前后属性是否一致 老的有新的没有,将老的删除掉,
  // 如果新的有 老的 也有,以新的为准
  // 如果新的有老的没有,直接替换成新的
  let oldStyle = oldProps.style || {}; // 如果前后都是样式
  let newStyle = newProps.style || {};
  for (let key in oldStyle) {
    if (!(key in newStyle)) { // 老的有的属性 但是新的没有,我就将他移除掉 
      el.style[key] = ''
    }
  }
  for (let key in oldProps) {
    if (!(key in newProps)) { // 老的有的属性 但是新的没有,我就将他移除掉 
      el.removeAttribute(key)
    }
  }
  for (let key in newProps) { // 以新的为准
    if (key == 'style') {
      for (let styleName in newStyle) {
        el.style[styleName] = newStyle[styleName]; // 对样式的特殊处理
      }
    } else {
      el.setAttribute(key, newProps[key]);
    }
  }
}

export function createElm(vnode) {
  const { tag, props, children, text } = vnode;
  if (typeof tag == 'string') {
    vnode.el = document.createElement(tag); // 把创建的真实dom和虚拟dom映射在一起方便后续更新和复用
    updateProperties(vnode); // 处理样式
    children && children.forEach(child => {
      vnode.el.appendChild(createElm(child))
    });
    // 样式稍后处理  diff算法的时候需要比较新老的属性进行更新??????
  } else {
    vnode.el = document.createTextNode(text);
  }
  return vnode.el;
}


diff 更新流程补全

我们前面把 diff 算法给撸了,但是用的时候肯定不能写在 src/index.js 中呀,我们希望写到 html 中!

改写 examples/6.diff.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>diff</title>
</head>
<body>
  <div id="app">
    <h1>
      <span>{{ name }}</span>
    </h1>
  </div>
  <script src="../dist/vue.js"></script>
  <script>
    let vm = new Vue({
      el: '#app',
      data: {
        name: 'ys'
      }
    })

    setTimeout(() => {
      // 这一步,我们期望只更新 span 内文本,而 h1 和 span 都进行标签复用。
      vm.name = 'sy 加油';
    }, 1000);
  </script>
</body>
</html>

对 _update 函数做更改,记录 preVnode,更新时传入两个 vnode。

Vue.prototype._update = function (vnode) { // 虚拟dom变成真实dom进行渲染的,后续更新也调用此方法
  const vm = this;
  let preVnode = vm._vnode; // 上一次的虚拟节点
 
  vm._vnode = vnode; // vm 上挂载旧 vnode

  if (!preVnode) {
    // 首次渲染,传入一个真实的 dom 和 vnode
    this.$el = patch(this.$el, vnode);
  } else {
    console.log(preVnode);
    // 非首次渲染,传入两个 vnode,进行 dom diff
    this.$el = patch(preVnode, vnode);
  }
}

当用户更新时,会执行 watcher.run,该方法调用了 watcher.get,而 get 方法又调用了 getter 方法,getter 方法就是 vm._update(vm._render()) 了,可以看出,当用户更新数据时,我们重新调用 render 生成了虚拟 dom,并交给 patch 做 dom diff,最后更新,并没有重新去生成 AST 对象和 render 函数(因为 render 函数包含了所有的 dom 情况,哪怕 v-if="false")

一道略显基础的面试题

Vue2 组件

概念和使用

Vue 组件的好处是什么呢?优雅?好维护?可复用?其实都不是,它最大的好处是组件级更新,不会说动一个节点或数据,整个页面全刷新了,当然也不能盲目拆组件,拆的越细,watcher 就越多。

组件有三大特性。属性,样式和插槽。

我们知道,组件分为全局组件(不用在组件内部注册)和局部组件,Vue 中全局的东西都放在了 Vue.options 上面,全局组件是放在 Vue.options.components 上,

// 全局组件定义在实例的 __proto__(vm.__proto__)
Vue.component('my-button', { 
  template: `<button>我的按钮</button>`
});

// 局部组件定义在自己身上(vm)
let vm = new Vue({
  component: { 
    'my-button': { 
      template: `<button>内部按钮</button>`
    }
  },
  el: '#app',
  data: {
    name: 'ys'
  }
})

Vue.component 内部会调用 Vue.extend 方法,该方法返回一个用于生成子组件实例的子类!

组件渲染在整体渲染中的宏观流程

1. 修改 src/initGlobalAPI.js, 提供全局组件注册的方法 Vue.component 和 生成组件类的方法 Vue.extend

  • Vue.component 方法就干了一件事,把组件配置传递给 Vue.extend(当然这里如果传递的是个已经被 extend 过的子类就不用了),并把返回的子组件的类挂载到全局 options 上的 components 属性上。
  • Vue.extend 创建了一个名为 Sub 的子类,继承了父类原型对象,并且子类内部调用了 _init 方法,其实也就是父类的初始化方法,可以看出,每 new 一个组件,都会进行一次 init 初始化,而且子类有个静态属性 options,它是合并了全局 options 和自己传入的 options,能做到优先在子类中查找组件,找不到去找全局组件,这里需要扩展一下 mergeOptions 方法
code
import { mergeOptions } from "../utils";

export function initGlobalAPI(Vue) {
    Vue.options = {}; // 所有的全局属性 都会放到这个变量上
    Vue.mixin = function(options) { // 用户要合并的对象
        this.options = mergeOptions(this.options, options)
    }

    Vue.options.components = {}; // 放的是全局组件


    /**
     * 创建全局组件的 api
     * @param {*} id  组件名,比如 'card-list'
     * @param {*} componentDef 组件定义,如果为对象,可能有 name,props,template 属性
     */
    Vue.component = function(id, componentDef) {
      componentDef.name = componentDef.name || id; 

      // 可以看出来,componentDef 会被传入 Vue.extend 方法,并返回一个类
      componentDef = this.extend(componentDef);

      // 把组件的类挂到了全局的 Vue.options.components 上
      this.options.components[id] = componentDef;
    }

    // 组件的核心方法,返回一个子类
    Vue.extend = function (options) {
      const Super = this; // 父类
      const Sub = function vueComponent(opts) {  // 子类
        this._init(opts); // 和 new Vue 一样,进行初始化流程
      }

      // 子类继承父类原型方法
      Sub.prototype = Object.create(Super.prototype);
      Sub.prototype.constructor = Sub;
      // 合并 Vue 全局选项和子类初始化选项,把全局挂自己身上
      // 那么可以实现子类中找不到组件定义,可以去找父亲的
      Sub.options = mergeOptions(this.options, options);

      return Sub;
    };
}



2. 修改 src/utils.js,增加局部组件配置和全局组件配置的父子关系

  • 增加 options 属性名为 components 的策略,解析组件时,会做一层代理,先找自己身上的 options.components,否则去找 Vue.options.components。
code
// 组件 options 和 全局 Vue.options 的合并策略,类似数组改写方法,先自己,再原型链
strats.components = function (parentVal, childVal) { // 组件的合并策略
  const obj = Object.create(parentVal); // obj.__proto__  = parentVal;
  if (childVal) {
    for (let key in childVal) {
      obj[key] = childVal[key];
    }
  }
  return obj;
}


3. 修改 src/vdom/index.js 中的 crateElement 方法,原生节点和组件节点转 vnode 的不同处理

  • 如果组件配置传递的是对象 obj,而不是 Vue.extend(obj) 返回的子类,这里要调用 Vue.extend 去把注册的子组件对象配置转为子组件类
  • compVnode.props 增加 hook 属性,hook 内部提供 init 方法,接收 compVnode 参数创建根据子组件类产生组件实例挂载到 compVnode.componentInstance 属性上,然后调用组件实例继承来的挂载方法 mount去挂载到页面,也就是说,一旦compVnode.props.hook.init执行,compVnode上就会多一个组件实例属性,并且会根据组件实例去进行组件挂载(mount 去挂载到页面,也就是说,一旦 compVnode.props.hook.init 执行,compVnode 上就会多一个组件实例属性,并且会根据组件实例去进行组件挂载(mount)
  • 针对组件,createElement 方法最后返回 compVnode,compVnode props 上携带了生成组件实例并且并执行组件挂载的逻辑, vnode 方法增加 componentOptions 参数。
code
const isResrved = tag => { // 判断是否为原生标签
  return ['a', 'div', 'p', 'span', 'ul', 'li', 'button', 'input', 'h1'].includes(tag);
}

function createComponent(vm, tag, props, children, Ctor) {
  if (typeof Ctor == 'object') {
    // 调用 extend,将对象的组件选校继续转为子类
    Ctor = vm.constructor.extend(Ctor); 
  }

  // 专门用来初始化组件的,组件的虚拟节点上还有一个 components,也就是 { Ctor, children }
  props.hook = {
    init(compVnode) { 
      // 创建组件实例
      let child = compVnode.componentInstance = new compVnode.componentOptions.Ctor({});

      // 内部会产生一个真实节点(patch),挂载到了 child.$el 和 
      // compVnode.componentInstance.$el
      child.$mount(); // 如果没传挂载目标,将组件挂载后的结果放到 $el 属性上
    }
  }

  return vnode(vm, `vue-componet-${ tag }`, props, undefined, undefined, props.key, { Ctor, children });
}

export function createElement(vm, tag, props = {}, children) {
  if (isResrved(tag)) { 
    // 原生标签
    return vnode(vm, tag, props, children, undefined, props.key);
  } else {
    // 根据当前组件的模板,它有两种可能的值,
    //  @1 一种是传入对象 { template: '<button>内部按钮</button>' },我们需要调用 
    //     extend 额外转为当前页面的子类
    //  @2 一种是传入类,我们就不需要处理转为子类了,比如 
    //    { 'my-button': Vue.extend({ template: '<button>内部按钮</button>' })}
    const Ctor = vm.$options['components'][tag];

    // 根据组件配置或组件类 生成 vnode
    return createComponent(vm, tag, props, children, Ctor);
  }
}

function vnode(vm, tag, props, children, text, key, componentOptions) {
  return {
    vm,
    tag,
    props,
    children,
    text,
    key,
    componentOptions
  }
}


4. 修改 src/vdom/patch.js 中生成真实节点的 createElm 方法,针对组件虚拟节点不直接渲染真实 dom 而是进行组件的初始化和挂载后,再返回组件的 $el

  • 如果是组件 vnode,则要进行(继承来的 _init) & 挂载(继承来的 $mount),其实类似一个递归了!这也是为什么父组件要等待子页面挂载完才会挂载。
  • mount挂载方法流程也需要更改,针对组件实例调用的mount 挂载方法流程也需要更改,针对组件实例调用的 mount,最后根据组件 render 直接生成 dom,挂载到组件实例的 el(this.el 上(this.el = patch()),这一步执行完毕后,createElm 中返回组件的真实节点
code
function createComponent(vnode){
  let i = vnode.props;
  if ((i = i.hook) && (i = i.init)){ // 组件有init方法 那就调用init
      i(vnode); // new Ctor().$mount(),并把 componentInstance 挂载到 vnode 上
  }

  if (vnode.componentInstance){ // vnode上有componentInstance 说明是组件的实例
      return true; // 是组件
  }
  return false;
}

export function createElm(vnode) {
  const { tag, props, children, text } = vnode;
  if (typeof tag == 'string') {
    if (createComponent(vnode)) { // 组件渲染
      console.log('组件渲染!!', vnode);
      // createComponent 中组件的 init 方法执行,会调用 _update
      // 生成真实节点挂载到组件实例的 $el 上 
      return vnode.componentInstance.$el;
    } else { // 正经元素
      vnode.el = document.createElement(tag); // 把创建的真实dom和虚拟dom映射在一起方便后续更新和复用
      updateProperties(vnode); // 处理样式
      children && children.forEach(child => {
        vnode.el.appendChild(createElm(child))
      });
    }
  } else {
    vnode.el = document.createTextNode(text);
  }
  return vnode.el;
}


组件渲染流程

针对组件初始化(_init)和组件挂载($mount)部分,我们这里继续展开。

组件初始化,因为整体走了 _init,相当于把 new 一个组件等于把应用重新初始化了一遍,所以组件有自己的 data,生命周期,watcher 等,也实现了数据的劫持,这个就不多做赘述。

组件挂载 mount,注意,我们调用组件实例的mount,注意,我们调用组件实例的 mount 方法并没有传入任何参数,这表示着,如果 $mount 无参,就是组件的挂载~

1. 修改 src/lifecycle.js 中 mountComponent 方法,填个坑,增加 mounted 回调~

code
export function mountComponent(vm, el) {
  // vue3 里面靠的是产生一个effect, vue2中靠的是watcher
  let updateComponent = () => {
    // 1.产生虚拟节点 2.根据虚拟节点产生真实节点
    vm._update(vm._render());
  }
  new Watcher(vm, updateComponent, () => {
    callHook('beforeUpdate')
  }); // 渲染是通过watcher来进行渲染的

  callHook(vm, 'mounted')
}


2. 更改 src/vdom/patch.js 中 patch 方法,如果没有传递 el,说明是组件 vnode 节点,直接渲染成真实 dom 返回即可~

code
export function patch(oldVnode, vnode) {
  if (!oldVnode) {
    // 组件初次渲染是没有 el 的, 直接生成真实节点即可
    return createElm(vnode);
  }
  // ...
}


测试代码
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>组件渲染</title>
</head>

<body>
  <div id="app">
    <h1>
      <my-button></my-button>
      <my-button></my-button>
      <my-button></my-button>
    </h1>
  </div>
  <script src="../dist/vue.js"></script>
  <!-- <script src="../node_modules/vue/dist/vue.js"></script> -->
  <script>
    Vue.component('my-button', { // 全局组件定义在实例的 __proto__(vm.__proto__)
      data() {
        return { a: 'wow' }
      },
      template: `<button>我的按钮 {{ a }}</button>`
    });

    let vm = new Vue({
      beforeCreate() {
        console.log('父 beforeCreate');
      },
      components: {
        'my-button': { // 局部组件定义在自己身上(vm)
          data() {
            return { b: 'nice' }
          },
          beforeCreate() {
            console.log('子 beforeCreate');
          },
          template: `<button>内部按钮{{ b }}</button>`,
          mounted() {
            console.log('子 mounted');
          },
        }
      },
      mounted() {
        console.log('父 mounted');
      },
      el: '#app',
      data: {
        name: 'yss'
      }
    })
  </script>
</body>

</html>


Vue2 计算属性 computed

我们知道,计算属性有缓存计算结果的作用,并且依赖属性改变后,也能触发计算属性重新计算渲染页面。

计算属性的使用

新建 examples/9.computed.html

<!DOCTYPE html>
<html lang="en">
<body>
  <div id="app">
    {{ firstName }}
  </div>
  <script src="../dist/vue.js"></script>
  <script>
    let vm = new Vue({
      el: '#app',
      data: {
        name: 'ys',
        age: 12
      },
      computed: {
        // 方式一
        firstName() {
          return this.name + this.age;
        }

        // 方式二,我们这里不去实现它,比较不常用
        // firstName: {
        //   get() {
        //      return this.name + this.age;
        //   },
        //   set() {

        //   }
        // }
      }
    })

    setTimeout(() => {
      vm.name = '杨帅';
    }, '1000');
  </script>
</body>

</html>

计算属性实现

不说了,都在图里,这个里面比较绕,烧脑!

1. 修改 src/state.js, 增加计算属性初始化方法

  • 初始化时根据 computed 配置中每个 key 创建一个 computedWatcher,并且对 vm[key] 进行代码,在页面使用计算属性的结果的时候,如果 computedWatcher.dirty 为 true,去重新计算,否则,取上次的计算结果。
  • 页面渲染时,渲染 watcher 进栈,stack 为 ["渲染watcher"],对计算属性进行取值操作,vm.firstName 被代理了,会去调用 computedWatcher.get 的执行,该方法第一行继续收集 computedWatcher, 此时 stack 为 ["渲染watcher", computedWatcher],计算属性 watcher 执行完毕后出栈,如果此时栈顶元素不为 null,说明是该计算属性的渲染 watcher,使 name 和 age 的 dep 去收集渲染 watcher,当二者改变后,就能先调用计算属性 watcher,再调用渲染 watcher 了
code
import Dep from "./observer/dep.js";
import { observe } from "./observer/index.js"; // rollup-plugin-rsolve
import Watcher from "./observer/watcher.js";

export function initState(vm) {
    // ...
    if (options.computed) {
        // 计算属性的初始化
        initComputed(vm)
    }
}


function defineComputed(target, key, fn) { // vm, firstname, 用户的 getter 函数
  Object.defineProperty(target, key, {
    get() {
      const watcher = target._computedWatchers[key];

      // 执行用户计算函数,如果计算函数内属性改变的话
      // 变量改变触发的 computedWatcher.update 时,会把 dirty 置为 false
      if (watcher && watcher.dirty) { // dirty 为 true 代表需要重新计算
        watcher.evaluate();
      }

      // 用户计算函数完毕后,计算属性 watcher 出栈
      // 如果 stack 中还有 watcher,那么必定是计算属性对应的渲染 watcher
      if (Dep.target) { 
        // 让 name 和 age 的 dep 收集渲染 watcher
        watcher.depend() 
      }

      // 返回实例上的值,是不是重新计算的得看 dirty 是否为 true
      return watcher.value; 
    }
  });
}

function initComputed(vm) {
  const computed = vm.$options.computed;
  const watchers = vm._computedWatchers = {}; // 存储计算属性的所以 watcher 到实例

  // 每一个计算属性都是一个 watcher
  for (let key in computed) {
    let userDef = computed[key]; // 用户指定的 getter 方法

    // exprOrFn 传入的是用户指定的 getter 方法,但是它需要默认不执行(lazy),取值时候(页面用到)才执行 
    watchers[key] = new Watcher(vm, userDef, () => {}, { lazy: true });

    // 给当前的实例上定义一个key,值就是该 key 的 getter 方法
    // 用户取值,直接执行 getter
    defineComputed(vm, key, userDef);
  }
}

// ...


2. 修改 src/observer/watcher.js get 方法中临时记录当前 watcher 的实现~

  • 增加 watcher.lazy 属性,作用是首次不调用 get 方法,增加 dirty 属性,作用是计算属性 watcher 触发更新时,是否重新计算。
  • 修改 watcher.get 方法,使用栈结构记录当前 watcher,以便找到与计算属性对应的渲染 watcher。
  • 增加 watcher.evaluate,用于计算属性重新计算值,计算完毕后,更改 dirty 为 false,也就是下次不用计算。
  • 修改 watcher.update 方法,如果是计算属性 watcher 的更新,只做 dirty = true 操作即可,为接下来的 渲染 watcher 做铺垫。
  • 增加 watcher.depend 方法,拥有当前组件 watcher 上收集的所有属性的 dep,都去收集当前 watcher,实际为了记录渲染 watcher。
code
let id = 0;
import Dep, { popTarget, pushTarget } from './dep';
class Watcher {
    // 用户的回调 是用户的函数  
    // exprOrFn: 可能是个表达式(计算属性)或者更新的函数(vm._update(vm._render()))或字符串(watch 创建的 watcher)
    // vm 是当前的实例  
    // options 就是参数列表
    constructor(vm,exprOrFn,callback,options = {}) {
        this.deps = []; // watcher对应存放的dep
        this.id = id++;
        if(typeof exprOrFn == 'function'){
            this.getter = exprOrFn; // 将用户传入的fn 保存在getter上
        }else{
            this.getter = () => vm[exprOrFn] // 取值的时候会收集watcher
        }
        this.depsId = new Set(); // 去重
        this.lazy = options.lazy; // lazy 为 true,则不要直接调用 get 方法,计算属性需要页面用到才执行
        this.dirty = this.lazy; // 计算属性默认 dirty 是 true, 为 true 代表需要重新计算
        this.vm = vm;
        this.value = this.lazy ? undefined : this.get();
        // this.value 就是老的值
        this.callback = callback;
        this.options = options;
    }
    get() {
        // Dep.target = this; // 将watcher暴露到全局变量上
        pushTarget(this);
        // 第一次渲染会默认调用getter  getter 可能为计算属性的表达式或者更新函数 vm._update(vm._render())
        let value = this.getter.call(this.vm); 
        // Dep.target = null;
        popTarget(this);
        return value
    }
    evaluate() {
      this.value = this.get();
      this.dirty = false
    }
    addDep(dep) {
        let id = dep.id;
        if (!this.depsId.has(id)) {
            this.depsId.add(id);
            this.deps.push(dep); // 让watcher记住dep
            dep.addSub(this)
        } 
    }
    update() {
        console.log('update')
        if (this.lazy) { 
          console.log('触发计算属性 watcher');
          // 如果依赖属性变化,我就让计算属性的 watcher dirty 变为 true
          // 这样下次取值就能进入到 evaluate 中重新计算。
          this.dirty = true;
        } else {
          console.log('触发渲染 watcher');
          queueWatcher(this);
        }
    }
    depend() {
      // this.deps 就是当前 watcher 实例对应的 deps
      // 计算属性 watcher 中收集着 name, age 二者的 dep
      let l = this.deps.length; 

      while(l--) { 
        // 调用 dep.depend,收集当前 stack 中的 watcher,
        // 当前 stack 中只有 [渲染 watcher],所以 name,age 二者的 dep 收集了渲染 watcher
        this.deps[l].depend(); 
      }
    }
    run() { // 真实的执行
        let newValue = this.get();
        let oldValue = this.value;
        this.value = newValue;
        if(this.options.user){
            this.callback(newValue,oldValue)
        }

    }
}
let watchsId = new Set();
let queue = [];
let pending = false;
function flushShedulerQueue() {
    for (let i = 0; i < queue.length; i++) {
        let watcher = queue[i];
        watcher.run();
    }
    queue = [];
    watchsId.clear();
    pending = false;
}

function queueWatcher(watcher) {
    const id = watcher.id; // 取出watcher的id 
    if (!watchsId.has(id)) { // 看一下这里有没有这个watcher
        watchsId.add(id); // 如果没有添加watcher到更新队列中
        queue.push(watcher) // 放到队列中
        if (!pending) {
            // vue2 里面要考虑兼容性 vue2里面会优先采用promise但是ie不支持promise 需要降级成 mutationObserver h5提供的一个方法
            // setImmediate 这个方法在ie中性能是比较好的,都不兼容fallback -> setTimeout
            Promise.resolve().then(flushShedulerQueue);
            pending = true
        }
    }
}
export default Watcher


3. 修改 src/observer/dep.js 增加全局 stack,提供 pushTarget,popTarget 方法~

code
let stack = []; // 全局的 stack,收集每一个 watcher

export function pushTarget (watcher) {
  Dep.target = watcher; // 记录当前的 watcher
  stack.push(watcher);
}

export function popTarget () {
  stack.pop();
  Dep.target = stack[stack.length - 1];
}


ok,测试代码如下

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

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>计算属性</title>
</head>

<body>
  <div id="app">
    {{ firstName }}
  </div>
  <script src="../dist/vue.js"></script>
  <!-- <script src="../node_modules/vue/dist/vue.js"></script> -->
  <script>
  

    let vm = new Vue({
      el: '#app',
      data: {
        name: 'ys',
        age: 12
      },
      computed: {
        firstName() {
          console.log('用户方法执行')
          return this.name + this.age;
        }
      }
    })

    setTimeout(() => {
      vm.name = '杨帅';
    }, '1000');
  </script>
</body>

</html>