2
头图

In petite-vue, we construct a context object through reactive , and pass the logic of rendering UI according to the state as an input parameter to effect , and then the magic happens, when the state changes will automatically trigger a UI re-render. So how exactly is this done?
@vue/reactivity is very rich in functions, while petite-vue only uses reactive and effect two most basic APIs. As an introduction, this article will only interpret the source code of these two APIs. .

Everything comes from Proxy

We know that Vue2 is based on Object.defineProperty to intercept the read and write operations of object properties, so as to achieve dependency collection and responsive UI rendering. As a sub-project of Vue3, @vue/reactivity uses the Proxy interface of ES6 to achieve this function.

 const state = {
  count: 1
}

const proxyState = new Proxy(state, {
  get(target: T, property: string, receiver?: T | Proxy): any {
    // 拦截读操作
    console.log('get')
    return Reflect.get(target, property, receiver)
  },
  set(target: T, property: string, value: any, receiver?: T | Proxy): boolean {
    // 拦截写操作
    console.log('set')
    return Reflect.set(target, property, value, receiver)
  },
  deleteProperty(target, prop) {
    // 拦截属性删除操作
    console.log('delete')
    delete target[prop]
    return true
  }
})

Relative Object.defineProperty , Proxy features:

  1. Only by operating the object constructed by new Proxy can the read and write operations of the object properties be intercepted, while the proxied object has no change;
  2. You can monitor the changes and additions of array elements;
  3. You can monitor the increase or decrease of object properties;
  4. Proxy can proxy object properties layer by layer, while Object.defineProperty needs to proxy the properties of all levels of the object at one time.

reactive programming

 // 定义响应式对象
const state = reactive({
  num1: 1,
  num2: 2
})

// 在副作用函数中访问响应式对象属性,当这些属性发生变化时副作用函数将被自动调用
effect(() => {
  console.log('outer', state.num1)
  effect(() => {
    console.log('inner', state.num2)
  })
})
// 回显 outer 1
// 回显 inner 2

state.num2 += 1
// 回显 inner 3

state.num1 += 1
// 回显 outer 2
// 回显 inner 3

state.num2 += 1
// 回显 inner 4
// 回显 inner 4

In this article, we will start with reactive and interpret how Vue3 constructs a reactive object.

Dive into reactive works

The source code of @vue/reactivity is located under the packages/reactivity of the vue-next project, and the reactive function is located in the src/reactive.ts file under it. reactive函数外, shallowReactivereadonlyshallowReadonly函数。
The core work of reactive is to convert an ordinary JavaScript object into a monitoring object through Proxy, intercept the read, write and delete operations of object properties, and collect side-effect functions that depend on the object (property). The general process is as follows:

  1. The responsive object constructed by reactive will save the mapping relationship between the proxy object and the responsive object in reactiveMap to prevent repeated generation of responsive objects and optimize performance;
  2. When reactive is called, the proxy object will be checked. If it is not a read-only object, a reactive object, a primitive value, and reactiveMap does not exist, the response will be constructed according to the type of the proxy object. object
  3. Call the track collection dependencies in effect.ts when intercepting read operations ( get , has and ownKeys )
  4. When intercepting a write operation ( set , deleteProperty ), call the trigger in effect.ts to trigger the execution of the side effect function

Let's understand the source code line by line!

Source code interpretation—— reactive Entry

 // Vue3内部定义的对象特性标识
export const enum ReactiveFlags {
  SKIP = '__v_skip', // 标识该对象不被代理
  IS_REACTIVE = '__v_isReactive', // 标识该对象是响应式对象
  IS_READONLY = '__v_isReadonly', // 标识该对象为只读对象
  RAW = '__v_raw' // 指向被代理的JavaScript对象
}

// 响应式对象的接口
export interface Target {
  [ReactiveFlags.SKIP]?: boolean
  [ReactiveFlags.IS_REACTIVE]?: boolean
  [ReactiveFlags.IS_READONLY]?: boolean
  [ReactiveFlags.RAW]?: any // 用于指向被代理的JavaScript对象
}

