在异步结果里调用vue的provide会发生什么

324 阅读14分钟

接上次看vue-router看到了provideinject, 觉得应该比较简单, 打算看一下实现.

结果并不简单.

  • provide, inject与其他一些 api 比如onMounted, onUnmounted是不能在异步结果中调用的. (更不能在setup外调用)
  • 在同一个setup中, provide的值是不能被inject到的.

基于这2个表现, 我深入看了下provide/inject的实现与vue相关启动流程.

看完以后对vue熟悉了很多, 记录下来分享给大家, 让大家也能避开一些错误操作.

provide/inject用法

普通的用法就是provide(key, value), 这样是子孙组件中就可以通过inject(key)获取到value了.

需要注意的是这需要在setup()中同步调用. 其实inject()也是一样的, 只是取值不会有异步场景所以文档中没提示.

setup()是组件里的, 而在插件中不是一定加载组件的, 所以还有所谓"app level provide", 方式就是app.provide(key, value).

先从比较简单的app-level provide开始.

app-level provide

function createApp(rootComponent, rootProps = null) {
    // ...option api兼容和其他app方法用到的变量声明
    const context = {
      // ...
      app: null as any,
      provides: Object.create(null),
    }
    // ...
    const app: App = (context.app = {
      _context: context,
      // ...use, directive, component等方法
      mount(
        rootContainer: HostElement,
        isHydrate?: boolean,
        namespace?: boolean | ElementNamespace,
      ): any {
        if (!isMounted) {
          const vnode = app._ceVNode || createVNode(rootComponent, rootProps)
          // store app context on the root VNode.
          // this will be set on the root instance on initial mount.
          vnode.appContext = context
          // ...mount流程
      },
      provide(key, value) {
        context.provides[key as string | symbol] = value
        return app
      },
      runWithContext(fn) {
        const lastApp = currentApp
        currentApp = app
        try {
          return fn()
        } finally {
          currentApp = lastApp
        }
      },
    })
    return app
  }

这个是我们启动vue流程要调用的createApp(), 他返回的app对象中的provide()方法非常简单, 就是把键值写到context.provides里.

而这个context可以从app._context, 或者是根节点的vnodeappContext里获取到.

这个vnode又会被mount()后续的动作传到后面的子组件实例里.

inject

provide设置的内容放在了app._context中, 我们看一下inject是如何取到的.

export function inject(
  key: InjectionKey<any> | string,
  defaultValue?: unknown,
  treatDefaultAsFactory = false,
) {
  // fallback to `currentRenderingInstance` so that this can be called in
  // a functional component
  const instance = currentInstance || currentRenderingInstance
  // also support looking up from app-level provides w/ `app.runWithContext()`
  if (instance || currentApp) {
    // #2400
    // to support `app.use` plugins,
    // fallback to appContext's `provides` if the instance is at root
    // #11488, in a nested createApp, prioritize using the provides from currentApp
    const provides = currentApp
      ? currentApp._context.provides
      : instance
        ? instance.parent == null
          ? instance.vnode.appContext && instance.vnode.appContext.provides
          : instance.parent.provides
        : undefined
    if (provides && (key as string | symbol) in provides) {
      // TS doesn't allow symbol as index type
      return provides[key as string]
    } else if (arguments.length > 1) {
      // ...默认值相关
    }
  }
}

可以看到关键点就在于const provides = ...的取值, 之后就是把provides对应的key返回就行.

我们仔细来看provides的取值优先级:

  1. 如果有currentApp就取currentApp._context.provides. 而currentApp这个变量非常明确, 只有runWithContext()可以调用他.

所以这第一个情况是runWithContext()+inject()专属情况.

接下来的情况是有currentInstance的, 也就是在setup()里调用的. (currentRenderingInstance是执行render()函数的时候设置的, 其实是同一个实例.)

  1. 如果是根节点, 就取instance.vnode.appContext.provides. 也就是app-level provide设置的值.
  2. 如果不是根节点, 就取instance.parent.provides.

看到这里就需要去了解currentInstance了, 因为:

  • 上面提到的(2)中, 其实我们只知道vnode.appContext是上面app-level provide的值, 但并不知道instancevnode是如何挂上的, 挂的是不是期望的vnode, 没有连起来.

  • 上面提到的(3)中, instanceparent是如何挂上的, parentprovides又是什么. (不能因为provides名字而和上文提到的context.provides搞混, 名字类似并不表示他们是指到一个地址的)

  • 最重要的是currentInstance是什么时候被创建的, 对应的是什么实例, parent之间的数据结构又是什么.

currentInstance的来源和相关的vue的启动流程

总结下我们现在的信息: app.provide是把信息存在了一个变量context里, inject取变量的时候分为三个情况, 是通过三个不同的路径取到context的.

其中通过runWithContext()currentApp_context, 比较明确, 而后面2个涉及到currentInstance这个变量, 就需要简单理一下从项目入口到调用inject()的过程了.

从入口到patch()

一个普通vue项目的入口大概是这样的: createApp(App).mount('#app').

其中App从sfc编译过来是个组件声明的js对象, 有setuprender属性, 其实就对应了sfc的scripttemplate.

.mount()方法是createApp()返回的, 本文开头有, 现在补充.mount()的详细内容:

{
  mount(
      rootContainer: HostElement,
      isHydrate?: boolean,
      namespace?: boolean | ElementNamespace,
    ): any {
      if (!isMounted) {
        const vnode = app._ceVNode || createVNode(rootComponent, rootProps)
        // store app context on the root VNode.
        // this will be set on the root instance on initial mount.
        vnode.appContext = context
      	// ...hmr相关
        if (isHydrate && hydrate) {
          hydrate(vnode as VNode<Node, Element>, rootContainer as any)
        } else {
          render(vnode, rootContainer, namespace)
        }
        isMounted = true
        app._container = rootContainer
        return getComponentPublicInstance(vnode.component!)
      }
    }
}

我们是客户端的情况, 所以会走到render(), 创建vnode的参数rootComponent就是createApp(App)App.

  const render = (vnode, container, namespace) => {
    if (vnode == null) {
      if (container._vnode) {
        unmount(container._vnode, null, null, true);
      }
    } else {
      patch(
        container._vnode || null,
        vnode,
        container,
        null,
        null,
        null,
        namespace
      );
    }
    if (!isFlushing) {
      isFlushing = true;
      flushPreFlushCbs();
      flushPostFlushCbs();
      isFlushing = false;
    }
    container._vnode = vnode;
  };

挂载和卸载都是调用render(), 如果是挂载的情况, 就会调用patch().

 const patch = (...) => {
    if (n1 === n2) {
      return;
    }
    if (n1 && !isSameVNodeType(n1, n2)) {
      anchor = getNextHostNode(n1);
      unmount(n1, parentComponent, parentSuspense, true);
      n1 = null;
    }
    if (n2.patchFlag === -2) {
      optimized = false;
      n2.dynamicChildren = null;
    }
    const { type, ref, shapeFlag } = n2;
    switch (type) {
      case Text:
        processText(...);
        break;
      case Comment:
        processCommentNode(...);
        break;
      case Static:
        if (n1 == null) {
          mountStaticNode(...);
        } else if (!!(process.env.NODE_ENV !== "production")) {
          patchStaticNode(...);
        }
        break;
      case Fragment:
        processFragment(...);
        break;
      default:
        if (shapeFlag & 1) {
          processElement(...);
        } else if (shapeFlag & 6) {
          processComponent(...);
        } else if (shapeFlag & 64) {
          type.process(...);
        } else if (shapeFlag & 128) {
          type.process(...);
        }
    }
    if (ref != null && parentComponent) {
      setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2);
    }
  };

