【Vue源码】Vue核心应用之响应式原理

Vue全家桶的系统学习,其中包括Vue源码分析Vue-Router的使用和原理Vuex的用法和原理Vue-ssr、如何进行单元测试 和 一些常见的Vue面试题


参考官网 vue.js官网

Vue全家桶的使用,已经是一个老生常谈的话题了。

具体的使用可以去参考Vue的官网,上面有很详细的用法介绍。

这篇文章中,我们会详细介绍一下Vue的响应式原理

准备工作

我们先要进行前期的准备工作。

首先我们要先创建一个默认的 package.json 文件。

1
npm init -y

配置文件创建好后,我们就需要使用 rollup 来进行代码编译了。

Rollup环境配置

因为 webpack 太大了,不方便做测试,所以我们会使用 rollup 作为打包工具。

rollup 简单的来说,就是一个 JavaScript 模块打包器,可以将小块代码编译成大块复杂的代码。rollup.js 更专注于Javascript类库打包 (开发应用时使用 Webpack,开发库时使用 Rollup)

随后我们来安装 rollup 及其相应的插件。

1
npm install @babel/preset-env @babel/core rollup rollup-plugin-babel rollup-plugin-serve cross-env -d
  • rollup:我们需要用到的模块打包工具。
  • rollup-plugin-babelbabelrollup 之间互通的插件,用来对ES6代码进行转义与编译。
  • @babel/preset-env / @babel/corebabel 需要用到的关联插件。
  • rollup-plugin-serve:可以用来启动本地服务。
  • cross-env:运行跨平台设置和使用环境变量的脚本。

随后我们来创建一个 rollup.config.js 文件作为它的配置文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import babel from "rollup-plugin-babel";
import serve from "rollup-plugin-serve";
export default {
input: "./src/index.js",
output: {
format: "umd", // 模块化类型
file: "dist/umd/vue.js",
name: "Vue", // 打包后的全局变量的名字
sourcemap: true,
},
plugins: [
babel({
exclude: "node_modules/**",
}),
process.env.ENV === "development"
? serve({
open: true,
openPage: "/public/index.html",
port: 3000,
contentBase: "",
})
: null,
],
};

配置 .babelrc 文件用来处理babel。

1
2
3
{
"presets": ["@babel/preset-env"]
}

package.json 中进行如下配置。

1
2
3
4
"scripts": {
"build:dev": "rollup -c",
"serve": "cross-env ENV=development rollup -c -w"
}

最终项目结构如下。

1
2
3
4
5
6
7
8
9
|-- vue-test
|-- .babelrc
|-- package-lock.json
|-- package.json
|-- rollup.congfig.js
|-- public
| |-- index.html
|-- src
|-- index.js

这样我们的配置文件就创建完毕了。

初始化流程

使用过Vue后都知道,Vue在作为插件库使用的时候,都是以 option Api选项进行配置)的形式进行创建的。

1
2
3
4
5
6
7
8
var vm = new Vue({
el: '#app',
data() {
return {
a: 1
}
}
})

所以我们第一步要实现的,就是将 Vue作为构造函数,并导出

导出构造函数

先来看一下完整的导出代码。

1
2
3
4
5
6
7
import {initMixin} from './init';

function Vue(options) {
this._init(options);
}
initMixin(Vue); // 给原型上新增_init方法
export default Vue;

(注:这里我们不采用ES6类的写法,因为它会将类或函数作为一个整体进行编写。我们希望它可以分散到不同的文件中,所以采用ES5构造函数的方式进行定义,这样在结构上看起来会更清晰。)

  1. 将创建的构造函数导出,他会接收一个参数options

    options 其实就是我们传入的属性,包括 eldatamethod 等。

    1
    2
    3
    4
    function Vue(options) {
    console.log(options); // 实例化时传入的"属性"
    }
    export default Vue;
  2. 新增 _init方法

    options 接收到了,我们就需要通过传入的参数对 Vue进行初始化

    1
    2
    3
    4
    5
    6
    7
    function Vue(options) {
    this._init(options);
    }
    Vue.prototype._init = function(){
    // ...
    }
    export default Vue;

    我们再对其进行 解耦,可以得到一个入口方法,可以使所有文件进行初始化操作

    1
    2
    3
    4
    5
    6
    7
    import {initMixin} from './init';

    function Vue(options) {
    this._init(options);
    }
    initMixin(Vue); // 给原型上新增_init方法
    export default Vue;

这个文件的最终目的是向 Vue原型上扩展方法

很明显,我们目前就需要对 Vue进行初始化

初始化Vue状态

上一步中,我们创建了 Vue类,并传入了 options参数

这一步我们就要将需要的内容封装成插件。

