看源码的思路:

  1. 先看整体架构,搞清楚每个目录都是干嘛的,不要一上来就盯着代码一行一行看。

  2. 可以结合测试用例看,测试用例能够让我们快速了解到某个方法的作用。比如如果有某行代码看不懂,可以把它先注释掉,然后跑一下测试用例,看看哪个用例报错了,就能知道这行代码是干嘛用的了。

  3. 在 package.json 中的构建命令里加上 -s,重新打包,然后打开 google 浏览器的 Enable JavaScript source maps,就可以在浏览器中直接调试 vue 源码了。

# 架构概览

vue

  • compiler ⽬录是编译模板;

  • core ⽬录是 vue 的核⼼;

  • entries ⽬录是⽣产打包的⼊口;

  • platforms 目录是针对核⼼模块的 ”平台“ 模块,该目录下有 web 和 weex 两个目录,web 目录下有对应的 compiler、runtime、server 和 util 目录;

  • server 目录是处理服务端渲染;

  • sfc ⽬录处理单⽂件 .vue;

  • shared ⽬录提供全局用到的⼯具函数。

vue 是由 core + 对应的 “平台” 补充代码构成的(独立构建和运行时构建只是 platforms 下 web 平台的两种选择)。

vue 的目标是通过尽可能简单的 API 实现响应的数据绑定和组合的视图组件。

vue

  • components 模板编译的代码

  • global-api 最上层的⽂件接⼝(全局 api)

  • instance ⽣命周期 -> init.js

  • observer 数据收集与订阅

  • util 常⽤⼯具⽅法类

  • vdom 虚拟 dom

# 双向数据绑定

# 涉及的技术

  • Object.defineProperty

  • Observer

  • Watcher

  • Dep

  • Directive

# 整体流程分析

vue

vue 采用数据劫持 & 发布-订阅模式的方式,通过 es5 提供的 Object.defineProperty 方法来劫持(监听)对象各属性的 getter 和 setter,并在数据发生变化时通知订阅者,触发相应的监听回调。

并且,由于是一个 watcher 对应一个组件,某个组件的数据变更只会引起对应组件的重新渲染,并不会影响其他组件

vue 在初始化的时候,会通过 Object.defineProperty 方法去劫持 data 里的数据,并将它们的读写转化成 getter 和 setter,然后通过订阅者 watcher 的 get() 方法去触发 observer 里的 getter。同时,触发 getter 的过程中会去调用 dep.depend() 方法,通过判断是否有 Dep.targetDep.target 就是 watcher),也就是说判断是否是通过 watcher 初始化调用的,如果是,depend() 方法里就会通过 Dep.target.addDep() 去收集依赖,依赖收集完成后,再把 Dep.target 置为 null,这样下次再获取同样的数据时,就不会重复收集依赖了。接着,再把获取到的数据通过 watcher.update() 重新渲染视图完成更新。到此,第一次初始化的过程就完成了。

当我们修改数据的时候,就会触发 setter,setter 通过调用 dep.notify() 方法(notify -> update -> run)去通知对应的 watcher,watcher 再去告诉对应的 Directive 更新视图。

因此,总的来说,要实现 vue 中的双向数据绑定,主要可以分为三个模块:Observer、Compiler、Watcher

  • Observer 是数据监听器,负责对数据对象的所有属性进行监听,监听到数据变化后通知订阅者。

  • Compiler 是指令解析器,扫描 vue 模板,并对指令进行解析,然后绑定事件。

  • Watcher 是订阅者,用于关联 Observer 和 Compiler,能够订阅并收到数据变化的通知,执行指令绑定的相应操作,更新视图。通过自身的 update 方法去执行 Compiler 中绑定的回调,更新视图。

# Object.defineProperty

Object.defineProperty (opens new window) 可以用来修改一个对象的属性,或者在对象上定义一个新增的属性,并返回该对象。

function defineReactive(data, key, value) {
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    get: function defineGet() {
      console.log(`get key:${key},value:${value}`);
      return value;
    },
    set: function defineSet(newValue) {
      console.log(`set key:${key},value:${newValue}`);
      value = newValue;
    }
  });
}

function observe(data) {
  Object.keys(data).forEach(function(key) {
    defineReactive(data, key, data[key]);
  });
}

let arr = ["abs", 2, 3];
observe(arr);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

Object.defineProperty 的缺点就是不能监听数组或对象新增的 key

this.$set() 和 Vue.set()

受 es5 的限制,vue 不能检测到对象属性的添加或删除。因为 vue 在初始化实例时将属性转为 getter/setter,所以属性必须在 data 对象上才能让 vue 转换它,才能让它是响应的。

当需要新增属性时,正确写法如下:

this.$set(this.data, key, value);
1

Vue.set() 方法用于向嵌套对象添加响应式属性。

const vm = new Vue({
  el:'#test',
  data:{
    user:{
      name:'小明';
    }
  }
});
Vue.set(vm.user, 'sex', '男');
1
2
3
4
5
6
7
8
9

vue 不允许动态添加根级响应式属性。比如以下代码会报错:

const app = new Vue({
  data: {
    a: 1
  }
  // render: h => h(Suduko)
}).$mount('#app1');

Vue.set(app.data, 'b', 2);
1
2
3
4
5
6
7
8

# 重写数组的方法

Object.defineProperty 在监听对象的时候,会先遍历它所有的 key,然后设置 getter 和 setter,进行监听。对象的 key 就是它的属性,数组的 key 就是它的索引。而在监听数组的时候,则会重写数组的原型方法。