patch()的任务是对比"上个状态"和"目标状态"的vnode(n1n2)调用dom操作.

叶子节点vnodepatch()分2步:

  1. 根据vnode的类型, 分配给不同函数处理.
  2. 根据n1是否存在, 来判断是挂载还是diff. 最后进行dom操作.

而我们关心的组件, 不是叶子节点, 会交给processComponent()处理.

组件里最终还是会包含叶子节点的, 在经过一些处理后, 会递归调用patch(), 直到叶子节点, 以dom操作退出递归.

挂载组件

n1为空的情况下, processComponent()会把挂载流程交给mountComponent().

  const mountComponent = (initialVNode, container, anchor, parentComponent, parentSuspense, namespace, optimized) => {
    const instance = (initialVNode.component = createComponentInstance(
      initialVNode,
      parentComponent,
      parentSuspense
    ));
    // ...keep-alive组件处理
    setupComponent(instance);
    if (instance.asyncDep) {
      // ...异步组件处理
    } else {
      setupRenderEffect(
        instance,
        initialVNode,
        container,
        anchor,
        parentSuspense,
        namespace,
        optimized
      );
    }
  };

可以看到关键的代码分为这三步:

  1. 创建组件实例, 并挂到vnodecomponent属性上.

inject()就是从这个组件实例中获取provides.