1
2
3
4
5
6
7
8
9
import {initState} from './state';
export function initMixin(Vue){
Vue.prototype._init = function (options) {
const vm = this; // 获取当前实例
vm.$options = options
// 初始化状态
initState(vm);
}
}

options 参数被传入了 vm.$options 中,这样我们就可以使用 $options 来进行配置。

initState 就是对状态进行初始化,也就是对数据进行一个初始化的劫持当我改变数据时,视图也会更新)。

根据属性进行初始化操作

Vue并不完全属于MVVM,它只是参考的MVVM。其包含的 $ref 就可以说明这个问题,它还是可以操作DOM的。

现在我们就需要对不同的属性进行不同的数据劫持。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export function initState(vm){
const opts = vm.$options;
if(opts.props){
initProps(vm);
}
if(opts.methods){
initMethod(vm);
}
if(opts.data){
// 初始化data
initData(vm);
}
if(opts.computed){
initComputed(vm);
}
if(opts.watch){
initWatch(vm);
}
}
function initProps(){}
function initMethod(){}
function initData(){}
function initComputed(){}
function initWatch(){}

根据不同的传入参数,来进行不同的处理。

下面我们就可以对拆分出来的不同属性,进行不同的初始化处理了。

数据(data)初始化

扩展 initData 方法之前,我们需要先知道一个概念。什么是数据劫持

数据劫持 就是通过 Object.defineProperty 来重写对象的 gettersetter 。数据更新时视图会发生改变,而视图改变时数据也会跟着更新,从而达到一个 视图和数据 互相影响的效果。

这种设计模式我们又称呼它为 观察者模式

1
2
3
4
5
6
import {observe} from './observer/index.js'
function initData(vm){
let data = vm.$options.data;
data = vm._data = typeof data === 'function' ? data.call(vm) : data; // 如果data是函数,改变其this指向
observe(data);
}

vm._data 的作用其实就是为了让 vm实例上可以获取到 data 的值

下面我们就来利用观察者模式,实现Vue中的数据劫持

对象属性劫持

数据劫持主要包括 对象劫持数组劫持 两个方面。

先来看一下对象属性劫持的完整代码。

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
class Observer {
// 观测值
constructor(value) {
this.walk(value);
}
walk(data) {
let keys = Object.keys(data);
keys.forEach((key) => {
defineReactive(data, key, data[key]);
});
}
}
function defineReactive(data, key, value) {
observe(value);
Object.defineProperty(data, key, {
get() {
return value;
},
set(newValue) {
if (newValue == value) return;
observe(newValue);
value = newValue;
},
});
}
export function observe(data) {
if (typeof data !== "object" || data == null) {
return;
}
return new Observer(data);
}
  1. 导出 observe 函数,进行类型判断。

    当前文件,我们只处理 Object 类型的值。不是 Object 类型的值不做处理。

    1
    2
    3
    4
    5
    6
    export function observe(data) {
    if (typeof data !== "object" || data == null) {
    return;
    }
    return new Observer(data);
    }
  2. 完善 Observer 类,让对象上的所有属性依次进行观测。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    class Observer {
    // 观测值
    constructor(value) {
    this.walk(value);
    }
    // 让对象上的所有属性依次进行观测
    walk(data) {
    let keys = Object.keys(data); // 获取对象上的key值
    keys.forEach((key) => {
    defineReactive(data, key, data[key]);
    });
    }
    }
  3. 定义 defineReactive 函数,用来对 Object.defineProperty 进行封装。

    循环调用 observe 函数,保证当前对象内的所有属性都被监听到。

    注:主要是为了处理 *{a:{a:{a:1}}}** 这种情况。但这样处理,层级越深性能越差,所以在 Vue3 中将其替换成了 proxy 进行处理。*)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    function defineReactive(data, key, value) {
    observe(value);
    Object.defineProperty(data, key, {
    get() {
    return value;
    },
    set(newValue) {
    if (newValue == value) return;
    observe(newValue);
    value = newValue;
    },
    });
    }

    setter 方法中我们会注意到,我们又重新监听了一次 newValue 值的变化。这个是因为用户在赋值时,可能会传入一个新的 Object 类型的对象,所以我们需要对新传入的值,也进行一次监听。保证所有值的 getset 都是被重写的。

如果我们使用这种方法去处理数组,也是可以行得通的。但是只能通过索引去进行监听,我们在处理数组时一般会使用专门的方法去进行处理(pushshiftpop等等)。为了性能考虑,我们还需要单独对数组方法进行处理。

数组方法劫持

其实数组的劫持,就是对原型上数组的方法进行重写

