14

带你彻底搞懂Vue3的Proxy响应式原理!TypeScript从零实现基于Proxy的响应式库。

 4 years ago
source link: https://juejin.im/post/5e21196fe51d454d523be084
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
2020年01月17日 阅读 7175

带你彻底搞懂Vue3的Proxy响应式原理!TypeScript从零实现基于Proxy的响应式库。

笔者最近在浏览React状态管理库的时候,发现了一些响应式的状态管理库如 hodux,react-easy-state,内部有一个基于proxy实现响应式的基础仓库observer-util,它的代码实现和Vue3中的响应式原理非常相似,这篇文章就从这个仓库入手,一步一步带你剖析响应式的实现。

本篇是系列第一篇,主要讲解了普通对象的响应式源码

系列终结篇也已经发布,讲解Map和Set的特殊响应式流程
带你彻底搞懂Vue3的Proxy响应式原理!基于函数劫持实现Map和Set的响应式

本文的代码是我参考observer-util用ts的重写的,并且会加上非常详细的注释。

阅读本文可能需要的一些前置知识:

Proxy
WeakMap
Reflect

首先看一下observer-util给出的代码示例:

import { observable, observe } from '@nx-js/observer-util';

const counter = observable({ num: 0 });

// 会在控制台打印出0
const countLogger = observe(() => console.log(counter.num));

// 会在控制台打印出1
counter.num++;
复制代码

这就是一个最精简的响应式模型了,乍一看好像和Vue2里的响应式系统也没啥区别,那么还是先看一下Vue2和Vue3响应式系统之间的差异吧。

和Vue2的差异

关于Vue2的响应式原理,感兴趣的也可以去看我之前的一篇文章:
实现一个最精简的响应式系统来学习Vue的data、computed、watch源码

其实这个问题本质上就是基于Proxy和基于Object.defineProperty之间的差异,来看Vue2中的一个案例:

Object.defineProperty

<template>
  {{ obj.c }}
</template>
<script>
export default {
  data: {
    obj: { a: 1 },
  },
  mounted() {
    this.obj.c = 3
  }
}
</script>

复制代码

这个例子中,我们对obj上原本不存在的c属性进行了一个赋值,但是在Vue2中,这是不会触发视图的响应式更新的,

这是因为Object.defineProperty必须对于确定的key值进行响应式的定义,

这就导致了如果data在初始化的时候没有c属性,那么后续对于c属性的赋值都不会触发Object.defineProperty中对于set的劫持,

在Vue2中,这里只能用一个额外的api Vue.set来解决,

Proxy

再看一下Proxy的api,

const raw = {}
const data = new Proxy(raw, {
    get(target, key) { },
    set(target, key, value) { }
})
复制代码

可以看出来,Proxy在定义的时候并不用关心key值,

只要你定义了get方法,那么后续对于data上任何属性的访问(哪怕是不存在的),

都会触发get的劫持,set也是同理。

这样Vue3中,对于需要定义响应式的值,初始化时候的要求就没那么高了,只要保证它是个可以被Proxy接受的对象或者数组类型即可。

当然,Proxy对于数据拦截带来的便利还不止于此,往下看就知道。

接下来就一步步实现这个基于Proxy的响应式系统:

本仓库基于TypeScript重构,所以会有一个类型定义的文件,可以当做接口先大致看一下

github.com/sl1673495/t…

首先响应式的思路无外乎这样一个模型:

  1. 定义某个数据为响应式数据,它会拥有收集访问它的函数的能力。
  2. 定义观察函数,在这个函数内部去访问响应式数据,访问到响应式数据的某个key的时候,会建立一个依赖关系key -> reaction观察函数
  3. 检测到响应式数据key的值更新的时候,会去重新执行一遍它所收集的所有reaction观察函数

以开头的例子来说

// 响应式数据
const counter = observable({ num: 0 });

// 观察函数
observe(() => console.log(counter.num));
复制代码

这已经一目了然了,

  • observable包裹的数据叫做响应式数据,
  • observe内部执行的函数叫观察函数

observable({ num: 0 }),会让{ num: 0 }这个普通的对象变成一个proxy,而后续对于这个proxy所有的getset等操作都会被我们内部拦截下来。

observe函数会先开启一个开始观察的开关,然后帮你去执行console.log(counter.num),执行到counter.num的时候

