Vue 是怎么从<HelloWorld />找到HelloWorld.vue

2,334 阅读3分钟

该系列是本人准备面试的笔记,或许有描述不当的地方,请在评论区指出,感激不尽。

其他篇章:

  1. Promise.try 和 Promise.withResolvers,你了解多少呢?
  2. 从 babel 编译看 async/await
  3. 挑战ChatGPT提供的全网最复杂“事件循环”面试题
  4. Vue.nextTick 从v3.5.13追溯到v0.7.0
  5. Vue 怎么监听 Set,WeakSet,Map,WeakMap 变化?

前言

刚刚兴起的念头,打算开源一个跟 Vue 全局组件相关的 plugin。所以今天来简单了解一下,Vue 组件标签是怎么一步一步解析成 HTML 标签的。

607622a04a0f3pOR.gif

调试准备

  1. 先克隆源码:git clone https://github.com/vuejs/core.git
  2. 安装依赖:pnpm install
  3. 启动开发环境:pnpm dev
  4. packages/vue/examples 目录下创建测试文件;
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Vue</title>
  <!-- 根据相对路径引入 -->
  <script src="../dist/vue.global.js"></script>
  <script type="module">
    const { createApp, defineComponent, h } = Vue;

    // 定义子组件
    const HelloWorld = defineComponent({
      props: {
        message: {
          type: String,
          default: 'Hello World!',
        },
      },
      setup(props) {
        return () => {
          return h('div', { class: 'hello-world' }, `Message: ${props.message}`);
        };
      },
    });

    // 定义父组件
    const App = defineComponent({
      components: { HelloWorld },
      template: `
        <div>
          <HelloWorld message="Hello Vue!"></HelloWorld>
        </div>
      `,
    });

    // 创建 Vue 应用
    const app = createApp(App);
    app.mount('#app');
  </script>
</head>
<body>
  <div id="app"></div>
</body>
</html>
  1. 启动 Live Server
  2. createApp 开始断点调试。

compile 过程

baseParse(source, resolvedOptions)

baseParse<div><HelloWorld message="Hello Vue!"></HelloWorld></div> 解析为 AST 树。

  1. 初始化解析上下文

    • 调用 createRoot() 创建根节点(RootNode),并设置输入字符串和一些默认配置。
    • 初始化 tokenizer 的模式(HTML、SFC 或 BASE)和属性(如 decodeEntities)。
  2. 调用 tokenizer.parse

    • tokenizer.parse(currentInput) 开始解析输入的模板字符串。
    • tokenizer 是解析的核心,负责将输入字符串逐步分解成一系列的 token(词法单元)。
  3. 扫描输入字符

    • tokenizer.parse 函数中,this.buffer 持有输入字符串,解析过程逐字符扫描。
    • 每个字符的处理基于 state(状态机),不同的状态决定如何解析字符。
  4. 根据当前状态处理字符

    • state 状态机控制当前解析的上下文,比如:

      • Text: 处理普通文本节点。
      • InTagName: 解析开始标签(如 <div><HelloWorld>)。
      • InAttrName: 解析属性名(如 message)。
      • InAttrValueDoubleQuotes: 解析属性值(如 "Hello Vue!")。
      • InClosingTagName: 解析结束标签(如 </HelloWorld></div>)。

    这些状态不断转换,直到所有字符都被处理完。

  5. 返回 AST

    • 解析完成后,tokenizer 会生成一个 AST 树结构。
    • 调用 condenseWhitespace 清理空白字符,将 AST 树的 children 作为最终结果。
  6. 返回根节点

    • 最终,baseParse 返回根节点(RootNode),包含 div 元素和它的子节点(HelloWorld 元素)。

transform(ast, ...)

