内容简介:作为 Vue 面试中的必考题之一,Vue 的响应式原理,想必用过 Vue 的同学都不会陌生,Vue 官方文档 对响应式要注意的问题也都做了详细的说明。但是对于刚接触或者了解不多的同学来说,可能还会感到困惑:为什么不能检测到对象属性的添加或删除?为什么不支持通过索引设置数组成员?相信看完本期文章,你一定会豁然开朗。本文会结合
作为 Vue 面试中的必考题之一,Vue 的响应式原理,想必用过 Vue 的同学都不会陌生,Vue 官方文档 对响应式要注意的问题也都做了详细的说明。
但是对于刚接触或者了解不多的同学来说,可能还会感到困惑:为什么不能检测到对象属性的添加或删除?为什么不支持通过索引设置数组成员?相信看完本期文章,你一定会豁然开朗。
本文会结合 Vue 源码分析,针对整个响应式原理一步步深入 。当然,如果你已经对响应式原理有一些认识和了解,大可以
文章仓库和源码都在 :tropical_drink::cake: fe-code ,欢迎 star 。
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<any>) { // 如果是数组,则调用 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<string>) { 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<any>) { 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<Watcher>; 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 作为典型的 MVVM 框架,大大提高了前端er 的生产力,我们这次就参考 Vue 自己实现一个简易的 MVVM。
实现部分参考自 剖析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,取值的时候便直接运行 计算方法 for(let key in computed){ Object.defineProperty(this.$data, key, { 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, { 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, { 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 这类相对复杂些的指令,感兴趣的同学可以自己再探究。
总结
文章完整代码在 文章仓库 :tropical_drink::cake:fe-code 。 本期主要讲了 Vue 的响应式原理,包括数据劫持、发布订阅、Proxy 和 Object.defineProperty
的不同点等等,还顺带简单写了个 MVVM。Vue 作为一款优秀的前端框架,可供我们学习的点太多,每一个细节都值得我们深究。后续还会带来系列的 Vue、javascript 等前端知识点的文章,感兴趣的同学可以关注下。
参考文章
- 剖析Vue实现原理 - 如何实现双向绑定mvvm
- Vue 源码分析
- 关于正则,推荐老姚的 《老姚 - JavaScript正则迷你书》 ,讲得非常易读
交流群
qq前端交流群:960807765,欢迎各种技术交流,期待你的加入
后记
如果你看到了这里,且本文对你有一点帮助的话,希望你可以动动小手支持一下作者,感谢:beers:。文中如有不对之处,也欢迎大家指出,共勉。
- 文章仓库 :tropical_drink::cake:fe-code
- 社交聊天系统(vue + node + mongodb)- :cupid::icecream::see_no_evil:Vchat
更多文章:
前端进阶之路系列
- 【2019 前端进阶之路】Vue 组件间通信方式完整版
- 【2019 前端进阶之路】JavaScript 原型和原型链及 canvas 验证码实践
- 【2019 前端进阶之路】站住,你这个Promise!
从头到脚实战系列
- 【从头到脚】WebRTC + Canvas 实现一个双人协作的共享画板 | 掘金技术征文
- 【从头到脚】撸一个多人视频聊天 — 前端 WebRTC 实战(一)
- 【从头到脚】撸一个社交聊天系统(vue + node + mongodb)- :cupid::icecream::see_no_evil:Vchat
欢迎关注公众号 前端发动机 ,第一时间获得作者文章推送,还有海量前端大佬优质文章,致力于成为推动前端成长的引擎。
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:- 前端科普系列(三):CommonJS 不是前端却革命了前端
- 前端科普系列(三):CommonJS 不是前端却革命了前端
- 前端技术演进(三):前端安全
- 【前端优化】前端常见性能优化
- 【前端学习笔记】前端安全详解
- 前端监控和前端埋点
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
JSON 在线解析
在线 JSON 格式化工具
图片转BASE64编码
在线图片转Base64编码工具