我们注册在counter这个proxyget拦截到了对于counter.num的访问,

这时候又可以知道访问者是() => console.log(counter.num)这个函数,

那么就把这个函数作为num这个key值的观察函数收集在一个地方。

下次对于counter.num修改的时候,会去找num这个key下所有的观察函数,轮流执行一遍。

这样就实现了响应式模型。

reactive的实现(定义响应式数据)

上文中关于observable的api,我换了个名字: reactive,感觉更好理解一些。

// 需要定义响应式的原值
export type Raw = object
// 定义成响应式后的proxy
export type ReactiveProxy = object

// 用来存储原始值和响应式proxy的映射
export const proxyToRaw = new WeakMap<ReactiveProxy, Raw>()
// 用来存储响应式proxy和原始值的映射
export const rawToProxy = new WeakMap<Raw, ReactiveProxy>()

function createReactive<T extends Raw>(raw: T): T {
  const reactive = new Proxy(raw, baseHandlers)

  // 双向存储原始值和响应式proxy的映射
  rawToProxy.set(raw, reactive)
  proxyToRaw.set(reactive, raw)

  // 建立一个映射
  // 原始值 -> 存储这个原始值的各个key收集到的依赖函数的Map
  storeObservable(raw)

  // 返回响应式proxy
  return reactive as T
}
复制代码

首先是定义proxy

const reactive = new Proxy(raw, baseHandlers)
复制代码

这个baseHandlers里就是对于数据的getset之类的劫持,

这里有两个WeakMap: proxyToRawrawToProxy

可以看到在定义响应式数据为一个Proxy的时候,会进行一个双向的存储,

这样后续无论是拿到原始对象还是拿到响应式proxy,都可以很容易的拿到它们的另一半

之后storeObservable,是用原始对象建立一个map:

const connectionStore = new WeakMap<Raw, ReactionForRaw>()

function storeObservable(value: object) {
  // 存储对象和它内部的key -> reaction的映射
  connectionStore.set(value, new Map() as ReactionForRaw)
}
复制代码

通过connectionStore的泛型也可以知道,

这是一个Raw -> ReactionForRaw的map。

也就是原始数据 -> 这个数据收集到的观察函数依赖

更清晰的描述可以看Type定义:

// 收集响应依赖的的函数
export type ReactionFunction = Function & {
  cleaners?: ReactionForKey[]
  unobserved?: boolean
}

// reactionForRaw的key为对象key值 value为这个key值收集到的Reaction集合
export type ReactionForRaw = Map<Key, ReactionForKey>

// key值收集到的Reaction集合
export type ReactionForKey = Set<ReactionFunction>

// 收集响应依赖的的函数
export type ReactionFunction = Function & {
  cleaners?: ReactionForKey[]
  unobserved?: boolean
}
复制代码

那接下来的重点就是proxy的第二个参数baseHandler里的getset

proxy的handler

/** 劫持get访问 收集依赖 */
function get(target: Raw, key: Key, receiver: ReactiveProxy) {
  const result = Reflect.get(target, key, receiver)
  
  // 收集依赖
  registerRunningReaction({ target, key, receiver, type: "get" })

  return result
}

复制代码

关于receiver这个参数,这里可以先简单理解为响应式proxy本身,不影响流程。

这里就是简单的做了一个求值,然后进入了registerRunningReaction函数,

get收集依赖

// 收集响应依赖的的函数
type ReactionFunction = Function & {
  cleaners?: ReactionForKey[]
  unobserved?: boolean
}

// 操作符 用来做依赖收集和触发依赖更新
interface Operation {
  type: "get" | "iterate" | "add" | "set" | "delete" | "clear"
  target: object
  key?: Key
  receiver?: any
  value?: any
  oldValue?: any
}

/** 依赖收集栈 */
const reactionStack: ReactionFunction[] = []

/** 依赖收集 在get操作的时候要调用 */
export function registerRunningReaction(operation: Operation) {
  const runningReaction = getRunningReaction()
  if (runningReaction) {
      // 拿到原始对象 -> 观察者的map
      const reactionsForRaw = connectionStore.get(target)
      // 拿到key -> 观察者的set
      let reactionsForKey = reactionsForRaw.get(key)
    
      if (!reactionsForKey) {
        // 如果这个key之前没有收集过观察函数 就新建一个
        reactionsForKey = new Set()
        // set到整个value的存储里去
        reactionsForRaw.set(key, reactionsForKey)
      }
    
      if (!reactionsForKey.has(reaction)) {
        // 把这个key对应的观察函数收集起来
        reactionsForKey.add(reaction)
        // 把key收集的观察函数集合 加到cleaners队列中 便于后续取消观察
        reaction.cleaners.push(reactionsForKey)
      }
  }
}