对已经解析成 AST 的模板进行转换和优化,生成可以用于渲染的最终结构。

  1. 创建转换上下文 (createTransformContext)

    • 调用 createTransformContext 函数,初始化转换上下文,这个上下文包含了许多与转换相关的状态和数据,比如当前正在处理的节点、帮助函数、导入项等。
  2. 遍历节点 (traverseNode)

    • 调用 traverseNode 函数,递归遍历整个 AST 树中的每一个节点。遍历过程中,应用所有的节点转换(nodeTransforms)插件,这些插件对每个节点进行修改或处理。
  3. 应用节点转换

    • 每个节点都会被 nodeTransforms 中的转换函数处理。
    • 如果转换函数返回一个退出回调(onExit),则会在节点处理完成后执行。
    • 节点的类型决定了如何继续遍历它的子节点。例如,<div> 会继续递归遍历它的子元素,而插值表达式和注释节点则可能直接进行转换。
    • 其中,transformElement 方法的作用是将模板中的元素节点(普通元素或组件)转换为用于生成渲染代码的 VNodeCall 表示,并处理其属性、子节点、指令和优化标志(如 patchFlag 等)。
  4. 生成代码 (createRootCodegen)

    • 调用 createRootCodegen 为模板生成渲染代码,就是 createVNode 生成虚拟 DOM 相关函数的代码。
  5. 收集元数据

    • 在转换过程中,收集 AST 节点相关的信息,最终将这些信息添加到 root 节点。
    • 收集的内容包括:helpers(辅助函数)、components(组件)、directives(指令)、imports(导入项)、hoists(提升的静态节点)、temps(临时变量)等。
  6. 完成转换

    • 最后,标记 root.transformedtrue,表示 AST 已经完成转换并可以进一步使用。

generate(ast, resolvedOptions)

将转换后的 AST 进一步处理,生成可执行的渲染函数代码(即最终的 render 代码),以便 Vue 的运行时能够正确渲染组件或模板。

  1. 创建上下文(createCodegenContext):

    • 初始化生成代码所需的上下文,包括辅助方法(如 push)、缩进控制和选项配置。
  2. 生成前置代码(Preambles):

    • 为不同模式(如 module 模式)生成模块导入代码、函数头部代码等前置内容:

      • 模块模式: 使用 genModulePreamble 生成模块依赖和作用域 ID 处理代码。
      • 函数模式: 使用 genFunctionPreamble 创建函数声明和作用域初始化。
  3. 处理辅助函数(Helpers):

    • 如果需要,解构 Vue 内置辅助函数(如 createVNoderesolveComponent 等)到局部作用域中,避免全局查找。
  4. 生成组件和指令的解析代码:

    • 使用 genAssets 方法,生成组件和指令的运行时解析代码(如 resolveComponentresolveDirective 调用)。
  5. 定义临时变量:

    • 根据 AST 的临时变量需求(temps 属性),定义必要的局部变量以供后续使用。
  6. 生成 VNode 树表达式:

    • codegenNode 节点进行递归处理,生成 VNode 树的 JavaScript 表达式。如果没有 VNode,则返回 null
  7. 封闭代码块并完成代码生成:

    • 根据上下文,补充必要的闭合大括号、缩进和返回语句,完成渲染函数定义。
  8. 返回生成结果:

    • 输出最终的渲染函数代码、AST 和其他元信息(如 source map)。

APP render 函数最终的模样:

const _Vue = Vue

return function render(_ctx, _cache) {
  with (_ctx) {
    const { resolveComponent: _resolveComponent, createVNode: _createVNode, openBlock: _openBlock, createElementBlock: _createElementBlock } = _Vue

    const _component_HelloWorld = _resolveComponent("HelloWorld")

    return (_openBlock(), _createElementBlock("div", null, [
      _createVNode(_component_HelloWorld, { message: "Hello Vue!" })
    ]))
  }
}

从以上代码可以看出,resolveComponent 就是被用来寻找 HelloWorld 的组件代码,是我这次的行动目的。这也是 Vue 暴露出来的 api,文档在此。