首先,需要先判断当前传入的值是否是数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import {arrayMethods} from './array';
class Observer { // 观测值
constructor(value) {
if (Array.isArray(value)) {
value.__proto__ = arrayMethods; // 重写数组原型方法
this.observeArray(value);
} else {
this.walk(value);
}
}
// 循环并观测数组上的每一个值
observeArray(value) {
value.forEach((item) => {
observe(item);
});
}
}

observeArray 可以保证数组上的每一个值都是被检测的,包括 Object 和 其他类型。

arrayMethods 就是重写了当前数组原型上的方法。

重写数组原型方法

下面我们来看一下 arrayMethods 到底是什么。

1
2
3
4
5
6
7
8
9
10
let oldArrayProtoMethods = Array.prototype;
export let arrayMethods = Object.create(oldArrayProtoMethods);
let methods = ["push", "pop", "shift", "unshift", "reverse", "sort", "splice"]; // 需要重写的方法
methods.forEach((method) => {
arrayMethods[method] = function (...args) {
const result = oldArrayProtoMethods[method].apply(this, args);
// ...
return result;
};
});

上面的意思是将 原始数组原型上的方法继承一份,并导出。然后将我们需要重写的方法循环出来,依次进行处理

我们这里使用 Object.create 相当于 arrayMethods.__proto__ = oldArrayProtoMethods,这样会只处理重写后的方法,没重写的方法不会被处理。

现在,我们需要对一些特定的方法进行处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ...
methods.forEach((method) => {
arrayMethods[method] = function (...args) {
// ...
const ob = this.__ob__;
let inserted; // 需要添加的项
switch (method) {
case "push":
case "unshift":
inserted = args;
break;
case "splice":
inserted = args.slice(2); // 截取新添加的项
default:
break;
}
if (inserted) ob.observeArray(inserted); // 对新添加的每一项进行观测
return result;
};
});

pushunshift 都是添加项的意思,一个是在头部添加,一个是在尾部添加。splice 从第三个参数开始,就是添加的项。

因为新添加的项可能也是 Object 类型,所以我们需要再次 对新添加的项进行劫持

但是这里就出现了一个问题,我们并没有办法使用劫持方法。又已知当前方法中的this就是被调用数组的value上面的 apply改变了 this指向)。所以我们就可以在value上增加一个属性__ob__

增加 __ ob __ 属性

__ob__ 属性的作用是给所有响应式数据增加标识,用来判断当前对象是否被劫持过。并且可以在响应式上获取Observer实例上的方法

1
2
3
4
5
6
7
8
9
10
11
class Observer { 
constructor(value){
// 添加 __ob__
Object.defineProperty(value, "__ob__", {
enumerable: false, // 不可枚举,也就是不能被循环出来
configurable: false, // 不能删除
value: this,
});
// ...
}
}

这样我们就在每一项中都追加了一个 __ob__ 属性,现在我们还需要进行一步处理。

1
2
3
4
5
6
7
8
9
10
// ...
export function observe(data) {
if (typeof data !== "object" || data == null) {
return data;
}
if (data.__ob__) {
return data;
}
return new Observer(data);
}

如果当前项已经被添加了 __ob__ 属性,则不进行处理。这样可以防止数据被重复劫持。

这样我们就完成了对数组的数据劫持。

注:在Vue中不能直接修改指定项,需要通过Array数组中的方法来进行修改。Vue.$set 可以修改特定值,后续我会详细介绍一下其使用和原理。

属性代理

我们已经对数据进行了相应的劫持,现在可以对 data 进行相应的操作了。

1
2
3
4
5
6
7
let vm = new Vue({
// ...
})
vm._data.arr.push({
b: 2
});
vm._data.arr[1].b = 3;

setget 方法都可以被正常触发。

但是这种操作太麻烦了,所以我们现在需要对属性进行代理,让用户可以直接通过 vm.arr 这种方式直接修改值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function proxy(vm, source, key) {
Object.defineProperty(vm, key, {
get() {
return vm[source][key];
},
set(newValue) {
vm[source][key] = newValue;
},
});
}
function initData(vm) {
let data = vm.$options.data;
data = vm._data = typeof data === "function" ? data.call(vm) : data;
for (let key in data) {
// 将_data上的属性全部代理给vm实例
proxy(vm, "_data", key);
}
observe(data);
}

同样是使用 Object.defineProperty 对传入的属性进行劫持监听。

当用户到 vm 中获取属性时,属性的值被代理到 vm._data 上。

至此,我们的Vue2响应式原理就介绍完毕了。


本篇文章由 莫小尚 创作,文章中如有任何问题和纰漏,欢迎您的指正与交流。
您也可以关注我的 个人站点博客园掘金,我会在文章产出后同步上传到这些平台上。
最后感谢您的支持!

请打赏并支持一下作者吧~

欢迎关注我的微信公众号