在一些流程中, 组件实例会被设置为currentInstance.

而要获取实例, 以及相关的变量关系是:

dom => dom._vnode (vnode) => vnode.component (component实例), vnode.type (component定义)

  1. 进行组件的setup.

setup()作用是为render()做准备的.

render()的作用是每次运行会返回最新的vnode, 把最新的vnode与老的一起给patch(), 就可以进行diff最后操作dom.

setup()只会执行一次, 而render()在每次更新组件都会执行.

setup()的形式有很多种, 最常见的sfc是返回template的执行环境, 在一些组件里会直接返回render()函数, 或者是异步组件会返回promise. (但作用都是为render()的执行做准备)

  1. 创建组件的render-effect.

effect的内容是执行render()函数, 获取到vnode, 并且patch(). (这个流程本文前面已经提到几次了, 以前的文章里有详细说)

然后把effect挂到组件实例上, 再给组件实例挂个update()方法, 就是执行一下effect.run().

顺带一提, patch组件如果有老vnode, 就会走到updateComponent, 而不是现在的mountComponent, 这时候就会直接执行组件实例的update()方法, 并且把老vnode上的组件实例赋值给新vnode, 而这个vnode会在effect执行的时候被挂到组件实例的subTree上.

其实讲到这里已经理清了. 如果想更清晰, 下面会贴一些这三个步骤的具体代码.

createComponentInstance细节

function createComponentInstance(vnode, parent, suspense) {
  const type = vnode.type;
  const appContext = (parent ? parent.appContext : vnode.appContext) || emptyAppContext;
  const instance = {
    // ...省略了一些属性, 保留了一些本文中提及的属性.
    type,
    parent,
    appContext,
    root: null,
    next: null,
    subTree: null,
    effect: null,
    update: null,
    render: null,
    proxy: null,
    provides: parent ? parent.provides : Object.create(appContext.provides),
  };
  // ...一些属性的初始化设置
  return instance;
}

在创建组件实例的时候我们细看2个点.

  • appContext的取值: 根节点会取createApp()时创建的context, 其余组件实例都会指向父实例.

也就是所有组件实例挂上的是同一个context.

(可以通过在任何组件的setup()中打印getCurrentInstance()appContext都是可以三等的)

  • provides的取值: 与context类似, 但根组件用Object.create()来创建了新对象.

利用js原型链来使修改组件实例的provides不影响context中的, 却能取到context中的值.

如果不是根节点, 就指向父节点. (但在调用provide()的时候会修改, 后面展开)

创建完的组件实例会被频繁的使用, 获取这个实例的方式请看上文的总结.