挂载

  1. Vue 调用 mount 方法从根组件开始,进入渲染过程:

    1. VNode 渲染: 递归遍历 VNode 树,调用渲染器的 patch 函数。
  2. Vue 递归比较(或直接创建)当前 VNode 和 DOM:

    1. 初次渲染: 如果没有旧 VNode,则直接调用 createElement 等方法创建真实 DOM。
    2. 属性绑定: 将 VNode 的 propsevents 等绑定到对应的 DOM 节点。
    3. 处理子节点: 递归渲染子 VNode,完成整个树的渲染。
  3. 挂载到真实 DOM

    1. 渲染器将生成的真实 DOM 树插入到根容器(#app)中,完成挂载。

resolveComponent

export function resolveComponent(
  name: string,
  maybeSelfReference?: boolean,
): ConcreteComponent | string {
  return resolveAsset(COMPONENTS, name, true, maybeSelfReference) || name
}

function resolveAsset(
  type: AssetTypes,
  name: string,
  warnMissing = true,
  maybeSelfReference = false,
) {
  const instance = currentRenderingInstance || currentInstance
  if (instance) {
    const Component = instance.type

    // explicit self name has highest priority
    if (type === COMPONENTS) {
      const selfName = getComponentName(
        Component,
        false /* do not include inferred name to avoid breaking existing code */,
      )
      if (
        selfName &&
        (selfName === name ||
          selfName === camelize(name) ||
          selfName === capitalize(camelize(name)))
      ) {
        return Component
      }
    }

    const res =
      // local registration
      // check instance[type] first which is resolved for options API
      resolve(instance[type] || (Component as ComponentOptions)[type], name) ||
      // global registration
      resolve(instance.appContext[type], name)

    if (!res && maybeSelfReference) {
      // fallback to implicit self-reference
      return Component
    }

    if (__DEV__ && warnMissing && !res) {
      const extra =
        type === COMPONENTS
          ? `\nIf this is a native custom element, make sure to exclude it from ` +
            `component resolution via compilerOptions.isCustomElement.`
          : ``
      warn(`Failed to resolve ${type.slice(0, -1)}: ${name}${extra}`)
    }

    return res
  } else if (__DEV__) {
    warn(
      `resolve${capitalize(type.slice(0, -1))} ` +
        `can only be used in render() or setup().`,
    )
  }
}

function resolve(registry: Record<string, any> | undefined, name: string) {
  return (
    registry &&
    (registry[name] ||
      registry[camelize(name)] ||
      registry[capitalize(camelize(name))])
  )
}

resolveComponent 的逻辑十分简单,主要目的就是在 Vue 实例和全局上下文中解析指定的组件、指令或过滤器等资源,以便在运行时找到相应的定义。

  1. 优先解析组件自身:

    • 如果是组件类型 (type === COMPONENTS),优先检查当前组件的名称是否与目标名称匹配(包括原始名称、驼峰格式和首字母大写格式)。
  2. 局部解析:

    • 在当前组件实例的注册表(如 instance[type] 或组件的 options 中)尝试找到匹配的资源。
  3. 全局解析:

    • 在应用程序上下文的全局注册表(instance.appContext[type])中查找,即通过 app.component() 注册的全局组件。
  4. 隐式自引用:

    • 如果 maybeSelfReferencetrue,且未找到匹配资源,则返回组件本身作为默认结果,通常出现在递归组件或动态组件的上下文中。

以上便是局部组件和全局组件的解析过程,那 <component is='HelloWorld'> 动态组件呢?

<component is='HelloWorld'>

我们改写一下 App 的 template:

const App = defineComponent({
  components: { HelloWorld },
  template: `
    <div>
      <component is="HelloWorld" message="Hello Vue!"></component>
    </div>
  `,
});

整体逻辑大差不差,在 transformElement 方法会对动态组件进行处理:通过 resolveComponentType 解析 :is 属性值,生成动态组件的类型。构建动态组件的属性、子节点以及运行时更新标志。将动态组件标记为块节点,便于运行时优化。

生成的 render 函数如下:

const _Vue = Vue

return function render(_ctx, _cache) {
  with (_ctx) {
    const { resolveDynamicComponent: _resolveDynamicComponent, openBlock: _openBlock, createBlock: _createBlock, createElementBlock: _createElementBlock } = _Vue

    return (_openBlock(), _createElementBlock("div", null, [
      (_openBlock(), _createBlock(_resolveDynamicComponent("HelloWorld"), { message: "Hello Vue!" }))
    ]))
  }
}

_resolveComponent("HelloWorld") 变成了 _resolveDynamicComponent("HelloWorld")

export function resolveDynamicComponent(component: unknown): VNodeTypes {
  if (isString(component)) {
    return resolveAsset(COMPONENTS, component, false) || component
  } else {
    // invalid types will fallthrough to createVNode and raise warning
    return (component || NULL_DYNAMIC_COMPONENT) as any
  }
}

如果 component 是字符串,表示可能是一个组件的名称。还是调用 resolveAsset 尝试从当前上下文中解析该名称对应的组件(例如在局部或全局注册的组件)。如果找不到组件,则直接返回字符串,这可能是一个原生 HTML 标签。

如果 component 是非字符串(如一个组件对象或其他值),直接返回。如果 componentnullundefined,返回 NULL_DYNAMIC_COMPONENT 作为占位符。

结语

虽然了解了小蝌蚪找妈妈的整个过程,但是我好像走进了死胡同,没有出路,不能通过 Vue plugin 方式实现。

最后,喜欢这篇文章的朋友不要忘了点赞收藏评论!

1-1720751939.jpeg