为什么 vue 要重写数组的方法而不重写对象的呢?

这是因为数组的变更(插入、删除元素)会导致频繁移位,从而触发多次 getter 和 setter,最终会导致视图频繁更新。

vue 源码中重写数组的代码在 core/observer/array.js 中,可以看到,重写的都是数组中会造成频繁移位的方法。并且,在重写数组方法的过程中都没有触发 getter 和 setter,而是在最后手动去调用 notify 方法通知视图更新

重写数组方法的方式就是,如果是数组,并且调用的方法会改变数组长度,则会重新增加索引之后更新数组,重新进行监听

/*
 * not type checking this file because flow doesn't play well with
 * dynamically accessing methods on Array prototype
 */

import { def } from "../util/index";

const arrayProto = Array.prototype; // Array 构造函数的原型对象
export const arrayMethods = Object.create(arrayProto); // 新对象继承了数组构造方法的原型对象

const methodsToPatch = [
  "push",
  "pop",
  "shift",
  "unshift",
  "splice",
  "sort",
  "reverse"
];

/**
 * Intercept mutating methods and emit events
 */
methodsToPatch.forEach(function(method) {
  // cache original method
  const original = arrayProto[method];
  def(arrayMethods, method, function mutator(...args) {
    const result = original.apply(this, args);
    const ob = this.__ob__;
    let inserted; // 新增项
    switch (method) {
      // push、unshift、splice 都会新增索引
      case "push":
      case "unshift":
        inserted = args;
        break;
      case "splice":
        inserted = args.slice(2);
        break;
    }
    if (inserted) ob.observeArray(inserted); // 新增索引,才会重新处理成响应数据
    // notify change
    ob.dep.notify(); // 触发视图更新
    return result;
  });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

# 模板编译

# 整体流程

.vue 文件 => 打包编译(词法分析、语法分析、构建 ast、转义 js)=> 输出 render() 函数
1

# 细节分析

  1. vue2 在线编译 (opens new window)

  2. vue2 主要是用正则表达式去匹配模板的,当页面资源多的时候效率会很低。这也是 vue2 为什么改版的一个主要原因。

  3. 什么是运行时?

当一段程序要运行的时候,它必须先装载到内存中,然后 cpu 再去运行它,在这个运行的过程中,会保存当前程序运行的数据。这些保存的数据就是运行时,当前保留的状态就是运行时状态。

运行时(Runtime)就是当前程序运行过程中,保留的数据和状态。

  1. .vue 文件是在上线前就在本地被编译成了 js,属于离线编译;但是平时用下面这种方法写的 template 是在上线之后才会被编译成 js 的,属于在线编译

离线编译和在线编译都是采用正则的方式。

new Vue({
  data: {
    a: 1
  },
  template: "<div>{a}</div>"
});
1
2
3
4
5
6
  • 因为本地分析的时候是不会分析 js 代码的,只会分析 .vue 文件的内容。只有在运行的时候才会去分析 js 代码。

  • 因此,推荐使用 .vue 文件的方式来书写 vue 代码,这样上线的时候就已经是编译好的 js 代码了,可以直接构建运行时,运行效率更快。这是使用 vue 时的一个优化点。

  1. vue 的编译打包方式有 RuntimeRuntime + compiler 两种,这个 complier 就是用来在线编译解析 vue 模板的。使用后者打包后的 vendor 文件还会更大,更占用资源。因此,推荐直接使用 Runtime 进行编译打包。这也是使用 vue 时的一个优化点。

  2. vue 代码编译打包后的内容是包裹在 with 里面的。

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

编译后的代码如下:

function render() {
  with (this) {
    return _c(
      "div",
      {
        attrs: {
          id: "app"
        }
      },
      [_v(_s(msg))]
    );
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

vue 源码中使用 with 的场景和原因

在 vue 的源码中,with 语句主要用于简化对对象属性的访问,以减少代码中的重复性,提高代码的可读性和简洁性。with 语句的使用场景主要集中在编译模板的过程中,特别是在处理模板字符串时。

  • 简化属性访问:使用 with 语句可以省略对对象的重复访问,提高代码的可读性。例如,如果模板中的大部分内容都在某个对象下,可以使用 with 语句将对象引入到作用域链中,从而简化对属性的访问。
with (obj) {
  console.log(prop1); // 等同于 console.log(obj.prop1);
  console.log(prop2); // 等同于 console.log(obj.prop2);
}
1
2
3
4
  • 模板字符串的编译:在编译模板字符串为渲染函数时,with 语句用于引入组件实例的作用域,使得在模板中直接访问组件实例的数据和方法变得更加方便。
// 简化前
function render() {
  _c('div', { class: vm.someClass, on: { click: vm.handleClick } }, [
    _v(vm.message)
  ])
}

// 简化后
// 在这里,with 语句将组件实例 vm 引入到作用域链中,使得在模板中直接访问 vm 下的属性和方法更加简洁
function render() {
  with (vm) {
    _c('div', { class: someClass, on: { click: handleClick } }, [
      _v(message)
    ])
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

然而,需要注意的是,with (opens new window) 语句在 JavaScript 中有一些潜在的问题,可能导致作用域链的不确定性,影响代码的可维护性。因此,在一般的 JavaScript 开发中,并不推荐过多地使用 with 语句。在 vue 的源码中,with 的使用是在编译模板的特定环境下,为了简化模板编译过程而进行的优化。

# 虚拟 dom

vue2 的虚拟 dom 是基于 snabbdom (opens new window) 实现的,并不是它自己独立实现的。

上次更新时间: 2023年12月10日 21:09:54