双向数据绑定是 Vue
的一大亮点和重点,这里讲解一下原理并且重写一遍。版本为 2.x
。
原理
Vue 2.x
中的双向数据绑定原理来源于一个对象方法, Object.defineProperty
, 通过这个方法我们可以定义对象属性的 getter
和 setter
,于是我们可以在数据被重新设置(即调用 setter
时)进行视图更新等操作。
大致思路
使用 Vue
时我们会引入 vue
的默认导出,这个默认导出是一个构造函数,我们利用这个构造函数创建一个 vue
实例,并传入我们的配置参数,例如管理的 dom
元素以及 data
等参数,虽然在一般开发中我们不会在根实例上挂载数据,但此处仅做一个简单还原。
传入参数后,我们在构造函数内调用初始化函数,初始化操作一般为挂载配置项,初始化各种数据,例如 data
, computed
props
等。
我们首先需要对data
做一层代理,因为在配置项中传入的 data
一般为函数,以防止组件复用时因为引用相同而数据混乱。并且在 vue
实例中无法直接访问到数据,我们把 data
挂载到实例上,并使用 Object.defineProperty
使得我们可以使用 this
直接访问到各项 data
。
做完代理,我们需要对数据进行响应式处理,即观察数据项的修改。首先是 data
本身,我们抽出一个 observe
函数来进行观察操作,首先判断传入的观察对象的类型,如果不是引用类型其实没有观察的必要,因为普通类型的修改已经在父级做了观察,我们需要深层观察的是子对象、子数组、子对象子数组内部的引用类型等。
如果是引用类型,我们初始化一个观察者来进行观察。在观察者内部,我们迭代传入的引用类型,对每个 key
和 value
做一个响应式处理,当访问当前对象的 key
属性时,我们返回 value
。此处可以做一些操作,例如收集依赖。当设置当前对象的时候,我们去修改 value
的值。需要注意的是,此处不能够直接去获取或者修改 obj[key]
( obj
指代做响应式处理的对象或数组),因为我们正是对 obj[key]
做了观察,当我们观察到需要获取 key
属性时,getter
内部又是获取 key
属性,会造成死循环。setter
同理。
数组无法直接做响应式处理,我们选择遍历数组,对每个元素单独观察。但此处又有一个问题,因为 Object.defineProperty
无法响应数组的变化,当我们修改了数组,例如增加了新的元素,新增的元素没有被观察,数组的修改也没有被观察到。那么我们就需要重新定义数组的修改方法,就是 push
pop
sort
splice
shift
unshift
reverse
,我们使用数组原型创建一个新的数组对象,重写这个数组对象上的七个方法,在方法内部还是依然执行原本的函数内容,但此处我们就可以增加一些自己的操作,例如视图刷新等。定义完这个数组对象后,我们将它赋值给需要被响应式处理的数组的原型,那么就单独影响了我们响应式的数组,而不对其余的普通数组产生影响。
在这个过程中还有一些小点,就是每次对对象的值或是数组的元素观察时,我们需要注意这个元素是否也是一个引用类型,如果是的话,我们需要再对这个元素进行观察。
代码实现
首先配置一个 webpack
环境,下载 webpack
webpack-cli
webpack-dev-server
以及 html-webpack-plugin
。在项目根目录配置 webpack.config.js
。我们需要配置项目的入口,出口,插件,以及模块引入的规则。
// /webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: './src/index.js', // 定义入口
output: {
filename: 'index.js', // 定义出口的文件名
path: path.resolve(__dirname, 'dist') // 定义出口路径
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, 'public/index.html') // 定义文件模板位置
})
],
devtool: 'source-map',
resolve: {
// 配置模块引入的规则,首先从 src 目录寻找,再去 node_modules 中寻找。
modules: [path.resolve(__dirname, 'src'), path.resolve(__dirname, 'node_modules')]
}
};
再来看入口文件:
import Vue from 'vue';
// 实例化 Vue
let app = new Vue({
el: '#app',
data() {
return {
title: "Sirine' working time",
time: '2020-10-11',
info: {
name: 'zhao',
age: 21,
isGood: true
},
say: [
123,
'456',
{
a: 123,
b: [1, 2, 3, 4],
c: {
today: 'is'
}
}
]
};
}
});
此处只是简单创建了实例。
然后看 vue
文件夹。
// src/vue/index.js
import initState from './init'; // 引入初始化数据的函数。
function Vue(options) {
// vue 的构造函数,初始化实例。
this._init(options);
}
Vue.prototype._init = function (options) {
// 在原型上挂载初始化函数,将传入的参数挂载到实例上。
var vm = this;
vm.$options = options;
initState(vm); // 调用初始化数据的函数。
};
export default Vue;
接下来看 initState
这个函数。
// src/vue/init.js
import observe from './observe';
import proxyData from './proxy';
// 引入观察函数和代理数据函数。
function initState(vm) {
var options = vm.$options;
// 首先取出 options
if (options.data) {
// 判断参数中是否有 data ,有才执行初始化。
initData(vm);
}
}
function initData(vm) {
var _data = vm.$options.data; // 取出数据
_data = vm._data = typeof _data === 'function' ? _data.call(vm) : _data || {};
// 将数据挂载到实例上,并且判断是否是函数,是的话调用它,否的话保持原样,并确保至少是一个对象。
for (var key in _data) {
// 遍历 data 做一层代理。
proxyData(vm, '_data', key);
}
// 观察这个 data
observe(_data);
}
export default initState;
首先看 proxyData
函数。
// src/vue/proxy.js
function proxyData(data, target, key) {
// 传入的参数分别是做代理的对象,挂载的属性名,和当前的属性名
Object.defineProperty(data, key, {
get() {
// 当我们获取 data[key] 时,实际上是从 data[target][key] 中获取。
return data[target][key];
},
set(newVal) {
// 当我们设置 data[key] 时,实际上设置在了 data[target][key]。
data[target][key] = newVal;
}
});
}
export default proxyData;
此处做一个解释,我们在 initData
中将 options
中的 data
挂载到了 vm._data
上,而我们想要直接调用 vm.xxx
来获取,但是 vm
上并没有这个属性,它实际上是在 vm._data.xxx
上,所以我们做一层代理,当访问 vm.xxx
时,我们返回 vm._data.xxx
,当设置 vm.xxx = yyy
时,实际上是执行了 vm._data.xxx = yyy
。
然后再看 observe
函数。
// src/vue/observe.js
import Observer from './observer'; // 引入实际的观察者
function observe(data) {
if (typeof data !== 'object' || data === null) return;
// 首先判断数据类型,如果不是引用类型,不需要再深入观察了。
return new Observer(data); // 否则,返回一个观察者实例。
}
export default observe;
当我们想要深入观察时,都会调用 observe
函数,所以我们需要判断类型,原始类型已经在父级中被观察了,不需要继续深入。
// src/vue/observer.js
import defineReactiveData from './reactive';
import observeArr from './observeArr';
import arrMethods from './array';
function Observer(data) {
if (Array.isArray(data)) {
// 判断数据是否为数组,是数组的话,给它赋予变异数组方法,再观察这个数组。
data.__proto__ = arrMethods;
observeArr(data); // 观察数组的方法。
} else {
// 如果是对象的话。
var keys = Object.keys(data); // 遍历对象的键值
keys.forEach(key => {
var value = data[key]; // 获取每个值
defineReactiveData(data, key, value);
// 将对象,键名,键值传入响应式函数进行响应式绑定。
});
}
}
export default Observer;
此处引入的函数较多,一个个看。
先看对象部分,逻辑已经非常清晰,对这个对象的每个键值进行 getter
setter
重写,我们需要先取出每个值形成闭包用来访问,否则会出现死循环问题。
// src/vue/reactive.js
import observe from './observe';
function defineReactiveData(data, key, value) {
observe(value); // 继续观察这个传入的值,可能需要深入观察。
Object.defineProperty(data, key, {
get() {
console.log('响应式 get', value);
return value;
},
set(newVal) {
console.log('响应式 set', value, newVal);
if (value === newVal) return;
value = newVal;
}
});
}
export default defineReactiveData;
依然是熟悉的自定义 setter
和 getter
,函数内部可以写入许多其余逻辑来进行其他操作。
此处调用 observe(value)
是因为,data[key]
的值可能是数组和对象,它们的值是一个引用地址,不是覆盖的情况下无法监听到变化, 我们需要再次观察它,使用 observe
来判断,如果是引用类型会深入观察,不是的话就直接返回。
谈完了对象,再来看数组。
Object.defineProperty
无法监听到数组的变化,所以我们需要遍历数组,进行单独的监听。
// src/vue/observeArr.js
import observe from './observe';
function observeArr(arr) {
arr.forEach(item => {
observe(item);
});
}
export default observeArr;
而数组对于自身变化的方法我们需要进行重写,否则数组的修改没有办法监听。
// src/vue/array.js
import ARRAY_METHODS from './config';
import observe from './observe';
var arrayPrototype = Array.prototype; // 取出数组的原型对象
var arrMethods = Object.create(arrayPrototype); // 根据这个原型对象创建一个数组
ARRAY_METHODS.forEach(m => {
// 遍历七个数组方法,对我们创建的这个数组上写入我们自定义的方法。
arrMethods[m] = function () {
var args = arrayPrototype.slice.call(arguments); // 转换传入的参数为数组。
var result = arrayPrototype[m].apply(this, args); // 调用原本的方法。
console.log('动态修改数组', m);
var newItem; // 此处用于监听新增的数据。
switch (m) {
case 'push':
case 'unshift':
newItem = args;
break;
// 如果是 push unshift 方法,只需要监听我们传入的参数即可。
case 'splice':
newItem = args.slice(2);
break;
// 如果是 splice 方法,我们监听可能存在的第三个参数以及更多。
default:
break;
}
newItem && observe(newItem); // 如果存在新的数据,我们进行监听。
return result; // 将原本数组的返回指返回。
};
});
export default arrMethods;
到此差不多完成了简单的数据劫持,当我们动态修改这些数据的时候,会被监听到并且进行一些需要的操作。