携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情
前言
我们知道,Vue3 响应式相关的主要有 4 个 api:ractive、watch、ref、conputed,我们会逐个将它们实现
由于内容较多,分上下两篇
- 上篇介绍并实现 ractive 与 watch
- 下篇实现 ref 与 conputed,并分析其中的一些优化
什么是响应式
上手之前,咱们先谈谈什么是响应式
我们有事件A与事件B,我们希望执行完事件A后,事件B能够自动执行,这便是响应式
以 Vue 的响应式举例,事件A就是 ractive 等数据的改变,而事件B是我们在 watch 中定义调度函数的执行
还有一种是CSS布局的响应式,与前者也同理,页面/容器大小的变化(事件A),引起元素样式改变(事件B)
而我们今天要实现的,就是 ractive 与 watch 的响应式功能
基础响应式实现
我们先根据一个简单的情况,实现一个基础的响应式,然后再不断改善,增强扩展其功能
明确目标
首先要知道,reactive 和 watch 要一起使用才有效,光调用 reactive 只是获取了一个 Proxy 代理对象,得用 watch 注册调度函数,才能在数据修改后体会到响应式的效果
然后我们明确要实现的功能
const obj = reactive({
a: 1,
})
watch(
() => obj.a,
(now, pre) => {
console.log(`obj.a从${pre}改变为${now}`)
}
)
obj.a = 2 // 控制台自动打印:obj.a从1改变为2
代码实现
基础 reactive
我们知道 reactive 用法是传入一个对象,用 Proxy 包装后返回
其中 Proxy 可以拦截数据的读写,所以基础代码如下
const handler = {
get(target, key) {
return Reflect.get(target, key)
},
set(target, key, value) {
return Reflect.get(target, key, value)
},
}
function reactive(obj) {
const p = new Proxy(obj, handler)
return p
}
基础 watch
而 watch 接受两个参数,数据源与调度函数,其中数据源也要以一个函数的形式传递
function watch(getter, callback) {}
二者结合
接下来,就要将两个函数结合
在 watch 中,我们有数据源函数,可以进行数据的访问,而数据的访问会被 getter 拦截,我们可以通过全局变量的形式将 callback 公开,getter 函数中就可以将此调度函数保存起来,在触发 setter 时访问函数并调用
let activeEffect // 全局变量
function watch(getter, callback) {
activeEffect = callback
getter() // 触发数据源的读取拦截器
activeEffect = undefined
}
下一步,getter 中要如何保存这一函数,在 setter 中如何调用呢?来看看 Vue 的做法
- 首先,项目中可能使用许多响应式对象,所以用
targetMap存储,其类型为 WeakMap,键为对象 - 其次,每个对象可能有许多属性,所以
targetMap的值是一个keyMap,其类型为 Map - 然后,对象的属性可能绑定很多调度函数,所以
keyMap的值是dep,类型为集合 - 在
dep中存储的就是一个个callback调度函数
代码如下
const targetMap = new WeakMap()
const handler = {
get(target, key) {
if (activeEffect) {
let keyMap = targetMap.get(target)
if (!keyMap) {
keyMap = new Map()
targetMap.set(target, keyMap)
}
let dep = keyMap.get(key)
if (!dep) {
dep = new Set()
keyMap.set(key, dep)
}
dep.add(activeEffect) // 存储调度函数
}
return Reflect.get(target, key)
},
set(target, key, value) {
let oldValue = target[key]
// 先赋值,再触发函数
const result = Reflect.set(target, key, value)
if (value !== oldValue) {
const keyMap = targetMap.get(target)
if (!keyMap) return // 可能未读取过就直接赋值
const dep = keyMap.get(key)
for (const job of dep) {
job(value, oldValue)
}
}
return result
},
}
用一张图展示它们间的关系
至此,基础响应式的功能就已经实现了,完整代码及测试结果如下
代码分离
我们已经实现了基础的响应式,但距离 Vue 的还有不小的差距。我们还有很多问题都没有处理,但是所有代码都写在一个函数中不易阅读也不易维护,所以先进行代码分离。
本节只进行了代码分离,功能没有变化,各函数逻辑可在下一节的逻辑图体现
const targetMap = new WeakMap()
let activeEffect
const get = createGetter()
const set = createSetter()
// 创建getter
function createGetter() {
return function (target, key) {
track(target, key)
return Reflect.get(target, key)
}
}
// 创建setter
function createSetter() {
return function (target, key, value) {
let oldValue = target[key]
const result = Reflect.set(target, key, value)
if (value !== oldValue) {
trigger(target, key)
}
return result
}
}
// 收集依赖
function track(target, key) {
if (activeEffect) {
let keyMap = targetMap.get(target)
if (!keyMap) {
keyMap = new Map()
targetMap.set(target, keyMap)
}
let dep = keyMap.get(key)
if (!dep) {
dep = createDep()
keyMap.set(key, dep)
}
trackEffects(dep) // 存储调度函数
}
}
function trackEffects(dep) {
dep.add(activeEffect)
}
// 触发依赖
function trigger(target, key) {
const keyMap = targetMap.get(target)
if (!keyMap) return
const dep = keyMap.get(key)
triggerEffects(dep) // 触发调度函数
}
function triggerEffects(dep) {
for (const job of dep) {
job()
}
}
// 创建dep,后续优化时使用
function createDep() {
const dep = new Set()
return dep
}
const handler = { get, set }
function reactive(obj) {
const p = new Proxy(obj, handler)
return p
}
// 将传参功能移到了watch中
function watch(getter, callback) {
let oldValue
const job = () => {
const newValue = getter()
callback(newValue, oldValue)
oldValue = newValue
}
activeEffect = job
oldValue = getter()
activeEffect = undefined
}
有些函数的分离目前来看可能多此一举,但它们将会在实现 ref 与 computed 时复用
ReactiveEffect
在源码中有一个 ReactiveEffect 类,封装了依赖收集的过程
假如我们在 watch 的 getter 里又调用 watch,这样前一个 activeEffect 就会被覆盖,无法正确注册调度函数。
虽然我们实际开发中不会这样,目前来看还没什么用,但在后续处理 computed 的嵌套与优化时都需要用到它,为了避免以后大篇幅地修改,趁着刚开始就使用这个类吧
class ReactiveEffect {
constructor(fn, scheduler) {
this.fn = fn // 收集依赖的函数
this.scheduler = scheduler // 调度程序
this.deps = [] // 存储的是dep
this.parent = undefined // 用于保存前一个 activeEffect
}
// 收集依赖
run() {
// 值的访问过程可能出错,所以采用 try finally 的形式
try {
// 保存之前的 effect
this.parent = activeEffect
activeEffect = this
return this.fn()
} finally {
// 处理完毕 恢复之前的 effect
activeEffect = this.parent
this.parent = undefined
}
}
// 停止作用,从所有dep中删除此effect
stop() {
const { deps } = this
if (deps.length) {
for (let i = 0; i < deps.length; i++) {
deps[i].delete(this)
}
deps.length = 0
}
}
}
跟着修改一下 watch、trackEffects、triggerEffects中的代码
function watch(getter, callback) {
let oldValue
const job = () => {
const newValue = effect.run()
callback(newValue, oldValue)
oldValue = newValue
}
const effect = new ReactiveEffect(getter, job)
oldValue = effect.run() // 收集依赖 维护老值
// 返回一个可以取消监听的函数
return () => {
effect.stop()
}
}
function trackEffects(dep) {
dep.add(activeEffect)
activeEffect.deps.push(dep) // 双向添加
}
function triggerEffects(dep) {
for (const effect of dep) {
effect.scheduler() // 执行调度函数
}
}
最终各部分关系是这样
reactive 功能增强
接下来让我们一步步增强基础响应式的功能
处理多层对象
当 reactive 中有多层对象时,每层都需要使用 Proxy 代理,不过这一过程是在真正访问时才进行
实现也很简单,递归就好了,主要修改 createGetter 和 reactive 函数
const isObject = (val) => val !== null && typeof val === 'object'
function createGetter() {
return function (target, key) {
track(target, key)
const res = Reflect.get(target, key)
return reactive(res) // 将返回值用reactive包裹
}
}
const reactiveMap = new WeakMap() // 创建的代理对象保存下来,避免重复创建
function reactive(obj) {
if (!isObject(obj)) return obj // 不是对象直接返回
let p = reactiveMap.get(obj)
if (!p) {
p = new Proxy(obj, handler)
reactiveMap.set(obj, p)
}
return p
}
拦截更多操作
Vue 能拦截的不止读写,还有属性的检测、遍历与删除,对应三个拦截器函数
拦截 has 操作很简单,和直接访问属性一样
function has(target, key) {
const result = Reflect.has(target, key)
track(target, key)
return result
}
拦截遍历操作时,要用一个特殊的 key 来注册依赖,表示迭代器
const ITERATE_KEY = Symbol('iterate') // 迭代器
function ownKeys(target) {
track(target, ITERATE_KEY)
return Reflect.ownKeys(target)
}
这里不跟踪具体属性是因为:用户通过遍历操作获取了元素的属性名,如有需要,自然会访问其属性值,在访问属性值的时候还会 track,这里只需要追踪迭代器就好了;而如果使用的只是属性名的话,能修改对象属性名的也就只有删除操作,让它触发迭代器就好了。
删除属性是一个写操作,要触发依赖。这里要给 trigger 传了第三个参数,因为删除操作不仅要触发 key 本身的依赖,还影响了迭代器
function deleteProperty(target, key) {
const hadKey = target.hasOwnProperty(key)
const result = Reflect.deleteProperty(target, key)
// 存在此属性且删除成功,触发依赖
if (result && hadKey) {
trigger(target, key, 'delete')
}
return result
}
function trigger(target, key, type) {
const keyMap = targetMap.get(target)
if (!keyMap) return
const deps = []
deps.push(keyMap.get(key)) // 属性自身的依赖
switch (type) {
case 'delete':
// 删除操作格外触发迭代器依赖
deps.push(keyMap.get(ITERATE_KEY))
break
}
if (deps.length == 0) {
triggerEffects(deps[0])
} else {
const effects = [] // 汇总 effect
for (const dep of deps) {
if (dep) {
effects.push(...dep)
}
}
triggerEffects(createDep(effects))
}
}
// 多添一个参数
function createDep(effects) {
const dep = new Set(effects)
return dep
}
本文相较于源码,将参数 key 与 type 的位置互换了,但不影响功能实现
逻辑图如下:
处理数组
思来想去还是把这部分讲了把,V3 的数组处理不比 V2 简单
因为数组的方法会触发很多次 get 与 set,详情可看我之前这篇文章,而且数组又要维护 length 这一特殊变量
拦截数组方法
Vue 中的处理也很粗暴,只要是查值类的方法,直接追踪所有元素,而改值类的方法,不追踪依赖,只触发扳机
说的很简单,但代码实现又是一堆,首先提供一个得到代理对象的原对象的方法 toRaw
toRaw 实现很简单,定义一个特殊属性就好
function createGetter() {
return function (target, key) {
// 使 toRaw 返回原对象
if (key === '__v_raw') {
return target
}
track(target, key)
const res = Reflect.get(target, key)
return reactive(res) // 将返回值用reactive包裹
}
}
// 获取代理的原对象
// 递归是为了处理多层代理嵌套的极端情况,本文其实并不需要
function toRaw(observed) {
const raw = observed && observed['__v_raw']
return raw ? toRaw(raw) : observed
}
然后在定义控制是否收集依赖的变量和方法
let shouldTrack = true
const trackStack = [] // 可能收集依赖时多层嵌套,用栈存储
function pauseTracking() { // 停止收集依赖
trackStack.push(shouldTrack)
shouldTrack = false
}
function resetTracking() { // 恢复依赖收集
shouldTrack = trackStack.pop()
}
// 将其加入收集依赖函数中
function track(target, key) {
if (shouldTrack && activeEffect) {
……
}
}
接下来就是重写数组的方法,并在 getter 中返回
function createGetter() {
return function (target, key) {
// 读取的是数组方法,返回已封装的方法
if (isArray(target) && arrayInstrumentations.hasOwnProperty(key)) {
return Reflect.get(arrayInstrumentations, key)
}
……
}
}
// 重写数组方法
const arrayInstrumentations = createArrayInstrumentations()
function createArrayInstrumentations() {
const instrumentations = {}
// 取值的方法
;['includes', 'indexOf', 'lastIndexOf'].forEach((key) => {
instrumentations[key] = function (...args) {
// 通过this获取调用此方法的数组对象
const arr = toRaw(this)
// 直接追踪全部索引
for (let i = 0, l = this.length; i < l; i++) {
track(arr, i + '')
}
// 然后用执行原生方法,使用“原值”和“代理值”分别执行一遍,获取结果
const res = arr[key](...args)
if (res === -1 || res === false) {
return arr[key](...args.map(toRaw))
} else {
return res
}
}
})
// 写值的方法
;['push', 'pop', 'shift', 'unshift', 'splice'].forEach((key) => {
instrumentations[key] = function (...args) {
pauseTracking() // 暂停依赖跟踪
// 调用原生方法,但是改变this指向代理对象
// 期间会有写值的操作,一样会触发扳机
const res = toRaw(this)[key].apply(this, args)
resetTracking() // 恢复依赖收集
return res
}
})
return instrumentations // 返回数组方法对象
}
这便是 Vue 针对数组方法的处理了
维护 length
监控 length
除了直接访问 length 属性外,遍历数组时也会用到
所以我们将遍历操作的拦截器修改一下,如果是数组的话,则跟踪的 key 是 length
const isArray = Array.isArray
function ownKeys(target) {
track(target, isArray(target) ? 'length' : ITERATE_KEY)
return Reflect.ownKeys(target)
}
触发 length
首先在 setter 中,如果拦截的是数组而且修改的是数字索引,要判断一下会不会修改 length
// 判断是否为索引数字的函数
const isIntegerKey = (key) => key !== 'NaN' && key[0] !== '-' && '' + parseInt(key) === key
function createSetter() {
return function (target, key, value) {
let oldValue = target[key]
const result = Reflect.set(target, key, value)
// 判断length是否改变
const changeLength = isArray(target) && isIntegerKey(key) && Number(key) >= target.length
if (value !== oldValue) {
trigger(target, key, changeLength ? 'add' : '')
}
return result
}
}
然后在 trigger 中添加 length 扳机的触发
function trigger(target, key, type) {
const keyMap = targetMap.get(target)
if (!keyMap) return
const deps = []
deps.push(keyMap.get(key)) // 属性自身的依赖
switch (type) {
case 'delete':
// 删除操作格外触发迭代器依赖
deps.push(keyMap.get(ITERATE_KEY))
break
case 'add':
// 修改了length
deps.push(keyMap.get('length'))
break
}
if (deps.length == 0) {
triggerEffects(deps[0])
} else {
const effects = []
for (const dep of deps) {
if (dep) {
effects.push(...dep)
}
}
triggerEffects(createDep(effects))
}
}
至此,数组处理完毕,逻辑图如下:
watch 功能增强
相比于 reactive 这改一点哪改一点,watch 的增强就简单一些,只需要实现一些配置项就好了
我们知道,Vue 的 watch 方法可以传递第三个参数,包含 immediate、deep、flush 配置项
immediate
给 watch 传递 immediate 选项,能使调度函数立刻执行一次,实现起来也很简单,一个判断语句搞定
function watch(getter, callback, { immediate } = {}) {
let oldValue
const job = () => {
const newValue = effect.run()
callback(newValue, oldValue)
oldValue = newValue
}
const effect = new ReactiveEffect(getter, job)
if (immediate) {
job()
} else {
oldValue = effect.run()
}
// 返回一个可以取消监听的函数
return () => {
effect.stop()
}
}
deep
给 watch 传递 deep 选项,表示开启深度监听
当要监视的属性值是一个对象时,watch 不仅会监视该属性的变化,还会监视其属性值对象内部属性的变化
这要求我们在收集依赖时,不仅仅是执行用户传来的 getter,还要在 getter 返回值是一个对象时,递归访问其属性
function watch(getter, callback, { immediate, deep } = {}) {
// 修改 getter 函数
if (deep) {
const baseGetter = getter // 保存之前的 getter
getter = () => traverse(baseGetter()) // 深度遍历对象
}
……
}
// 深度遍历对象
function traverse(value, seen = new Set()) {
// 不是对象就返回
if (!isObject(value)) {
return value
}
// 处理循环引用
if (seen.has(value)) {
return value
}
seen.add(value)
// 数组的话就遍历每一项
if (isArray(value)) {
for (let i = 0; i < value.length; i++) {
traverse(value[i], seen)
}
} else if (value) {
// 对象的话就遍历所有属性,读取属性值
for (const key in value) {
traverse(value[key], seen)
}
}
return value
}
flush
watch 的 flush 配置项,是设置该监听器调度函数的执行是同步还是异步的,有三个可选项:
- sync:调度函数同步执行
- pre:调度函数异步执行,默认项
- post:也是异步执行,用来实现异步组件的功能,本文并不涉及
而我们之前实现的 watch 是 flush: 'sync' 的版本,接下来我们实现调度函数的异步执行
异步执行与同步执行的区别在于,同步执行会立刻执行调度函数,而异步执行会先将调度函数存储起来,当同步代码执行完成后,再统一执行。好处在于减少了执行次数,只以最终值为准
实现方式就是当监听到数据源改变,立刻将调度函数推入队列中,利用 promise 来创建微任务,异步执行
在 watch 中就只需要改一下回调函数,将同步执行改为使用 queuePreFlushCb 异步执行
function watch(getter, callback, { immediate, deep, flush } = {}) {
……
let scheduler
if (flush == 'sync') {
scheduler = job // 同步执行
} else {
// 默认是 pre
scheduler = () => {
queuePreFlushCb(job) // 异步执行
}
}
const effect = new ReactiveEffect(getter, scheduler)
……
}
而 queuePreFlushCb 的实现还是较复杂的,先展示代码,再进行讲解
const pendingPreFlushCbs = [] // 等待异步执行的函数队列,简称等待队列
let activePreFlushCbs = null // 正在同步执行的函数队列,简称执行队列
let preFlushIndex = 0 // 执行队列的索引
const resolvedPromise = Promise.resolve() // 已解决的 promise 用于创建微任务
let currentFlushPromise = null // 当前正在执行的 promise
function queuePreFlushCb(cb) {
// 没有执行队列或执行队列中不包含此函数
if (!activePreFlushCbs || !activePreFlushCbs.includes(cb, preFlushIndex + 1)) {
pendingPreFlushCbs.push(cb)
}
if (!currentFlushPromise) {
currentFlushPromise = resolvedPromise.then(flushPreFlushCbs)
}
}
function flushPreFlushCbs() {
if (pendingPreFlushCbs.length) {
// 等待函数队列去重
activePreFlushCbs = [...new Set(pendingPreFlushCbs)]
pendingPreFlushCbs.length = 0 // 清空等待队列
for (preFlushIndex = 0; preFlushIndex < activePreFlushCbs.length; preFlushIndex++) {
// 函数执行过程中,可能会往等待队列添加新函数
activePreFlushCbs[preFlushIndex]()
}
activePreFlushCbs = null
preFlushIndex = 0
flushPreFlushCbs() // 递归执行,直至清空所有调度函数
} else {
currentFlushPromise = null // 置空当前正在执行的 promise
}
}
开始定义一堆变量,变量含义在注释中也已经表明了
queuePreFlushCb 函数负责将调度函数推入等待队列中,并注册一个 promise,让其异步执行 flushPreFlushCbs 函数。
而推入队列的条件 !activePreFlushCbs || !activePreFlushCbs.includes(cb, preFlushIndex + 1) 意思就是正在执行的函数队列为空(无调度函数正在执行)或者从执行队列的当前索引开始,没有找到要推入的函数(将来不会执行),就将此函数推入等待队列中。
includes(cb, preFlushIndex + 1) 的判断条件还有隐层含义,表示允许调度函数递归执行自身的,如果去掉 +1 的话,就表示不允许其递归执行,我们以允许递归的形式实现。(Vue 的模板更新函数就是不允许递归执行自身的)
flushPreFlushCbs 函数负责清空等待队列中的调度函数
先将等待队列中的函数去重,赋给执行队列,然后遍历执行
因为调度函数执行过程中可能会改变响应式数据,触发 watch,往等待队列中添加新的函数
所以在末尾又递归执行了 flushPreFlushCbs,直至等待队列中无任务。
逻辑图如下:
在 Vue 的实现中,还使用了一个 Map 保存了在一次微任务执行过程中每个调度函数的执行次数,每个函数最多递归执行 100 次,所以我们也加一下吧
function flushPreFlushCbs(seen = new Map()) {
if (pendingPreFlushCbs.length) {
// 等待函数队列去重
activePreFlushCbs = [...new Set(pendingPreFlushCbs)]
pendingPreFlushCbs.length = 0 // 清空等待队列
for (preFlushIndex = 0; preFlushIndex < activePreFlushCbs.length; preFlushIndex++) {
// 函数执行过程中,可能会往等待队列添加新函数
const job = activePreFlushCbs[preFlushIndex]
const count = seen.get(job)
// 每个函数最多递归执行100次
if (!(count > 100)) {
seen.set(job, count + 1 || 1)
job()
}
}
activePreFlushCbs = null
preFlushIndex = 0
flushPreFlushCbs() // 递归执行,直至清空所有调度函数
} else {
currentFlushPromise = null // 置空当前正在执行的 promise
}
}
总结
至此,reactive 和 watch 的功能就实现完了
为方便讲解,相较于 Vue 源码有部分改动,但功能实现完全一致,完整代码如下
还是有一部分内容没有实现的,就是对 Set 与 Map 类型对象的处理,但是本文已经很长了,相关内容在我的这篇文章中有所讲解,同学们如有需要,后续再补上这部分功能的代码实现。
结语
下篇已更新,实现了 ref 和 computed
如果喜欢或有所帮助的话,希望能点赞关注,鼓励一下作者。
如果文章有不正确或存疑的地方,欢迎评论指出。