// 用于缓存被代理对象和代理对象的关系,防止重复代理
export const reactiveMap = new WeakMap<Target, any>()

// 将被代理对象的处理方式分为不代理(INVALID)、普通对象和数组(COMMON)和Map、Set(COLLECTION)
const enum TargetType {
  INVALID = 0,
  COMMON = 1,
  COLLECTION = 2,
}

function targetTypeMap(rawType: string) {
  switch(rawType) {
    case 'Object':
    case 'Array':
      return TargetType.COMMON
    case 'Map':
    case 'Set':
    case 'WeakMap':
    case 'WeakSet':
      return TargetType.COLLECTION
    default:
      return TargetType.INVALID
  }
}

function getTargetType(value: Target) {
  // 若对象标记为跳过,或不可扩展则不代理该对象
  return value[ReactiveFlags.SKIP] || !Object.isExtensible(value)
    ? TargetType.INVALID
    // 根据类型决定处理方式
    : targetTypeMap(toRawType(value))
}

export function reative(target: object) {
  // 不拦截只读对象的读写删操作
  if (target && (target as Target)[ReactiveFlags.IS_READONLY]) {
    return target
  }
  return createReactiveObject(
    target,
    false,
    mutableHandlers,
    mutableCollectionHandlers,
    reactiveMap
  )
}

function createReactiveObject (
  target: Target,
  isReadonly: boolean,
  beaseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>,
  proxyMap: WeakMap<Target, any>
) {
  // reactive函数入参必须是JavaScript对象或数组,若是primitive value则会直接返回primitive value
  if (!isObject(target)) {
    return target
  }
  /**
   * 1. 仅能对非响应式和非只读对象构造响应式对象
   * 2. 可以对非只读对象构造响应式对象
   */
  if (
    target[ReactiveFlags.RAW] &&
    !(isReadonly && target[ReactiveFlags.IS_REACTIVE])
  ) {
    return target
  }
  // 若对象已被代理过,则直接返回对应的代理对象
  const existingProxy = proxyMap.get(target)
  if (existingProxy) {
    return existingProxy
  }
  // 根据被代理的对象类型决定采用哪种代理方式
  const targetType = getTargetType(target)
  if (targetType === TargetType.INVALID) {
    return target
  }
  const proxy = new Proxy (
    target,
    targetType == TargetType.COLLECTION ? collectionHandlers : baseHandlers
  )
  proxyMap.set(target, proxy)
  return proxy
}

It can be seen that the reactive method will perform various checks on the proxied object, thereby reducing unnecessary operations and improving performance. Finally, if the type of the proxy object is Object or Array , then use baseHandlers to generate the proxy, otherwise use collectionHandlers to generate the proxy.

Source code interpretation-agent Object and Array baseHandlers

 //文件 ./baseHandlers.ts

// /*#__PURE__*/用于告诉webpack等bundler工具后面紧跟的函数是纯函数,若没被调用过则可以采用tree-shaking移除掉该函数
const get = /*#__PURE__*/ createGetter()

export const mutableHandlers: ProxyHandler<object> = {
  get,
  set,
  deleteProperty,
  has,
  ownKeys,
}

Let's first look at how to intercept read operations.

Intercept read operations

The core of the interception read operation is to collect the information of the auxiliary functions that depend on the read attributes. The specific process logic is as follows:

  1. For the read operation of Vue3 internal properties, that is, return the corresponding value without collecting dependencies
  2. For the read operation of array built-in methods, these built-in methods need to be rewritten to perform dependency collection on array elements before calling the method, or to solve some boundary problems
  3. For read operations of built-in Symbol properties and other Vue3 internal properties, return the original value directly without collecting dependencies
  4. For read operations of the remaining properties of non-read-only objects except the above, perform dependency collection ( core logic )
  5. If it is a shallow reactive object, the property value will be returned directly. Otherwise, if the property value is an object, it will be constructed as a reactive object ( reactive ) or a read-only object ( readonly )
 //文件 ./baseHandlers.ts

/**
 * isNonTrackableKeys = {'__proto__': true, '__v_isRef': true, '__isVue': true}
 */
const isNonTrackableKeys = /*#__PURE__*/ makeMap(`__proto__,__v_isRef,__isVue`)

// 内置的Symbol实例包含:hasInstance, isConcatSpreadable, iterator, asyncIterator, match, matchAll, replace, search, split, toPrimitive, toStringTag, species, unscopables
const builtInSymbols = new Set(
  Object.getOwnPropertyNames(Symbol)
    .map(key => (Symbol as any)[key])
    .filter(isSymbol)
)

function createGetter(isReadonly = false, shallow = false) {
  return function get(target: Target, key: string | symbol, receiver: object) {
    // 处理Vue3内部属性名(`__v_isReactive`, `__v_isReadonly`, `__v_raw`)的值
    if (key === ReactiveFlags.IS_REACTIVE) {
      return !isReadonly
    }
    else if (key === ReactiveFlags.IS_READONLY) {
      return isReadonly
    }
    else if (
      key === ReactiveFlags.RAW &&
      receiver === reactiveMap.get(target)
    ) {
      return target
    }

    // 如果key是includes,indexOf,lastIndexOf,push,pop,shift,unshift,splice时则返回能跟踪依赖变化的版本
    const targetIsArray = isArray(target)
    if (!isReadonly && targetIsArray && hasOwn(arrayInstrumentations, key)) {
      return Reflect.get(arrayInstrumentations, key, receiver)
    }

    const res = Reflect.get(target, key, receiver)

    // 不拦截内置Symbol属性和__proto__,__v_isRef和__isVue属性
    if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
      return res
    }

    // 收集依赖该属性的副作用函数
    if (!isReadonly) {
      track(target, TrackOpTypes.GET, key)
    }

    // 如果是构建ShallowReactive则不会基于属性值构造响应式式对象
    if (shallow) {
      return res
    }

    /* 对于属性值为@vue/reactivity的Ref实例时,如果不是执行[1,2,3][0]的操作则返回Ref实例包含的primitive value,否则返回Ref实例
     * 因此我们在effect updator中可以通过如下方式直接获取Ref实例属性的primitive value
     * const age = ref(0), state = reactive({ age })
     * console.log(age.value) // 回显0
     * effect(() => { console.log(state.age) }) // 回显0
     */
    if (isRef(res)) {
      const shouldUnwrap = !targetIsArray || !isIntegerKey(key)
      return shouldUnwrap ? res.value : res
    }

    // 若属性值不是primitive value或BOM,则基于属性值构造响应式对象
    if (isObject(res)) {
      return isReadonly ? readonly(res) :  reactive(res)
    }
  }
}

Here you can see that when reading the attribute, the responsive object is constructed for the attribute value according to the attribute value type, instead of traversing all the attributes of the object when we call reactive , and constructing a response for each attribute type object.

In addition, for array operations such as includes , the corresponding version that can track dependency changes will be returned. What exactly is a version that can track dependency changes?

 // 文件 ./baseHandlers.ts

const arrayInstrumentations = /*#__PURE__*/ createArrayInstrumentations()

