5

composition-api 源码解析

 3 years ago
source link: https://4ark.me/post/composition-api-score-code/
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.
neoserver,ios ssh client

# 版本说明

本文是针对 composition-api v1.0.0-rc.6 (opens new window) 版本的一次源码解析,主要是想探析以下两点:

  1. Vue 在安装 composition-api 时做了些什么?
  2. Vue 在执行每个组件的 setup 方法时做了什么?

好了,废话不多说,我们直接开始。

# 一、安装过程

# 1. 检测是否已安装

// src/install.ts

if (isVueRegistered(Vue)) {
  if (__DEV__) {
    warn(
      '[vue-composition-api] already installed. Vue.use(VueCompositionAPI) should be called only once.'
    )
  }
  return
}

首先是检查是否重复安装,如果是则在开发环境中发出警告,主要是调用了 isVueRegistered 方法来进行检测,下面是它的定义:

// src/runtimeContext.ts

const PluginInstalledFlag = '__composition_api_installed__'

export function isVueRegistered(Vue: VueConstructor) {
  return hasOwn(Vue, PluginInstalledFlag)
}

通过检测 Vue 的 __composition_api_installed__   这个属性来 composition-api 是否已经安装。

那很明显后来真正安装 composition-api 时会设置这个属性。

# 2. 检测 Vue 版本

if (__DEV__) {
  if (Vue.version) {
    if (Vue.version[0] !== '2' || Vue.version[1] !== '.') {
      warn(
        `[vue-composition-api] only works with Vue 2, v${Vue.version} found.`
      )
    }
  } else {
    warn('[vue-composition-api] no Vue version found')
  }
}

然后在开发环境中判断 Vue 的版本,必须是 2.x 的版本才能使用 composition-api。

# 3. 添加 setup 这个 option api

Vue.config.optionMergeStrategies.setup = function(
  parent: Function,
  child: Function
) {
  return function mergedSetupFn(props: any, context: any) {
    return mergeData(
      typeof parent === 'function' ? parent(props, context) || {} : undefined,
      typeof child === 'function' ? child(props, context) || {} : undefined
    )
  }
}

接着通过 Vue 的 自定义选项合并策略 (opens new window) 来添加 setup 这个 api。

ps:是否还有同学不知道我们可以自定义 Vue 的 options 呢?可以尝试利用这个 api 来实现一个 asyncComputedmultiWatch 来玩玩哦!

# 4. 设置已安装标记

// src/runtimeContext.ts

const PluginInstalledFlag = '__composition_api_installed__'

export function setVueConstructor(Vue: VueConstructor) {
  // @ts-ignore
  if (__DEV__ && vueConstructor && Vue.__proto__ !== vueConstructor.__proto__) {
    warn('[vue-composition-api] another instance of Vue installed')
  }
  vueConstructor = Vue
  Object.defineProperty(Vue, PluginInstalledFlag, {
    configurable: true,
    writable: true,
    value: true
  })
}

上面提到过,就是在这里设置一个表示已经安装的标记。

# 5. 设置全局混合

Vue.mixin({
  beforeCreate: functionApiInit
  // ... other
})

然后添加一个全局的 mixin ,在每个组件的 beforeCreate 生命周期执行一下 functionApiInit 方法。

以上就是安装 composition-api 做的事,关于 functionApiInit 的内容我们在下一小节中详细讲解 。

# 二、执行 setup

我们知道  composition-api 主要是新增了一个 setup 选项,以及一系列 hooks,而 steup 也不是简单调用一下就完事,在这之前需要做一些事,比如传入的两个参数:propsctx 是怎么来的,以及 setup 的返回值为何可以在 template 中使用等等。

前面讲了 compsition-api 会在每个组件的 beforeCreate 时执行一下 functionApiInit 方法 :

Vue.mixin({
  beforeCreate: functionApiInit
  // ... other
})

下面是这个方法主要做的事。

# 1. 检测是否有 render

