@vue/composition-api 解析

avatar
@滴滴出行

作者:周超

前言

组合式 API 是 vue3 提出的一个新的开发方式,而在 vue2 中我们可以使用新的组合式 API 进行组件开发。本篇通过一个例子,来分析这个插件是如何提供功能。

关于该插件的安装、使用,可以直接阅读文档。

安装

我们从最开始安装分析,一探究竟。

vue.use

按照文档所提到的,我们必须通过 Vue.use() 进行安装:

// vue.use 安装
import Vue from 'vue'
import VueCompositionAPI from '@vue/composition-api'
Vue.use(VueCompositionAPI)

我们先看入口文件

// index.js
import type Vue from 'vue'
import { Data, SetupFunction } from './component'
import { Plugin } from './install'
 
export default Plugin
 
// auto install when using CDN
if (typeof window !== 'undefined' && window.Vue) {
  window.Vue.use(Plugin)
}

可以知道我们 Vue.use 时,传入的就是 install 文件中的 Plugin 对象。

// install.ts 折叠源码
export function install(Vue: VueConstructor) {
  if (isVueRegistered(Vue)) {
    if (__DEV__) {
      warn(
        '[vue-composition-api] already installed. Vue.use(VueCompositionAPI) should be called only once.'
      )
    }
    return
  }
 
  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.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
      )
    }
  }
 
  setVueConstructor(Vue)
  mixin(Vue)
}
 
export const Plugin = {
  install: (Vue: VueConstructor) => install(Vue),
}

install

通过上面的代码和 Vue.use 可知,我们安装时其实就是调用了 install 方法,先分析一波 install。根据代码块及功能可以分成三个部分:

  1. 前两个大 if 的开发 check 部分
  2. 关于 setup 合并策略
  3. 通过 mixin 混入插件关于 组合式 API 的处理逻辑

第一部分中的第一个 if 是为了确保该 install 方法只被调用一次,避免浪费性能;第二个 if 则是确保vue版本为2.x。不过这里有个关于第一个if的小问题:多次注册插件时,Vue.use 自己本身会进行重复处理——安装过的插件再次注册时,不会调用 install 方法(Vue.use代码见下)。那么这个 if 的目的是啥?

// Vue.use 部分源码
Vue.use = function (plugin: Function | Object) {
  const installedPlugins = (this._installedPlugins || (this._installedPlugins = []))
  if (installedPlugins.indexOf(plugin) > -1) {
    return this
  }
 
  // additional parameters
  const args = toArray(arguments, 1)
  args.unshift(this)
  if (typeof plugin.install === 'function') {
    plugin.install.apply(plugin, args)
  } else if (typeof plugin === 'function') {
    plugin.apply(null, args)
  }
  installedPlugins.push(plugin)
  return this
}

根据上面代码可知 Vue.use 实际上还是传入 vue 并调用插件的 install 方法,那么如果有大神(或者是奇葩?)绕过 Vue.use 直接调用,那么这个 if 的判断就生效了。如下方代码,此时第二个 install 会判断重复后,抛出错误

// 直接调用 install
import Vue from 'vue'
import VueCompositionAPI from '@vue/composition-api'
import App from './App.vue'
 
Vue.config.productionTip = false
 
VueCompositionAPI.install(Vue)
VueCompositionAPI.install(Vue)

报错:

image2021-6-23_16-38-4.png

第二部分的合并策略是“Vue.config.optionMergeStrategies”这个代码块。Vue 提供的这个能力很生僻,我们日常的开发中几乎不会主动接触到。先上文档

image2021-6-23_16-50-30.png

这是用来定义属性的合并行为。比如例子中的 extend 在调用时,会执行 mergeOptions。

// Vue.extend
Vue.extend = function (extendOptions) {
    const Super = this
    extendOptions = extendOptions || {}
    Sub.options = mergeOptions(
      Super.options,
      extendOptions
    )
}

而 mergeOptions 里关于 _my_option的相关如下:

const strats = config.optionMergeStrategies
function mergeOptions (parent, child, vm){
  for (key in parent) {
    mergeField(key)
  }
  for (key in child) {
    if (!hasOwn(parent, key)) {
      mergeField(key)
    }
  }
 
 
  function mergeField (key) {
    const strat = strats[key] || defaultStrat
    options[key] = strat(parent[key], child[key], vm, key)
  }
}

这里的 parent 就是 Super.options 也就是 Vue.options,而 child 就是 extendOptions 也就是我们传入的 { _my_option: 1 }。在这里使用了两个 for 循环,确保父子元素种所有的 key 都会执行到 mergeField,而第二个 for 循环中的 if 判断确保不会执行两次,保证了正确性及性能。而 mergeField 则是最终执行策略的地方。从 strats 中获取到我们定义的方法,把对应参数传入并执行,在这里就是:

// demo执行
strat(undefined, 1, vm, '_my_option') // return 2

顺便一提,Vue.mixin 的实现就是 mergeOptions,也就是说当我们使用了 mixin 且里面具有 setup 属性时,会执行到上述合并策略。

Vue.mixin = function (mixin) {
  this.options = mergeOptions(this.options, mixin)
  return this
}

而我们插件中相关的策略也很简单,获取好定义的父子 setup,然后合并成一个新的,在调用时会分别执行父子 setup,并通过 mergeData 方法合并返回:

// optionMergeStrategies.setup
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
    )
  }
}

第三部分则是通过调用 mixin 方法向 vue 中混入一些事件,下面是 mixin 的定义:

function mixin(Vue) {
  Vue.mixin({
    beforeCreate: functionApiInit,
    mounted(this: ComponentInstance) {
      updateTemplateRef(this)
    },
    updated(this: ComponentInstance) {
      updateTemplateRef(this)
    }
  })
   
  function functionApiInit() {}
  function initSetup() {}
  // 省略...
}

可以看到 mixin 内部调用了 Vue.mixin 来想 beforeCreate、mounted、updated 等生命周期混入事件。这样就完成 install 的执行, Vue.use(VueCompositionAPI) 也到此结束。

初始化 — functionApiInit

functionApiInit 执行

我们知道在new Vue 时,会执行组件的 beforeCreate 生命周期。此时刚才通过 Vue.mixin 注入的函数 “functionApiInit”开始执行。

function Vue (options) {
  this._init(options)
}
Vue.prototype._init = function (options) {
  initLifecycle(vm)
  initEvents(vm)
  initRender(vm)
  callHook(vm, 'beforeCreate') // 触发 beforeCreate 生命周期,执行 functionApiInit
  initInjections(vm) // resolve injections before data/props
  initState(vm)
  initProvide(vm) // resolve provide after data/props
  callHook(vm, 'created')
}

该方法也很清晰,分别暂存了组件最开始的 render方法和 data方法(我们平常写的 data 是一个函数),然后在这基础上又扩展了一下这两个方法,达到类似钩子的目的。

function functionApiInit(this: ComponentInstance) {
  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))
    }
  }
 
  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
  }
 
  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 || {}
  }
}

虽然是先扩展的 render,但在 new Vue 的实际执行中会优先执行下方扩展的方法 “wrappedData”。因为 data 的执行是在 new Vue 时发生,而 render 的执行在 $mount 中。所以我们这里就按照执行顺序来看看如何扩展我们的 wrappedData。

wrappedData 这里只是简单执行了 initSetup 方法,对原先的 data 做了判断。这里是因为 Vue 执行时拿到的 data 已经是 wrappedData 这个函数而不是用户编写的 data,所以关于原 data 的处理移交在了 wrappedData 中。可以说 99%的逻辑都在 initSetup 中。我们接下来看这个方法。

setup 调用及处理

这块是通过 initSetup 函数实现的,代码很长且仅有几行是这里不用关心的(可自行研究),整体上可以跟着注释走一遍。

function initSetup(vm: ComponentInstance, props: Record<any, any> = {}) {
  // 获取定义好的 setup
  const setup = vm.$options.setup!
  // 创建 setup 方法接收的第二个参数 context,主流程中使用不上,先忽略
  const ctx = createSetupContext(vm)
 
  // fake reactive for `toRefs(props)`
  // porps 相关,主流成可先忽略(毕竟可以不写 props...)
  def(props, '__ob__', createObserver())
 
  // resolve scopedSlots and slots to functions
  // slots 相关,同 props 先忽略
  // @ts-expect-error
  resolveScopedSlots(vm, ctx.slots)
 
  let binding: ReturnType<SetupFunction<Data, Data>> | undefined | null
  // 执行 setup
  activateCurrentInstance(vm, () => {
    // make props to be fake reactive, this is for `toRefs(props)`
    binding = setup(props, ctx)
  })
 
  // 以下都是根据 setup 返回值,进行的一些处理
  if (!binding) return
  if (isFunction(binding)) {  // setup 可以返回一个渲染函数(render)
    // keep typescript happy with the binding type.
    const bindingFunc = binding
    // keep currentInstance accessible for createElement
    // 获取到渲染函数后,手动添加再 vue 实例上
    vm.$options.render = () => {
      // @ts-expect-error
      resolveScopedSlots(vm, ctx.slots)
      return activateCurrentInstance(vm, () => bindingFunc())
    }
    return
  } else if (isPlainObject(binding)) { // setup 返回的是一个普通对象
    if (isReactive(binding)) { // 如果返回的是通过 reactive 方法定义的对象,需要通过 toRefs 结构
      binding = toRefs(binding) as Data
    }
     
    // 用于 slots 及 $refs ,先忽略
    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)}"`
    )
  }
}