setup细节

function setupComponent(instance, isSSR = false) {
  isSSR && setInSSRSetupState(isSSR);
  const { props, children } = instance.vnode;
  const isStateful = isStatefulComponent(instance);
  initProps(instance, props, isStateful, isSSR);
  initSlots(instance, children);
  const setupResult = isStateful ? setupStatefulComponent(instance, isSSR) : void 0;
  isSSR && setInSSRSetupState(false);
  return setupResult;
}

vnode上的属性同步到组件实例上, 并调用setupStatefulComponent()

function setupStatefulComponent(instance, isSSR) {
  var _a;
  const Component = instance.type;
  instance.accessCache = /* @__PURE__ */ Object.create(null);
  instance.proxy = new Proxy(instance.ctx, PublicInstanceProxyHandlers);
  const { setup } = Component;
  if (setup) {
    const setupContext = instance.setupContext = setup.length > 1 ? createSetupContext(instance) : null;
    const reset = setCurrentInstance(instance);
    pauseTracking();
    const setupResult = callWithErrorHandling(
      setup,
      instance,
      0,
      [
        !!(process.env.NODE_ENV !== "production") ? shallowReadonly(instance.props) : instance.props,
        setupContext
      ]
    );
    resetTracking();
    reset();
    if (isPromise(setupResult)) {
      // ...异步组件的处理
    } else {
      handleSetupResult(instance, setupResult, isSSR);
    }
  } else {
    finishComponentSetup(instance, isSSR);
  }
}

可以看到在执行setup()前后分别调用了setCurrentInstance(instance)reset().

然后获得setup()的执行结果, 会在接下来的handleSetupResult()里来处理不同类型的结果.

function handleSetupResult(instance, setupResult, isSSR) {
  if (isFunction(setupResult)) {
    if (instance.type.__ssrInlineRender) {
      instance.ssrRender = setupResult;
    } else {
      instance.render = setupResult;
    }
  } else if (isObject(setupResult)) {
    instance.setupState = proxyRefs(setupResult);
  }
  finishComponentSetup(instance, isSSR);
}

常用的sfc, setup()返回的是对象, 这个对象会作为template的执行环境, 会走到instance.setupState = proxyRefs(setupResult)

组件因为比较灵活, setup()可能返回render()函数, 会走到instance.render = setupResult.

(到这里已经可以猜到, 在创建render-effect的时候, 就会带着"setupState"来跑render()函数来获取最新的vnode)

到现在, sfc情况的组件实例还没有render()函数, 所以在finishComponentSetup()里处理.

function finishComponentSetup(instance, isSSR, skipOptions) {
  const Component = instance.type;
  if (!instance.render) {
    if (!isSSR && compile && !Component.render) {
      const template = Component.template || resolveMergedOptions(instance).template;
      if (template) {
        const { isCustomElement, compilerOptions } = instance.appContext.config;
        const { delimiters, compilerOptions: componentCompilerOptions } = Component;
        const finalCompilerOptions = extend(
          extend(
            {
              isCustomElement,
              delimiters
            },
            compilerOptions
          ),
          componentCompilerOptions
        );
        Component.render = compile(template, finalCompilerOptions);
      }
    }
    instance.render = Component.render || NOOP;
    if (installWithProxy) {
      installWithProxy(instance);
    }
  }
}

可以看到给组件实例的render()函数赋值为instance.render = Component.render.

这个Component是组件定义对象. 按我理解, 正常的sfc走到这里, template已经在编译时被编译成render()函数了.

如果在组件定义时使用了js对象, 又手动写了template属性, 在这里会进行一次运行时编译. (如果引入的vue没有运行时编译, 会进行提示, 我这里没有贴这段代码)

到这里, setup()的任务已经做完了, 组件实例有了render()方法和执行环境, (执行render()方法后就能获得最新vnode), 就可以下一步建立组件的render-effect了.

render-effect细节