/** 从栈的末尾取到正在运行的observe包裹的函数 */
function getRunningReaction() {
  const [runningReaction] = reactionStack.slice(-1)
  return runningReaction
}
复制代码

这里做的一系列操作,就是把用原始数据connectionStore里拿到依赖收集的ma【p,

然后在reaction观察函数把对于某个key访问的时候,把reaction观察函数本身增加到这个key的观察函数集合里,对于observe(() => console.log(counter.num));这个例子来说,就会收集到 { num -> Set<Reaction >}

注意这里对于数组来说,也是一样的流程,只是数组访问的key是下标数字而已。 所以会收集类似于 { 1 -> Set<Reaction>} 这样的结构。

那么这个runningReaction正在运行的观察函数是哪来的呢,剧透一下,当然是observe这个api内部开启观察模式后去做的。

// 此时 () => console.log(counter.num) 会被包装成reaction函数
observe(() => console.log(counter.num));
复制代码

set触发更新

/** 劫持set访问 触发收集到的观察函数 */
function set(target: Raw, key: Key, value: any, receiver: ReactiveProxy) {
  // 拿到旧值
  const oldValue = target[key]
  // 设置新值
  const result = Reflect.set(target, key, value, receiver)
  
  queueReactionsForOperation({
      target,
      key,
      value,
      oldValue,
      receiver,
      type: 'set'
  })

  return result
}

/** 值更新时触发观察函数 */
export function queueReactionsForOperation(operation: Operation) {
  getReactionsForOperation(operation).forEach(reaction => reaction())
}

/**
 *  根据key,type和原始对象 拿到需要触发的所有观察函数
 */
export function getReactionsForOperation({ target, key, type }: Operation) {
  // 拿到原始对象 -> 观察者的map
  const reactionsForTarget = connectionStore.get(target)
  const reactionsForKey: ReactionForKey = new Set()

  // 把所有需要触发的观察函数都收集到新的set里
  addReactionsForKey(reactionsForKey, reactionsForTarget, key)

  return reactionsForKey
}
复制代码

set赋值操作的时候,本质上就是去检查这个key收集到了哪些reaction观察函数,然后依次触发。(数组也是同理)

observe 观察函数

observe这个api接受一个用户传入的函数,在这个函数内访问响应式数据才会去收集观察函数作为自己的依赖。

/** 
 * 观察函数
 * 在传入的函数里去访问响应式的proxy 会收集传入的函数作为依赖
 * 下次访问的key发生变化的时候 就会重新运行这个函数
 */
export function observe(fn: Function): ReactionFunction {
  // reaction是包装了原始函数只后的观察函数
  // 在runReactionWrap的上下文中执行原始函数 可以收集到依赖。
  const reaction: ReactionFunction = (...args: any[]) => {
    return runReactionWrap(reaction, fn, this, args)
  }

  // 先执行一遍reaction
  reaction()

  // 返回出去 让外部也可以手动调用
  return reaction
}
复制代码

核心的逻辑在runReactionWrap里,


/** 把函数包裹为观察函数 */
export function runReactionWrap(
  reaction: ReactionFunction,
  fn: Function,
  context: any,
  args: any[],
) {
  try {
    // 把当前的观察函数推入栈内 开始观察响应式proxy
    reactionStack.push(reaction)
    // 运行用户传入的函数 这个函数里访问proxy就会收集reaction函数作为依赖了
    return Reflect.apply(fn, context, args)
  } finally {
    // 运行完了永远要出栈
    reactionStack.pop()
  }
}
复制代码

简化后的核心逻辑很简单,

reaction推入reactionStack后开始执行用户传入的函数,

在函数内访问响应式proxy的属性,又会触发get的拦截,

这时候getreactionStack找当前正在运行的reaction,就可以成功的收集到依赖了。

下一次用户进行赋值的时候

const counter = reactive({ num: 0 });