function createArrayInstrumentations() {
  const instrumentations: Record<string, Function> = {}
  ;(['includes', 'indexOf', 'lastIndexOf'] as const).forEach(key => {
    instrumentations[key] = function(this: unknown[], ...args: unknown[]) {
      /**
       * 获取原始数组对象,然后调用数组原生的include,indexOf或lastIndexOf方法获取返回值。
       * 但由于原生方法的执行无法跟踪被遍历的数组元素,因此这里会提前遍历数组中的所有元素,实现数组元素变化的跟踪处理。
       * 
       * 这里有一个待优化点,我们看看如下示例:
       * const arr = reactive([2,1,3])
       * effect(() => {
       *   console.log(arr.indexOf(2))
       * })
       * setTimeout(() =>{
       *   arr[1] = 4
       * }, 5000)
       * 我们可以看到修改arr[1]的值将会触发副作用函数的触发,但arr[1]值的变化不会影响副作用函数的结果,因此可以判定这是一个无效触发。
       */
      const arr = toRaw(this) as any
      for (let i = 0, l = this.length; i < l; i++) {
        track(arr, TrackOpTypes.GET, i + '')
      }

      // 调用数组原生的includes,indexOf和lastIndexOf方法
      const res = arr[key](...args)
      if (res === -1 // indexOf和lastIndexOf
          || res === false // includes
      ) {
        // 由于入参有可能是响应式对象,因此当匹配失败,则将尝试获取入参的被代理对象重新匹配
        return arr[key](...args.map(toRaw))
      } else {
        return res
      }
    }
  })
  // 下面的操作会修改数组的长度,这里避免触发依赖长度的副作用函数执行
  ;(['push', 'pop', 'shift', 'unshift', 'splice'] as const).forEach(key => {
    instrumentations[key] = function(this: unknown[], ...args: unknown[]) {
      pauseTracking()
      const res = (toRaw(this) as any)[key].apply(this, args)
      resetTracking()
      return res
    }
  })

  return instrumentations
}

// 文件 ./reactive.ts
export function toRaw<T>(observed: T): T {
  const raw = observed && (observed as Target)[ReactiveFlags.RAW]
  return raw ? toRaw(raw) : observed
}

TypeScript Small Class 1: ['includes', 'indexOf', 'lastIndexOf'] as const in TypeScript to identify objects or arrays as unmodifiable objects. which is

 let a = ['includes', 'indexOf', 'lastIndexOf'] as const
a[0] = 'hi' // 编译时报错

const b = ['includes', 'indexOf', 'lastIndexOf']
b[0] = 'hi' // 修改成功
console.log(b[0]) // 回显 hi

TypeScript class 2: instrumentations[key] = function(this: unknown[], ...args: unknown[]) {...} uses TypeScript's this parameter, which is used to limit the this type when calling a function.
Converting to JavaScript is

 instrumentations[key] = function(...args){
  pauseTracking()
  const res = (toRaw(this) as any)[key].apply(this, args)
  resetTracking()
  return res
}

Block write operations

Since intercepting read operations is to collect dependencies, intercepting write operations is naturally used to trigger side-effect functions. The process logic is as follows:

  1. If the attribute value is a Ref object, and the new value is not a Ref object after taking the original value, the value of the Ref object is updated, and the side effect function is triggered internally by Ref
  2. Determine whether it is a new attribute or update the attribute value, and trigger the side effect function
 const set = /*#__PURE__*/ createSetter()

function createSetter(shallow = false) {
  return function set(
    target: Object,
    key: string | symbol,
    value: unknown,
    receiver: object
  ): boolean {
    // Proxy的set拦截器返回true表示赋值成功,false表示赋值失败

    let oldValue = (target as any)[key]
    if (!shallow) {
      /* 若旧属性值为Ref,而新值不是Ref,则直接将新值赋值给旧属性的value属性
       * 一眼看上去貌似没有触发依赖该属性的副作用函数执行任务压入调度器,但Ref对象也是响应式对象,赋值给它的value属性,会触发依赖该Ref对象的辅佐用函数压入调度器
       */  
      value = toRaw(value)
      oldValue = toRaw(oldValue)
      if (!isArray(target) && isRef(oldValue) && !isRef(value)) {
        oldValue.value = value
        return true
      }
    }

    // 用于判断是新增属性还是修改属性
    const hadKey = 
      isArray(target) && isIntegerKey(key)
        ? Number(key) < target.length // 数组索引的处理
        : hasOwn(target, key) // 对象或数组非索引的而处理
    // 赋值后再将副作用函数执行任务压入调度器
    const result = Reflect.set(target, key, value, receiver)
    if (target === toRaw(receiver)) {
      if (!hadKey) {
        // 触发依赖该属性的副作用函数执行任务压入调度器
        trigger(target, TriggerOpTypes.ADD, key, value)
      }
      else if (hasChange(value, oldValue)) {
        // 触发依赖该属性的副作用函数执行任务压入调度器
        trigger(target, TriggerOpTypes.SET, key, value, oldValue)
      }
    }
    return result
  }
}

// 文件 @vue/shared
export const hasChanged = (value: any, oldValue: any): boolean => !Object.is(value, oldValue)

Why not use === instead of Object.is to compare two values for equality?
-0===0返回true , NaN === NaN返回falseObject.is(-0, 0)返回false , Object.is(NaN, NaN) returns true .
For more information, see Source Code Reading for Vue 3: How does hasChanged work?

Block delete operations

The delete operation will modify the attribute and will naturally trigger the side effect function that depends on the attribute.

 function deleteProperty(target: object, key: string | symbol): boolean {
  const hadKey = hasOwn(target, key)
  const oldValue = (target as any)[key]
  const result = Reflect.deleteProperty(target, key)
  if (result && hadKey) {
    // 若删除成功,且存在旧值则触发依赖该属性的副作用函数执行任务压入调度器
    trigger(target, TriggerOpTypes.DELETE, key, undefined, oldValue)
  }
  return result
}

Intercept check existence operation ( 'name' in state )

Checking for existence is a read operation, so we can use it for dependency collection.

 function has(target: object, key: string | symbol): boolean {
  const result = Reflect.has(target, key)
  // Symbol内置属性不收集
  if (!isSymbol(key) || !builtInSymbols.has(key)) {
    track(target, TrackOpTypes.HAS, key)
  }
  return result
}

Intercept key traversal operations

The following operations will be executed ownKeys Proxy trap method

  • Object.getOwnPropertyNames
  • Object.getOwnPropertySymbols
  • Object.keys
  • Object.names
  • for..in

The process logic is: for the array, track the length of the array, otherwise track the ITERATE_KEY provided by the effect module. What is this? Keep reading to find out :)

 function ownKeys(target: object): (string | symbol)[] {
  track(target, TrackOpTypes.ITERATE, isArray(target) ? 'length' : ITERATE_KEY)
  return Reflect.ownKeys(target)
}

What exactly is the receiver in Proxy?

In the above code, we found that the Proxy interception function input parameter receiver will be used, such as:

  1. Reflect.get(xxx, xxx, receiver) , Reflect.get(xxx, xxx, receiver) will be called during read and write interception.
  2. When writing interception, if target === toRaw(receiver) is established, the side effect function execution is triggered
  3. Create a scope chain in the opening "petite-vue source code analysis - starting from a static view" createScopedContext with the following code

     const reactiveProxy = reactive(
       new Proxy(mergeScope, {
         set(target, key, val, receiver) {
           // 若当设置的属性不存在于当前作用域则将值设置到父作用域上,由于父作用域以同样方式创建,因此递归找到拥有该属性的祖先作用域并赋值
           if (receiver === reactiveProxy && !target.hasOwnProperty(key)) {
             return Reflect.set(parentScope, key, val)
           }
           return Reflect.set(target, key, val, receiver)
         }
       })
     )

So what exactly is receiver ?

  1. For the interception of data properties, receiver points to the current build Proxy the instance itself

     // `receiver`指向当前构建的`Proxy`实例本身
    const state = {
      name: 'john'
    }
    let pState = new Proxy(state, {
      get(target, key, receiver) {
        console.log("receiver === pState:", receiver === pState)
        return Reflect.get(target, key, receiver)
      }
    })
    
    pState.name
    // 回显 receiver === pState: true
  2. For interception of accessor properties, receiver points to this or inherits Proxy the instance object

     const state = {
      _name: 'john',
      name() {
        return this._name
      }
    }
    
    let pState = new Proxy(state, {
      get(target, key, receiver) {
        console.log("target[key]():", target[key])
        console.log("receiver !== pState:", receiver !== pState)
        return Reflect.get(target, key, receiver)
      }
    })
    
    const son = {
      __proto__: pState,
      _name: 'son'
    }
    
    console.log(son.name)
    // 回显 target[key](): john
    // 回显 receiver !== pState: true
    // 回显 son

Analysis of problem 1

What if we change Reflect.get(target, key, receiver) to target[key] in the read operation?
It has no effect on the data property of the proxy object, but the accessor property of the proxy object will have the following problems

 const state = { 
  _value: 1, 
  get value() {
    return this._value
  } 
}
const reactiveState = new Proxy(state, {
  get(target, key, receiver) {
    console.log(key)
    return target[key]
  }
})

reactiveState.value

The above code will only echo value , that is, it only intercepts the read operation of the value attribute, but does not intercept the get value() internal access attribute _value , then the dependency on _value is not collected in @vue/reactivity.
target[key] _value时, target state ,而receiver refers to the proxy object. So if you collect dependencies on _value , you need to read the value of _value --- through Reflect.get(target, key, receiver) . Note: calling receiver[key] will enter infinite recursion!

Analysis of problem 2

When writing interception, if target === toRaw(receiver) is established, the side effect function execution is triggered.
And here to solve the prototype chain inheritance problem.
First, the proxied object and the proxied object share a prototype chain, and changes to the prototype chain in one will cause synchronous changes in the other.

 const reactive = (state: object) => {
  const RAW = '__V_RAW'
  return new Proxy(state, {
    get(target, key, receiver) {
      if (key === RAW) {
        return state.type
      }

      console.log('target.type', target.type)
      console.log('key', key)
      console.log('receiver.type', receiver[RAW])

      return Reflect.get(target, key, receiver)
    }
  })
}

const parent1 = { type: 'parent1', value: 1}
const parent2 = { type: 'parent2', value: 2}
const child = { type: 'child' }

const pParent1 = reactive(parent1)
const pChild = reactive(child)

Object.setPrototypeOf(pChild, pParent1)
pChild.value
/**
 * 回显如下
 * target.type child
 * key value
 * receiver.type child
 * target.type parent1
 * key value
 * receiver.type child
 */
Object.setPrototypeOf(child, pParent2)
pChild.value
/**
 * 回显如下
 * target.type child
 * key value
 * receiver.type child
 * target.type parent2
 * key value
 * receiver.type child
 */

It can be seen from the above that whether the prototype chain of the proxy object or the proxy object is modified, the prototype chains of the two are always synchronized. In addition, we can see that when accessing properties that the subproxy object does not have, the object lookup properties of its prototype chain are accessed, and the get interceptor's receiver keeps pointing to the subproxy object.
Then the side effect function will not only depend on ---b17dedd4f926c8fcc7a2c2034f62f2b8--- when accessing the reactive object through effect(() => { console.log(pChild.value) }) pChild.value , but also accidentally depend on pParent.value . So what can be done about it?
Since the parent agent get interceptor's receiver still points to the sub-agent object, then naturally, by judging whether toRaw(receiver) is equal to target .

Analysis of problem 3

In addition, create a scope chain in the opening "petite-vue source code analysis - starting from a static view" createScopedContext following code
receiver === reactiveProxy && !target.hasOwnProperty(key) that is, when writing to the current scope ( receiver === reactiveProxy ), if the attribute does not exist in the scope object, the write operation is performed recursively to the parent scope.

Summarize

Next let's take a look at the realization of the agent Map/WeakMap/Set/WeakSet mutableCollectionHandlers !

"Anatomy of Petite-Vue Source Code" booklet

"Petite-Vue Source Code Analysis" combines examples to interpret the source code line by line from online rendering, responsive system and sandbox model, and also analyzes the SMI optimization dependency cleaning algorithm using the JS engine in the responsive system in detail. It is definitely an excellent stepping stone before getting started with Vue3 source code. If you like it, remember to forward it and appreciate it!


肥仔John
2.8k 声望1.8k 粉丝

《Petite-Vue源码剖析》作者