第一步是检测是否定义 render 方法,如果有 render 方法,则修改它内部。

const vm = this
const $options = vm.$options
const { setup, render } = $options

if (render) {
  // keep currentInstance accessible for createElement
  $options.render = function(...args: any): any {
    return activateCurrentInstance(vm, () => render.apply(this, args))
  }
}

activateCurrentInstance 的作用就是设置当前实例,所以我们可以在 render 中通过 getCurrentInstance 访问到当前实例。

ps:值得说明的是即便我们写的是 template,但到了目前这个阶段这里它已经被转换成 render 函数了。

# 2. 检测是否有 setup

如果没有定义 setup ,说明这个组件没有使用 composition-api ,这时候则直接跳过该组件:

if (!setup) {
  return
}
if (typeof setup !== 'function') {
  if (__DEV__) {
    warn(
      'The "setup" option should be a function that returns a object in component definitions.',
      vm
    )
  }
  return
}

# 3. 在 data 方法初始化 setup

如果存在 setup ,就会修改这个组件的 data 方法,在初始化真正的 data 方法之前先初始化一下 setup 方法:

const { data } = $options
// wrapper the data option, so we can invoke setup before data get resolved
$options.data = function wrappedData() {
  initSetup(vm, vm.$props)
  return typeof data === 'function'
    ? (data as (this: ComponentInstance, x: ComponentInstance) => object).call(
        vm,
        vm
      )
    : data || {}
}

还记得 Vue 初始化 data 的时机是什么时候吗?答案是在 beforeCreatecreated 之间,所以 setup 也是一样。

# 4. 初始化 setup

initSetup 方法内部还做了挺多事的,下面是这个方法的全貌,先简单瞄一眼,我们后面会一步步拆解:

function initSetup(vm: ComponentInstance, props: Record<any, any> = {}) {
  const setup = vm.$options.setup!
  const ctx = createSetupContext(vm)
  // fake reactive for `toRefs(props)`
  def(props, '__ob__', createObserver())
  // resolve scopedSlots and slots to functions
  // @ts-expect-error
  resolveScopedSlots(vm, ctx.slots)
  let binding: ReturnType<SetupFunction<Data, Data>> | undefined | null
  activateCurrentInstance(vm, () => {
    // make props to be fake reactive, this is for `toRefs(props)`
    binding = setup(props, ctx)
  })
  if (!binding) return
  if (isFunction(binding)) {
    // keep typescript happy with the binding type.
    const bindingFunc = binding
    // keep currentInstance accessible for createElement
    vm.$options.render = () => {
      // @ts-expect-error
      resolveScopedSlots(vm, ctx.slots)
      return activateCurrentInstance(vm, () => bindingFunc())
    }
    return
  } else if (isPlainObject(binding)) {
    if (isReactive(binding)) {
      binding = toRefs(binding) as Data
    }
    vmStateManager.set(vm, 'rawBindings', binding)
    const bindingObj = binding
    Object.keys(bindingObj).forEach((name) => {
      let bindingValue: any = bindingObj[name]
      if (!isRef(bindingValue)) {
        if (!isReactive(bindingValue)) {
          if (isFunction(bindingValue)) {
            bindingValue = bindingValue.bind(vm)
          } else if (!isObject(bindingValue)) {
            bindingValue = ref(bindingValue)
          } else if (hasReactiveArrayChild(bindingValue)) {
            // creates a custom reactive properties without make the object explicitly reactive
            // NOTE we should try to avoid this, better implementation needed
            customReactive(bindingValue)
          }
        } else if (isArray(bindingValue)) {
          bindingValue = ref(bindingValue)
        }
      }
      asVmProperty(vm, name, bindingValue)
    })
    return
  }
  if (__DEV__) {
    assert(
      false,
      `"setup" must return a "Object" or a "Function", got "${Object.prototype.toString
        .call(binding)
        .slice(8, -1)}"`
    )
  }
}