// 会在控制台打印出0
const counterReaction = observe(() => console.log(counter.num));

// 会在控制台打印出1
counter.num = 1;
复制代码

以这个示例来说,observe内部对于counter的key值num的访问,会收集counterReaction作为num的依赖。

counter.num = 1的操作,会触发对于counter的set劫持,此时就会从key值的依赖收集里面找到counterReaction,再重新执行一遍。

以上实现只是一个最基础的响应式模型,还没有实现的点有:

  • 深层数据的劫持
  • 数组和对象新增、删除项的响应

接下来在上面的代码的基础上来实现这两种情况:

深层数据的劫持

在刚刚的代码实现中,我们只对Proxy的第一层属性做了拦截,假设有这样的一个场景

const counter = reactive({ data: { num: 0 } });

// 会在控制台打印出0
const counterReaction = observe(() => console.log(counter.data.num));

counter.data.num = 1;
复制代码

这种场景就不能实能触发counterReaction自动执行了。

因为counter.data.num其实是对data上的num属性进行赋值,而counter虽然是一个响应式proxy,但counter.data却只是一个普通的对象,回想一下刚刚的proxyget的拦截函数:

/** 劫持get访问 收集依赖 */
function get(target: Raw, key: Key, receiver: ReactiveProxy) {
  const result = Reflect.get(target, key, receiver)
  
  // 收集依赖
  registerRunningReaction({ target, key, receiver, type: "get" })

  return result
}
复制代码

counter.data只是通过Reflect.get拿到了原始的 { data: {number } }对象,然后对这个对象的赋值不会被proxy拦截到。

那么思路其实也有了,就是在深层访问的时候,如果访问的数据是个对象,就把这个对象也用reactive包装成proxy再返回,这样在进行counter.data.num = 1;赋值的时候,其实也是针对一个响应式proxy赋值了。

/** 劫持get访问 收集依赖 */
function get(target: Raw, key: Key, receiver: ReactiveProxy) {
  const result = Reflect.get(target, key, receiver)
  // 收集依赖
  registerRunningReaction({ target, key, receiver, type: "get" })

+  // 如果访问的是对象 则返回这个对象的响应式proxy
+  if (isObject(result)) {
+    return reactive(result)
+  }

  return result
}
复制代码

数组和对象新增属性的响应

以这样一个场景为例

const data: any = reactive({ a: 1, b: 2})

observe(() => console.log( Object.keys(data)))

data.c = 5
复制代码

其实在用Object.keys访问data的时候,后续不管是data上的key发生了新增或者删除,都应该触发这个观察函数,那么这是怎么实现的呢?

首先我们需要知道,Object.keys(data)访问proxy的时候,会触发proxy的ownKeys拦截。

那么我们在baseHandler中先新增对于ownKeys的访问拦截:

/** 劫持get访问 收集依赖 */
function get() {}

/** 劫持set访问 触发收集到的观察函数 */
function set() {
}

/** 劫持一些遍历访问 比如Object.keys */
+ function ownKeys (target: Raw) {
+   registerRunningReaction({ target, type: 'iterate' })
+   return Reflect.ownKeys(target)
+ }
复制代码

还是和get方法一样,调用registerRunningReaction方法注册依赖,但是这里type我们需要定义成了一个特殊的值: iterate

这个type怎么用呢。我们继续改造registerRunningReaction函数:

+ const ITERATION_KEY = Symbol("iteration key")

export function registerRunningReaction(operation: Operation) {
  const runningReaction = getRunningReaction()
  if (runningReaction) {
+      if (type === "iterate") {
+        key = ITERATION_KEY
+      }
      // 拿到原始对象 -> 观察者的map
      const reactionsForRaw = connectionStore.get(target)
      // 拿到key -> 观察者的set
      let reactionsForKey = reactionsForRaw.get(key)
    
      if (!reactionsForKey) {
        // 如果这个key之前没有收集过观察函数 就新建一个
        reactionsForKey = new Set()
        // set到整个value的存储里去
        reactionsForRaw.set(key, reactionsForKey)
      }
    
      if (!reactionsForKey.has(reaction)) {
        // 把这个key对应的观察函数收集起来
        reactionsForKey.add(reaction)
        // 把key收集的观察函数集合 加到cleaners队列中 便于后续取消观察
        reaction.cleaners.push(reactionsForKey)
      }
  }
}
复制代码

也就是type: iterate触发的依赖收集,我们会把key改成ITERATION_KEY这个特殊的Symbol,然后把收集到的观察函数放在ITERATION_KEY的收集中,那么再来看看触发更新时的set改造:

/** 劫持set访问 触发收集到的观察函数 */
function set(target: Raw, key: Key, value: any, receiver: ReactiveProxy) {
+  // 先检查一下这个key是不是新增的
+  const hadKey = hasOwnProperty.call(target, key)
  // 拿到旧值
  const oldValue = target[key]
  // 设置新值
  const result = Reflect.set(target, key, value, receiver)

+  if (!hadKey) {
+    // 新增key值时触发观察函数
+    queueReactionsForOperation({ target, key, value, receiver, type: 'add' })
  } else if (value !== oldValue) {
    // 已存在的key的值发生变化时触发观察函数
    queueReactionsForOperation({
      target,
      key,
      value,
      oldValue,
      receiver,
      type: 'set'
    })
  }

  return result
}

复制代码

这里对新增的key也进行了的判断,传入queueReactionsForOperation的type变成了add,接下来的一步就会针对add进行一些特殊的操作


/** 值更新时触发观察函数 */
export function queueReactionsForOperation(operation: Operation) {
  getReactionsForOperation(operation).forEach(reaction => reaction())
}

/**
 *  根据key,type和原始对象 拿到需要触发的所有观察函数
 */
export function getReactionsForOperation({ target, key, type }: Operation) {
  // 拿到原始对象 -> 观察者的map
  const reactionsForTarget = connectionStore.get(target)
  const reactionsForKey: ReactionForKey = new Set()

  // 把所有需要触发的观察函数都收集到新的set里
  addReactionsForKey(reactionsForKey, reactionsForTarget, key)

  // add和delete的操作 需要触发某些由循环触发的观察函数收集
  // observer(() => rectiveProxy.forEach(() => proxy.foo))
+  if (type === "add" || type === "delete") {
+    const iterationKey = Array.isArray(target) ? "length" : ITERATION_KEY
+    addReactionsForKey(reactionsForKey, reactionsForTarget, iterationKey)
  }
  return reactionsForKey
}
复制代码

这里需要注意的是,如果我们在观察函数中对数据做了遍历操作,那么后续加入对数据进行了新增删除操作,也需要触发它的重新执行,这是很合理的,

这里又有一个知识点,对于数组遍历的操作,都会触发它对length的读取,然后把观察函数收集到length这个key的依赖中,比如

observe(() => proxyArray.forEach(() => {}))
// 会访问proxyArray的length。
复制代码

所以在触发更新的时候,

  1. 如果目标是个数组,那就从length的依赖里收集。
  2. 如果目标是对象,就从ITERATION_KEY的依赖里收集。(也就是刚刚所说的,对于对象做Object.keys读取时收集的依赖)。

如此一来,就实现了对遍历和新增属性这些边界情况的支持。

删除属性的拦截

/** 劫持删除操作 触发收集到的观察函数 */
function deleteProperty (target: Raw, key: Key) {
  // 先检查一下是否存在这个key
  const hadKey = hasOwnProperty.call(target, key)
  // 拿到旧值
  const oldValue = target[key]
  // 删除这个属性
  const result = Reflect.deleteProperty(target, key)
  // 只有这个key存在的时候才触发更新
  if (hadKey) {
    // type为delete的话 会触发遍历相关的观察函数更新
    queueReactionsForOperation({ target, key, oldValue, type: 'delete' })
  }
  return result
}
复制代码

基本是同一个套路,只是queueReactionsForOperation寻找收集观察函数的时候,type换成了delete,所以会触发内部做了循环操作的观察函数重新执行。

github.com/sl1673495/t…

由于篇幅原因,有一些优化的操作我没有在文中写出来,在仓库里做了几乎是逐行注释,而且也可以用npm run dev对example文件夹中的例子进行调试。感兴趣的同学可以自己看一下。

如果读完了还觉得有兴致,也可以直接去看observe-util这个库的源码,里面对于更多的边界情况做了处理,代码也写的非常优雅,值得学习。

从本文里讲解的一些边界情况也可以看出,基于Proxy的响应式方案比Object.defineProperty要强大很多,希望大家尽情的享受Vue3带来的快落吧。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK