前言
作为 Vue 面试中的必考题之一,Vue 的响应式原理,想必用过 Vue 的同学都不会陌生,Vue 官方文档 对响应式要注意的问题也都做了详细的说明。
但是对于刚接触或者了解不多的同学来说,可能还会感到困惑:为什么不能检测到对象属性的添加或删除?为什么不支持通过索引设置数组成员?相信看完本期文章,你一定会豁然开朗。
本文会结合 Vue 源码分析,针对整个响应式原理一步步深入。当然,如果你已经对响应式原理有一些认识和了解,大可以 直接前往实现部分 MVVM
文章仓库和源码都在 ?? fe-code,欢迎 star。
经大佬提醒,Vue 并不完全是 MVVM 模型,大家审慎阅读。
虽然没有完全遵循 MVVM 模型,但是 Vue 的设计也受到了它的启发。因此在文档中经常会使用 vm (ViewModel 的缩写) 这个变量名表示 Vue 实例。 — Vue 官网
Vue 官方的响应式原理图镇楼。
思考
进入主题之前,我们先思考如下代码。
<template>
<div>
<ul>
<li v-for="(v, i) in list" :key="i">{{v.text}}li>
ul>
div>
template>
<script>
export default{
name: 'responsive',
data() {
return {
list: []
}
},
mounted() {
setTimeout(_ => {
this.list = [{text: 666}, {text: 666}, {text: 666}];
},1000);
setTimeout(_ => {
this.list.forEach((v, i) => { v.text = i; });
},2000)
}
}
script>
复制代码
我们知道在 Vue 中,会通过 Object.defineProperty
将 data 中定义的属性做数据劫持,用来支持相关操作的发布订阅。而在我们的例子里,data 中只定义了 list 为一个空数组,所以 Vue 会对它进行劫持,并添加对应的 getter/setter。
所以在 1 s 的时候,通过 this.list = [{text: 666}, {text: 666}, {text: 666}]
给 list 重新赋值,便会触发 setter,进而通知对应的观察者(这里的观察者是模板编译)做更新。
在 2 s 的时候,我们又通过数组遍历,改变了每一个 list 成员的 text 属性,视图再次更新。这个地方需要引起我们的注意,如果在循环体内直接用 this.list[i] = {text: i}
来做数据更新操作,数据可以正常更新,但是视图不会。这也是前面提到的,不支持通过索引设置数组成员。
但是我们用 v.text = i
这样的方式,视图却能正常更新,这是为什么?按照之前说的,Vue 会劫持 data 里的属性,可是 list 内部成员的属性,明明没有进行数据劫持啊,为什么也能更新视图呢?
这是因为在给 list 做 setter 操作时,会先判断赋的新值是否是一个对象,如果是对象的话会再次进行劫持,并添加和 list 一样的观察者。
我们把代码再稍微修改一下:
// 视图增加了 v-if 的条件判断
<ul>
<li v-for="(v, i) in list" :key="i" v-if="v.status === '1'">{{v.text}}li>
ul>
// 2 s 时,新增状态属性。
mounted() {
setTimeout(_ => {
this.list = [{text: 666}, {text: 666}, {text: 666}];
},1000);
setTimeout(_ => {
this.list.forEach((v, i) => {
v.text = i;
v.status = '1'; // 新增状态
});
},2000)
}
复制代码
如上,我们在视图增加了 v-if 的状态判断,在 2 s 的时候,设置了状态。但是事与愿违,视图并不会像我们期待的那样在 2 s 的时候直接显示 0、1、2,而是一直是空白的。
这是很多新手易犯的错误,因为经常会有类似的需求。这也是我们前面提到的 Vue 不能检测到对象属性的添加或删除。如果我们想达到预期的效果该怎么做呢?很简单:
// 在 1 s 进行赋值操作时,预置 status 属性。
setTimeout(_ => {
this.list = [{text: 666, status: '0'}, {text: 666, status: '0'}, {text: 666, status: '0'}];
},1000);
复制代码
当然 Vue 也 提供了 vm.$set( target, key, value )
方法来解决特定情况下添加属性的操作,但是我们这里不太适用。
Vue 响应式原理
前面我们讲了两个具体例子,举了易犯的错误以及解决办法,但是我们依然只知道应该这么去做,而不知道为什么要这么去做。
Vue 的数据劫持依赖于 Object.defineProperty
,所以也正是因为它的某些特性,才引起这个问题。不了解这个属性的同学看这里 MDN。
Object.defineProperty 基础实现
Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象。— MDN
看一个基础的数据劫持的栗子,这也是响应式最根本的依赖。
function defineReactive(obj, key, val) {
Object.defineProperty(obj, key, {
enumerable: true, // 可枚举
configurable: true,
get: function() {
console.log('get');
return val;
},
set: function(newVal) {
// 设置时,可以添加相应的操作
console.log('set');
val += newVal;
}
});
}
let obj = {name: '成龙大哥', say: ':其实我之前是拒绝拍这个游戏广告的,'};
Object.keys(obj).forEach(k => {
defineReactive(obj, k, obj[k]);
});
obj.say = '后来我试玩了一下,哇,好热血,蛮好玩的';
console.log(obj.name + obj.say);
// 成龙大哥:其实我之前是拒绝拍这个游戏广告的,后来我试玩了一下,哇,好热血,蛮好玩的
obj.eat = '香蕉'; // ** 没有响应
复制代码
可以看见,Object.defineProperty
是对已有属性进行的劫持操作,所以 Vue 才要求事先将需要用到的数据定义在 data 中,同时也无法响应对象属性的添加和删除。被劫持的属性会有相应的 get、set 方法。
另外,Vue 官方文档 上说:由于 JavaScript 的限制,Vue 不支持通过索引设置数组成员。对于这一点,其实直接通过下标来对数组进行劫持,是可以做到的。
let arr = [1,2,3,4,5];
arr.forEach((v, i) => { // 通过下标进行劫持
defineReactive(arr, i, v);
});
arr[0] = 'oh nanana'; // set
复制代码
那么 Vue 为什么不这么处理呢?尤大官方回答是性能问题。关于这个点更详细的分析,各位可以移步 Vue为什么不能检测数组变动?
Vue 源码实现
以下代码 Vue 版本为:2.6.10。
Observer
我们知道了数据劫持的基础实现,顺便再看看 Vue 源码是如何做的。
// observer/index.js
// Observer 前的预处理方法
export function observe (value: any, asRootData: ?boolean): Observer | void {
if (!isObject(value) || value instanceof VNode) { // 是否是对象或者虚拟dom
return
}
let ob: Observer | void
// 判断是否有 __ob__ 属性,有的话代表有 Observer 实例,直接返回,没有就创建 Observer
if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
ob = value.__ob__
} else if ( // 判断是否是单纯的对象
shouldObserve &&
!isServerRendering() &&
(Array.isArray(value) || isPlainObject(value)) &&
Object.isExtensible(value) &&
!value._isVue
) {
ob = new Observer(value) // 创建Observer
}
if (asRootData && ob) {
ob.vmCount++
}
return ob
}
// Observer 实例
export class Observer {
value: any;
dep: Dep;
vmCount: number; // number of vms that have this object as root $data
constructor (value: any) {
this.value = value
this.dep = new Dep() // 给 Observer 添加 Dep 实例,用于收集依赖,辅助 vm.$set/数组方法等
this.vmCount = 0
// 为被劫持的对象添加__ob__属性,指向自身 Observer 实例。作为是否 Observer 的唯一标识。
def(value, '__ob__', this)
if (Array.isArray(value)) { // 判断是否是数组
if (hasProto) { // 判断是否支持__proto__属性,用来处理数组方法
protoAugment(value, arrayMethods) // 继承
} else {
copyAugment(value, arrayMethods, arrayKeys) // 拷贝
}
this.observeArray(value) // 劫持数组成员
} else {
this.walk(value) // 劫持对象
}
}
walk (obj: Object) { // 只有在值是 Object 的时候,才用此方法
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i]) // 数据劫持方法
}
}
observeArray (items: Array) { // 如果是数组,则调用 observe 处理数组成员
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i]) // 依次处理数组成员
}
}
}
复制代码
上面需要注意的是 __ob__
属性,避免重复创建,__ob__
上有一个 dep 属性,作为依赖收集的储存器,在 vm.$set、数组的 push 等多种方法上需要用到。然后 Vue 将对象和数组分开处理,数组只深度监听了对象成员,这也是之前说的导致不能直接操作索引的原因。但是数组的一些方法是可以正常响应的,比如 push、pop 等,这便是因为上述判断响应对象是否是数组时,做的处理,我们来看看具体代码。
// observer/index.js
import { arrayMethods } from './array'
const arrayKeys = Object.getOwnPropertyNames(arrayMethods)
// export function observe 省略部分代码
if (Array.isArray(value)) { // 判断是否是数组
if (hasProto) { // 判断是否支持__proto__属性,用来处理数组方法
protoAugment(value, arrayMethods) // 继承
} else {
copyAugment(value, arrayMethods, arrayKeys) // 拷贝
}
this.observeArray(value) // 劫持数组成员
}
// ···
// 直接继承 arrayMethods
function protoAugment (target, src: Object) {
target.__proto__ = src
}
// 依次拷贝数组方法
function copyAugment (target: Object, src: Object, keys: Array ) {
for (let i = 0, l = keys.length; i < l; i++) {
const key = keys[i]
def(target, key, src[key])
}
}
// util/lang.js def 方法长这样,用来给对象添加属性
export function def (obj: Object, key: string, val: any, enumerable?: boolean) {
Object.defineProperty(obj, key, {
value: val,
enumerable: !!enumerable,
writable: true,
configurable: true
})
}
复制代码
可以看到关键点在 arrayMethods
上,我们再继续看:
// observer/array.js
import { def } from '../util/index'
const arrayProto = Array.prototype // 存储数组原型上的方法
export const arrayMethods = Object.create(arrayProto) // 创建一个新的对象,避免直接改变数组原型方法
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
// 重写上述数组方法
methodsToPatch.forEach(function (method) {
const original = arrayProto[method]
def(arrayMethods, method, function mutator (...args) { //
const result = original.apply(this, args) // 执行指定方法
const ob = this.__ob__ // 拿到该数组的 ob 实例
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2) // splice 接收的前两个参数是下标
break
}
if (inserted) ob.observeArray(inserted) // 原数组的新增部分需要重新 observe
// notify change
ob.dep.notify() // 手动发布,利用__ob__ 的 dep 实例
return result
})
})
复制代码
由此可见,Vue 重写了部分数组方法,并且在调用这些方法时,做了手动发布。但是 Vue 的数据劫持部分我们还没有看到,在第一部分的 observer 函数的代码中,有一个 defineReactive 方法,我们来看看:
export function defineReactive (
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
) {
const dep = new Dep() // 实例一个 Dep 实例
const property = Object.getOwnPropertyDescriptor(obj, key) // 获取对象自身属性
if (property && property.configurable === false) { // 没有属性或者属性不可写就没必要劫持了
return
}
// 兼容预定义的 getter/setter
const getter = property && property.get
const setter = property && property.set
if ((!getter || setter) && arguments.length === 2) { // 初始化 val
val = obj[key]
}
// 默认监听子对象,从 observe 开始,返回 __ob__ 属性 即 Observer 实例
let childOb = !shallow && observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val // 执行预设的getter获取值
if (Dep.target) { // 依赖收集的关键
dep.depend() // 依赖收集,利用了函数闭包的特性
if (childOb) { // 如果有子对象,则添加同样的依赖
childOb.dep.depend() // 即 Observer时的 this.dep = new Dep();
if (Array.isArray(value)) { // value 是数组的话调用数组的方法
dependArray(value)
}
}
}
return value
},
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val
// 原有值和新值比较,值一样则不做处理
// newVal !== newVal && value !== value 这个比较有意思,但其实是为了处理 NaN
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
if (process.env.NODE_ENV !== 'production' && customSetter) {
customSetter()
}
if (getter && !setter) return
if (setter) { // 执行预设setter
setter.call(obj, newVal)
} else { // 没有预设直接赋值
val = newVal
}
childOb = !shallow && observe(newVal) // 是否要观察新设置的值
dep.notify() // 发布,利用了函数闭包的特性
}
})
}
// 处理数组
function dependArray (value: Array ) {
for (let e, i = 0, l = value.length; i < l; i++) {
e = value[i]
e && e.__ob__ && e.__ob__.dep.depend() // 如果数组成员有 __ob__,则添加依赖
if (Array.isArray(e)) { // 数组成员还是数组,递归调用
dependArray(e)
}
}
}
复制代码
Dep
在上面的分析中,我们弄懂了 Vue 的数据劫持以及数组方法重写,但是又有了新的疑惑,Dep 是做什么的?Dep 是一个发布者,可以被多个观察者订阅。
// observer/dep.js
let uid = 0
export default class Dep {
static target: ?Watcher;
id: number;
subs: Array;
constructor () {
this.id = uid++ // 唯一id
this.subs = [] // 观察者集合
}
// 添加观察者
addSub (sub: Watcher) {
this.subs.push(sub)
}
// 移除观察者
removeSub (sub: Watcher) {
remove(this.subs, sub)
}
depend () { // 核心,如果存在 Dep.target,则进行依赖收集操作
if (Dep.target) {
Dep.target.addDep(this)
}
}
notify () {
const subs = this.subs.slice() // 避免污染原来的集合
// 如果不是异步执行,先进行排序,保证观察者执行顺序
if (process.env.NODE_ENV !== 'production' && !config.async) {
subs.sort((a, b) => a.id - b.id)
}
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update() // 发布执行
}
}
}
Dep.target = null // 核心,用于闭包时,保存特定的值
const targetStack = []
// 给 Dep.target 赋值当前Watcher,并添加进target栈
export function pushTarget (target: ?Watcher) {
targetStack.push(target)
Dep.target = target
}
// 移除最后一个Watcher,并将剩余target栈的最后一个赋值给 Dep.target
export function popTarget () {
targetStack.pop()
Dep.target = targetStack[targetStack.length - 1]
}
复制代码
Watcher
单个看 Dep 可能不太好理解,我们结合 Watcher 一起来看。
// observer/watcher.js
let uid = 0
export default class Watcher {
// ...
constructor (
vm: Component, // 组件实例对象
expOrFn: string | Function, // 要观察的表达式,函数,或者字符串,只要能触发取值操作
cb: Function, // 被观察者发生变化后的回调
options?: ?Object, // 参数
isRenderWatcher?: boolean // 是否是渲染函数的观察者
) {
this.vm = vm // Watcher有一个 vm 属性,表明它是属于哪个组件的
if (isRenderWatcher) {
vm._watcher = this
}
vm._watchers.push(this) // 给组件实例的_watchers属性添加观察者实例
// options
if (options) {
this.deep = !!options.deep // 深度
this.user = !!options.user
this.lazy = !!options.lazy
this.sync = !!options.sync // 同步执行
this.before = options.before
} else {
this.deep = this.user = this.lazy = this.sync = false
}
this.cb = cb // 回调
this.id = ++uid // uid for batching // 唯一标识
this.active = true // 观察者实例是否激活
this.dirty = this.lazy // for lazy watchers
// 避免依赖重复收集的处理
this.deps = []
this.newDeps = []
this.depIds = new Set()
this.newDepIds = new Set()
this.expression = process.env.NODE_ENV !== 'production'
? expOrFn.toString()
: ''
// parse expression for getter
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else { // 类似于 Obj.a 的字符串
this.getter = parsePath(expOrFn)
if (!this.getter) {
this.getter = noop // 空函数
process.env.NODE_ENV !== 'production' && warn(
`Failed watching path: "${expOrFn}" ` +
'Watcher only accepts simple dot-delimited paths. ' +
'For full control, use a function instead.',
vm
)
}
}
this.value = this.lazy
? undefined
: this.get()
}
get () { // 触发取值操作,进而触发属性的getter
pushTarget(this) // Dep 中提到的:给 Dep.target 赋值
let value
const vm = this.vm
try {
// 核心,运行观察者表达式,进行取值,触发getter,从而在闭包中添加watcher
value = this.getter.call(vm, vm)
} catch (e) {
if (this.user) {
handleError(e, vm, `getter for watcher "${this.expression}"`)
} else {
throw e
}
} finally {
if (this.deep) { // 如果要深度监测,再对 value 执行操作
traverse(value)
}
// 清理依赖收集
popTarget()
this.cleanupDeps()
}
return value
}
addDep (dep: Dep) {
const id = dep.id
if (!this.newDepIds.has(id)) { // 避免依赖重复收集
this.newDepIds.add(id)
this.newDeps.push(dep)
if (!this.depIds.has(id)) {
dep.addSub(this) // dep 添加订阅者
}
}
}
update () { // 更新
/* istanbul ignore else */
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run() // 同步直接运行
} else { // 否则加入异步队列等待执行
queueWatcher(this)
}
}
}
复制代码
到这里,我们可以大概总结一些整个响应式系统的流程,也是我们常说的 观察者模式:第一步当然是通过 observer 进行数据劫持,然后在需要订阅的地方(如:模版编译),添加观察者(watcher),并立刻通过取值操作触发指定属性的 getter 方法,从而将观察者添加进 Dep (利用了闭包的特性,进行依赖收集),然后在 Setter 触发的时候,进行 notify,通知给所有观察者并进行相应的 update。
我们可以这么理解 观察者模式:Dep 就好比是掘金,掘金有很多作者(相当于 data 的很多属性)。我们自然都是充当订阅者(watcher)角色,在掘金(Dep)这里关注了我们感兴趣的作者,比如:江三疯,告诉它江三疯更新了就提醒我去看。那么每当江三疯有新内容时,我们都会收到类似这样的提醒:江三疯发布了【2019 前端进阶之路 ***】
,然后我们就可以去看了。
但是,每个 watcher 可以订阅很多作者,每个作者也都会更新文章。那么没有关注江三疯的用户会收到提醒吗 ?不会,只给已经订阅了的用户发送提醒,而且只有江三疯更新了才提醒,你订阅的是江三疯,可是站长更新了需要提醒你吗?当然不需要。这,也就是闭包需要做的事情。
Proxy
Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。— 阮一峰老师的 ECMAScript 6 入门
我们都知道,Vue 3.0 要用 Proxy
替换 Object.defineProperty
,那么这么做的好处是什么呢?
好处是显而易见的,比如上述 Vue 现存的两个问题,不能响应对象属性的添加和删除以及不能直接操作数组下标的问题,都可以解决。当然也有不好的,那就是兼容性问题,而且这个兼容性问题 babel 还无法解决。
基础用法
我们用 Proxy 来简单实现一个数据劫持。
let obj = {};
// 代理 obj
let handler = {
get: function(target, key, receiver) {
console.log('get', key);
return Reflect.get(target, key, receiver);
},
set: function(target, key, value, receiver) {
console.log('set', key, value);
return Reflect.set(target, key, value, receiver);
},
deleteProperty(target, key) {
console.log('delete', key);
delete target[key];
return true;
}
};
let data = new Proxy(obj, handler);
// 代理后只能使用代理对象 data,否则还用 obj 肯定没作用
console.log(data.name); // get name 、undefined
data.name = '尹天仇'; // set name 尹天仇
delete data.name; // delete name
复制代码
在这个栗子中,obj 是一个空对象,通过 Proxy 代理后,添加和删除属性也能够得到反馈。再来看一下数组的代理:
let arr = ['尹天仇', '我是一个演员', '柳飘飘', '死跑龙套的'];
let array = new Proxy(arr, handler);
array[1] = '我养你啊'; // set 1 我养你啊
array[3] = '先管好你自己吧,傻瓜。'; // set 3 先管好你自己吧,傻瓜。
复制代码
数组索引的设置也是完全 hold 得住啊,当然 Proxy 的用处也不仅仅是这些,支持拦截的操作就有 13 种。有兴趣的同学可以去看 阮一峰老师的书,这里就不再啰嗦。
Proxy 实现观察者模式
我们前面分析了 Vue 的源码,也了解了观察者模式的基本原理。那用 Proxy 如何实现观察者呢?我们可以简单写一下:
class Dep {
constructor() {
this.subs = new Set();
// Set 类型,保证不会重复
}
addSub(sub) { // 添加订阅者
this.subs.add(sub);
}
notify(key) { // 通知订阅者更新
this.subs.forEach(sub => {
sub.update();
});
}
}
class Watcher { // 观察者
constructor(obj, key, cb) {
this.obj = obj;
this.key = key;
this.cb = cb; // 回调
this.value = this.get(); // 获取老数据
}
get() { // 取值触发闭包,将自身添加到dep中
Dep.target = this; // 设置 Dep.target 为自身
let value = this.obj[this.key];
Dep.target = null; // 取值完后 设置为nul
return value;
}
// 更新
update() {
let newVal = this.obj[this.key];
if (this.value !== newVal) {
this.cb(newVal);
this.value = newVal;
}
}
}
function Observer(obj) {
Object.keys(obj).forEach(key => { // 做深度监听
if (typeof obj[key] === 'object') {
obj[key] = Observer(obj[key]);
}
});
let dep = new Dep();
let handler = {
get: function (target, key, receiver) {
Dep.target && dep.addSub(Dep.target);
// 存在 Dep.target,则将其添加到dep实例中
return Reflect.get(target, key, receiver);
},
set: function (target, key, value, receiver) {
let result = Reflect.set(target, key, value, receiver);
dep.notify(); // 进行发布
return result;
}
};
return new Proxy(obj, handler)
}
复制代码
代码比较简短,就放在一块了。整体思路和 Vue 的差不多,需要注意的点仍旧是 get 操作时的闭包环境,使得 Dep.target && dep.addSub(Dep.target)
可以保证再每个属性的 getter 触发时,是当前 Watcher 实例。闭包不好理解的话,可以类比一下 for 循环 输出 1、2、3、4、5 的例子。
再看一下运行结果:
let data = {
name: '渣渣辉'
};
function print1(data) {
console.log('我系', data);
}
function print2(data) {
console.log('我今年', data);
}
data = Observer(data);
new Watcher(data, 'name', print1);
data.name = '杨过'; // 我系 杨过
new Watcher(data, 'age', print2);
data.age = '24'; // 我今年 24
复制代码
MVVM
说了那么多,该练练手了。Vue 大大提高了前端er 的生产力,我们这次就参考 Vue 自己实现一个简易的 Vue 框架。
实现部分参考自 剖析Vue实现原理 - 如何实现双向绑定mvvm
什么是 MVVM ?
简单介绍一下 MVVM,更全面的讲解,大家可以看这里 MVVM 模式。MVVM 的全称是 Model-View-ViewModel,它是一种架构模式,最早由微软提出,借鉴了 MVC 等模式的思想。
ViewModel 负责把 Model 的数据同步到 View 显示出来,还负责把 View 对数据的修改同步回 Model。而 Model 层作为数据层,它只关心数据本身,不关心数据如何操作和展示;View 是视图层,负责将数据模型转化为 UI 界面展现给用户。
图片来自 MVVM 模式
如何实现一个 MVVM?
想知道如何实现一个 MVVM,至少我们得先知道 MVVM 有什么。我们先看看大体要做成个什么模样。
<body>
<div id="app">
姓名:<input type="text" v-model="name"> <br>
年龄:<input type="text" v-model="age"> <br>
职业:<input type="text" v-model="profession"> <br>
<p> 输出:{{info}} p>
<button v-on:click="clear">清空button>
div>
body>
<script src="mvvm.js">script>
<script>
const app = new MVVM({
el: '#app',
data: {
name: '',
age: '',
profession: ''
},
methods: {
clear() {
this.name = '';
this.age = '';
this.profession = '';
}
},
computed: {
info() {
return `我叫${this.name},今年${this.age},是一名${this.profession}`;
}
}
})
script>
复制代码
运行效果:
好,看起来是模仿(抄袭)了 Vue 的一些基本功能,比如双向绑定、computed、v-on等等。为了方便理解,我们还是大致画一下原理图。
从图中看,我们现在需要做哪些事情呢?数据劫持、数据代理、模板编译、发布订阅,咦,等一下,这些名词是不是看起来很熟悉?这不就是之前分析 Vue 源码时候做的事吗?(是啊,是啊,可不就是抄的 Vue 嘛)。OK,数据劫持、发布订阅我们都比较熟悉了,可是模板编译还没有头绪。不急,这就开始。
new MVVM()
我们按照原理图的思路,第一步是 new MVVM()
,也就是初始化。初始化的时候要做些什么呢?可以想到的是,数据的劫持以及模板(视图)的初始化。
class MVVM {
constructor(options) { // 初始化
this.$el = options.el;
this.$data = options.data;
if(this.$el){ // 如果有 el,才进行下一步
new Observer(this.$data);
new Compiler(this.$el, this);
}
}
}
复制代码
好像少了点什么,computed、methods 也需要处理,补上。
class MVVM {
constructor(options) { // 初始化
// ··· 接收参数
let computed = options.computed;
let methods = options.methods;
let that = this;
if(this.$el){ // 如果有 el,才进行下一步
// 把 computed 的key值代理到 this 上,这样就可以直接访问 this.$data.info,取值的时候便直接运行 计算方法
// 注意 computed 需要代理,不需要Observer
for(let key in computed){
Object.defineProperty(this.$data, key, {
enumerable: true,
configurable: true,
get() {
return computed[key].call(that);
}
})
}
// 把 methods 的方法直接代理到 this 上,这样可以访问 this.clear
for(let key in methods){
Object.defineProperty(this, key, {
get(){
return methods[key];
}
})
}
}
}
}
复制代码
上面代码中,我们把 data 放到了 this.$data 上,但是想想我们平时,都是用 this.xxx 来访问的。所以,data 也和计算属性它们一样,需要加一层代理,方便访问。对于计算属性的详细流程,我们在数据劫持的时候再讲。
class MVVM {
constructor(options) { // 初始化
if(this.$el){
this.proxyData(this.$data);
// ··· 省略
}
}
proxyData(data) { // 数据代理
for(let key in data){
// 访问 this.name 实际是访问的 this.$data.name
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
get(){
return data[key];
},
set(newVal){
data[key] = newVal;
}
})
}
}
}
复制代码
数据劫持、发布订阅
初始化后我们还剩两步操作等待处理。
new Observer(this.$data); // 数据劫持 + 发布订阅
new Compiler(this.$el, this); // 模板编译
复制代码
数据劫持和发布订阅,我们文章前面花了很长的篇幅一直在讲这个,大家应该都很熟悉了,所以先把它干掉。
class Dep { // 发布订阅
constructor(){
this.subs = []; // watcher 观察者集合
}
addSub(watcher){ // 添加 watcher
this.subs.push(watcher);
}
notify(){ // 发布
this.subs.forEach(w => w.update());
}
}
class Watcher{ // 观察者
constructor(vm, expr, cb){
this.vm = vm; // 实例
this.expr = expr; // 观察数据的表达式
this.cb = cb; // 更新触发的回调
this.value = this.get(); // 保存旧值
}
get(){ // 取值操作,触发数据 getter,添加订阅
Dep.target = this; // 设置为自身
let value = resolveFn.getValue(this.vm, this.expr); // 取值
Dep.target = null; // 重置为 null
return value;
}
update(){ // 更新
let newValue = resolveFn.getValue(this.vm, this.expr);
if(newValue !== this.value){
this.cb(newValue);
this.value = newValue;
}
}
}
class Observer{ // 数据劫持
constructor(data){
this.observe(data);
}
observe(data){
if(data && typeof data === 'object') {
if (Array.isArray(data)) { // 如果是数组,遍历观察数组的每个成员
data.forEach(v => {
this.observe(v);
});
// Vue 在这里还进行了数组方法的重写等一些特殊处理
return;
}
Object.keys(data).forEach(k => { // 观察对象的每个属性
this.defineReactive(data, k, data[k]);
});
}
}
defineReactive(obj, key, value) {
let that = this;
this.observe(value); //对象属性的值,如果是对象或者数组,再次观察
let dep = new Dep();
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get(){ // 取值时,判断是否要添加 Watcher,收集依赖
Dep.target && dep.addSub(Dep.target);
return value;
},
set(newVal){
if(newVal !== value) {
that.observe(newVal); // 观察新设置的值
value = newVal;
dep.notify(); // 发布
}
}
})
}
}
复制代码
取值的时候,我们用到了 resolveFn.getValue
这么一个方法,这是一个工具方法的集合,后续编译的时候还有很多。我们先仔细看看这个方法。
resolveFn = { // 工具函数集
getValue(vm, expr) { // 返回指定表达式的数据
return expr.split('.').reduce((data, current)=>{
return data[current]; // this[info]、this[obj][a]
}, vm);
}
}
复制代码
我们在之前的分析中提到过,表达式可以是一个字符串,也可以是一个函数(如渲染函数),只要能触发取值操作即可。我们这里只考虑了字符串的形式,哪些地方会有这种表达式呢?比如 {{info}}
、比如 v-model="name"
中 = 后面的就是表达式。它也有可能是 obj.a
的形式。所以这里利用 reduce 达到一个连续取值的效果。
计算属性 computed
初始化时候遗留了一个问题,因为涉及到发布订阅,所以我们在这里详细分析一下计算属性的触发流程,初始化的时候,模板中用到了 {{info}}
,那么在模板编译的时候,就需要触发一次 this.info 的取值操作获取真实的值用来替换 {{info}}
这个字符串。我们就同样在这个地方添加一个观察者。
compileText(node, '{{info}}', '') // 假设编译方法长这样,初始值为空
new Watcher(this, 'info', () => {do something}) // 我们紧跟着实例化一个观察者
复制代码
这个时候会触发什么操作?我们知道 new Watcher()
的时候,会触发一次取值。根据刚才的取值函数,这时候会去取 this.info
,而我们在初始化的时候又做了代理。
for(let key in computed){
Object.defineProperty(this.$data, key, {
get() {
return computed[key].call(that);
}
})
}
复制代码
所以这时候,会直接运行 computed 定义的方法,还记得方法长什么样吗?
computed: {
info() {
return `我叫${this.name},今年${this.、age},是一名${this.profession}`;
}
}
复制代码
于是又会接连触发 name、age 以及 profession 的取值操作。
defineReactive(obj, key, value) {
// ···
let dep = new Dep();
Object.defineProperty(obj, key, {
get(){ // 取值时,判断是否要添加 Watcher,收集依赖
Dep.target && dep.addSub(Dep.target);
return value;
}
// ···
})
}
复制代码
这时候就充分利用了 闭包 的特性,要注意的是现在仍然还在 info 的取值操作过程中,因为是 同步 方法,这也就意味着,现在的 Dep.target 是存在的,并且是观察 info 属性的 Watcher。所以程序会在 name、age 和 profession 的 dep 上,分别添加上 info 的 Watcher,这样,在这三个属性后面任意一个值发生变化,都会通知给 info 的 Watcher 重新取值并更新视图。
打印一下此时的 dep,方便理解。
模板编译
其实前面已经提到了一些模板编译相关的东西,这一部分主要做的事就是将 html 上的模板语法编译成真实数据,将指令也转换为相对应的函数。
在编译过程中,避免不了要操作 Dom 元素,所以这里用了一个 createDocumentFragment 方法来创建文档碎片。这在 Vue 中实际使用的是虚拟 dom,而且在更新的时候用 diff 算法来做 最小代价渲染。
文档片段存在于内存中,并不在DOM树中,所以将子元素插入到文档片段时不会引起页面回流(对元素位置和几何上的计算)。因此,使用文档片段通常会带来更好的性能。— MDN
class Compiler{
constructor(el, vm) {
this.el = this.isElementNode(el) ? el : document.querySelector(el); // 获取app节点
this.vm = vm;
let fragment = this.createFragment(this.el); // 将 dom 转换为文档碎片
this.compile(fragment); // 编译
this.el.appendChild(fragment); // 变易完成后,重新放回 dom
}
createFragment(node) { // 将 dom 元素,转换成文档片段
let fragment = document.createDocumentFragment();
let firstChild;
// 一直去第一个子节点并将其放进文档碎片,直到没有,取不到则停止循环
while(firstChild = node.firstChild) {
fragment.appendChild(firstChild);
}
return fragment;
}
isDirective(attrName) { // 是否是指令
return attrName.startsWith('v-');
}
isElementNode(node) { // 是否是元素节点
return node.nodeType === 1;
}
compile(node) { // 编译节点
let childNodes = node.childNodes; // 获取所有子节点
[...childNodes].forEach(child => {
if(this.isElementNode(child)){ // 是否是元素节点
this.compile(child); // 递归遍历子节点
let attributes = child.attributes;
// 获取元素节点的所有属性 v-model class 等
[...attributes].forEach(attr => { // 以 v-on:click="clear" 为例
let {name, value: exp} = attr; // 结构获取 "clear"
if(this.isDirective(name)) { // 判断是不是指令属性
let [, directive] = name.split('-'); // 结构获取指令部分 v-on:click
let [directiveName, eventName] = directive.split(':'); // on,click
resolveFn[directiveName](child, exp, this.vm, eventName);
// 执行相应指令方法
}
})
}else{ // 编译文本
let content = child.textContent; // 获取文本节点
if(/\{\{(.+?)\}\}/.test(content)) { // 判断是否有模板语法 {{}}
resolveFn.text(child, content, this.vm); // 替换文本
}
}
});
}
}
// 替换文本的方法
resolveFn = { // 工具函数集
text(node, exp, vm) {
// 惰性匹配,避免连续多个模板时,会直接取到最后一个花括号
// {{name}} {{age}} 不用惰性匹配 会一次取全 "{{name}} {{age}}"
// 我们期望的是 ["{{name}}", "{{age}}"]
let reg = /\{\{(.+?)\}\}/;
let expr = exp.match(reg);
node.textContent = this.getValue(vm, expr[1]); // 编译时触发更新视图
new Watcher(vm, expr[1], () => { // setter 触发发布
node.textContent = this.getValue(vm, expr[1]);
});
}
}
复制代码
在编译元素节点(this.compile(node))的时候,我们判断了元素属性是否是指令,并调用相对应的指令方法。所以最后,我们再来看看一些指令的简单实现。
- 双向绑定 v-model
resolveFn = { // 工具函数集
setValue(vm, exp, value) {
exp.split('.').reduce((data, current, index, arr)=>{ //
if(index === arr.length-1) { // 最后一个成员时,设置值
return data[current] = value;
}
return data[current];
}, vm.$data);
},
model(node, exp, vm) {
new Watcher(vm, exp, (newVal) => { // 添加观察者,数据变化,更新视图
node.value = newVal;
});
node.addEventListener('input', (e) => { //监听 input 事件(视图变化),事件触发,更新数据
let value = e.target.value;
this.setValue(vm, exp, value); // 设置新值
});
// 编译时触发
let value = this.getValue(vm, exp);
node.value = value;
}
}
复制代码
双向绑定大家应该很容易理解,需要注意的是 setValue 的时候,不能直接用 reduce 的返回值去设置。因为这个时候返回值,只是一个值而已,达不到重新赋值的目的。
- 事件绑定 v-on 还记得我们初始化的时候怎么处理的 methods 吗?
for(let key in methods){
Object.defineProperty(this, key, {
get(){
return methods[key];
}
})
}
复制代码
我们将所有的 methods 都代理到了 this 上,而且我们在编译 v-on:click="clear"
的时候,将指令解构成了 'on'、'click'、'clear' ,那么 on 函数的实现是不是呼之欲出了呢?
on(node, exp, vm, eventName) { // 监听对应节点上的事件,触发时调用相对应的代理到 this 上的方法
node.addEventListener(eventName, e => {
vm[exp].call(vm, e);
})
}
复制代码
Vue 提供的指令还有很多,比如:v-if,实际是将 dom 元素添加或移除的操作;v-show,实际是操作元素的 display 属性为 block 或者 none;v-html,是将指令值直接添加给 dom 元素,可以用 innerHTML 实现,但是这种操作太不安全,有 xss 风险,所以 Vue 也是建议不要将接口暴露给用户。还有 v-for、v-slot 这类相对复杂些的指令,感兴趣的同学可以自己再探究。
总结
文章完整代码在 文章仓库 ??fe-code 。 本期主要讲了 Vue 的响应式原理,包括数据劫持、发布订阅、Proxy 和 Object.defineProperty
的不同点等等,还顺带简单写了个 MVVM。Vue 作为一款优秀的前端框架,可供我们学习的点太多,每一个细节都值得我们深究。后续还会带来系列的 Vue、javascript 等前端知识点的文章,感兴趣的同学可以关注下。
参考文章
- 剖析Vue实现原理 - 如何实现双向绑定mvvm
- Vue 源码分析
- 关于正则,推荐老姚的《JavaScript正则迷你书》,讲得非常易读
交流群
qq前端交流群:960807765,欢迎各种技术交流,期待你的加入
后记
如果你看到了这里,且本文对你有一点帮助的话,希望你可以动动小手支持一下作者,感谢?。文中如有不对之处,也欢迎大家指出,共勉。
- 文章仓库 ??fe-code
- 社交聊天系统(vue + node + mongodb)- ???Vchat
更多文章:
前端进阶之路系列
- 【2019 前端进阶之路】Vue 组件间通信方式完整版
- 【2019 前端进阶之路】JavaScript 原型和原型链及 canvas 验证码实践
- 【2019 前端进阶之路】站住,你这个Promise!
从头到脚实战系列
- 【从头到脚】WebRTC + Canvas 实现一个双人协作的共享画板 | 掘金技术征文
- 【从头到脚】撸一个多人视频聊天 — 前端 WebRTC 实战(一)
- 【从头到脚】撸一个社交聊天系统(vue + node + mongodb)- ???Vchat
欢迎关注公众号 前端发动机,第一时间获得作者文章推送,还有海量前端大佬优质文章,致力于成为推动前端成长的引擎。