做为 Vue 面试中的必考题之一,Vue 的响应式原理,想必用过 Vue 的同窗都不会陌生,Vue 官方文档 对响应式要注意的问题也都作了详细的说明。javascript
可是对于刚接触或者了解很少的同窗来讲,可能还会感到困惑:为何不能检测到对象属性的添加或删除?为何不支持经过索引设置数组成员?相信看完本期文章,你必定会豁然开朗。html
本文会结合 Vue 源码分析,针对整个响应式原理一步步深刻。固然,若是你已经对响应式原理有一些认识和了解,大能够 直接前往实现部分 MVVM前端
文章仓库和源码都在 🍹🍰 fe-code,欢迎 star。vue
经大佬提醒,Vue 并不彻底是 MVVM 模型,你们审慎阅读。java
虽然没有彻底遵循 MVVM 模型,可是 Vue 的设计也受到了它的启发。所以在文档中常常会使用 vm (ViewModel 的缩写) 这个变量名表示 Vue 实例。 — Vue 官网node
Vue 官方的响应式原理图镇楼。react
进入主题以前,咱们先思考以下代码。git
<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。es6
因此在 1 s 的时候,经过 this.list = [{text: 666}, {text: 666}, {text: 666}]
给 list 从新赋值,便会触发 setter,进而通知对应的观察者(这里的观察者是模板编译)作更新。github
在 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、一、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 的数据劫持依赖于 Object.defineProperty
,因此也正是由于它的某些特性,才引发这个问题。不了解这个属性的同窗看这里 MDN。
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 版本为:2.6.10。
咱们知道了数据劫持的基础实现,顺便再看看 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)
}
}
}
复制代码
在上面的分析中,咱们弄懂了 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]
}
复制代码
单个看 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 能够理解成,在目标对象以前架设一层“拦截”,外界对该对象的访问,都必须先经过这层拦截,所以提供了一种机制,能够对外界的访问进行过滤和改写。— 阮一峰老师的 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 种。有兴趣的同窗能够去看 阮一峰老师的书,这里就再也不啰嗦。
咱们前面分析了 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 循环 输出 一、二、三、四、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
复制代码
说了那么多,该练练手了。Vue 大大提升了前端er 的生产力,咱们此次就参考 Vue 本身实现一个简易的 Vue 框架。
实现部分参考自 剖析Vue实现原理 - 如何实现双向绑定mvvm
简单介绍一下 MVVM,更全面的讲解,你们能够看这里 MVVM 模式。MVVM 的全称是 Model-View-ViewModel,它是一种架构模式,最先由微软提出,借鉴了 MVC 等模式的思想。
ViewModel 负责把 Model 的数据同步到 View 显示出来,还负责把 View 对数据的修改同步回 Model。而 Model 层做为数据层,它只关心数据自己,不关心数据如何操做和展现;View 是视图层,负责将数据模型转化为 UI 界面展示给用户。
图片来自 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()
,也就是初始化。初始化的时候要作些什么呢?能够想到的是,数据的劫持以及模板(视图)的初始化。
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 达到一个连续取值的效果。
初始化时候遗留了一个问题,由于涉及到发布订阅,因此咱们在这里详细分析一下计算属性的触发流程,初始化的时候,模板中用到了 {{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))的时候,咱们判断了元素属性是不是指令,并调用相对应的指令方法。因此最后,咱们再来看看一些指令的简单实现。
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 的返回值去设置。由于这个时候返回值,只是一个值而已,达不到从新赋值的目的。
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 等前端知识点的文章,感兴趣的同窗能够关注下。
qq前端交流群:960807765,欢迎各类技术交流,期待你的加入
若是你看到了这里,且本文对你有一点帮助的话,但愿你能够动动小手支持一下做者,感谢🍻。文中若有不对之处,也欢迎你们指出,共勉。
更多文章:
前端进阶之路系列
从头到脚实战系列
欢迎关注公众号 前端发动机,第一时间得到做者文章推送,还有海量前端大佬优质文章,致力于成为推进前端成长的引擎。