Skip to content

composition-api 源码解析

Published:

版本说明

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

  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 的 自定义选项合并策略 来添加 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 的原理还不太熟悉,可以看这里 数据对象的 ,本文就不赘述了。

然后给 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》

然后将前面得到的 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. 标记安装
  5. 利用全局混入来对 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 方法来编写更优雅、可复用的代码。

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


作者 : 4Ark

地址 : https://4ark.me/posts/2021-07-15-composition-api/

来源 : https://4ark.me

著作权归作者所有,转载请联系作者获得授权。