likes
comments
collection
share

浅读VCA源码-从一个重复引入的bug开始

作者站长头像
站长
· 阅读数 20

前言-初遇bug

这两天做低代码平台开发的时候遇到奇怪的bug,排查花费了不少时间,还逼我去读了VCA的源码,特地在这里记录一下。

组件库内引入了一个新的组件,该组件依赖了@vue/composition-api这个库(以下称VCA),因此组件库打包的时候将VCA也打包了。而该组件库的umd文件在低代码平台进行了加载,就导致低代码平台内的渲染器调用getCurrentInstance这个方法时,获取到的实例是null

浅读VCA源码-从一个重复引入的bug开始

遇到这种事,肯定要研究一下,为什么获取不到实例了。于是就去研究了一下VCA的源码。下列源码都以1.7.0版本为例子进行介绍。

一、注册前

常规项目中,我们都是通过下面这种方式将VCA注册到Vue上:

import Vue from 'vue';
import VCA from '@vue/composition-api';

...

Vue.use(VCA);

...

而对于CDN方式引入VCA插件的情况,该如何注册呢?其实VCA内部做了兼容处理:

浅读VCA源码-从一个重复引入的bug开始

src/index.ts文件内,有专门针对CDN方式使用VCA的入口。VCA会自动取寻找Vue,并将VCA插件进行注册。

到这里读者可能会有疑问,即使我的项目不是通过CDN方式加载的,但如果window上挂载了Vue,还是会触发这里的注册。我在项目内部已经Vue.use()了一次,加上这里的一次注册,那我的项目还是会注册两次VCA么?这里我们就需要关注注册的过程都发生了什么,如何确保一个VCA只会注册一次。

在这次的问题项目中,我们通过CDN的方式加载了组件库,组件库内打包了VCA,而正好我们也将Vue挂载到了window上,因此这个VCA也被注册了。

1.1 注册过程发生了什么?

即使知道VCA被重复注册了,我们也不清楚为什么插件重复注册会导致获取不到实例。再加上前面提出的问题,如何确保一个VCA不会被注册两次呢?我们需要观察注册过程中都发生了什么:

浅读VCA源码-从一个重复引入的bug开始

这是在src/install.ts文件中的注册函数。我们可以观察到VCA是通过install的方式进行注册的。而注册的主要流程是四个:

  1. 判断该VCA是否已经被注册过,注册过直接返回,啥也不干。
  2. 对setup进行合并。
  3. 将当前Vue设置为已注册。
  4. 将VCA注册到Vue上。

这就是核心的四个流程。第一步和第二步先不看,我们先看第三步setVueConstructor做了什么:

浅读VCA源码-从一个重复引入的bug开始

setVueConstructor就一个核心功能,将vueConstructor赋值为将要注册的Vue; 往Vue上设置一个'composition_api_installed'属性,将值设置为true。

这样我们再回过头来看第一步是如何判断Vue是否已注册过:

浅读VCA源码-从一个重复引入的bug开始

VCA会判断两个东西:vueConstructor,且Vue上是否有__composition_api_installed__这个属性。这个vueConstructor实际上就是VCA内部的一个全局变量,在注册函数的第三步,将其赋值为了Vue,且Vue也设置了__composition_api_installed__属性。

1.2 VCA对于重复注册的处理

阅读到这里,对于之前提出的问题,我们能够给出答案:对于同一个VCA,能够避免重复注册,但如果是两个VCA,就都会注册到Vue上

对于同一个VCA,是能够避免重复注册的。VCA在未注册时,vueConstructor为null,isVueRegistered返回null,会执行后续的注册流程,将vueConstructor赋值为Vue。无论是后续重复Vue.use(VCA),还是在window上获取Vue进行注册,都能识别到vueConstructor已经有值,且Vue上已有__composition_api_installed__属性,将会被判定为已注册过,不会重复注册。

而如果加载了第二个VCA(例如此处业务场景,CDN方式又引入了一个VCA),那么对于第二个VCA来说,其vueConstructor变量初始还是为null,这就会导致其判定结果为未注册,执行后续注册流程,将第二个VCA注册到Vue上。

从代码上看,这似乎是开发团队有意为之。对于同一个VCA,当然要避免重复注册。而如果你项目中有两个VCA,那么这可能是有意为之,系统将会允许你注册两个不同的VCA。

那么两个VCA都注册了会有什么后果呢?

二、注册VCA

注册VCA的过程,核心就是就是之前install函数中的最后一步,mixin(Vue)。我们来详细看一下这里做了什么:

浅读VCA源码-从一个重复引入的bug开始

我们可以观察到,注册VCA的过程,实际上就是在Vue上进行mixin,在几个生命周期做了一些工作。我们最常用的setup,实际上就是在beforeCreate这里初始化的。

2.1 初始化API

我们再看看在beforeCreate里执行了什么:

浅读VCA源码-从一个重复引入的bug开始

首先是对于render,会将vm包裹一下,包裹成vue3结构的instance。

这里对render进行了重写,在原始render执行前,通过activateCurrentInstance,设置currentInstance,等原始render执行完后,再将currentInstance设置为回去。这样就保证嵌套结构中,能够获取到正确的instance。

浅读VCA源码-从一个重复引入的bug开始

2.2 初始化setup

上面的内容都旁枝末节,下面我们来重点看看initSetup做了什么:

浅读VCA源码-从一个重复引入的bug开始

我们可以观察到,在这里,我们会执行setup,在执行之前,我们会做一些预处理,包括创建setup的上下文,以及将vm包裹为vue3结构的实例。这样在setup内部,我们能够正确访问到currentInstance。

执行完setup后,根据setup返回值的类型进行不同的处理,返回如果是函数,那么就认为是render函数,替换vm的render函数;如果setup返回了一个对象,那么就对这个对象进行处理。

浅读VCA源码-从一个重复引入的bug开始

在对setup返回的对象进行处理的过程中,我们会将对象的值进行处理。如果一个值没有被响应式,且是函数,那么这个函数会被绑定到vm上;如果这个值没被响应式,且不是object类型(如基本类型),那么就会被ref处理成响应式。如果一个非响应式的object其内部有响应式的数组,那么会有特殊的处理。看官方的注释,不推荐这么搞。

这里有个奇怪的点,如果这个值是一个reactive的数组,会用ref将其再包裹后,才挂载到vm上。目前暂不清楚为什么要这么做。

2.3 关于Vue.mixin

后续的其他内容咱们这里就先不管了。代码看到这里,似乎还是看不出来为什么instance会变成null。这其实和mixin的机制有关系。

刚刚我们看的functionApiInit,是在组件的beforeCreate这个生命周期内执行的,是通过mixin注册到全局的。

我们来看Vue.mixin这个API的定义:

全局注册一个混入,影响注册之后所有创建的每个 Vue 实例。插件作者可以使用混入,向组件注入自定义的行为。不推荐在应用代码中使用

而关于mixin,官方的解释在这里:混入 — Vue.js

这里面有一个非常重要的内容,即

同名钩子函数将合并为一个数组,因此都将被调用。另外,混入对象的钩子将在组件自身钩子之前调用。

也就是说,如果我们有多个插件注册到了Vue上,且这些插件声明了某个生命周期的钩子函数(例如beforeCreate),那么组件的同名钩子函数会被合并为数组,将这些生命周期函数取出来依次执行。

在这里,也就是我们的两个VCA都注册到了Vue上,则两次mixin的functionApiInit会依次执行。

2.4 问题复现

我们通过一个简单的测试能够证明这一点:

1、首先我们的项目本身应该注册了一个VCA,且Vue应该挂载到window上,这样我们通过CDN方式引入的VCA也能正确注册。

import Vue from 'vue';
import App from './App.vue';
import VCA from '@vue/composition-api';

Vue.config.productionTip = false;
Vue.use(VCA);

window.Vue = Vue;

new Vue({
  render: (h) => h(App),
}).$mount('#app');

2、在父组件中我们以CDN的形式加载一个VCA,此时我们的子组件还未渲染。

<template>
  <div id="app">
    <img alt="Vue logo" src="./assets/logo.png" />
    <HelloWorld v-if="show" msg="Hello Vue 2 + Vite" />
    <button @click="clickBtn">切换子组件是否展示</button>
  </div>
</template>

<script>
  import HelloWorld from "./components/HelloWorld.vue";
  import {
    getCurrentInstance,
    ref,
    onMounted,
  } from "@vue/composition-api";
  export default {
    components: {
      HelloWorld,
    },
    setup() {
      const instance = getCurrentInstance();
      console.log("父组件setup执行,获取instance", instance);

      const show = ref(false);
      const clickBtn = () => {
        show.value = !show.value;
      };

      onMounted(() => {
        const script = document.createElement("script");
        script.src = "https://unpkg.com/@vue/composition-api@1.7.0/dist/vue-composition-api.prod.js";
        document.head.appendChild(script);
        console.log("父组件以CDN的形式再次加载一个VCA");
      });

      return {
        show,
        clickBtn,
      };
    }
  };
</script>

<style>
  #app {
    font-family: Avenir, Helvetica, Arial, sans-serif;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    text-align: center;
    color: #2c3e50;
    margin-top: 60px;
  }
</style>

在父组件创建时,额外的VCA还未注册,此时Vue就mixin了一次VCA,setup应该只执行一次。

浅读VCA源码-从一个重复引入的bug开始

看看控制台打印,符合预期。

3、等CDN加载完成,且第二个VCA注册完成后(稍等几秒,确认VCA注册完成),我们点击按钮,触发子组件的创建。此时Vue已经mixin了两次,子组件setup函数应该执行两次。

<template>
  <div>

    <h1>{{ msg }}</h1>
    我是子组件
  </div>
</template>

<script>
import {getCurrentInstance } from '@vue/composition-api';

export default {
  props: {
    msg: String,
  },
  setup() {
    const instance = getCurrentInstance();
    console.log('子组件创建,获取instance',instance)
    
  },
};
</script>

<style scoped>
a {
  color: #42b983;
}
</style>

浅读VCA源码-从一个重复引入的bug开始

观察控制台打印,符合预期。那为什么setup执行,其中一次取到的isntance会是null呢?

2.5 执行流程

我们通过阅读源码,能够发现,所谓的getCurrentInstance方法,实际上就是使用一个变量currentInstance保存当前组件的实例。而在setup执行前,通过activateCurrentInstance方法,更新currentInstance,等setup执行完后,就将currentInstance恢复为之前的值。

浅读VCA源码-从一个重复引入的bug开始

也就是当我两次执行的时候,首先是第二个VCA的initSetup,然后是第一个VCA的initSetup。而getCurrentInstance方法始终是获取的是第一个VCA的当前实例。当执行第二个VCA的initSetup时,第一个VCA的currentInstance没有被赋值为当前实例,因此返回了null。

用一张图表示:

浅读VCA源码-从一个重复引入的bug开始

这里我们又要抛出疑问了。按官方文档的描述,钩子函数的执行顺序明明是按注册顺序来的,第一个VCA的functionApiInit先于第二个VCA执行,这里没问题。但是为什么在initState阶段,第二个VCA的initSetup会先于第一个VCA执行呢?

这里我们要回过头去看源码。实际上在functionApiInit中,initSetup并不是立即执行的,而是修改了data函数。

延迟到data函数执行时,再执行的initSetup。

浅读VCA源码-从一个重复引入的bug开始

也就是在这个子组件中,data函数一共被修改了两次。

第一次是第一个VCA的functionApiInit执行,data被改写,第一个initSetup会先被调用,然后才执行原始的data函数。

第二次是第二个VCA的fucntionApiInit执行,data再次被改写,第二个initSetup会被调用,然后才执行被前一个data函数,即之前被改写的data函数。

data经过这样的层层改写,最后的调用顺序就变成了先改写data的后执行,后改写的先执行。

data何时执行

那么我们简单查阅一下vue2的源码,能够注意到,data函数的最终调用是在beforeCreate钩子之后,created之前。具体是在initState这个函数内进行的调用。

浅读VCA源码-从一个重复引入的bug开始

三、其他内容

3.1 defineComponent做了什么?

src/component/defineComponent中,除了几个重载,defineComponent其实啥也没做。

浅读VCA源码-从一个重复引入的bug开始

在这里,defineComponent只是通过重载,能让你的TS进行正确的类型推导,除此之外,别无他用。

3.2 getCurrentInstance实现原理

浅读VCA源码-从一个重复引入的bug开始

实际上VCA只是使用了一个变量来保存当前组件的实例。 每当setup执行前会将currentInstance赋值为当前组件实例,等钩子函数执行完成后,再将currentInstance重新赋值为之前的组件实例。当然不只是setup,例如VCA提供的onBeforeMount,onMounted等钩子函数,在执行前后也会更改当前组件实例。 如果此时没有其他组件钩子正在执行(例如组件套组件,生命周期钩子函数的执行就会有交替),那么前一个的组件实例就是null了。

可以简单认为这是类似于栈的设计。新的钩子函数执行就入栈,执行完成就出栈,currentInstance始终返回栈顶元素。

3.3 Reactive做了什么?

可以简单认为这就是Vue.observable的语法糖。

export function observe<T>(obj: T): T {
  const Vue = getRegisteredVueOrDefault()
  let observed: T
  if (Vue.observable) {
    observed = Vue.observable(obj)
  } else {
    const vm = defineComponentInstance(Vue, {
      data: {
        $$state: obj,
      },
    })
    observed = vm._data.$$state
  }

  // in SSR, there is no __ob__. Mock for reactivity check
  if (!hasOwn(observed, '__ob__')) {
    mockReactivityDeep(observed)
  }

  return observed
}


/**
 * Make obj reactivity
 */
export function reactive<T extends object>(obj: T): UnwrapRef<T> {
  if (!isObject(obj)) {
    if (__DEV__) {
      warn('"reactive()" must be called on an object.')
    }
    return obj
  }

  if (
    !(isPlainObject(obj) || isArray(obj)) ||
    isRaw(obj) ||
    !Object.isExtensible(obj)
  ) {
    return obj as any
  }

  const observed = observe(obj)
  setupAccessControl(observed)
  return observed as UnwrapRef<T>
}

即VCA是向前兼容的,并没有使用Proxy,但这也就导致了和Vue3的差异。VCA的Reactive传入的对象会被变更,和返回的对象是同一个对象;而Vue3中的Reactive返回的是一个新的代理对象。这一点在官方文档上也有提及。