# 4.1. 初始化 context

这个 ctxsetup 中接受的第二个参数,这个对象里面的内容是怎么生成的呢?

const ctx = createSetupContext(vm)

下面是 createSetupContext 所做的事,首先是定义 ctx 对象中所有的 key

const ctx = { slots: {} } as SetupContext

const propsPlain = [
  'root',
  'parent',
  'refs',
  'listeners',
  'isServer',
  'ssrContext',
]
const propsReactiveProxy = ['attrs']
const methodReturnVoid = ['emit']

接下来就是给这些属性利用 Object.defineProperty 做一层代理,当然它们都是只读的:

propsPlain.forEach((key) => {
  let srcKey = `$${key}`
  proxy(ctx, key, {
    get: () => vm[srcKey],
    set() {
      warn(`Cannot assign to '${key}' because it is a read-only property`, vm)
    }
  })
})

另外两个 propsReactiveProxymethodReturnVoid 也差不多,这里就略过了。

# 4.2. 响应式 props

接着就是将 props 对象进行一遍 Observer:

def(props, '__ob__', createObserver())

// src/reactivity/reactive.ts
export function createObserver() {
  return observe < any > {}.__ob__
}

首先通过 createObserver 拿到一个把空对象经过 Vue.Observer 后的 __ob__ 属性,也就是当前 Observer 实例对象,如果同学们对于 Vue Observer 的原理还不太熟悉,可以看这里 数据对象的 (opens new window),本文就不赘述了。

然后给 props 新增一个 __ob_ 属性,指向前面拿到的这个 __ob__

# 4.3. 解析 slots

接着就是把当前实例的 slots 给代理到前面定义的 ctx.slots 中,这时候它只是一个空对象:

resolveScopedSlots(vm, ctx.slots)

下面是 resolveScopedSlots 的实现:

export function resolveScopedSlots(
  vm: ComponentInstance,
  slotsProxy: { [x: string]: Function }
): void {
  const parentVNode = (vm.$options as any)._parentVnode
  if (!parentVNode) return

  const prevSlots = vmStateManager.get(vm, 'slots') || []
  const curSlots = resolveSlots(parentVNode.data.scopedSlots, vm.$slots)
  // remove staled slots
  for (let index = 0; index < prevSlots.length; index++) {
    const key = prevSlots[index]
    if (!curSlots[key]) {
      delete slotsProxy[key]
    }
  }

  // proxy fresh slots
  const slotNames = Object.keys(curSlots)
  for (let index = 0; index < slotNames.length; index++) {
    const key = slotNames[index]
    if (!slotsProxy[key]) {
      slotsProxy[key] = createSlotProxy(vm, key)
    }
  }
  vmStateManager.set(vm, 'slots', slotNames)
}

简单来说就是将父组件的 slots 数组(真正被使用的)代理到 ctx.slots 中,并且在这个 slots 数组有变化时 ctx.slots 也会相应地更新。

# 4.4. 执行 setup

终于到了最重要的关头,开始执行 setup 了:

activateCurrentInstance(vm, () => {
  // make props to be fake reactive, this is for `toRefs(props)`
  binding = setup(props, ctx)
})

activateCurrentInstance 之前讲过了,就是使组件的 setup 内部可以通过 getCurrentInstance 访问当前实例,相信真正使用过 composition-api 的同学们都知道这个方法的便利性了,但不知道同学们是否遇到过 getCurrentInstance 方法返回 null 值的情况呢?如果想知道为什么,可以看这篇文章:《从 Composition API 源码分析 getCurrentInstance() 为何返回 null》 (opens new window)

然后将前面得到的 propsctx 传进去,最后将返回值赋值给 binding

# 4.6. 处理 setup 返回值

处理返回值前需要先对它进行类型判断,有三种条件分支:

  1. 为空,直接返回
  2. 是一个函数,当成 render 方法处理
  3. 是一个普通对象,做一系列转换