const setupRenderEffect = (instance, initialVNode, container, anchor, parentSuspense, namespace, optimized) => {
    const componentUpdateFn = () => {
      // ..
    };
    const effect = instance.effect = new ReactiveEffect(
      componentUpdateFn,
      NOOP,
      () => queueJob(update),
      instance.scope
      // track it in component's effect scope
    );
    const update = instance.update = () => {
      if (effect.dirty) {
        effect.run();
      }
    };
    update.id = instance.uid;
    toggleRecurse(instance, true);
    update();
  };

上文的总结已经提到, 建立render-effect的时候做了这几件事:

  • 创建一个effect, 内容是执行render()函数, 获取最新vnode, 再把新老vnode进行patch().

(effect属于响应式知识, 以前的文章有写过, patch()的作用前文也提到几次了)

  • 组件实例挂上这个effect.

  • 组件实例挂上update()方法, 内容是执行effect(). 这是便于updateComponent调用.

  • 立马执行这个effect(). (最后一行update())

现在来看一下执行render()函数, 获取vnode进行patch()的细节:

const componentUpdateFn = () => {
      if (!instance.isMounted) {
        let vnodeHook;
        const { el, props } = initialVNode;
        const { bm, m, parent } = instance;
        const isAsyncWrapperVNode = isAsyncWrapper(initialVNode);
        toggleRecurse(instance, false);
        if (bm) {
          invokeArrayFns(bm);
        }
        if (!isAsyncWrapperVNode && (vnodeHook = props && props.onVnodeBeforeMount)) {
          invokeVNodeHook(vnodeHook, parent, initialVNode);
        }
        toggleRecurse(instance, true);
        if (el && hydrateNode) {
          // ...同构渲染的情况
        } else {
          const subTree = instance.subTree = renderComponentRoot(instance);
          patch(
            null,
            subTree,
            container,
            anchor,
            instance,
            parentSuspense,
            namespace
          );
          initialVNode.el = subTree.el;
        }
        // ...后续流程, 以后再讨论
        instance.isMounted = true;
        initialVNode = container = anchor = null;
      } else {
        let { next, bu, u, parent, vnode } = instance;
        let originNext = next;
        let vnodeHook;
        toggleRecurse(instance, false);
        if (next) {
          next.el = vnode.el;
          updateComponentPreRender(instance, next, optimized);
        } else {
          next = vnode;
        }
        if (bu) {
          invokeArrayFns(bu);
        }
        if (vnodeHook = next.props && next.props.onVnodeBeforeUpdate) {
          invokeVNodeHook(vnodeHook, parent, next, vnode);
        }
        toggleRecurse(instance, true);
        const nextTree = renderComponentRoot(instance);
        const prevTree = instance.subTree;
        instance.subTree = nextTree;
        patch(
          prevTree,
          nextTree,
          // parent may have changed if it's in a teleport
          hostParentNode(prevTree.el),
          // anchor may have changed if it's in a fragment
          getNextHostNode(prevTree),
          instance,
          parentSuspense,
          namespace
        );
        next.el = nextTree.el;
        if (originNext === null) {
          updateHOCHostEl(instance, nextTree.el);
        }
      }
    };

根据instance.isMounted判断是首次挂载还是更新, 其实都调用了同一个函数renderComponentRoot()来根据组件实例获得vnode.

也调用了同样的patch()函数, 新建的时候"老vnode"参数为空而已.

那么更新时是怎么获取老vnode呢, 把vnode挂在instancesubTree属性下.

(组件实例还有另外个属性vnode是创建时就有的, 他表示组件本身, 而组件本身其实是空, 所有组件的vnode属性的el都是空文本, 真正内容在他subTree的children里. )

(所以推论vnode下的component(组件实例)只要不为null, 这个componenttype一定是组件定义, 并且这个vnodeel一定是空文本.)

然后更新组件的时候设个临时变量简简单单操作下就好了.

