2023-08-21
VueJS
00
请注意,本文编写于 457 天前,最后修改于 400 天前,其中某些信息可能已经过时。

目录

准备工作
proxy数据劫持
为什Proxy要搭配Reflect使用?
依赖收集设计思路
reactive实现
ref实现
复杂数据的支持
原生类型数据的支持
computed实现
先实现展示功能
响应式的实现
computed的缓存特性
watch
先实现effect的懒执行
watch基本骨架
watch完整实现

Vue3中有三大特性响应式运行时编译器,我将分三篇文章进行讲解。本篇将讲解Vue3的响应式篇章,会依次讲解以下内容

  1. Proxy数据劫持解决了definePrototype的什么问题
  2. 依赖收集的设计思路
  3. reactiverefcomputedschedulerwatch的实现 带你手撸Vue3响应式。

本文所有代码 vue3_study

image.png

准备工作

  • 考虑实现尽可能与源码相符合, 本文的目录结构与官方目录结构完全一致,且使用TS编写代码,使用Rollup构建。如果不熟悉TS的同学可以使用npx tsc xx.ts -t esnext命令转一下.
  • 另一方面,为了照顾读者的理解,本文代码实现尽可能抛弃边缘情况,尽可能减少函数嵌套调用层次。
  • 关于官方源码调试image.png

vue3_study/1.begin这里是一个干净的项目工程骨架

image.png

我先说一下大致情况

  1. 考虑避免反复安装依赖,我将node_modules安装到了外层(node_module有递归往上查找的特性)
  2. packages/*/src 借助tsconfig.js把路径映射为 "@vue/*" 这样通过@vue/引入模块了。
    • packages/*/src 每个目录内容都一样只有一个index.ts,里面没内容。
  3. 推荐使用npm安装,(我开始是用pnpm安装,在测试computed时遇到了问题)
  4. 启动 npm run dev, demovue/examples/*.html 借助 vscode Live Server插件启动

基本情况就这些, 具体看代码吧!

proxy数据劫持

首先来看一下vue2响应式弊端:

  • 响应化过程需要递归遍历,消耗较大
  • 新加或删除属性无法监听
  • 数组响应化需要额外实现
  • MapSetClass等无法响应式
  • 修改语法有限制

proxy实现数据响应性demo

js
const isObject = val => val !== null && typeof val === 'object' const toProxy = new WeakMap() // obj:observed function reactive(obj) { if (!isObject(obj)) return obj if (toProxy.has(obj)) return toProxy.get(obj) const observed = new Proxy(obj, { get(target, p, receiver) { const res = Reflect.get(target, p, receiver) // return res; // 体验Proxy惰性劫持 return isObject(res) ? reactive(res) : res }, set(target, p, value, receiver) { const result = Reflect.set(target, p, value, receiver) console.log('set', p, value, result) return result }, deleteProperty(target, p) { const res = Reflect.deleteProperty(target, p) console.log('delete', p, res) return res } }) toProxy.set(obj, observed) return observed } var state = reactive({ foo: 'foo', bar: { a: 1 } }) // 1.获取 state.foo // ok // 2.设置已存在属性 state.foo = 'fooooooo' // ok // 3.设置不存在属性 state.dong = 'dong' // ok // 4.删除属性 delete state.dong // ok // 5.嵌套对象也支持 state.bar.a = 3

总结:

  • proxy数据劫持是惰性(用时)劫持, 性能要好很多
  • proxy远比存取器劫持方式强大很多,支持删除属性、新增属性、属性检测、对象遍历等
  • 修改语法无限制(数组的用法与对象完全一致)

为什Proxy要搭配Reflect使用?

js
// 思考 为什 Proxy 要搭配 Reflect 使用呢? // 通过查看mdn文档知道 // Reflect相关的方法最后一个参数 receiver 是绑定this的意思 var obj = { firstName: '张', lastname: '三', get fullName() { return this.firstName + this.lastname } } var observed1 = new Proxy(obj, { get(target, p, receiver) { console.log('触发get', p) return target[p] }, set(target, p, value, receiver) { target[p] = value return true } }) observed1.fullName // 打印 “触发get fullName” // 只打印一次 var observed2 = new Proxy(obj, { get(target, p, receiver) { const res = Reflect.get(target, p, receiver) console.log('触发get', res) return res }, set(target, p, value, receiver) { const res = Reflect.set(target, p, value, receiver) console.log('set', p, value) return res } }) observed2.fullName // 打印 触发get 张 // 打印 触发get 三 // 打印 触发get 张三

依赖收集设计思路

建立响应数据key和更新函数之间的对应关系。

用法:

js
const state = reactive({ foo: 'Foo' }); // 设置响应函数 effect(() => console.log(state.foo)) ; // 用户修改关联数据会触发响应函数 state.foo = 'xxx';

设计思路
实现三个函数:

  • effect:将回调函数保存起来备用,立即执行一次回调函数触发它里面一些响应数据的getter
  • trackgetter中调用track,把前面存储的回调函数和当前targetkey之间建立映射关系
  • triggersetter中调用trigger,把target,key对应的响应函数都执行一遍

image.png

target,key和响应函数映射关系

image.png

reactive实现

  1. 先上测试用例
html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <script src="../dist/vue.js"></script> </head> <body> <div id="app"></div> </body> <script> const {reactive, effect} = Vue; const obj = reactive({ name: '张三' }); effect(() => { document.getElementById('app').innerText = obj.name }); setTimeout(() => { obj.name = '李四'; }, 2000); </script> </html>
  1. reactivity/reactive.ts
ts
import { trigger, track } from './effect' // Map缓存 obj:observed export const reactiveMap = new WeakMap<object, any>() export function reactive(target: object): any { if (reactiveMap.has(target)) { return reactiveMap.get(target) } const observed = new Proxy(target, { get(target: object, p: string | symbol, receiver: object) { const res = Reflect.get(target, p, receiver) track(target, p) return res }, set(target: object, p: string | symbol, value: unknown, receiver: object) { const res = Reflect.set(target, p, value, receiver) trigger(target, p) return res } }) reactiveMap.set(target, observed) return observed }
  1. reactivity/effect.ts
js
export function effect<T = any>(fn: () => T) { const _effect = new ReactiveEffect<T>(fn) _effect.run() } /** * targetMap 大致结构如下 * {target: Map<{key: Set<Effect>}>} * target | depsMap * obj | key | Dep * k1 | effect1,effect2... * k2 | effect3,effect4... */ const targetMap = new WeakMap<object, Map<any, Set<ReactiveEffect>>>() export let activeEffect: ReactiveEffect | undefined export class ReactiveEffect<T = any> { constructor(public fn: () => T) { this.fn = fn } run() { activeEffect = this this.fn() } } export function track(target: object, p: unknown) { let depsMap = targetMap.get(target) if (!depsMap) { targetMap.set(target, (depsMap = new Map())) } let dep = depsMap.get(p) if (!dep) { depsMap.set(p, (dep = new Set())) } dep.add(activeEffect!) } export function trigger(target: object, p: unknown) { const depsMap = targetMap.get(target) if (!depsMap) return const dep = depsMap.get(p) if (!dep) return triggerEffects(dep) } export function triggerEffects(effects: Set<ReactiveEffect>) { for (let effect of effects) { effect.run() } }

注: packages/*/src文件夹的所有文件都有通过index.ts聚合导出,如packages/reactivity/src/index.ts

ts
export * from './reactive'; export * from './effect';

后面不在强调这个细节

运行效果

ref实现

  • 官方ref支持 复杂数据(对象类型) 和简单数据(基本类型)
  • 我们先实现 复杂数据的支持,在实现简单数据的支持
  • 复杂数据的支持是借助reactive的依赖收集

复杂数据的支持

这一步不难我先上代码

  • reactivity/ref.ts
ts
import { isObject } from '@vue/shared' import { activeEffect, ReactiveEffect } from './effect' import { reactive } from './reactive' export function ref(value: unknown) { return new RefImpl(value) } class RefImpl<T> { private _value: T private _rawValue: T public dep?: Set<ReactiveEffect> = undefined constructor(value: T) { this._value = toReactive(value) this._rawValue = value } get value() { // 收集依赖 if (activeEffect) { this.dep ??= new Set<ReactiveEffect>() this.dep.add(activeEffect) } return this._value } } export const toReactive = <T extends unknown>(value: T): T => isObject(value) ? reactive(value as object) : value

运行效果

原生类型数据的支持

我们在修改一下ref让它支持简单数据

image.png

运行效果

computed实现

思路

  1. 先实现展示功能
  2. 再实现响应式

先实现展示功能

  • reactivity/computed.ts
ts
import { ReactiveEffect, activeEffect } from './effect' export function computed(getterOrOptions) { // 这里只考虑简单情况 即 getterOrOptions 为 getter const getter = getterOrOptions return new ComputedRefImpl(getter) } export class ComputedRefImpl<T> { public dep?: Set<ReactiveEffect> = undefined private _value!: T private readonly effect: ReactiveEffect<T> constructor(getter) { this.effect = new ReactiveEffect(getter) } get value() { this.dep ??= new Set<ReactiveEffect>() this.dep.add(activeEffect!) this._value = this.effect.run() return this._value } }

image.png

运行效果

image.png

响应式的实现

  • reactivity/computed.ts 改动如下 image.png

  • reactivity/effect.ts改动 image.png image.png

运行效果

这一步理解起来还是比较费劲的,建议用断点调试多调试几遍

image.png

我说一下大致流程

  1. 首先第一步分别创建了 一个ReactiveEffect实例, 就是 obj
  2. 第二步创建了一个ComputedRefImpl实例, 即computedObjcomputedObj有个effect属性, 与 obj类型相同,不同点是前者(computedObj.effect)会标识computed属性且它的effect属性拥有scheduler方法,这样 trigger()是调用的是 effect.scheduler() 而不再是 effect.run()
  3. 执行 effect() 完成依赖收集,targetMap依赖如下
  4. 最后一步,算是唯一难理解的
    • obj.name赋值 触发 setter ---> triggger() ---> 执行副作用,就是上图的ReactiveEffect实例 即computedObj.effect.sheduler()effect的第一个参数
    • 注意ComputedRefImpl实例(这里是computedObj)有个标志_dirty,初始值是true, 在第三步的时候已经触发getter,完成依赖收集后变成false了, 由于现在执行依赖收集副作用触发.effect.sheduler()方法再次把_dirty标记置为true并执行它的副作用(effect的第一个参数)
    • image.png
    • 这时又触发ComputedRefImpl实例的getter, 再次执行副作用,这时副作用是指 第二步 computed()的第一个参数() => '姓名:' + obj.name
    • 接着触发 代理对象objgetter(), 进行副作用收集,其实前面第三步已经收集了,这里等于什么都没做,逐级回退调用栈,函数就执行结束了

文采不够,解释的比较牵强,源码的艺术只可意会不可言谈😂,还是要多调试自己品味才行。

总结: computed也是要完成依赖收集,setter 触发副作用从它开始逐级执行副作用(effect的回调参数 --> computed的回调参数 --> 从reactive代理对象取值返回),是一个递归过程

computed的缓存特性

我们在改一下案例 测试computed的缓存性

image.png

断点调试发现 computedObj.dep 有两个 ReactiveEffect的实例

image.png 第一个副作用是effect()里面的回调参数 第二个副作用是computed()的回调参数

当执行到定时器中的代码 触发setter, 执行依赖(前面分析过只有一个依赖即computed()生成的的副作用)

执行scheduler()_dirty标记为true,

第一次执行computedObj.value 由于_dirtyfalse 再执行它的副作用, 这里就造成递归,正确的做法是先执行存值器 将_dirty标记为false 再执行schedule() 这样就不会造成死循环了

如果我们先执行computedObj.dep中的schedule()

image.png

第一次执行computedObj.value_dirtyfalse 再执行它的副作用, 这次是先执行了schedule() _dirtyfalse 不在执行副作用,就结束了。

computed的缓存性如何体现呢?

  1. 第一次执行computedObj.value,进入存值器,这时_dirtytrue 会执行副作用重新生成值,
  2. 第一次执行computedObj.value,进入存值器,这时_dirtyfalse 就不在重新计算值了。

注: 这一块很难解释, 耐心多调试方能理解。

这一次运行就正常了 6.gif

watch

因为watch默认是懒执行(immediate: false)的, 所以我们effect要适配,让它支持懒执行。

先实现effect的懒执行

image.png

effect支持scheduler参数

image.png

scheduler的完整实现 前面的调度方法是同步执行,如果我们想异步执行,Vue提供了queuePostFlushCb,这个方法比较独立,是一个不错的手写题

  • runtime-core/src/scheduler.ts
ts
const cbs: Function[] = [] export function queuePostFlushCb(cb: Function) { cbs.push(cb) queueFlush() } let isFlushPending = false async function queueFlush() { // console.log('queueFlush', isFlushPending) if (!isFlushPending) { isFlushPending = true await Promise.resolve() let _cbs = [...new Set(cbs)] for (let cb of _cbs) { cb() } cbs.length = 0 isFlushPending = false } }
  • 测试用例如下
js
const { reactive, effect, queuePostFlushCb, } = Vue const obj = reactive({ count: 1 }) effect(() => { // 初始化执行 console.log(obj.count); }, { scheduler(){ // 发生变更执行这里 queuePostFlushCb(() => { console.log(obj.count); }) } }) obj.count = 2 obj.count = 3 // 打印 1 // 打印两次 3

运行结果与前面一致, 这里就不截图了

watch基本骨架

Vue3watch 是放在 runtime-core/src/apiWatch.ts文件中的。

先来实现watch的骨架和immediate功能

  • runtime-core/src/apiWatch.ts
ts
import { ReactiveEffect, isReactive } from '@vue/reactivity' import { queuePostFlushCb } from './scheduler' export function watch( source: any, cb: Function, { immediate, deep }: WatchOptions = {} ) { let getter: () => any if (isReactive(source)) { getter = () => source deep = true } else { getter = () => {} } let oldValue = {} const job = () => { const newValue = effect.run() if (deep || Object.is(newValue, oldValue)) { cb(newValue, oldValue) oldValue = newValue } } const scheduler = () => queuePostFlushCb(job) const effect = new ReactiveEffect(getter, scheduler) if (immediate) { job() } else { oldValue = effect.run() } return () => effect.stop() } export interface WatchOptions { immediate?: boolean deep?: boolean }

在看一下测试用例和效果

image.png

watch完整实现

前面的案例watch没有实现响应式,为什么呢? 其实是因为 watcheffect(就是watch的第二个参数)没有触发getter, 我们只需递归遍历一下watch监听的对象即可以完成依赖收集

修改 runtime-core/src/apiWatch.ts 增加代码如下 image.png

image.png

在看一下测试用例

image.png

本文作者:郭敬文

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!