导读
记得初学Vue源码的时候,在defineReactive、Observer、Dep、Watcher等等内部设计源码之间跳来跳去,发现再也绕不出来了。Vue发展了很久,很多fix和feature的增加让内部源码越来越庞大,太多的边界情况和优化设计掩盖了原本精简的代码设计,让新手阅读源码变得越来越困难,但是面试的时候,Vue的响应式原理几乎成了Vue技术栈的公司面试中高级前端必问的点之一。
这篇文章通过自己实现一个响应式系统,尽量还原和Vue内部源码同样结构,但是剔除掉和渲染、优化等等相关的代码,来最低成本的学习Vue的响应式原理。
预览
源码地址(ts):
https://github.com/sl1673495/vue-reactive
源码地址(js)
https://github.com/sl1673495/vue-reactive/tree/js-version
预览地址:
https://sl1673495.github.io/vue-reactive/
reactive
Vue最常用的就是响应式的data了,通过在vue中定义
new Vue({ data() { return { msg: 'Hello World' } } })
在data发生改变的时候,视图也会更新,在这篇文章里我把对data部分的处理单独提取成一个api:reactive,下面来一起实现这个api。
要实现的效果:
const data = reactive({ msg: 'Hello World', }) new Watcher(() => { document.getElementById('app').innerHTML = `msg is ${data.msg}` })
在data.msg发生改变的时候,我们需要这个app节点的innerHTML同步更新,这里新增加了一个概念Watcher,这也是Vue源码内部的一个设计,想要实现响应式的系统,这个Watcher是必不可缺的。
在实现这两个api之前,我们先来理清他们之间的关系,reactive这个api定义了一个响应式的数据,其实大家都知道响应式的数据就是在它的某个属性(比如例中的data.msg)被读取的时候,记录下来这时候是谁在读取他,读取他的这个函数肯定依赖它。
在本例中,下面这段函数,因为读取了data.msg并且展示在页面上,所以可以说这段渲染函数依赖了data.msg。
// 渲染函数 document.getElementById('app').innerHTML = `msg is ${data.msg}`
这也就解释清了,为什么我们需要用new Watcher来传入这段渲染函数,我们已经可以分析出来Watcher是帮我们记录下来这段渲染函数依赖的关键。
在js引擎执行渲染函数的途中,突然读到了data.msg,data已经被定义成了响应式数据,读取data.msg时所触发的get函数已经被我们劫持,这个get函数中我们去记录下data.msg被这个渲染函数所依赖,然后再返回data.msg的值。
这样下次data.msg发生变化的时候,Watcher内部所做的一些逻辑就会通知到渲染函数去重新执行。这不就是响应式的原理嘛。
下面开始实现代码
import Dep from './dep' import { isObject } from '../utils' // 将对象定义为响应式 export default function reactive(data) { if (isObject(data)) { Object.keys(data).forEach(key => { defineReactive(data, key) }) } return data } function defineReactive(data, key) { let val = data[key] // 收集依赖 const dep = new Dep() Object.defineProperty(data, key, { get() { dep.depend() return val }, set(newVal) { val = newVal dep.notify() } }) if (isObject(val)) { reactive(val) } }
代码很简单,就是去遍历data的key,在defineReactive函数中对每个key进行get和set的劫持,Dep是一个新的概念,它主要用来做上面所说的dep.depend()去收集当前正在运行的渲染函数和dep.notify() 触发渲染函数重新执行。
可以把dep看成一个收集依赖的小筐,每当运行渲染函数读取到data的某个key的时候,就把这个渲染函数丢到这个key自己的小筐中,在这个key的值发生改变的时候,去key的筐中找到所有的渲染函数再执行一遍。
Dep
export default class Dep { constructor() { this.deps = new Set() } depend() { if (Dep.target) { this.deps.add(Dep.target) } } notify() { this.deps.forEach(watcher => watcher.update()) } } // 正在运行的watcher Dep.target = null
这个类很简单,利用Set去做存储,在depend的时候把Dep.target加入到deps集合里,在notify的时候遍历deps,触发每个watcher的update。
没错Dep.target这个概念也是Vue中所引入的,它是一个挂在Dep类上的全局变量,js是单线程运行的,所以在渲染函数如:
document.getElementById('app').innerHTML = `msg is ${data.msg}`
运行之前,先把全局的Dep.target设置为存储了这个渲染函数的watcher,也就是:
new Watcher(() => { document.getElementById('app').innerHTML = `msg is ${data.msg}` })
这样在运行途中data.msg就可以通过Dep.target找到当前是哪个渲染函数的watcher正在运行,这样也就可以把自身对应的依赖所收集起来了。
这里划重点:Dep.target一定是一个Watcher的实例。
又因为渲染函数可以是嵌套运行的,比如在Vue中每个组件都会有自己用来存放渲染函数的一个watcher,那么在下面这种组件嵌套组件的情况下:
// Parent组件 <template> <div> <Son组件 /> </div> </template>
watcher的运行路径就是: 开始 -> ParentWatcher -> SonWatcher -> ParentWatcher -> 结束。
是不是特别像函数运行中的入栈出栈,没错,Vue内部就是用了栈的数据结构来记录watcher的运行轨迹。
// watcher栈 const targetStack = [] // 将上一个watcher推到栈里,更新Dep.target为传入的_target变量。 export function pushTarget(_target) { if (Dep.target) targetStack.push(Dep.target) Dep.target = _target } // 取回上一个watcher作为Dep.target,并且栈里要弹出上一个watcher。 export function popTarget() { Dep.target = targetStack.pop() }
有了这些辅助的工具,就可以来看看Watcher的具体实现了
import Dep, { pushTarget, popTarget } from './dep' export default class Watcher { constructor(getter) { this.getter = getter this.get() } get() { pushTarget(this) this.value = this.getter() popTarget() return this.value } update() { this.get() } }
回顾一下开头示例中Watcher的使用。
const data = reactive({ msg: 'Hello World', }) new Watcher(() => { document.getElementById('app').innerHTML = `msg is ${data.msg}` })
传入的getter函数就是
() => { document.getElementById('app').innerHTML = `msg is ${data.msg}` }
在构造函数中,记录下getter函数,并且执行了一遍get
get() { pushTarget(this) this.value = this.getter() popTarget() return this.value }
在这个函数中,this就是这个watcher实例,在执行get的开头先把这个存储了渲染函数的watcher设置为当前的Dep.target,然后执行this.getter()也就是渲染函数
在执行渲染函数的途中读取到了data.msg,就触发了defineReactive函数中劫持的get:
Object.defineProperty(data, key, { get() { dep.depend() return val } })
这时候的dep.depend函数:
depend() { if (Dep.target) { this.deps.add(Dep.target) } }
所收集到的Dep.target,就是在get函数开头中pushTarget(this)所收集的
new Watcher(() => { document.getElementById('app').innerHTML = `msg is ${data.msg}` })
这个watcher实例了。
此时我们假如执行了这样一段赋值代码:
data.msg = 'ssh'
就会运行到劫持的set函数里:
Object.defineProperty(data, key, { set(newVal) { val = newVal dep.notify() } })
此时在控制台中打印出dep这个变量,它内部的deps属性果然存储了一个Watcher的实例。
运行了dep.notify以后,就会触发这个watcher的update方法,也就会再去重新执行一遍渲染函数了,这个时候视图就刷新了。
computed
在实现了reactive这个基础api以后,就要开始实现computed这个api了,这个api的用法是这样:
const data = reactive({ number: 1 }) const numberPlusOne = computed(() => data.number + 1) // 渲染函数watcher new Watcher(() => { document.getElementById('app2').innerHTML = ` computed: 1 + number 是 ${numberPlusOne.value} ` })
vue内部是把computed属性定义在vm实例上的,这里我们没有实例,所以就用一个对象来存储computed的返回值,用.value来拿computed的真实值。
这里computed传入的其实还是一个函数,这里我们回想一下Watcher的本质,其实就是存储了一个需要在特定时机触发的函数,在Vue内部,每个computed属性也有自己的一个对应的watcher实例,下文中叫它computedWatcher
先看渲染函数:
// 渲染函数watcher new Watcher(() => { document.getElementById('app2').innerHTML = ` computed: 1 + number 是 ${numberPlusOne.value} ` })
这段渲染函数执行过程中,读取到numberPlusOne的值的时候
首先会把Dep.target设置为numberPlusOne所对应的computedWatcher
computedWatcher的特殊之处在于
- 渲染watcher只能作为依赖被收集到其他的dep筐子里,而computedWatcher实例上有属于自己的dep,它可以收集别的watcher作为自己的依赖。
- 惰性求值,初始化的时候先不去运行getter。
export default class Watcher { constructor(getter, options = {}) { const { computed } = options this.getter = getter this.computed = computed if (computed) { this.dep = new Dep() } else { this.get() } } }
其实computed实现的本质就是,computed在读取value之前,Dep.target肯定此时是正在运行的渲染函数的watcher。
先把当前正在运行的渲染函数的watcher作为依赖收集到computedWatcher内部的dep筐子里。
把自身computedWatcher设置为 全局Dep.target,然后开始求值:
求值函数会在运行() => data.number + 1
的途中遇到data.number的读取,这时又会触发'number'这个key的劫持get函数,这时全局的Dep.target是computedWatcher,data.number的dep依赖筐子里丢进去了computedWatcher。
此时的依赖关系是 data.number的dep筐子里装着computedWatcher,computedWatcher的dep筐子里装着渲染watcher。
此时如果更新data.number的话,会一级一级往上触发更新。会触发computedWatcher的update,我们肯定会对被设置为computed特性的watcher做特殊的处理,这个watcher的筐子里装着渲染watcher,所以只需要触发 this.dep.notify(),就会触发渲染watcher的update方法,从而更新视图。
下面来改造代码:
// Watcher import Dep, { pushTarget, popTarget } from './dep' export default class Watcher { constructor(getter, options = {}) { const { computed } = options this.getter = getter this.computed = computed if (computed) { this.dep = new Dep() } else { this.get() } } get() { pushTarget(this) this.value = this.getter() popTarget() return this.value } // 仅为computed使用 depend() { this.dep.depend() } update() { if (this.computed) { this.get() this.dep.notify() } else { this.get() } } }
computed初始化:
// computed import Watcher from './watcher' export default function computed(getter) { let def = {} const computedWatcher = new Watcher(getter, { computed: true }) Object.defineProperty(def, 'value', { get() { // 先让computedWatcher收集渲染watcher作为自己的依赖。 computedWatcher.depend() return computedWatcher.get() } }) return def }
这里的逻辑比较绕,如果没理清楚的话可以把代码下载下来一步步断点调试,data.number被劫持的set触发以后,可以看一下number的dep到底存了什么。
watch
watch的使用方式是这样的:
watch( () => data.msg, (newVal, oldVal) => { console.log('newVal: ', newVal) console.log('old: ', oldVal) } )
传入的第一个参数是个函数,里面需要读取到响应式的属性,确保依赖能被收集到,这样下次这个响应式的属性发生改变后,就会打印出对饮的新值和旧值。
分析一下watch的实现原理,这里依然是利用Watcher类去实现,我们把用于watch的watcher叫做watchWatcher,传入的getter函数也就是() => data.msg,Watcher在执行它之前还是一样会把自身(也就是watchWatcher)设为Dep.target,这时读到data.msg,就会把watchWatcher丢进data.msg的依赖筐子里。
如果data.msg更新了,则就会触发watchWatcher的update方法
直接上代码:
// watch import Watcher from './watcher' export default function watch(getter, callback) { new Watcher(getter, { watch: true, callback }) }
没错又是直接用了getter,只是这次传入的选项是{ watch: true, callback },接下来看看Watcher内部进行了什么处理:
export default class Watcher { constructor(getter, options = {}) { const { computed, watch, callback } = options this.getter = getter this.computed = computed this.watch = watch this.callback = callback this.value = undefined if (computed) { this.dep = new Dep() } else { this.get() } } }
首先是构造函数中,对watch选项和callback进行了保存,其他没变。
然后在update方法中。
update() { if (this.computed) { ... } else if (this.watch) { const oldValue = this.value this.get() this.callback(oldValue, this.value) } else { ... } }
在调用this.get去更新值之前,先把旧值保存起来,然后把新值和旧值一起通过调用callback函数交给外部,就这么简单。
我们仅仅是改动寥寥几行代码,就轻松实现了非常重要的api:watch。
总结。
有了精妙的Watcher和Dep的设计,Vue内部的响应式api实现的非常简单,不得不再次感叹一下尤大真是厉害啊!
免责声明:本站资源来自互联网收集,仅供用于学习和交流,请遵循相关法律法规,本站一切资源不代表本站立场,如有侵权、后门、不妥请联系本站删除!
《魔兽世界》大逃杀!60人新游玩模式《强袭风暴》3月21日上线
暴雪近日发布了《魔兽世界》10.2.6 更新内容,新游玩模式《强袭风暴》即将于3月21 日在亚服上线,届时玩家将前往阿拉希高地展开一场 60 人大逃杀对战。
艾泽拉斯的冒险者已经征服了艾泽拉斯的大地及遥远的彼岸。他们在对抗世界上最致命的敌人时展现出过人的手腕,并且成功阻止终结宇宙等级的威胁。当他们在为即将于《魔兽世界》资料片《地心之战》中来袭的萨拉塔斯势力做战斗准备时,他们还需要在熟悉的阿拉希高地面对一个全新的敌人──那就是彼此。在《巨龙崛起》10.2.6 更新的《强袭风暴》中,玩家将会进入一个全新的海盗主题大逃杀式限时活动,其中包含极高的风险和史诗级的奖励。
《强袭风暴》不是普通的战场,作为一个独立于主游戏之外的活动,玩家可以用大逃杀的风格来体验《魔兽世界》,不分职业、不分装备(除了你在赛局中捡到的),光是技巧和战略的强弱之分就能决定出谁才是能坚持到最后的赢家。本次活动将会开放单人和双人模式,玩家在加入海盗主题的预赛大厅区域前,可以从强袭风暴角色画面新增好友。游玩游戏将可以累计名望轨迹,《巨龙崛起》和《魔兽世界:巫妖王之怒 经典版》的玩家都可以获得奖励。
更新日志
- 凤飞飞《我们的主题曲》飞跃制作[正版原抓WAV+CUE]
- 刘嘉亮《亮情歌2》[WAV+CUE][1G]
- 红馆40·谭咏麟《歌者恋歌浓情30年演唱会》3CD[低速原抓WAV+CUE][1.8G]
- 刘纬武《睡眠宝宝竖琴童谣 吉卜力工作室 白噪音安抚》[320K/MP3][193.25MB]
- 【轻音乐】曼托凡尼乐团《精选辑》2CD.1998[FLAC+CUE整轨]
- 邝美云《心中有爱》1989年香港DMIJP版1MTO东芝首版[WAV+CUE]
- 群星《情叹-发烧女声DSD》天籁女声发烧碟[WAV+CUE]
- 刘纬武《睡眠宝宝竖琴童谣 吉卜力工作室 白噪音安抚》[FLAC/分轨][748.03MB]
- 理想混蛋《Origin Sessions》[320K/MP3][37.47MB]
- 公馆青少年《我其实一点都不酷》[320K/MP3][78.78MB]
- 群星《情叹-发烧男声DSD》最值得珍藏的完美男声[WAV+CUE]
- 群星《国韵飘香·贵妃醉酒HQCD黑胶王》2CD[WAV]
- 卫兰《DAUGHTER》【低速原抓WAV+CUE】
- 公馆青少年《我其实一点都不酷》[FLAC/分轨][398.22MB]
- ZWEI《迟暮的花 (Explicit)》[320K/MP3][57.16MB]