一文搞懂Vue2源码实现原理~ 手写Vue2系列~

1,599 阅读31分钟

前言

本文是通过优秀作者【李永宁】得文章和视频学习得,内容或许大致与作者文章一样,因为是学习他,所以如果您对于vue的源码及其理论知识已经有一定的了解,建议直接阅读【李永宁】作者的【手写vue系列】

Vue3的生态已经成熟, 其API组合语法糖让我放弃了继续使用Vue2的念头,但是随着写了那么多的V3项目, 让我好奇的不再是V3还有那些我未使用过的API?而是wc, 这个nb,他是咋实现的啊,赶紧学习一下,抱着这个态度,我决定先学习一下V2的源码技术,因为V3是支持V2的写法的,所以先学习V2以后在学习V3会事半功倍,话不多说,直接开整

GITEE项目地址 下的手写VUE2源码系列

通读顺序原理顺序

根据源码技术的实现,大概顺序为如下几个入口,先了解一下它们分别主要负责功能

main.js 调用入口 通过new Vue()来使用的文件
index.js 构建入口 创建Vue的方法和做一些逻辑层处理
initData.js 挂载响应式数据入口 挂载响应式数据到Vue实例上
initMethodes.js 挂载方法入口 挂载Methdos方法到Vue实例上
initComputed.js 挂载计算属性入口 挂载Computed方法到Vue实例上
renderHelper.js 安装运行时的渲染帮助函数 比如 _c_v_t,这些函数会生成 Vnode
patch.js 主要用于根据生成的AST数据生成真实的DOM节点,也是diff算法的核心文件
mount.js 渲染文件 主要用于将template模板转换为AST对象
asyncUpdateQueue.js 异步队列,按序渲染Watcher

大致了解完以后,接下来逐步实现一个自己的Vue2框架,学习建议,根据GITEE里的代码一起学习,会更加容易理解~

new Vue

在使用Vue框架时,首先要使用的API也就是实例化Vue了, 那么在new Vue的过程中 Vue都干了些什么呢? 注:本文只简单介绍源码构建中基本的原理构建流程

运行流程图

以下流程图为手写Vue2源码项目的流程图,主要是抽取了Vue2源码技术中的核心功能点, 如下(非Vue2全部执行过程)

image.png

现在就开始一起跟着流程图一步一步实现一个Vue核心框架吧~

实例化Vue

实例化 Vue 也就是通过 new Vue() 去实例化一个 Vue 对象并创造一个作用域空间, 在这个作用域空间下能够进行一些 Vue 的特性展示比如: 数据响应式更新vModelvBind等, 有了这些特性, 极大程度的减轻了开发者的繁琐流程

main.js

首先先大致的列举出使用Vue时, 常用的语法

  • el 挂载的作用域空间, Vue必须有一个最上层的Root根节点来表示作用于空间
  • data 声明响应式, 该属性有两种数据类型,分别是ObjectFunction 闭包
  • methods 方法, 主要定义一些函数方法
  • components 定义子组件的属性, 此属性下都是一个独立的组件对象
  • computed 计算属性, 用于缓存data下响应式变量的操作, 缓存后如果绑定的data没有发生改变,就不会重新执行
  • slot 插槽, 如果子组件没有children, 那么就显示默认插槽数据

以上属性是重点实现Vue2的一些特征, 像一些其他属性mountedcreatedfilters等均可在实现以上属性后自行实现、其原理都可以在熟悉运用Vue2框架后,实现出来

import Vue from "./index.js";
var vm = new Vue({
  el: "#app",   // 挂载点
  data: {
    name: "yunhe达摩院",
    age: 16,
    school: "郑州大学",
    checked: true,
    selectIndex: 0,
    className: "bgRed",
  },
  // data也可以是闭包
  // data(){  
  //   return {
  //     name: "yunhe达摩院",
  //     age: 16,
  //     school: "郑州大学",
  //     checked: true,
  //     selectIndex: 0,
  //     className: "bgRed",
  //   }
  // },
  methods: {
    printData() {
      this.name = "printData 修改了 name数据";
    },
  },
  // 组件
  components: {
    coms: {
      template: `
      <div>{{scopeSlot}}</div>
      `,
      data() {
        return {
          sonMsg: "son name is lyf",
        };
      },
    },
    // 插槽子组件
    "scope-slot": {
      template: `
      <div>
        <slot name="default" v-bind:slotKey="slotKey">
          <div>插槽默认</div>
        </slot>
      </div>
    `,
      data() {
        return {
          slotKey: "scope slot content",
        };
      },
    },
  },
  computed: {
    getAge() {
      return this.age * 2;
    },
  },
});

Vue 核心处理

在梳理完常用的属性后, 一起来看一下Vue函数的具体实现

import mount from "./compiler/mount.js";  // 渲染变量到Dom文件
import patch from "./compiler/patch.js";  // Diff算法核心文件
import renderHelper from "./compiler/renderHelper.js";  // 渲染器: 虚拟Dom渲染成真实Dom
import initComputed from "./initComputed.js";  // 初始化并挂载计算属性到Vue
import initData from "./initData.js";  // 初始化并挂载data到Vue
import initMethodes from "./initMethodes.js";  // 初始化并挂载方法到Vue
export default function Vue(options) {
  // debugger;
  this._init(options);
}
// _init 初始化Vue的工作流程
Vue.prototype._init = function (options) {
  // 把数据配置挂载到Vue.$options 上
  this.$options = options;
  // 初始数据  (data methods props computed等)
  initData(this);
  // 初始化方法
  initMethodes(this);
  // 初始化计算属性
  initComputed(this);
  // 安装运行时的渲染工具函数
  renderHelper(this);
  // 在实例上安装 patch 函数
  this.__patch__ = patch;
  // 挂在到dom
  if (this.$options.el) {
    this.$mount();
  }
};

// 挂在实例
Vue.prototype.$mount = function () {
  // 挂载到页面上
  mount(this);
};

以上代码我们重点看流程, 到目前为止, 我们需要知道头部的 import xxx from "xx.js"是干什么用的, 因为后续我们要实现它, 这很重要!

在 Vue方法体中接受了一个options, 这个options就是new Vue({})定义的一些属性data、computed、el

export default function Vue(options) {
  // debugger;
  this._init(options);
}

_init方法中, 分别去处理了这几个不同的属性

Vue.prototype._init = function (options) {
  // 把数据配置挂载到Vue.$options上 方便在全局任何地方都可以拿到配置项
  this.$options = options;
  // 初始数据  (data methods props computed等)
  initData(this);
  // 初始化方法
  initMethodes(this);
  // 初始化计算属性
  initComputed(this);
  // 安装运行时的渲染工具函数
  renderHelper(this);
  // 在实例上安装 patch 函数
  this.__patch__ = patch;
  // 挂在到dom
  if (this.$options.el) {
    this.$mount();
  }
};

处理数据层

  1. initData 只处理 data
  2. initMethodes 只处理 methods
  3. initComputed 只处理 computed

处理模板层

  1. $mount 用于根据tempalte模板生成AST对象
  2. renderHelper 用于生成将处理过的AST对象转换为虚拟DOM(VNode: Virtual Node)的方法, 并且还是diff更新算法的核心文件,当数据更新时, 负责对比节点更新
  3. __patch__ 用于根据虚拟DOM生成真实的DOM以及DOM节点的属性等等

处理数据层

initData

initData文件主要负责将data下的数据绑定到Vue实例上, 使他可以通过 this.xxx 进行访问, 并设置响应式拦截

import defineReactive from "./defineReactive.js";
import Dep from "./dep.js";
// 初始化数据 具体的方法实现
function initData(vm) {
  const { data } = vm.$options;
  // data 就是我们在首页 new Vue({data(){return {}}})  的data
  let _data;
  if (data) {
    // 把data的值挂载到vm实例上
    // data 有可能是个闭包方法 所以要判断如果是闭包执行以下获取一下里面的json
    _data = vm._data = typeof data === "function" ? data() : data;
  }
  for (const key in _data) {
    // 把data数据代理到 _data 下 是他支持 this.xxx 调用
    // 原理 当我们调用 this.xxx 时,会把我们的指向为 this._data.xxx
    // props methods computed同理
    proxyDataToVm(vm, "_data", key);
  }
  // 设置完代理以后 给data数据设置响应式更新
  observe(vm._data);
}

// 响应式判断和设置响应式
function observe(data) {
  // 如果数据已经是响应式 就不需要observe进行依赖收集 否则会造成 重复更新指定节点
  if (data.__ob__) return value.__ob__;
  return new Observer(data);
}

function Observer(data) {
  // 给每个数据都加上一个 __ob__ 属性 表示已经处理了响应式拦截和更新
  Object.defineProperty(data, "__ob__", {
    value: this,
    // 数据可枚举
    enumerable: false,
    // 数据可修改
    writable: true,
    // 数据可配置
    configurable: true,
  });
  // console.log("data ==> ", data);
  // 对值进行依赖收集
  data.__ob__.dep = new Dep();
  // 给对象的每一项都设置响应式
  this.walk(data);
}

// 给对象属性设置响应式 (只支持对象)
Observer.prototype.walk = function (obj) {
  for (let key in obj) {
    // 依次给对象设置拦截
    defineReactive(obj, key, obj[key]);
  }
};

// 挂载Data到实例Vm
function proxyDataToVm(target, sourceKey, key) {
  // 代理 当访问指定targert对象时 例:Obj.a 代理到 Obj.data.a
  Object.defineProperty(target, key, {
    get() {
      return target[sourceKey][key];
    },
    set(val) {
      target[sourceKey][key] = val;
    },
  });
}

export default initData;

现在让我们一起来解释一下以上的几个重要部分

  • 获取到Data响应式数据,并绑定到Vue实例上
function initData(vm) {
  const { data } = vm.$options;
  let _data;
  if (data) {
    _data = vm._data = typeof data === "function" ? data() : data;
  }
  for (const key in _data) {
    // 把data数据代理到 _data 下 是他支持 this.xxx 调用
    // 原理 当我们调用 this.xxx 时,会把我们的指向为 this._data.xxx
    // props methods computed同理
    proxyDataToVm(vm, "_data", key);
  }
  // 设置完代理以后 给data数据设置响应式更新
  observe(vm._data);
}

// 挂载Data到实例Vm
function proxyDataToVm(target, sourceKey, key) {
  // 代理 当访问指定targert对象时 例:Obj.a 代理到 Obj.data.a
  Object.defineProperty(target, key, {
    get() {
      return target[sourceKey][key];
    },
    set(val) {
      target[sourceKey][key] = val;
    },
  });
}

上文中说过data有两种类型,所以要判断他的类型并取出它的值

_data = vm._data = typeof data === "function" ? data() : data;

取出data数据以后, 需要让他支持通过 this.xxx 的方式来调用data变量, 所以要把数据全部代理到Vue原型上, 因为this指向的就是Vue示例, 设置完代理以后, 给data添加拦截器, 当调用data时, 我们要知道是谁调用了data, 这对我们后面的响应式更新很有必要

for (const key in _data) {
    // 把data数据代理到 _data 下 是他支持 this.xxx 调用
    // 原理 当我们调用 this.xxx 时,会把我们的指向为 this._data.xxx
    // props methods computed同理
    proxyDataToVm(vm, "_data", key);
  }
} 
// 设置完代理以后 给data数据设置响应式更新
observe(vm._data);

设置数据拦截 响应式中的重点

// 响应式判断和设置响应式
function observe(data) {
  // 需要对数据defineProperty 进行重新观测
  // 只对对象类型进行观测,非对象类型无法观测
  if (typeof data !== "object" || data == null) return;
  // 如果已经被观察过
  if (data.__ob__) return value.__ob__;
  return new Observer(data);
}

function Observer(data) {
  // 给每个数据都加上一个 __ob__ 属性 表示已经处理了响应式拦截和更新
  Object.defineProperty(data, "__ob__", {
    value: this,
    // 数据可枚举
    enumerable: false,
    // 数据可修改
    writable: true,
    // 数据可配置
    configurable: true,
  });
  // console.log("data ==> ", data);
  // 对值进行依赖收集
  data.__ob__.dep = new Dep();
  // 给对象的每一项都设置响应式
  this.walk(data);
}

// 给对象属性设置响应式 (只支持对象)
Observer.prototype.walk = function (obj) {
  for (let key in obj) {
    // 依次给对象设置拦截 
    defineReactive(obj, key, obj[key]);
  }
};

我们对data进行了依赖收集, 并且我们对依赖收集过的对象添加一个 __ob__的属性, 这样当用户 this.xxx.b = this.xxx 将对象赋值给自身属性时, 依赖收集才不会触发死循环

// 响应式判断和设置响应式
function observe(data) {
  // 需要对数据defineProperty 进行重新观测
  // 只对对象类型进行观测,非对象类型无法观测
  if (typeof data !== "object" || data == null) return;
  // 如果已经被观察过
  if (data.__ob__) return value.__ob__;
  return new Observer(data);
}

如果不过滤掉已经依赖收集过的, 当我们依赖收集的时候, 会无限嵌套下去

image.png

data进行依赖收集以后,我们开始处理data的属性,如果发现data下还有Object类型的数据,那么就继续依赖收集它

function Observer(data) {
  // 给每个对象类型的属性都加上一个 __ob__ 属性 表示已经处理了响应式拦截和更新
  Object.defineProperty(data, "__ob__", {
    value: this,
    // 数据可枚举
    enumerable: false,
    // 数据可修改
    writable: true,
    // 数据可配置
    configurable: true,
  }); 
  // 对data对象进行依赖收集
  data.__ob__.dep = new Dep();
  // 给对象的每一项都设置响应式 也就是拦截每一项
  this.walk(data);
}

// 给对象属性设置响应式 (只支持对象)
Observer.prototype.walk = function (obj) {
  for (let key in obj) {
    // 依次给对象设置拦截 如果data下面还有属性是对象
    if (typeof obj[key] === "object") {
      observe(obj[key]);
    } else {
    // 如果不是对象直接拦截
      defineReactive(obj, key, obj[key]);
    }
  }
};
defineReactive拦截器

Object.defineProperty绑定过的对象,会变成响应式化。也就是改变这个对象的时候会触发getset事件。进而触发一些视图更新。

当我们使用 this.x 调用被Object.defineProperty绑定过的对象时,会触发get拦截,我们可以在get拦截中处理我们的监听逻辑,我们使用``

import Dep from "./dep.js";
// 拦截器
export default function defineReactive(target, key, val) {
  const dep = new Dep();
  Object.defineProperty(target, key, {
    get() {
      // Dep.target 指向当前组件的渲染函数
      if (Dep.target) {
        dep.depend();
      }
      return val;
    },
    set(value) {
      // 如果赋值相同就不更新
      if (val === value) return;
      val = value;
      // 不考虑设置的值为对象的情况
      // 通知更新
      dep.notify();
    },
  });
}

上文代码我们对所有的变量进行了拦截,并通过Dep让他成为了发布者, 当被get订阅的时候, 我们会拦截并将订阅者存储到我们当前变量的Dep.watcher中,示例图如下

image.png

修改一下代码, 输出查看拦截的Watcher是否与调用的次数对应,我们主要输出agename, 看看是否每一个变量都被调用了两次

import Vue from "./index.js";
new Vue({
  el: "#app",
  data: {
    name: "张三",
    age: 16,
  },
  computed: {
    getAge() {
      return this.age * 2;
    },
    getName() {
      return this.name + "是个好人";
    },
  },
});

template模板中分别调用datacomputed

  <body>
    <div id="app">
      <div>{{name}}</div>
      <div>{{age}}</div>
      <div>{{getAge}}</div>
      <div>{{getName}}</div>
    </div>
    <script type="module" src="./main.js"></script>
  </body>

defineReactive.js 拦截中输出

export default function defineReactive(target, key, val) {
  const dep = new Dep();
  console.log(`${key} ==> `, dep);
  ...
}

查看浏览器输出,我们发现已经根据指定的key, 将我们的订阅者全部输出了出来了 订阅者分别为:

  • data: 在模板中调用 age 触发data响应式拦截收集的 Watcher
  • computed: 在模板中调用getAge触发计算属性拦截的watcher, 并将dirty设置为false, 后续继续读取不会再执行

image.png

当我们的data更新时, 会触发dep.notify, dep.notify就是去遍历调用我们订阅者watcher的回调函数,由图可知,当我们修改name时, 会触发{{name}} 模板调用computed 计算属性的更新 我们在浏览器修改一下name, 查看触发的watcher更新是否是我们订阅的两个watcher

defineReactive.js

    // set 部门添加 console.log(`更新的dep ===> `, dep.watcher);
    set(value) {
      if (val === value) return;
      val = value;
      console.log(`更新的dep ===> `, dep.watcher);
      debugger;
      dep.notify();
    },
  });

当我们修改name的时候, 触发了nameset拦截, 修改name数据后, 我们调用dep.notify方法通知订阅namewatcher逐个更新。 示例输出结果很明显, 触发了图中两个订阅者 Watcher的更新

image.png

以上介绍完拦截器以后, 来重点分析一下上文中提到的Dep究竟干了什么事情,为什么对我们做到响应式变量更新那么重要!

Dep 发布者

当在渲染层使用data变量时候,会触发data变量的defineReactiveget拦截而进行依赖收集, 当data变量发生改变, Dep会去通知订阅者watcher更新状态

Dep说白了他唯一的作用就是通知更新, 在观察者模式下, 将发布者订阅者通过某种手段连接起来,当发布者发布新状态时, 订阅者能都做到及时的响应, 这就是响应式的原理核心

export default class Dep {
  constructor() {
    this.watcher = [];
  }
  static target = null;

  depend() {
    this.watcher.push(Dep.target);
  }

  // 通知依赖 watcher 更新
  notify() {
    this.watcher.forEach((sub) => {
      sub.update();
    });
  }
}

继续参考一下上文中的示例图

image.png

Dep类就是一个很明显的发布者类, 订阅者通过depend来完成订阅, 当发生变化时, 通过 notify 方法, 通知更新状态

depend() {
  this.watcher.push(Dep.target);
}

// 通知依赖 watcher 更新
notify() {
this.watcher.forEach((sub) => {
  sub.update();
});

重点: 一个data变量可以被多次订阅, 例如:模板层调用计算属性调用过滤器调用等, 当data属性发生改变, 会通知和这个data绑定的watcher更新, 不会影响其他的变量执行更新

Watcher 订阅者

上文说过Dep发布者, watcher订阅者, 如果只有发布者, 没有订阅者, 那就好比拿着喇叭在沙漠里吆喝买菜:啊~ 五块五块~ 全场五块~ 但是根本无人问津~
Watcher就是负责根据发布者的通知执行回调, 它就好比一个打工人任人使唤~~

import Dep from "./dep.js"
class Watcher {
    // cb 是修改指定dom的闭包回调,只修改某一个
    constructor(cb) {
        // 当在视图层面调用 new Wathcer 时, 把当前的节点赋值给 target
        // console.log('this ===> ', this);
        Dep.target = this
        this._cb = cb
        // 执行以下回调修改参数
        this._cb()
        // 处理完毕后 要制空 否则会阻断后面的变量
        Dep.target = null
    }
    // 执行更新
    update() {
        this._cb()
    }
}

export default Watcher

当前展示Watcher暂不支持Computed计算属性缓存, 在initComputed模块中会有详细介绍

在当前Watcher中, 有一个update方法, 用来执行this._cb方法, 这个方法就是我们在new Watcher(cb)时传进来的回调函数, 也就是我们想让它做什么事情

例如:在Vue1当中, 我们通过缓存修改Dom节点回调来实现响应式更新

function compuleTextNode(node, vm) {
  // 获取匹配的结果
  // 替换dom得值
  // 写一个回调 专门修改当前这个节点的数据
  // 缓存content 因为cb替换后就没有了
  const contentCache = node.textContent;
  function cb() {
    // 取出 {{ 引用的变量 }}
    const braceRegx = /({{.*?}})/g;
    // 可变动的
    let TContent = contentCache;
    const getBrace = TContent.match(braceRegx);
    for (const keyItem in getBrace) {
      // 获取每一项 {{name}}  or {{age}}
      const keyBrace = getBrace[keyItem].replace(/\s*/g, "");
      // 获取花括号下的key
      const keyData = keyBrace.match(/{{(.*?)}}/);
      // 替换成真实的数据
      TContent = TContent.replace(getBrace[keyItem], vm[keyData[1]]);
    }
    node.textContent = TContent || `${key} 未定义`;
  }
  // 订阅后即可支持动态修改
  new Watcher(cb);
}

上文中我们将替换变量模板字符串封装成了一个方法, 传递给了Watcher, 当我们模板的值更新时, 告诉Watcher执行一下(也就是update)这个回调函数就可以完成模板的更新.

initMethodes

对于methods初始化我没有做过多的处理,这里仅仅只将他绑定到vm实例上,感兴趣的同学可以将项目clone下来后自行补充

export default function initMethodes(vm) {
  const methods = vm.$options.methods || {};
  //   console.log("methods ==> ", methods);
  // 挂载到 $vm 上 是他可以通过  this.xxx() 调用
  for (const key in methods) {
    proxyMethdosToVm(vm, methods, key);
  }
}

// 代理方法到Vm实例上
function proxyMethdosToVm(target, sourceKey, key) {
  Object.defineProperty(target, key, {
    get() {
      return target[sourceKey][key];
    },
    set(val) {
      console.log("methdos is not set function");
    },
  });
}

initComputed

如果一定要我在Vue中选出一个最实用的API, 那么我一定选择 computed 计算属性

先来回顾计算属性的特征

  • 缓存结果:只有依赖项更新的时候才会触发重新计算,否则使用上一次计算缓存的结果
  • 惰性求值:只有在真正读取它的 value 时,才会进行计算求值
惰性求值

我们定义了两个计算属性, 当我们在Dom层或者js代码中使用 computed 时, 它才会进行计算。如果一个计算属性, 计算开销非常非常大, 但它没有被任何地方使用, 也不会进行求值

 computed: {
    getAge() {
      return this.age + '岁了';
    },
    getName() {
      return this.name + "是个好人";
    },
  },
缓存结果

当我们在页面上同时调用多次Computed计算属性时, 我们会发现, Computed函数只执行了一次

computed: {
    getAge() {
      console.log('getAge 执行了')
      return this.age + '岁了';
    },
    getName() {
      return this.name + "是个好人";
    },
  },
 <div id="app">
      <div>{{getAge}}</div>
      <div>{{getAge}}</div>
      <div>{{getAge}}</div>
      <div>{{getAge}}</div>
      <div>{{getAge}}</div>
      <div>{{getAge}}</div>
    </div>

接着我们在页面调用6getAge计算属性,我们会发现 getAge计算属性函数只执行了一次, 这就是计算属性的核心, 会在第一次执行时缓存计算属性执行的结果, 在后续的调用中直接读取缓存的结果, 这样当我们有一笔巨大的计算开销时, 计算属性可以帮助我们省下很多的性能空间

image.png

实现缓存
import Watcher from "./watcher.js";

export default function initComputed(vm) {
  // 获取配置项
  const computeds = vm.$options.computed || {};
  // 记录 watcher
  const watcher = (vm._watcher = Object.create(null));
  // 挂载到 $vm 上 是他可以通过  this.xxx() 调用
  for (const key in computeds) {
    // 实例化watcher 回调函数默认懒执行
    watcher[key] = new Watcher(computeds[key], { lazy: true }, vm);
    // 将computed属性代理到Vue实例上
    proxyMethdosToVm(vm, key);
  }
}

// 代理计算属性computed到Vm实例上
function proxyMethdosToVm(vm, key) {
  const descriptor = {
    get() {
      // 拿到计算属性
      const watcher = vm._watcher[key];
      if (watcher.dirty) {
        // watcher.dirty 为true 表示在第一次渲染中没有执行缓存
        // 那么就执行它 通知watcher执行 computed回调函数 将回调函数赋值给 watcher.value
        watcher.evalute();
      }

      return watcher.value;
    },
    set() {
      console.log("computed is not setter function");
    },
  };

  Object.defineProperty(vm, key, descriptor);
}

proxyMethdosToVm 方法将我们的 computed 属性挂载到vm实例上, 挂载方式和initData雷同,但是需要注意的是, 因为计算属性具有缓存的特性, 所以当我们get的时候就不仅仅是Watcher订阅了

官方使用了两个参数标记当前Watcher是否是计算属性

  • options : { lazy: true } 表示是计算属性
  • watcher.dirty : 保存当前计算属性的缓存状态, true需要执行缓存 false表示已经缓存过了,不需要缓存了
  const watcher = (vm._watcher = Object.create(null));
  // 挂载到 $vm 上 是他可以通过  this.xxx() 调用
  for (const key in computeds) {
    // 实例化watcher 回调函数默认懒执行
    watcher[key] = new Watcher(computeds[key], { lazy: true }, vm);
    // 将computed属性代理到Vue实例上
    proxyMethdosToVm(vm, key);
  }

我们将计算属性computeds[key]保存到Watcher中, 并lazy: true标注这个Watcher是个计算属性

修改一下Watcher.js

import queueWatcher from "./asyncUpdateQueue.js";
import Dep, { popTarget, pushTarget } from "./dep.js";

let uid = 0;
class Watcher {
  // cb 是修改指定dom的闭包回调,只修改某一个
  constructor(cb, options = {}, vm = null) {
    // 序号 异步有序执行时需要 Uid 不能重复
    this._Uid = uid++;
    // 当在视图层面调用 new Wathcer 时, 把当前的节点赋值给 target
    this._cb = cb;
    this.vm = vm;
    // 记录配置项
    this.options = options;
    // dirty 计算属性实现缓存的本质 状态 true 未缓存 false 已缓存
    this.dirty = !options.dirty;
    // 记录cb的执行结果
    this.value = null;
    // 如果非懒执行, 则直接执行cb函数,cb函数执行的过程会触发 vm.xx 的属性读取行为
    if (!options.lazy) {
      this.get();
    }
  }
}

/**
 * GET负责执行 Watcher 的 cb 函数
 * 负责执行时的依赖收集
 */
Watcher.prototype.get = function () {
  pushTarget(this);
  this.value = this._cb.apply(this.vm);
  popTarget();
};

/**
 * 执行 computed 计算属性的回调函数并缓存
 */
Watcher.prototype.evalute = function () {
  // 出发计算函数 cb
  this.get();
  // 状态改为已缓存 实现一次刷新周期内 computed 只执行一次
  this.dirty = false;
};

export default Watcher;

改善过的Watcher用了一个lazy属性区分了datacomputed, 如果lazy不存在或者为false, 那么就表示不是计算属性而是data, 可以直接去获取他的值, 如果是计算属性lazy: true这里不会做任何处理, 因为计算属性是惰性执行的, 当在模板层或者js层面调用计算属性时, 才会去执行它并缓存

// 如果非懒执行, 则直接执行cb函数,cb函数执行的过程会触发 vm.xx 的属性读取行为
if (!options.lazy) {
  this.get();
}

get方法主要用于执行 _cb 回调函数, 在initComputed.js中, 我们将computed[key] 传递给了 Watcher的回调,那么在computed计算属性中, 我们读取data的方式是通过 this.xxx 的方式去读取的,所以我们要将_cb的回调函数的this指向vm实例

Watcher.prototype.get = function () { 
  pushTarget(this); 
  this.value = this._cb.apply(this.vm); 
  popTarget();
};

当我们在Dom层面调用计算属性时, 会触发我们的拦截器get

get() {
      // 拿到计算属性
      const watcher = vm._watcher[key];
      if (watcher.dirty) {
        // watcher.dirty 为true 表示在第一次渲染中没有执行缓存
        // 那么就执行它 通知watcher执行 computed回调函数 将回调函数赋值给 watcher.value
        watcher.evalute();
      }

      return watcher.value;
    }

当拦截到get操作时,会首先从vm实例上取出对应的Watcher, 并通过watcher.dirty 判断计算属性是否已经被计算过, 如果没有就使用 watcher.evalute方法执行计算属性,然后返回watcher.value计算的结果。 那么当第二次在调用相同的计算属性进来时,watcher.dirty已经变成了false, 就表示不需要在进行计算,这时直接使用watcher.value即可

evalute

Watcher.prototype.evalute = function () {
  // 出发计算函数 cb
  this.get();
  // 状态改为已缓存 实现一次刷新周期内 computed 只执行一次
  this.dirty = false;
};

evalute方法就是执行计算属性, 并将结果赋值给 Watcher.value 存储起来, 然后修改Watcherdirty
至此,计算属性缓存就完成了

依赖项更新时重新计算

当计算属性的依赖项发生改变时, 如何进行重新计算?因为在初始化计算的时候,已经将dirty设置为了false, 表示后续的调用不需要重新执行计算属性。

所以现在需要在watcher更新update的时候检测一下,如果是lazy = true表示是懒执行也就是计算属性, 那就直接将当前Watcherdirty设置为true, 然后什么也不做, 等到Dom更新的时候,会触发computedget操作, 这个时候因为dirty: true,所以会去重新执行计算属性,实现依赖项更新计算属性同步计算

Watcher.prototype.update = function () {
  // 如果是计算属性 lazy 为true表示懒执行也就是计算属性
  if (this.options.lazy) {
    // 将 dirty 置为 true, 当组件更新时,重新执行 updateComponent 方法,进而执行 render 函数
    // 生成新的 vnode, patch 更新阶段将 vnode 变成真实的DOM 节点
    // 发生了 this.getAge 的属性读取操作,从而触发 get, 这是 watcher.dirty为true
    // 所以会去重新执行 evalute 方法 计算新的 computed 的值
    // 执行结束 将dirty 置为false,本次刷新周期内不会重复执行
    this.dirty = true;
  } else {
     // 渲染 render 更新组件
     ...
  }
};

处理模板层

上文中, 我们介绍了数据响应式绑定以及computed计算属性的实现, 到这里就不再对于处理数据层进行过多的阐述, 处理完数据层以后, 我们开始介绍一下渲染数据模板层的实现逻辑

模板转换AST结构对象

Vue 采用了一种虚拟DOM(vnode)的方式, 来对我们的页面模板进行缓存和更新, 这种处理模板的优势是可以避免对无修改模板进行更新而浪费性能, 那么vue是如何将我们的模板转换为vnode的呢? 接下来我们就一起来看一看Vue是如何转换模板文件AST对象的

修改模板调用

 <div id="app">
      <div v-on:click="printData('name','张三')">{{name}}</div>
      <div>hh{{age}}</div>
      <div>{{getAge}}</div>
      <div>{{newObj}}</div>
      <div>{{getAge}}</div>
      <div>{{getSchool}}</div>
      <div>{{getAge}}</div>
      <div>{{school}}</div>
      <div v-bind:class="className">{{name}}</div>
      <input type="text" v-model="name" />
      <input type="checkbox" v-model="checked" />
      <div>checked is: {{checked}}</div>
      <div>selectIndex is: {{selectIndex}}</div>
      <select v-model="selectIndex">
        <option value="Ipad">Ipad</option>
        <option value="Coco">Coco</option>
        <option value="HUAWEI">HUAWEI</option>
      </select>
 </div>

mount.js 渲染文件入口

import compileToFunction from "./compileToFunction.js";

export default function mount(vm) {
  if (!vm.$options.render) {
    // 如果没有render 就去编译,编译后会给render函数赋值
    let templateStr = "";
    const { el, template } = vm.$options;
    // 如果存在 template 表示是子组件
    if (template) {
      templateStr = template;
    } else if (el) {
      // el 存在 表示是挂载点 也就是根节点
      // <div id="app"></div>  outerHTML表示的整个标签  innerHTML表示里面的东西
      templateStr = document.querySelector(el).outerHTML;
      // 在实例上记录挂载点,记录我们得最外层的父元素, 也就是#app 子元素要插入到这里
      vm.$el = document.querySelector(el);
    }
    // 生成渲染函数
    const render = compileToFunction(templateStr);
    ....
  }
}

templateStr 是我们通过 document.querySelector(el).outerHTML 获取的 dom节点 字符串, 也就是我们在new Vue时候定义的作用域el<div id="app"></div>, 内容如下

image.png

输出的内容就是我们的tempalte模板字符串, Vue就是通过对模板字符串的处理来转换为我们的AST数据结构的, 现在我们已经拿到了我们的模板字符串, 我们可以开始进行转换了

compiler/compileToFunction.js 转换模板文件为AST的核心文件

import parseAstToStr from "./parseAstToStr.js";

export default function compileToFunction(templateStr) {
  // 将模板字符串编译为 ast 树结构
  const Ast = parseAstToStr(templateStr);
  ....
}

模板字符串转换AST对象的原理

比如我们的template 模板字符串如下

<div id="app" class="123" v-bind:style="123123">
  <div>{{name}}</div>
  <div>{{age}}</div>
  <div>{{getAge}}</div>
  <div>{{getName}}</div>
</div>

字符串匹配规则:

  • 匹配< 表示标签的开始位置
  • 匹配> 或者 /> 表示开始标签闭合的位置 根据匹配的内容可以获取到 div id="app" class="123" v-bind:style="123123", 可以根据这段信息来分析出标签名称(div)和标签的属性(id、class、v-bind:style)
  • 匹配 </ 标签结束的位置,可以拿到标签开始和结束之前的元素,这段数据就是他的子元素 <div>{{name}}</div> 判断是否是表达式, 如果是就转换为响应式数据(在这里调用会触发get拦截)

大致匹配原理如上, 还有一些特殊的匹配规则例如:过滤注释

逻辑实现
export default function parseAstToStr(templateStr) {
  let root = null;
  // 备份模板,不要直接修改模板
  let html = templateStr;
  // 存放元素的 ast 对象
  const stack = [];
  // 遍历处理html字符串
  // debugger;
  while ((html = html.trim())) {
    const startIdx = html.indexOf("<");
    if (startIdx === 0) {
      // 匹配到标签 <div  <img <input 等
      if (html.indexOf("</") === 0) {
        // 结束标签 标签结束以后执行的 (处理标签属性)
        parseEnd();
      } else {
        // 开始标签 分离标签中的 标签名 和 标签属性
        parseStartTag();
      }
    }  else if (startIdx > 0) {
      // 处理文本 <div>这里面的文本</div>
      const nextStartIdx = html.indexOf("<");
      if (stack.length) {
        // 如果stack不为空说明文字属于栈顶元素的文本节点
        processChars(html.slice(0, nextStartIdx));
      }
      // 处理完截取掉
      html = html.slice(nextStartIdx);
    }
  }

  return root;
}

首先是对模板字符串进行了trim, 然后匹配html.indexOf("<")开始标签后, 会通过parseStartTag方法来匹配开始节点的结束标记处,也就是匹配到了实例中 <div id="app" class="123" v-bind:style="123123">, 并分离出标签名属性

parseStartTag() 分离开始标签的名称属性

/*
   * 处理开始标签:
   */
  function parseStartTag() {
    // 找到结束标记
    const Inx = html.indexOf(">");
    // 截取开始标签内的所有内容 获取它的属性  div id="a" class="vb"
    const content = html.slice(1, Inx);
    // 更新截取后的html
    html = html.slice(Inx + 1);
    let tagName = "";
    let attrStr = "";
    // 找到 div id="a" class="vb" 中的第一个空格 用于区分标签名和属性
    const firstSpaceIdx = content.indexOf(" ");
    if (firstSpaceIdx === -1) {
      // 没有空格表示没有属性 <div></div>
      tagName = content;
    } else {
      // 标签名
      tagName = content.slice(0, firstSpaceIdx);
      // 属性名
      attrStr = content.slice(firstSpaceIdx + 1);
    }
    // 分离成数组  ['id="1"', 'class="2"']
    const attrs = attrStr ? attrStr.split(" ") : [];
    // 处理属性数组,得到map对象
    const attrsToMaps = parseAstToMap(attrs);
    // 生成AST对象
    const elementAst = generateToAst(tagName, attrsToMaps);
    if (!root) {
      root = elementAst;
    }
    // 将处理的ast对象插入stack栈中
    stack.push(elementAst);
  }

分离属性方法 和 根据属性标签名定义的vnode结构生成方法

  // 数组转换map
  function parseAstToMap(attrArr) {
    const attrMap = {};
    for (const attr of attrArr) {
      // v-model="123"
      // 分离后变成 ['v-model', '"123"']
      const [attrName, attrValue] = attr.split("=");
      // debugger
      attrMap[attrName] = attrValue.replace(/"|'/g, "");
      // debugger;
    }

    return attrMap;
  }

  // 生成AST对象 AST对象结构
  function generateToAst(tag, attrMap) {
    return {
      // 元素节点类型
      type: 1,
      // 标签名
      tag,
      // 原始属性对象
      rawAttr: attrMap,
      // 子节点
      children: [],
      // 父节点
      parent: [],
    };
  }
  
  // 处理文本
  function processChars(text) {
    if (!text.trim()) return;
    // 构建文本节点的AST对象
    const textAST = {
      type: 3,
      text,
    };
    // 文本如果有引入变量 {{name}}
    if (text.match(/{{(.*)}}/)) {
      textAST.expression = RegExp.$1.trim();
    }
    // 将文本节点放到栈顶元素的肚子里
    stack[stack.length - 1].children.push(textAST);
  }

处理完以后匹配的字符串后, 会对已经处理过的内容进行截取, 也就是说, 当我们处理过一个 '<' 开始标记后, 处理过的这段字符串会被截取, 此时我们的模板字符串变成了这样:

  <div>{{name}}</div>
  <div>{{age}}</div>
  <div>{{getAge}}</div>
  <div>{{getName}}</div>
</div>

注意:我们的AST结构中需要保存元素的父节点子节点, 子节点很容易找, 那就是标签开始标记结束标记内的所有元素都是子元素, 那么如何获取节点的父元素?

这里Vue官方采用了一个节点处理栈, 把匹配到的头节点存放到中, 当匹配到结束节点后, 会移除这个栈元素, 表示当前节点已经处理完成。

当我们匹配到头节点并处理完毕属性后,在处理节点内容的过程中遇到了子元素的头节点那么会将子元素入栈处理, 等子元素遇到结束标记后会进行移除, 子元素移除后,此时栈顶元素就是这个子元素父元素, 因为父元素还没有匹配到结束标识, 流程图如下:

image.png

匹配到结束标记会执行parseEnd()

 // 结束标记
  function parseEnd() {
    // 将闭合标签从html中截断  </div>  </body>  </xsxsxsxs>
    // 我们无法知道标签的长度 所以只能匹配后面的那个字符 > 而不是匹配 </
    const endIdx = html.indexOf(">");
    html = html.slice(endIdx + 1);
    // 截取完闭合标记后执行
    processElement();
  }

当一个节点处理完毕后, 我们就要开始处理他的属性了, 因为我们传递的可能有一些: v-bind:value v-model='xxx' @click="xxx" 绑定式的属性, 标签默认情况下不支持这些属性, 所以我们要进行处理和转换


 /*
   *节点闭合标签被处理完执行
   *</div> 被截取掉以后执行
   *主要处理节点上面的 v-bind, v-on, v-model等等
   */
  function processElement() {
    // 栈顶标签处理完以后,弹出栈顶标签 并进一步处理
    const curPop = stack.pop();
    // 获取栈顶元素的属性
    const { rawAttr } = curPop;
    // console.log("rawAttr ===> ", rawAttr);
    // rawAttr 是未处理的attr属性列表 我们要将处理好的可以使用的绑定到 attrs属性列表中去
    curPop.attrs = {};
    // 获取属性名列表
    const attrKeysArr = Object.keys(rawAttr); // ['v-model', 'v-bind:title', 'v-on']等
    // console.log("attrKeysArr ===> ", attrKeysArr);
    // 遍历属性名
    for (const item of attrKeysArr) {
      // console.log("key ========> ", item);
      let modelReg = /v-model(.*?)/; // 匹配  v-model="value"
      let bindReg = /^(v-bind:|:)(.*?)/; // 匹配 v-bind:title 和 :title
      let onReg = /^(v-on:|@)(.*?)/; // 匹配 v-on:click 和 @click

      if (modelReg.test(item)) {
        // 如果有双向绑定
        let bindKey = item.replace(modelReg, "");
        processVModel(curPop, bindKey);
      } else if (bindReg.test(item)) {
        // 如果有v-bind绑定  curPop key value
        let key = item.replace(bindReg, "");
        // console.log("key bindReg ===>  ", key, `v-bind:${key}`);
        processVBind(curPop, key, rawAttr[`v-bind:${key}`]);
      } else if (onReg.test(item)) {
        // 如果有v-on绑定 curPop key value
        let key = item.replace(onReg, "");
        // console.log("key onReg ===>  ", key);
        processVOn(curPop, key, rawAttr[`v-on:${key}`]);
      } else {
        // 如果是常规元素比如 name、id、style静态
        curPop.attrs[item] = rawAttr[item];
      }
    }
  }

通过for循环遍历截取的属性, 如果有命中以下任何一个正则表达式, 就表示是我们动态传递过来的, 需要进行处理, 如果没有命中表示是原生的属性,直接用即可

let modelReg = /v-model(.*?)/; // 匹配  v-model="value"
let bindReg = /^(v-bind:|:)(.*?)/; // 匹配 v-bind:title 和 :title
let onReg = /^(v-on:|@)(.*?)/; // 匹配 v-on:click 和 @click

处理动态属性

 /*
   * 处理属性上的v-model双向绑定
   * 将处理结果放置到 curPop.attrs 的 VModel 上
   * 使用场景
   * <select v-model="xx"></select>
   * <input type="text" v-model="xx"></input>
   * <input type="checkbox" v-model="xx"></input>
   */
  function processVModel(curPop, bindKey) {
    const { tag, attrs, rawAttr } = curPop;
    const { type, "v-model": vModelValue } = rawAttr;
    if (tag == "input") {
      // 输入框  checkbox'
      if (/text/.test(type)) {
        attrs.vModel = { tag, type: "text", value: vModelValue };
      }
      if (/checkbox/.test(type)) {
        attrs.vModel = { tag, type: "checkbox", value: vModelValue };
      }
    } else if (tag == "textarea") {
      // 文本域
      attrs.vModel = { tag, type: "textarea", value: vModelValue };
    } else if (tag == "select") {
      // 选择框
      attrs.vModel = { tag, value: vModelValue };
    }
  }

  /*
   * 处理属性上的v-model双向绑定
   * 将处理结果放置到 curPop.attrs 的 VBind 上
   * <div v-bind:style="xxx" v-bind:id="xxx"></div>
   */
  function processVBind(curPop, bindKey, bindValue) {
    curPop.attrs.vBind = {
      [bindKey]: bindValue,
    };
  }

  /*
   * 处理属性上的v-model双向绑定
   * 将处理结果放置到 curPop.attrs 的 VOn 上
   * <div v-on:click=""></div>
   */
  function processVOn(curPop, vOnKey, vOnValue) {
    curPop.attrs.vOn = { [vOnKey]: vOnValue };
  }

文中不涉及插槽的处理, 插槽的原理其实很简单, 即时根据匹配到的 slot 标签名去替换传递过来的子元素, 可以自行翻阅示例代码

至此, tempalte模板转换AST对象就处理完了, 回顾一下转换逻辑无非就几点

  • 根据匹配标签开始标记符, 来筛出标签的 标签名属性集合 并转换成AST对象
  • 根据匹配标签结束标记符, 来处理动态属性
  • 根据匹配到的文本内容, 筛选出是否使用了表达式并存储

处理好的AST数据结构如下:

image.png

根据AST结构生成VNODE渲染函数

上文中我们将template转换成了AST结构的对象, 下一步就是根据生成的AST对象生成VNODE渲染函数, VNODEVue的一个最大的特性, 在模板更新渲染时, 通过VNODE的新旧节点对比更新,能够极大程度的节省浏览器性能

现在, 我们需要将AST对象转换为vnode也就是虚拟DOM, 虚拟DOM真实DOM是对应的

在我们生成的AST对象中, 通过标签类型将标签划分了大致两个分类(插槽本文不涉及)

  • 节点类型: divinputselectspana等标签
  • 文本类型: 纯内容节点内的元素, <div>123</div> 中的 123 就是文本类型的

现在我们需要通过不同的类型, 去处理生成不同的Vnode

renderHelper.js

import VNode from "./vnode.js";
/**
 * 在 Vue 实例上安装运行时的渲染帮助函数,比如 _c、_v,这些函数会生成 Vnode
 * @param {VueContructor} target Vue 实例
 */
export default function renderHelper(target) {
  /**
   * @param {vm} target vm 实例
   * 将 ._c 和 ._v 绑定到 vm 实例上去
   * 1. _c 创建VNode标签
   * 2. _v 创建文本节点
   */
  target._c = createElement;
  target._v = createTextNode;
}

/**
 * 根据标签信息创建 Vnode
 * @param {string} tag 标签名
 * @param {Map} attr 标签的属性 Map 对象
 * @param {Array<Render>} children 所有的子节点的渲染函数
 */
function createElement(tag, attr, children) {
  // 标签节点: 标签名 属性 children
  return VNode(tag, attr, children, this);
}

/**
 * 生成文本节点的 VNode
 * @param {*} textAst 文本节点的 AST 对象
 */
function createTextNode(textAst) {
  // 文本节点只有 text 
  return VNode(null, null, null, this, textAst);
}

/**
 * VNode
 * @param {*} tag 标签名
 * @param {*} attr 属性 Map 对象
 * @param {*} children 子节点组成的 VNode
 * @param {*} text 文本节点的 ast 对象
 * @param {*} context Vue 实例
 * @returns VNode
 */
export default function VNode(tag, attr, children, context, text = null) {
  return {
    // 标签名称  div span a select
    tag,
    // 属性 Map 对象
    attr,
    // 父节点
    parent: null,
    // 子节点组成的 Vnode 数组
    children,
    // 文本节点的 Ast 对象
    text,
    // Vnode 的真实节点
    elm: null,
    // Vue 实例
    context,
  };
}

以上代码中, 我们定义了createElementcreateTextNode来分别处理节点文本, 并将其绑定在了 vm_c_t, 这样我就可以在全局调用生成vnode函数

  target._c = createElement;
  target._v = createTextNode;

target 指向的是 vm 示例, 因为我们在_init初始化时候将this传递给了renderHelper

// 初始化 把data绑定到
Vue.prototype._init = function (options) {
  ...
  // 安装运行时的渲染工具函数
  renderHelper(this);
 ...
};

将生成vnode的函数绑定到vm实例上以后, 现在开始处理AST对象, AST对象是一个Obj结构的数据, 所以要递归去处理它, 在compileToFunction.js中, 我们将parseAstToStr处理的Ast传递给generate处理成渲染Vnode函数

import generate from "./generate.js";
import parseAstToStr from "./parseAstToStr.js";

export default function compileToFunction(templateStr) {
  // 将模板字符串编译为 ast 树结构
  const Ast = parseAstToStr(templateStr);
  // console.log(" Ast======> ", Ast);
  // 从Ast结构生成渲染函数
  const render = generate(Ast);
  return render;
}

generate.js

Ast 对象是一个嵌套关系的结构对象, 如果一个节点的children属性有值就表示它具有子节点,在children属性中存在的也是多个独立的节点,所以要继续去循环处理判断它是否具有children, 如果没有children就表示到这里就是最里层了也就是文本节点,就去读取它的text属性

  • 节点有children: 表示具有子元素, 遍历调用子元素
  • 节点有text: 表示是文本节点, 直接读取后终止当前遍历

节点有 children

image.png

节点有 text

image.png

实现

/**
 * 从 ast 生成渲染函数
 * @param {*} ast ast 语法树
 * @returns 渲染函数
 */
export default function generate(ast) {
  // 渲染函数字符串形式
  const renderStr = getElement(ast);
  // 根据不同的类型 调用不同的处理函数
  // 通过 new Function 将字符串形式的函数变成可执行函数,并用 with 为渲染函数扩展作用域链
  return new Function(`with(this) { return ${renderStr} }`);
}
/**
 * 解析 ast 生成 渲染函数
 * @param {*} ast 语法树
 * @returns {string} 渲染函数的字符串形式
 */
function getElement(ast) {
  // console.log("ast => ", ast);
  const { tag, rawAttr, attrs } = ast;
  // 生成属性 Map 对象,静态属性 + 动态属性
  // rawAttr 是保留的原生的属性 id class 等
  // attr 是处理的 v-bind:title  @click 等动态属性
  const attrObj = { ...rawAttr, ...attrs };

  // 处理子节点,得到一个所有子节点渲染函数组成的数组
  const children = genChildren(ast);
  // console.log("children ==> ", children);
  // 生成 VNode 的可执行方法
  return `_c('${tag}', ${JSON.stringify(attrObj)}, [${children}])`;
}

/**
 * 处理 ast 节点的子节点,将子节点变成渲染函数
 * @param {*} ast 节点的 ast 对象
 * @returns [childNodeRender1, ....]
 */
function genChildren(ast) {
  const ret = [];
  const { children } = ast;

  for (let i = 0; i < children.length; i++) {
    const child = children[i];
    if (child.type === 3) {
      // 文本
      ret.push(`_v(${JSON.stringify(child)})`);
    } else if (child.type === 1) {
      // 如果子节点有节点类型 继续递归执行
      ret.push(getElement(child));
    }
  }

  return ret;
}

getElementgenChildren 分别用于处理 父节点子节点, 当一个节点有children属性, 会去优先处理子元素, 将处理好的子元素VNode插入到父元素Vnodechildren里。

递归处理结束后, 处理后的内容如下:

image.png

_c是生成标签函数, 会在执行函数 target._c时, 生成标签, _v是生成文本函数, 会在执行target._v时, 生成文本

但是由于我们在递归时输出的是字符串类型的函数体结构(为什么生成字符串而不是直接调用, 因为我们在生成渲染函数时不需要调用执行, 调用的时机是new Watcher传递渲染函数, watcher的回调函数会在初次渲染时立即执行render渲染函数, 因为render需要在初次渲染更新渲染时调用, 所以创建时不需要调用)

如何将字符串函数体转化为函数,并让他能够调用vm实例上的_v_c

With 拓展符

官方解释 With通常被当做重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身

什么意思呢? 大概就是我想使用一个对象的多个属性, 比如obj, 我需要用到obj里面的一些属性, 但是我不想每次都使用obj.aobj.bobj.c, 这样会使我的代码变得很累赘, with就是可以解决这个问题的(但是通常也可以使用es6解构运算符)

const obj = {
    a:1,
    b:2,
    c:3,
    ...
}

with 使用多个Obj元素计算

with (obj) {
  a+b+c
}

输出查看结果

image.png

with可以使我们快速的调用对象用的一些属性

大致了解完with的作用后, 我们看上文中的示例代码:

export default function generate(ast) {
  const renderStr = getElement(ast);
  // 通过 new Function 将字符串形式的函数变成可执行函数,并用 with 为渲染函数扩展作用域链
  return new Function(`with(this) { return ${renderStr} }`);
}

with(this) { return ${renderStr} }, 我们在with中使用了this, this指向的就是我们调用的时候当前的作用域

mount.js中, 我们将 render 绑定到了vm 实例上

   // 将渲染函数挂载到 $options 上
   vm.$options.render = render;

所以with(this) 就指向了vm实例, 这样当我们执行的 _c 或者 _v时, 就等于调用了 this._cthis._v, 随后通过 new Function字符串函数体转换为函数

示例程序:

image.png

实例中 new Function 将我们的字符串函数体 const funStr = function a(){return 123} 转换为了可执行函数, 执行fun()() 即可执行字符串函数体转换的函数

渲染vnode的函数render,绑定到vm的实例上, 因为需要通过with拿到_c_v, 并且全局可以使用它

export default function mount(vm) {
    ...
    // 生成渲染函数
    const render = compileToFunction(templateStr);
    // 将渲染函数挂载到 $options 上
    vm.$options.render = render;
  }

模板渲染

上文中, 我们处理了响应式数据计算属性模板字符串转换AST对象然后在转换vnode, 处理完这些流程以后, 我们就可以通过vnode来创建真实的Dom, 并将表达式数据(data、computed)绑定到真实Dom

模板渲染大致流程:

image.png

渲染函数注册到Watcher中, 一个组件对应一个组件渲染Watcher

import mountComponent from "./mountComponent.js";
...
export default function mount(vm) {
  ....
  mountComponent(vm);
}

mountComponent.js

import Watcher from "../watcher.js";
import Vue from "../index.js";

/**
 * 根据处理好的render进行编译
 */
export default function mountComponent(vm) {
  /**
   * updateComponent更新组件的的函数
   * 当data修改时候调用vm._update函数实行页面响应时
   */
  const updateComponent = () => {
    /**
     * vm._render() 返回的是处理过的ast对象
     */
    vm._update(vm._render());
  };
  // 实例化一个渲染 Watcher,当响应式数据更新时,这个更新函数会被执行
  new Watcher(updateComponent);
}

/**
 * 负责执行 vm.$options.render 函数
 */
Vue.prototype._render = function () {
  /**
   * 给 render 函数绑定 this 上下文为 Vue 实例
   * this.$options.render 返回的是一个渲染函数 用来渲染dom节点
   */
  return this.$options.render.apply(this);
};

/*
 * 负责对比节点完成更新 
 */
Vue.prototype._update = function (vnode) {
  // 老的 VNode
  const prevVNode = this._vnode;
  // 新的 VNode
  this._vnode = vnode;
  // debugger;
  if (!prevVNode) {
    // 老的 VNode 不存在,则说明时首次渲染根组件
    // this.$el 老节点
    // vnode 新节点
    this.$el = this.__patch__(this.$el, vnode);
  } else {
    // 后续更新组件或者首次渲染子组件,都会走这里
    this.$el = this.__patch__(prevVNode, vnode);
  }
};

mountComponent 方法注册了一个组件渲染Watcher, 回调函数是我们上文中处理过的render生成vnode的函数

 const updateComponent = () => {
    /**
     * vm._render() 返回的是处理过的ast对象
     */
    vm._update(vm._render());
  };

当我们的组件第一次执行或者更新时, 都会重新执行render函数生成新的vnode, 而不是直接更新DOM层, 避免了浏览器直接更新真实Dom所消耗的高额性能

render 函数我们使用apply改变了this 指向, 因为我们在将字符串函数体转换为函数的时候, 使用了with

Vue.prototype._render = function () {
  /**
   * 给 render 函数绑定 this 上下文为 Vue 实例
   * this.$options.render 返回的是一个渲染函数 用来虚拟dom节点
   */
  return this.$options.render.apply(this);
};

update更新函数, 会在执行时进行判断处理是否是初次更新, 判断条件就是判断是否拥有老vnode

/*
 * 负责对比节点完成更新 
 */
Vue.prototype._update = function (vnode) {
  // 老的 VNode
  const prevVNode = this._vnode;
  // 新的 VNode
  this._vnode = vnode;
  if (!prevVNode) {
    // 老的 VNode 不存在,则说明时首次渲染根组件
    // this.$el 老节点
    // vnode 新节点
    this.$el = this.__patch__(this.$el, vnode);
  } else {
    // 后续更新组件或者首次渲染子组件,都会走这里
    this.$el = this.__patch__(prevVNode, vnode);
  }
};
patch

patch的逻辑主要由两块:

  • 初次渲染根据 vnode 创建 真实dom
  • 更新时对比新旧 vnode依赖DOM块进行更新

patch 实现逻辑

/**
 * 初始渲染和后续更新的入口
 * @param {VNode} oldVnode 老的 VNode
 * @param {VNode} vnode 新的 VNode
 * @returns VNode 的真实 DOM 节点
 */
export default function patch(oldVnode, vnode) {
  // console.log("vnode ===> 新节点 ==> ", vnode);
  if (oldVnode && !vnode) {
    // 老节点存在,新节点不存在,销毁了
    return;
  }

  if (!oldVnode) {
    // 如果没有老节点,说明是初次渲染
    createElm(vnode);
  } else {
    // 更新
    if (oldVnode.nodeType) {
      // nodeType是真实节点携带的
      // 父节点
      const parent = oldVnode.parentNode;
      // 参考节点,即老的 vnode 的下一个节点 也就是 script标签,新节点要插在 script 的前面
      const referNode = oldVnode.nextSibling;
      // 创建元素 将vnode变成真是节点,并添加到父节点内
      createElm(vnode, parent, referNode);
      // 移除老的 vnode
      parent.removeChild(oldVnode);
    } else {
      // 更新 update
      patchVndoe(oldVnode, vnode);
    }
  }

  return vnode.elm;
}

生成虚拟DOM后, 会进入patch处理阶段, 也就是生成真实DOM更新DOM的逻辑层

  • 没有oldVnode: 初次渲染 全部渲染DOM节点
  • oldVode: 更新DOM节点

第一次更新 dom 代码的处理逻辑

为什么更新的时候要判断oldVnode.nodeType 因为第一次渲染是全部节点渲染

    if (oldVnode.nodeType) {
      // nodeType是真实节点携带的
      // 父节点
      const parent = oldVnode.parentNode;
      // 参考节点,即老的 vnode 的下一个节点 也就是 script标签,新节点要插在 script 的前面
      const referNode = oldVnode.nextSibling;
      // 创建元素 将vnode变成真是节点,并添加到父节点内
      createElm(vnode, parent, referNode);
      // 移除老的DOM 移除页面上最开始的`DOM`
      parent.removeChild(oldVnode);
    } else {
      // 更新 diff 算法核心 update
      patchVndoe(oldVnode, vnode);
    }

当我们根据template生成ast结构时, template已经存在, 并且是真实的DOM 在未渲染前, 页面是这个样子的

image.png

所以当我们初次渲染更新时, 会去根据页面上已经存在的dom (<div>{{name}}</div>)更新成对应的新生成的真实DOM (<div>我是name变量对应的数据</div>), 也就是说在我们根据vnode生成真实的DOM后, 会替换移除掉页面上最开始的DOM, 当我们再次更新时 oldVnode 就不会再是原生的DOM属性,而是我们生成的vnode解构

替换原生 DOM

image.png

移除原生DOM, 更新成自己生成的真实DOM

image.png

根据 vnode 创建 真实dom

创建 真实dom的逻辑和上文中根据AST数据结构生成vnode虚拟dom是相似的, 都是通过递归创建, 最后将创建的子元素插入到父元素内

在创建文本节点时会判断文本中是否存在表达式调用, 如果text.expression有值那么就表示有表达式, 就通过vm实例去取出对应的数据, 在取出时会触发dataget拦截, 更新时会重新执行渲染更新

/**
 * 创建元素
 * @param {*} vnode VNode
 * @param {*} parent VNode 的父节点,真实节点
 * @param {*} referNode 参考节点
 * @returns
 */
function createElm(vnode, parent, referNode) {
  // 记录节点的父节点 因为我们要将当前创建的元素 插入到父节点内
  vnode.parent = parent;
  // 创建自定义组件,如果是非组件,则会继续后面的流程
  if (createComponent(vnode)) return;
  const { attr, children, text } = vnode;
  if (text) {
    // text存在说明是文本节点
    // 创建文本节点,并插入到父节点内
    vnode.elm = createTextNode(vnode);
  } else {
    // 元素节点
    // 创建元素节点,在vnode上记录对应的dom节点
    vnode.elm = document.createElement(vnode.tag);
    // 给元素设置属性
    setAttribute(attr, vnode);
    // 递归创建子节点 子节点的父元素是当前得本身 也就是vnode.elm
    for (let i = 0, len = children.length; i < len; i++) {
      createElm(children[i], vnode.elm);
    }
  }
  // 如果存在 parent,表示知道是谁的子节点, 将创建的节点插入到父节点内
  // 如果没有表示是最外层的 因为#app是作用域,是最外层的,所以要将他插入到对应的位置,也就是获取他的同级元素,因为我们的作用域可以是任何地方
  if (parent) {
    const elm = vnode.elm;
    if (referNode) {
      // 如果是#app,就插入到下方兄弟元素的上方
      parent.insertBefore(elm, referNode);
    } else {
      // 否则插入父元素内
      parent.appendChild(elm);
    }
  }
}

/**
 * 创建文本节点
 * @param {*} textVNode 文本节点的 VNode
 */
function createTextNode(textVNode) {
  let { text } = textVNode,
    textNode = null;
  if (text.expression) {
    // 存在表达式,这个表达式的值是一个响应式数据
    const value = textVNode.context[text.expression];
    textNode = document.createTextNode(
      typeof value === "object" ? JSON.stringify(value) : String(value)
    );
  } else {
    // 纯文本
    textNode = document.createTextNode(text.text);
  }
  return textNode;
}

setAttribute 属性设置, 在vnode渲染中我们只是区分了动态属性原生属性, 并没有对动态属性进行进一步的处理, 所以在渲染真实Dom时,我们要进行动态属性的实现 (vOnvModelvBind)

也就是说, 动态属性处理的时机是: 在渲染真实DOM时,根据vnode层面处理的动态属性对象的集合来实现动态属性的具体逻辑:

本文只处理vOnvModelvBind, 感兴趣的同学可以自行实现其他动态绑定操作

/**
 * 给节点设置属性
 * @param {*} attr 属性 Map 对象
 * @param {*} vnode
 */
function setAttribute(attrs, vnode) {
  // 遍历属性,如果是普通属性,直接设置,如果是指令,则特殊处理
  for (let name in attrs) {
    if (name === "vModel") {
      // v-model 指令
      const { tag, value } = attrs.vModel;
      setVModel(tag, value, vnode);
    } else if (name === "vBind") {
      // v-bind 指令
      setVBind(vnode);
    } else if (name === "vOn") {
      // v-on 指令
      setVOn(vnode);
    } else {
      // 普通属性
      vnode.elm.setAttribute(name, attrs[name]);
    }
  }
}

/**
 * v-model 的原理
 * @param {*} tag 节点的标签名
 * @param {*} value 属性值
 * @param {*} node 节点
 */
function setVModel(tag, value, vnode) {
  const { context: vm, elm } = vnode;
  // vm[value]
  if (tag === "select") {
    // 选择框
    // 下拉框,<select></select>
    Promise.resolve().then(() => {
      // 利用 promise 延迟设置,直接设置不行,
      // 因为这会儿 option 元素还没创建
      elm.value = vm[value];
    });
    elm.addEventListener("change", function () {
      vm[value] = elm.value;
    });
  } else if (tag === "input" && elm.type === "text") {
    // 文本框,<input type="text" />
    elm.value = vm[value];
    elm.addEventListener("input", function () {
      vm[value] = elm.value;
    });
  } else if (tag === "input" && vnode.elm.type === "checkbox") {
    // 多选框
    elm.checked = vm[value];
    elm.addEventListener("change", function () {
      vm[value] = elm.checked;
    });
  }
}

/**
 * v-bind 原理
 * @param {*} vnode
 */
function setVBind(vnode) {
  const {
    attr: { vBind },
    elm,
    context: vm,
  } = vnode;
  // 处理bind得绑定数据
  for (const attrName in vBind) {
    // v-bind:id="name"   变成id="123"
    elm.setAttribute(attrName, vm[vBind[attrName]]);
    // 删除掉  v-bind:id
    elm.removeAttribute(`v-bind:${attrName}`);
  }
}

/**
 * v-on 原理
 * @param {*} vnode
 */
function setVOn(vnode) {
  const {
    attr: { vOn },
    elm,
    context: vm,
  } = vnode;

  for (const eventName in vOn) {
    elm.addEventListener(eventName, function (...args) {
      // 调用方法,因为方法也被代理到vm上 可以通过 this. 来访问,例如 this.app()
      vm[vOn[eventName]].apply(vm, args);
    });
  }
}

vModel 的逻辑相对来说比较复杂, 因为需要根据不同的标签处理不同的逻辑, 这里只处理了三个标签, 在vue源码中, 处理的量是非常庞大的, 这也就是我们用起来非常舒服的核心, 尤大已经给我们所有可能的操作全部实现了一遍

到这里为止, 我们已经处理了:

  • 根据tempalte模板文件生成ast对象
  • 根据ast对象创建生成vnode渲染方法
  • 根据生成的vnode构建真实的dom

下一步, 也就是Vue处理模板更新的核心 diff算法 等真正看懂diff算法以后, 我相信你说一句尤大NB

diff 更新

上文中, 我们在初次渲染时, 会根据生成的虚拟Dom Vnode 的属性, 生成对应的真实的DOM, 如果vnode嵌套层数较高时,每次都重新渲染全部的真实DOM, 会造成一定的性能消耗, 所以当我们data时, 不能直接重新生成真实的DOM, 我们应该只修改被更新数据调用的DOM层。 这个时候diff算法出现了, 他就是帮助我们解决避免大面积更新真实DOM的救世主

patch方法中, 当data更新时, 会执行处理patchVndoe(oldVnode, vnode)方法, 分别传入老vnode虚拟节点新生成的虚拟节点

patchVndoe 更新逻辑如下:

  • 如果新老节点oldVnode === vnode一致, 表示当前dom没有发生改变
  • 如果新老节点都存在子节点: vnode.children oldVnode.children, 那么就使用diff算法去更新
  • 如果新节点有子节点, 老节点不存在子节点, 就新增节点
  • 如果新节点没有子节点, 老节点有子节点, 就移除节点
  • 如果新节点存在表达式 vnode.text.expression文本节点, 验证表达式的新旧值, 不同替换相同略之
  • .........(其他逻辑暂不多叙述)
/**
 * 对比新老节点 oldVnode 和 vnode , 找出其中的不同,然后更新老节点
 * @param {*} oldVnode
 * @param {*} vnode
 */
function patchVndoe(oldVnode, vnode) {
  // 没有进行更新
  if (oldVnode === vnode) return;

  // 如果有更新 就将旧的 vnode 同步到新的vnode上
  vnode.elm = oldVnode.elm;

  // 拿到新老 vnode 得孩子节点
  const newChild = vnode.children;
  const oldChild = oldVnode.children;
  // 新节点不存在文本节点
  if (!vnode.text) {
    // 新老节点都存在子节点
    if (newChild && oldChild) {
      // diff处理
      updateChildren(newChild, oldChild);
    } else if (newChild && !oldChild) {
      // 新节点有孩子,老节点没孩子 新增节点
    } else if (oldChild && !newChild) {
      // 老节点有孩子 新节点没孩子 删除老节点
    }
  } else {
    // 如果存在文本节点
    if (vnode.text.expression) {
      // 文本中是否存在表达式
      // 获取表达式的新值 也就是更新后的新值
      const value = JSON.stringify(vnode.context[vnode.text.expression]);
      try {
        // 获取表达式旧值
        const oldValue = oldVnode.elm.textContent;
        if (value !== oldValue) {
          // 新表达式值和旧表达式数值不一样 更新
          oldVnode.elm.textContent = value;
        }
      } catch (error) {
        // 防止更新时候遇到插槽
      }
    } else {
      // 文本不存在表达式
    }
  }

  if (newChild === oldChild) {
  }
}

diff算法 验证新旧节点更新

updateChildren diff 验证逻辑

  1. diff算法采用的是双端对比算法
  2. diff算法在处理前做了四种假设猜想
双端对比法

两对指针判断

  • 新节点数组的开始位置、新节点数组的结束位置
  • 就节点数组的开始位置、就节点数组的结束位置

对比逻辑验证

  • 如果新节点开始位置和新节点结束位置重复 表示新节点先遍历结束, 剩余的节点就是需要删除的节点
  • 如果旧节点开始位置和旧节点结束位置重复 表示旧节点先遍历结束, 剩余的节点就是需要新增的节点

具体的验证逻辑参考一下假设, 命中假设一命中假设四 完美的阐述了双端对比的优势和逻辑

diff四种假设
  • 假设一: 新开始节点和老开始节点 是同一个节点 newStartNdoe oldStartNode
  • 假设二: 新开始节点和老结束节点 是同一个节点 newStartNdoe oldEndNode
  • 假设三: 新结束节点和老开始节点 是同一个节点 newEndNode oldEndNode
  • 假设四: 新结束节点和老结束节点 是同一个节点 newEndNode oldEndNodem

命中假设一

image.png

命中假设二

image.png

命中假设三

image.png

命中假设四

image.png

没有命中任何假设

如果diff没有命中以上四种假设逻辑, 那么就会老老实实的去进行遍历对比更新, 一般增删改查都会命中四种假设, 除非当数据完全替换时, 数据完全替换必然要发生子元素全部重新渲染DOM

实现逻辑

/**
 * diff算法 对比孩子节点,找出不同点,然后将不同点更新到老节点
 */
function updateChildren(newChild, oldChild) {
  // 四个游标 分别记录新老节点的遍历位置和总长度
  let newStartIdx = 0;
  let newEndIdx = newChild.length - 1;
  let oldStartIdx = 0;
  let oldEndIdx = oldChild.length - 1;

  // 遍历 循环遍历所有的新老节点,找出节点不一样的地方,然后更新
  // 做假设降低时间复杂度 两端对比
  while (newStartIdx <= newEndIdx && oldStartIdx <= oldEndIdx) {
    // 获取新开始记录节点
    const newStartNdoe = newChild[newStartIdx];
    // 新结束节点
    const newEndNode = newChild[newEndIdx];
    // 老开始节点
    const oldStartNode = oldChild[oldStartIdx];
    // 老结束节点
    const oldEndNode = oldChild[oldEndIdx];
    // 假设
    // newCHILDREN  A B C D
    // oldCHILDREN  a b c d
    // 假设一: 新开始节点 和 老开始节点 是同一个节点
    // 假设二: 新开始节点 和 老结束节点 是同一个节点
    // 假设三: 新结束节点 和 老开始节点 是同一个节点
    // 假设四: 新结束节点 和 老结束节点 是同一个节点
    // 如果没有命中以上任何假设 就老老实实遍历
    if (someVnode(newStartNdoe, oldStartNode)) {
      // 命中假设一
      patchVndoe(oldStartNode, newStartNdoe);
      // 移动游标
      newStartIdx++;
      oldStartIdx++;
    } else if (someVnode(newStartNdoe, oldEndNode)) {
      // 命中假设二
      patchVndoe(oldEndNode, newStartNdoe);
      // 将老节点移动到新开始的位置
      oldEndNode.elm.parentNode.insertBefore(
        oldEndNode.elm,
        oldChild[newStartIdx]
      );
      // 移动游标
      newStartIdx++;
      oldEndIdx--;
    } else if (someVnode(newEndNode, oldStartNode)) {
      // 命中假设三
      patchVndoe(oldEndNode, newEndNode);
      // 将老节点移动到新开始的位置
      oldEndNode.elm.parentNode.insertBefore(
        oldEndNode.elm,
        oldChild[newEndIdx]
      );
      // 移动游标
      newEndIdx--;
      oldStartIdx++;
    } else if (someVnode(newEndNode, oldEndNode)) {
      // 命中假设四
      patchVndoe(oldEndNodem, newEndNode);
      // 移动游标
      newStartIdx--;
      oldStartIdx--;
    } else {
      // 都没命中 老老实实遍历
    }
  }

  // 跳出循环,说明有一个节点已经遍历结束们需要处理下后续的事情
  if (newStartIdx < newEndIdx) {
    // 说明老节点遍历结束, 则将剩余的新节点添加到dom中
  } else if (oldStartIdx < oldEndIdx) {
    // 说明新节点先遍历结束 则将剩余的老节点删除掉
  }
}

/**
 * 判断两个节点是否是同一个
 */
function someVnode(a, b) {
  // if (!a && !b) return false;
  return a && b && a.key === b.key && a.tag === b.tag;
}

假设猜想的优势:渲染真实DOM是非常消耗性能的, 尤其是大批量的渲染, 当对比更新子组件时, 如果循环语句命中一条假设猜想, 就可以少判断渲染一次

  • 当老节点先遍历结束就表示新节点有新增DOM, 创建节点插入
  • 当新节点先遍历结束就表示新节点有要移除DOM, 移除多余节点

asyncUpdateQueue.js 异步更新队列

当侦听到数据变化, Vue将开启一个队列, 并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发, 只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的

watcher加入到异步队列,让其有序执行

/**
 * 响应式数据更新时,dep通知watcher执行update
 * 让update 方法执行 this._cb 函数 进而更新 DOM
 */
Watcher.prototype.update = function () {
  if (this.options.lazy) {
  ...
  } else {
    // 加入异步队列
    // 将当前watcher放入到watcher异步更新队列 按次更新
    queueWatcher(this);
  }
};

异步更新逻辑:

  1. 根据模板渲染时调用data生成的watcher, 进行标识UID排序, UID越小执行优先级越高
  2. 如果队列中没有刷新的watcher, 直接入队后执行即可
  3. 如果队列中有刷新的watcher队列, 就插入到指定到位置中 例如当前执行的: [2,3,6,7,8] , 新插入的是4, 那么就需要插入到6的前面组成 [2,3,4,6,7,8] 使其有序执行
  4. 执行完毕后移除掉已经执行过的watcher

queueWatcher 实现逻辑

// 全局watcher队列
const queue = [];
// 标识当前的watcher队列是否正在被刷新
let flushing = false;
// 表示callbacks数组中是否已经存在一个刷新的watcher队列的函数
let waiting = false;
// 存放刷新 watcher 队列的函数,或者用户调用 nextTick 方法时的回调函数
const callbacks = [];
// 表示当前浏览器任务队列中是否已经存在callback数组的函数了
let penging = false;

export default function queueWatcher(watcher) {
  // debugger;
  // 如果队列中已经存在当前watcher 就不需要重复添加
  if (queue.includes(watcher)) return;

  // watcher不存在 并且当前队列不在刷新状态 就入队
  // 因为在刷新中入队会影响watcher执行的顺序
  if (!flushing) {
    // 直接入队
    queue.push(watcher);
  } else {
    // 当前 watcher 队列正在被刷新
    // 当你的 watcher 回调函数存在更改响应式数据的情况时
    // 这个情况下就会出现刷新watcher队列时,进来新的wather
    // 但是因为队列是有序的,所以要将这个watcher插入到合适的地方,保证队列的有序进行

    // 例如
    // 刷新中的watcher队列UID为 [3,4,5,7,9]
    // 现在因为用户的nextTick回调 回调了一个 watcher 8
    // 我们要插入到指定的位置也就是 [3,4,5,7,watcher8,9]
    let flag = false;
    for (let i = queue.length - 1; i >= 0; i--) {
      if (queue[i]._Uid < watcher._Uid) {
        // 找到了要插入的位置 也就是 queue[i]的后面
        queue.splice(i + 1, 0, watcher);
        flag = true;
        break;
      }
    }

    // 如果循环完毕没找到比自己小的 那自己就是最小的
    // 直接插入到头部即可
    if (!flag) {
      queue.unshift(watcher);
    }
  }

  if (!waiting) {
    // 保证callbacks数组中只有一个刷新watcher队列的函数
    waiting = true;
    nextTick(flushSchedulerQueue);
  }
}

// 负责刷新watcher队列函数 由flushCallbacks 函数调用
function flushSchedulerQueue() {
  // flushing true 表示正在刷新
  flushing = true;
  // 给 watcher 队列 排序 根据 watcher 的 this._Uid
  queue.sort((a, b) => a._Uid - b._Uid);
  console.log("刷新 flushSchedulerQueue queue ==> ", queue);
  // 依次执行队列的 run 方法
  while (queue.length) {
    const watcher = queue.shift();
    // 执行第一个 因为要按顺序
    console.log("watcher.run ==> ", watcher._cb);
    watcher.run();
  }
  // 走到这里 watcher 队列已经为空了 控制状态初始化
  flushing = waiting = false;
}

function nextTick(cb) {
  callbacks.push(cb);
  // 没有存在callback任务队列
  if (!penging) {
    // pengding 设置为true 表示开始刷新队列
    penging = true;
    // 放入到浏览器的异步队列
    Promise.resolve().then(flushCallbacks);
  }
}

// 负责刷新 callback数组 也就是执行callback中的每一个函数
function flushCallbacks() {
  // 表示浏览器任务队列已经被拿到执行栈执行了
  // 新的 flushCallbacks可以入栈了
  penging = false;
  while (callbacks.length) {
    const cb = callbacks.shift();
    // 执行第一个 因为要按顺序
    cb();
  }
}

Vue 源码面试常见题

  • 谈谈你对Vue中响应式数据的理解

    答:Vue中响应式的实现是通过defineReactive拦截对象方法来实现的, 拦截时给每个对象注册了一个Dep发布者对象, 当data数据发生改变的时候, 会触发拦截器的get拦截方法, 将调用订阅者通过depend存储到watcher数组中, 当数据更新时, 触发set拦截, 并通过Dep 的 notify方法, 通知这个data的订阅者进行更新

  • 如何理解Vue中的模板编译原理?

  1. template模板转换成ast结构对象
  2. 根据ast结构对象, 创建生成节点 _c文本 _v函数, 递归生成vnode的渲染函数render函数体字符串, 通过 new Functionwith 将函数体字符串转换为可执行函数
  3. 根据vnode虚拟dom 生成或更新 真实DOM
  • 说明nextTick的原理?

答:nextTick 是一个微任务异步任务, 用于将任务加入到异步更新队列, 防止阻碍宏任务执行, nextTick 最后的判断兼容其实就是通过settimeout Promise.resolve() 来实现的

  • computed是怎么实现的?

答:computed实现缓存和重新计算 需要依靠watcher, watcher中会判断当前的watcher是否是惰性的, 如果是惰性的,那么就会在调用的时候才会去计算结果,计算结果后将dirty改为false表示已缓存, 当依赖项修改的时候, 会执行Watcher的update更新函数将dirty属性改为true, 当模板渲染对比更新时,会重新执行并缓存

  • Vue为什么要用vnode?

  1. vnode就是用js对象来描述真实Dom树, 是对真实Dom的映射
  2. 由于直接操作Dom性能低,但是js层的操作效率高,可以将Dom操作转化成对象操作。最终通过diff算法比对差异进行更新Dom
  • Vue的diff算法原理是什么?

Vue的diff算法是平级比较,不考虑跨级比较的情况。内部采用深度递归的方式+双指针方式比较

  1. 先比较两个节点是不是相同节点
  2. 相同节点比较属性,复用老节点
  3. 先比较儿子节点,考虑老节点和新节点children的情况
  4. 假设对比:头头、尾尾、头尾、尾头

结尾

看到这里, 想必你应该已经对Vue源码的大部分实现原理有了一定的认识理解, 但是阅读源码不止于源码, 我们要知道在一个优秀的框架中使用了这种逻辑思想, 那必然是有一定的意义的, 我们要能将这种优秀的思想和实现逻辑转换为自己的东西, 并在自己的项目中能够加以运用, 这才是阅读源码的核心目的, 如果只是想仅仅了解他的实现, 那不如去直接阅读面试经验, 面试经验足够让你理解这些思想的原理, 但是当你去实现的时候, 你会发现, 你也就仅仅只会说出这些原理罢了

补充一句

本文中涉及的理解和观点均为本人自己的理解, 如有错误请提出我会及时改正

参考优秀博主

一个人学习的路上难免有波折, 感谢一下大佬的讲解和文章著作, 让我提升了自己的阅历和知识面~~ 感谢

李永宁