最后深入看一下renderComponentRoot是如何执行render()函数获得vnode

function renderComponentRoot(instance) {
  const {
    // ...取了很多组件实例的属性
  } = instance;
  const prev = setCurrentRenderingInstance(instance);
  let result;
  let fallthroughAttrs;
  try {
    if (vnode.shapeFlag & 4) {
      const proxyToUse = withProxy || proxy;
      const thisProxy = proxyToUse;
      result = normalizeVNode(
        render.call(
          thisProxy,
          proxyToUse,
          renderCache,
          !!(process.env.NODE_ENV !== "production") ? shallowReadonly(props) : props,
          setupState,
          data,
          ctx
        )
      );
      fallthroughAttrs = attrs;
    } else {
      const render2 = Component;
      // ...这里让Component作为render函数, 我猜测是函数组件的case, 不是本文探究范围
    }
  } catch (err) {
    blockStack.length = 0;
    handleError(err, instance, 1);
    result = createVNode(Comment);
  }
  // ...有一段比较长的逻辑看起来不是主线, 没有贴上来
  setCurrentRenderingInstance(prev);
  return result;
}

可以看到在执行render()函数前后也设置了CurrentRenderingInstance, 来使上面提到的provide/inject系列的api生效. 但render()函数里调用这些api, 应该是setup()返回的函数, sfc不会出现这个情况.

然后用一些参数执行了render()函数. proxy是用来提示错误设置的, setupState就是在setup阶段准备好的render执行环境.

最后用normalizeVNode()包了一下, 交给调用方去patch()了.

provide

从上文inject()的分析知道了三种取值, 在了解了组件实例后, 再配合组件setup中的provide看就能得出最后结论了.

function provide(key, value) {
  if (!currentInstance) {
    if (!!(process.env.NODE_ENV !== "production")) {
      warn$1(`provide() can only be used inside setup().`);
    }
  } else {
    let provides = currentInstance.provides;
    const parentProvides = currentInstance.parent && currentInstance.parent.provides;
    if (parentProvides === provides) {
      provides = currentInstance.provides = Object.create(parentProvides);
    }
    provides[key] = value;
  }
}

从刚才createComponentInstance()我们可以知道, 创建组件实例的时候, provides就是取parent.provides地址的.

所以在一个setup里调用的第一次provide(), 会走到if里, 把当前组件实例的provides修改成继承parent.provides的对象.

这个操作在createComponentInstance()provides属性里是有过的, 目的就是"改变自己不影响父级, 但能取到父级的值".

这样保证了inject()只能获取到自己祖先级的provides.

我们分析下根节点, 其实也是如此的: inject()取值是instance.vnode.appContext.provides, 而自己组件实例上的provides值是Object.create(vnode.appContext.provides), 此时provide()取到的provides不是当前环境inject()的取值, 所以同一个setup()中是取不到当前环境provide()的值的.

总结

2个方面的总结.

第一是provide/inject的.

  • provide/inject是有传递方向的. 由app.provide, 根组件实例, 向更深的组件实例传递.

  • 同级组件实例的setup()中, 自己取不到自己provide()的值.

  • 如果位于不同2个大分支的组件实例, 是可以provide同一个key不同值的. (key是同一个Symbol也如此)

另外个总结是为了看组件实例, 对 vue3 有了一些深一些的认识.

vue3 的composition api为了代码的复用, 使用了这个useXXX的形式, 其实就是让代码在不同地方都可以取到变量, 而不需要在组件内部.

组件实例还是在的, 为了useXXX能准确的指到期望的实例, 就有了currentXXX的概念.

而设置currentXXX是 vue 内部流程进行的, 异步操作要注意回调执行的时候是不是已经脱离环境, 即使看起来代码是写在组件的setup()中的. 具体解决方案主要靠避免, 或者是调用一些 api 里预留的参数, 用getCurrentInstance()把实例传进去.