前言
通过手写Vue2源码,更深入了解Vue; 在项目开发过程,一步一步实现Vue核心功能,我会将不同功能放到不同分支,方便查阅; 另外我会编写一些开发文档,阐述编码细节及实现思路; 源码地址:手写Vue2源码
数据初始化流程
先看一下入口页:
// src/index.js
import { initMixin } from "./init"
function Vue(options) {
this._init(options)
}
initMixin(Vue)
export default Vue
我们在 new Vue()
时会执行 this._init(options)
;这个 _init()
从原型上获取,定义在initMixin()
中;接下来我们在initMixin(Vue)
中往Vue原型上添加_init()
方法:
// src/init.js
import { initState } from "./state";
export function initMixin(Vue) {
Vue.prototype._init = function (options) {
// this指向实例
const vm = this;
vm.$options = options; // 在实例上添加$options属性
callHook(vm, "beforeCreate");
// 初始化状态,包括initProps、initMethod、initData、initComputed、initWatch等
initState(vm);
callHook(vm, "created");
// 如果有el属性 进行模板渲染
if (vm.$options.el) {
vm.$mount(vm.$options.el);
}
};
Vue.prototype.$mount = function(el) {}
}
_init()
方法中比较核心的方法是initState(vm)
及vm.$mount
;$mount
是与模板编译、VNode、diff算法、页面挂载相关,我们后面再分析;下面我们着重看一下initState(vm)
:
// src/state.js
import { observe } from "./observer/index";
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(vm) {
let data = vm.$options.data;
// 往实例上添加一个属性 _data,即传入的data
// vue组件data推荐使用函数 防止数据在组件之间共享
data = vm._data = isFunction(data) ? data.call(vm) : data;
// 将vm._data上的所有属性代理到 vm 上
for (let key in data) {
proxy(vm, "_data", key);
}
// 对数据进行观测 -- 数据响应式
observe(data);
}
function initComputed() {}
function initWatch() {}
// 将vm._data上的属性代理到 vm 上
function proxy(vm, source, key) {
Object.defineProperty(vm, key, {
get() {
return vm[source][key];
},
set(newValue) {
vm[source][key] = newValue;
},
});
}
}
其中 initData(vm)
是数据的初始化,在该函数中我们往实例上添加了一个 vm._data
属性,保存data函数返回的数据,另外我们使用 Object.defineProperty
对数据进行代理,实现了通过 vm.key
可以访问到 vm._data[key]
;另外我们使用 observe(data)
对数据进行监测,observe()
就是实现数据劫持的核心逻辑。
observe()做了什么
思考一下,如何实现observe()?
- 第一次调用data的时候,它一定是个对象(data函数返回一个对象);如果对象的属性还是个对象,那么我们就需要进行递归监听,即递归调用observe();如果对象的属性是一个基本数据类型,由于它们已经劫持过了,就无需再次劫持;所以我们需要在observe()中对数据类型进行过滤;
- 如果已经经历过数据劫持的数据,无需再次劫持,所以我们需要在进行数据劫持的时候,添加一个标识符__ob__,并在observe()中对标识符进行判断;
- observe()只是一个入口文件,具体的劫持流程,我们放到Observer class中执行
// src/observer/index.js
export function observe(data) {
// 如果是object类型(对象或数组)才观测;第一次调用observe(vm.$options._data)时,_data一定是个对象(data函数返回一个对象)
if (!(typeof data === 'object' && data !== null)) {
return;
}
// 如果已经是响应式的数据,直接return
if (data.__ob__) {
return;
}
// 返回经过响应式处理之后的data
return new Observer(data);
}
Observer是一个class,在Observer中对数据进行劫持;
class Observer如何处理对象和数组
思考一下如何进行数据劫持?
- 在劫持时需要给数据添加一个标识
__ob__
- 需要考虑两种数据类型:对象和数组
- 对于对象,先遍历,使用
Object.defineProperty
对每个属性进行劫持;然后递归调用observe(),对子孙属性进行劫持 - 对于数组,如果同样遍历数组,使用
Object.defineProperty
对每个索引进行劫持,当数组长度很长时,性能很差;考虑到用户直接通过索引修改数组的情况很少,我们通过对数据使用原型继承,在原型上重写7中改变原数组的操作方法,在数组方法中进行劫持。如果数组某一项是对象/数组,还需要递归调用observe()进行劫持。
// src/observer/index.js
class Observer {
// 通过new命令生成class实例时,会自动调用constructor()
constructor(data) {
// 在数据data上新增属性 data.__ob__;指向经过new Observer(data)创建的实例,可以访问Observer.prototype上的方法observeArray、walk等
// 所有被劫持过的数据都有__ob__属性(通过这个属性可以判断数据是否被检测过)
Object.defineProperty(data, "__ob__", {
// 值指代的就是Observer的实例,即监控的数据
value: this,
// 设为不可枚举,防止在forEach对每一项响应式的时候监控__ob__,造成死循环
enumerable: false,
writable: true,
configurable: true,
});
/**
* 思考一下数组如何进行响应式?
* 和对象一样,对每一个属性进行代理吗?
* 如果数组长度为10000,给每一项设置代理,性能非常差!
* 用户很少通过索引操作数组,我们只需要重写数组的原型方法,在方法中进行响应式即可。
*/
if (Array.isArray(data)) {
// 数组响应式处理
// 重写数组的原型方法,将data原型指向重写后的对象
data.__proto__ = arrayMethods;
// 如果数组中的数据是对象,需要监控对象的变化
this.observeArray(data);
} else {
// 对象响应式处理
this.walk(data);
}
}
observeArray(data) {
// 【关键】遍历数组,递归调用observe,监控数组每一项(observe会筛选出对象和数组,其他的不监控)的改变,数组长度很长的话,会影响性能
// 【*********】数组并没有对索引进行监控,但是对数组的原型方法进行了改写,还对每一项(数组和对象类型)进行了监控
data.forEach((item) => {
observe(item);
});
}
walk(data) {
Object.keys(data).forEach((key) => {
// 对data中的每个属性进行响应式处理
defineReactive(data, key, data[key]);
});
}
}
使用defineReactive对对象的数据进行劫持
思考一下defineReactive需要实现哪些功能?
- 使用 Object.defineProperty() 劫持对象属性;
- 对属性值递归调用observe()
// src/observer/index.js
function defineReactive(data, key, value) {
observe(value); // 【关键】递归,劫持对象中所有层级的所有属性
// 如果Vue数据嵌套层级过深 >> 性能会受影响【******************************】
Object.defineProperty(data, key, {
get() {
// todo...收集依赖
return value;
},
set(newVal) {
// 对新数据进行观察
observe(newVal);
value = newVal;
// todo...更新视图
},
});
}
在get()中进行依赖收集,在set()中更新视图,后续再实现;
数组数据劫持
实现思路:
- 数组数据通过原型继承,重写数据的原型
- 在原型上重写数组方法
- 如果数组方法有新增数据,需要对新增数据进行劫持
- 遍历数组,如果数组元素是对象/数组类型,还需要进行递归劫持
实现第1、4步:
// src/observer/index.js
class Observer {
constructor(data) {
if (Array.isArray(data)) {
// 1. 数组数据通过原型继承,重写数据的原型
data.__proto__ = arrayMethods;
// 4. 遍历数组,如果数组元素是对象/数组类型,还需要进行递归劫持
this.observeArray(data);
} else {
// 对象响应式处理
this.walk(data);
}
}
observeArray(data) {
data.forEach((item) => {
observe(item);
});
}
}
实现第2、3步:
// src/observer/array.js
let oldArrayPrototype = Array.prototype
export let arrayMethods = Object.create(oldArrayPrototype)
let methods = [
'push',
'pop',
'unshift',
'shift',
'sort',
'reverse',
'splice'
]
methods.forEach(method => {
// 2. 在原型上重写数组方法
arrayMethods[method] = function(...args) {
const result = oldArrayPrototype[method].call(this,...args)
const ob = this.__ob__;
let inserted;
switch (method) {
case 'push':
case 'unshift':
inserted = args;
break;
case "splice":
inserted = args.slice(2);
default:
break;
}
// 3. 如果数组方法有新增数据,需要对新增数据进行劫持
if (inserted) ob.observeArray(inserted);
return result
}
})
Vue2数据劫持的缺点及开发注意事项
缺点:
- 无法原生劫持数组
- 通过调用重写的数组方法修改数据能被监测到
- 由于数组经过了遍历递归劫持,如果数组元素是对象,修改对象属性也能触发响应式【但是通过索引修改该对象整体,无法触发响应式;因为defineReactive劫持的是该对象属性,而不是该对象本身】
- 如果是嵌套的多维数组,修改深层数组也能触发响应式(在defineReactive中的getter里,对多维数组进行了遍历递归依赖收集,所以多维数组也会影响性能;后续文章再补充)
- 通过索引修改基本类型的数组元素,或修改数组长度,新增/删除元素,无法被监测
- 新增和删除属性无法被劫持到:对象的劫持使用的是Object.defineProperty(),新增和删除属性无法触发getter、setter
- 对象劫持需要一次性递归到底,给所有层级所有属性添加getter、setter,当数据复杂层级很深时,会影响性能
开发注意事项:
- 使用
$set
、$delete
修改和删除数据 - 当需要新增对象子属性时,可以通过直接修改整个父属性来触发setter,例如:
this.obj = Object.assign(this.obj, {k: v})
,而使用this.obj.k = v
无法触发setter
系列文章
- 手写Vue2源码(一)—— 环境搭建
- 手写Vue2源码(二)—— 数据劫持
- 手写Vue2源码(三)—— 模板编译
- 手写Vue2源码(四)—— 初次渲染
- 手写Vue2源码(五)—— 观察者模式
- 手写Vue2源码(六)—— 异步更新及nextTick
- 手写Vue2源码(七)—— 侦听属性
- 手写Vue2源码(八)—— 计算属性
- 手写Vue2源码(九)—— 混入原理与生命周期
- 手写Vue2源码(十)—— 组件原理
- 手写Vue2源码(十一)—— diff算法
- 手写Vue2源码(十二)—— keep-alive
- 手写Vue2源码(十三)—— 全局API
- vue-router原理解析
- vuex原理解析
- vue3原理解析