我们先聚焦到 setup 的执行。setup 包裹在 activateCurrentInstance 方法中,activateCurrentInstance 目的是为了设置当前的实例。类似我们平常写的交换a、b变量的值。setup 在调用前,会先获取 currentInstance 变量并赋值给 preVm,最开始时currentInstance 为 null。接着再把 currentInstance 设置成当前的 vue 实例,于是我们变可以在 setup 通过 插件提供的 getCurrentInstance 方法获取到当前实例。在执行完毕后,又通过 setCurrentInstance(preVm) 把 currentInstance 重置为null。所以印证了文档中所说的,只能在 setup 及生命周期(不在本篇重点)中使用 getCurrentInstance 方法。

// setup执行
activateCurrentInstance(vm, () => {
  // make props to be fake reactive, this is for `toRefs(props)`
  binding = setup(props, ctx)
})
 
function activateCurrentInstance(vm, fn, onError) {
  let preVm = getCurrentVue2Instance()
  setCurrentInstance(vm)
  try {
    return fn(vm)
  } catch (err) {
    if (onError) {
      onError(err)
    } else {
      throw err
    }
  } finally {
    setCurrentInstance(preVm)
  }
}
 
 
let currentInstance = null
 
 
function setCurrentInstance(vm) {
  // currentInstance?.$scopedSlots
  currentInstance = vm
}
 
 
function getCurrentVue2Instance() {
  return currentInstance
}
 
 
function getCurrentInstance() {
  if (currentInstance) {
    return toVue3ComponentInstance(currentInstance)
  }
  return null
}

这里有个思考,为什么需要在最后把 currentInstance 设置为 null?我们写了一个点击事件,并在相关的事件代码里调用了getCurrentInstance 。如果在 setup 调用重置为 null ,那么在该事件里就可能导致获取到错误的 currentInstance。于是就置为null 用来避免这个问题。(个人想法,期待指正)。

setup 内部可能会执行的东西有很多,比如通过 ref 定义一个响应式变量,这块放在后续单独说。

当获取完 setup 的返回值 binding 后,会根据其类型来做处理。如果返回函数,则说明这个 setup 返回的是一个渲染函数,便把放回值赋值给 vm.$options.render 供挂载时调用。如果返回的是一个对象,则会做一些相应式处理,这块内容和响应式相关,我们后续和响应式一块看。

// setup 返回对象

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)
})

我们这里只看重点函数 “asVmProperty”。我们知道 setup 返回的是一个对象 (赋值给了 binding / bindingObj),且里面的所有属性都能在 vue 的其他选项中使用。那么这块是如何实现的呢?

访问 setup 返回值 — asVmProperty 实现

这个函数执行后,我们就可以在 template 模版及 vue 选项中访问到 setup 的返回值,的下面是“asVmProperty” 这个函数的实现:

function asVmProperty(vm, propName, propValue) {
  const props = vm.$options.props
  if (!(propName in vm) && !(props && hasOwn(props, propName))) {
    if (isRef(propValue)) {
      proxy(vm, propName, {
        get: () => propValue.value,
        set: (val: unknown) => {
          propValue.value = val
        },
      })
    } else {
      proxy(vm, propName, {
        get: () => {
          if (isReactive(propValue)) {
            ;(propValue as any).__ob__.dep.depend()
          }
          return propValue
        },
        set: (val: any) => {
          propValue = val
        },
      })
    }
  }
}
function proxy(target, key, { get, set }) {
  Object.defineProperty(target, key, {
    enumerable: true,
    configurable: true,
    get: get || noopFn,
    set: set || noopFn,
  })
}

函数很短,这里有3个处理逻辑:

  1. 普通属性的 get 和 set 正常返回
  2. 如果是 ref 类型的属性(通过 ref 创建),通过 vm.xxx 访问/修改时,访问/修改 ref 的 value 属性
  3. 代理 reactive 类型的属性 (通过 reactive 创建),reactive 返回的是一个响应式对象。当访问这个对象时, 需要调用 响应式对象种的 depend 收集watcher(观察者),以便数据更新时通知 watcher 进行更新。

总之 asVmProperty 是拿到 setup 返回值中的一个键值对后,再通过 Object.defineProperty 劫持了 this(是vm,也就是组件实例)中访问改键值对的 get 和 set,这样我们便可以通过 this.xxx 访问到 setup 中return 出去的属性。

而模版访问也同理,因为 template 编译成 render 后,上面的变量都实际会编译成 _vm.xxx,而 _vm 就是 this ,也就是组件实例。