如果返回值是一个函数,则把它当成 render 方法处理,当然在这之前需要重新调用一下 resolveScopedSlots 检测 slots 的更新,并且调用 activateCurrentInstance  :

if (isFunction(binding)) {
  // keep typescript happy with the binding type.
  const bindingFunc = binding
  // keep currentInstance accessible for createElement
  vm.$options.render = () => {
    // @ts-expect-error
    resolveScopedSlots(vm, ctx.slots)
    return activateCurrentInstance(vm, () => bindingFunc())
  }
  return
}

ps:也可以直接在 setup 中返回 JSX 哦,因为 Babel 会把它变成一个函数。

但通常我们是在 setup 返回一个对象,然后可以直接在 template 中使用这个这些值,所以我们看看返回值是一个对象的情况:

else if (isPlainObject(binding)) {
  if (isReactive(binding)) {
    binding = toRefs(binding) as Data
  }

  vmStateManager.set(vm, 'rawBindings', binding)
  const bindingObj = binding

  Object.keys(bindingObj).forEach((name) => {
    let bindingValue: any = bindingObj[name]

    if (!isRef(bindingValue)) {
      if (!isReactive(bindingValue)) {
        if (isFunction(bindingValue)) {
          bindingValue = bindingValue.bind(vm)
        } else if (!isObject(bindingValue)) {
          bindingValue = ref(bindingValue)
        } else if (hasReactiveArrayChild(bindingValue)) {
          // creates a custom reactive properties without make the object explicitly reactive
          // NOTE we should try to avoid this, better implementation needed
          customReactive(bindingValue)
        }
      } else if (isArray(bindingValue)) {
        bindingValue = ref(bindingValue)
      }
    }
    asVmProperty(vm, name, bindingValue)
  })

  return
}

首先如果返回的对象是经过 reactive 的,则要调用 toRefs 将它的子属性变成 ref 包装过的,然后调用 vmStateManager.set 将这些属性存放起来,以供别的地方使用。

然后遍历这个对象,经过一系列类型判断和处理后,将它的子属性设置为当前实例的变量,这样我们就可以在 templte 或者通过 this.xxx 去访问这些变量。

这里的类型处理简单总结一下就是:

  1. 如果属性值是一个函数,则这个函数被调用时已经 this 就是当前实例
  2. 如果属性值一个非对象非函数的值,则会自动经过 ref 包装
  3. 如果属性值是一个普通对象且有子属性值为经过 reactive 后的数组,则要将这个普通对象也要转换为经过 reactive 包装才行,所以我们在开发时要避免如下情况:
setup() {
  return {
   	obj: {
      arr: reactive([1, 2, 3, 4])
    }
  }
}

最后,在开发环境下判断返回值不是对象是抛出一个错误。到此 setup 函数的执行就完了。

# 总结

关于 composition-api 的安装和执行过程就讲完了,下面我们来简单总结一下,composition-api 在安装时会做以下事情:

  1. 通过检查 Vue 的 __composition_api_installed__ 属性来判断是否重复安装
  2. 检查 Vue 版本是否 2.x
  3. 使用合并策略添加 setup api
  4. 利用全局混入来对 setup 进行初始化

而在执行 setup 时会做以下事情:

  1. 检查当前组件是否使用 render 方法,如果有则在这之前标记当前实例,以便 render 方法内部可以通过 getCurrentInstance 方法访问到当前实例。
  2. 检查当前组件有 setup api,没有则直接返回,否则在初始化 data 时先初始化一下 setup
  3. 而初始化 setup 做的事就是构造 setup 接受的两个参数:props、ctx
  4. 然后执行 setup ,根据它的返回值类型进行相应的处理

当然,compsition-api 真正的魅力在于 hooks,下次我就来讲讲 composition-api 的一系列 hooks 是如何实现的,这也能帮助我们更好地利用这些 hooks 方法来编写更优雅、可复用的代码。

本文就到此,感谢你的阅读。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK