有点儿神奇,原来vue3的setup语法糖中组件无需注册因为这个

vue3,setup · 浏览次数 : 15

小编点评

前言 众所周知,在 Vue 2 时,使用一个 Vue 组件要么需要全局注册,要么局部注册。但在 Vue 3 中,我们可以在 setup 语法糖中直接通过 import 导入组件,而无需进行注册。本文将探讨这一现象的原因。 关注公众号:【前端欧阳】,给自己一个进阶 Vue 的机会。 我们先来看一个简单的示例: ```html ``` 在这个示例中,我们在 setup 语法糖中通过 import 导入了 Child 子组件,并在 template 中直接使用了它。下面是编译后的代码: ```javascript import { createBlock as _createBlock, defineComponent as _defineComponent, openBlock as _openBlock } from "/node_modules/.vite/deps/vue.js?v=23bfe016"; import Child from "/src/components/setupComponentsDemo/child.vue"; const _sfc_main = _defineComponent({ __name: "index", setup(__props, { expose }) { __expose(); const __returned__ = { Child }; return __returned__; }, }); function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) { return _openBlock(), _createBlock($setup["Child"]); } _sfc_main.render = _sfc_render; export default _sfc_main; ``` 从编译后的代码中可以看出,原本在 setup 语法糖中通过 import 导入的 Child 组件已经被提升到了 setup 函数外部。在 render 函数中,我们可以通过 `$setup["Child"]` 获取到 Child 子组件,并通过 `$createBlock($setup["Child"])` 将其渲染到页面上。 从命名上我们可以推测,`$setup` 对象与 setup 函数的返回对象有关。实际上,`$setup["Child"]` 就是 setup 函数返回对象中的 Child 组件。 那么,如何在 render 函数中获取 setup 函数返回的对象呢?请参考我之前发布的一篇文章:Vue 3 的 setup 语法糖到底是什么东西? 接下来,我们将通过调试的方式深入了解编译过程中是如何将 Child 组件收集到 setup 函数的返回对象中,以及 import 导入 Child 子组件的语句是如何提升到 setup 函数外部的。 总结 本文重点讨论了 Vue 3 中 setup 语法糖如何处理其中的 import 导入语句。通过调试和分析编译过程,我们了解到 Child 组件是如何被收集到 setup 函数的返回对象中,并最终在 render 函数中被渲染到页面上的。希望这篇文章能够帮助你更好地理解 Vue 3 中的 setup 语法糖。

正文

前言

众所周知,在vue2的时候使用一个vue组件要么全局注册,要么局部注册。但是在setup语法糖中直接将组件import导入无需注册就可以使用,你知道这是为什么呢?注:本文中使用的vue版本为3.4.19

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

看个demo

我们先来看个简单的demo,代码如下:

<template>
  <Child />
</template>

<script lang="ts" setup>
import Child from "./child.vue";
</script>

上面这个demo在setup语法糖中import导入了Child子组件,然后在template中就可以直接使用了。

我们先来看看上面的代码编译后的样子,在之前的文章中已经讲过很多次如何在浏览器中查看编译后的vue文件,这篇文章就不赘述了。编译后的代码如下:

import {
  createBlock as _createBlock,
  defineComponent as _defineComponent,
  openBlock as _openBlock,
} from "/node_modules/.vite/deps/vue.js?v=23bfe016";
import Child from "/src/components/setupComponentsDemo/child.vue";

const _sfc_main = _defineComponent({
  __name: "index",
  setup(__props, { expose: __expose }) {
    __expose();
    const __returned__ = { Child };
    return __returned__;
  },
});

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
  return _openBlock(), _createBlock($setup["Child"]);
}

_sfc_main.render = _sfc_render;
export default _sfc_main;

从上面的代码可以看到,编译后setup语法糖已经没有了,取而代之的是一个setup函数。在setup函数中会return一个对象,对象中就包含了Child子组件。

有一点需要注意的是,我们原本是在setup语法糖中import导入的Child子组件,但是经过编译后import导入的代码已经被提升到setup函数外面去了。

在render函数中使用$setup["Child"]就可以拿到Child子组件,并且通过_createBlock($setup["Child"]);就可以将子组件渲染到页面上去。从命名上我想你应该猜到了$setup对象和上面的setup函数的return对象有关,其实这里的$setup["Child"]就是setup函数的return对象中的Child组件。至于在render函数中是怎么拿到setup函数返回的对象可以看我的另外一篇文章: Vue 3 的 setup语法糖到底是什么东西?

接下来我将通过debug的方式带你了解编译时是如何将Child塞到setup函数的return对象中,以及怎么将import导入Child子组件的语句提升到setup函数外面去的。

compileScript函数

在上一篇 有点东西,template可以直接使用setup语法糖中的变量原来是因为这个 文章中我们已经详细讲过了setup语法糖是如何编译成setup函数,以及如何根据将顶层绑定生成setup函数的return对象。所以这篇文章的重点是setup语法糖如何处理里面的import导入语句。

还是一样的套路启动一个debug终端。这里以vscode举例,打开终端然后点击终端中的+号旁边的下拉箭头,在下拉中点击Javascript Debug Terminal就可以启动一个debug终端。
debug-terminal

然后在node_modules中找到vue/compiler-sfc包的compileScript函数打上断点,compileScript函数位置在/node_modules/@vue/compiler-sfc/dist/compiler-sfc.cjs.js。接下来我们来看看简化后的compileScript函数源码,代码如下:

function compileScript(sfc, options) {
  const ctx = new ScriptCompileContext(sfc, options);
  const setupBindings = Object.create(null);
  const scriptSetupAst = ctx.scriptSetupAst;

  for (const node of scriptSetupAst.body) {
    if (node.type === "ImportDeclaration") {
      // 。。。省略
    }
  }

  for (const node of scriptSetupAst.body) {
    // 。。。省略
  }

  let returned;
  const allBindings = {
    ...setupBindings,
  };
  for (const key in ctx.userImports) {
    if (!ctx.userImports[key].isType && ctx.userImports[key].isUsedInTemplate) {
      allBindings[key] = true;
    }
  }
  returned = `{ `;
  for (const key in allBindings) {
    // ...遍历allBindings对象生成setup函数的返回对象
  }

  return {
    // ...省略
    content: ctx.s.toString(),
  };
}

我们先来看看简化后的compileScript函数。

compileScript函数中首先使用ScriptCompileContext类new了一个ctx上下文对象,在new的过程中将compileScript函数的入参sfc传了过去,sfc中包含了<script setup>模块的位置信息以及源代码。

ctx.scriptSetupAst<script setup>模块中的code代码字符串对应的AST抽象语法树。

接着就是遍历AST抽象语法树的内容,如果发现当前节点是一个import语句,就会将该import收集起来放到ctx.userImports对象中(具体如何收集接下来会讲)。

然后会再次遍历AST抽象语法树的内容,如果发现当前节点上顶层声明的变量、函数、类、枚举声明,就将其收集到setupBindings对象中。

最后就是使用扩展运算符...setupBindingssetupBindings对象中的属性合并到allBindings对象中。

对于ctx.userImports的处理就不一样了,不会将其全部合并到allBindings对象中。而是遍历ctx.userImports对象,如果当前import导入不是ts的类型导入,并且导入的东西在template模版中使用了,才会将其合并到allBindings对象中。

经过前面的处理allBindings对象中已经收集了setup语法糖中的所有顶层绑定,然后遍历allBindings对象生成setup函数中的return对象。

我们在debug终端来看看生成的return对象,如下图:
return

从上图中可以看到setup函数中已经有了一个return对象了,return对象的Child属性值就是Child子组件的引用。

收集import导入

接下来我们来详细看看如何将setup语法糖中的全部import导入收集到ctx.userImports对象中,代码如下:

function compileScript(sfc, options) {
  // 。。。省略
  for (const node of scriptSetupAst.body) {
    if (node.type === "ImportDeclaration") {
      hoistNode(node);
      for (let i = 0; i < node.specifiers.length; i++) {
        // 。。。省略
      }
    }
  }
  // 。。。省略
}

遍历scriptSetupAst.body也就是<script setup>模块中的code代码字符串对应的AST抽象语法树,如果当前节点类型是import导入,就会执行hoistNode函数将当前import导入提升到setup函数外面去。

hoistNode函数

将断点走进hoistNode函数,代码如下:

function hoistNode(node) {
  const start = node.start + startOffset;
  let end = node.end + startOffset;
  while (end <= source.length) {
    if (!/\s/.test(source.charAt(end))) {
      break;
    }
    end++;
  }
  ctx.s.move(start, end, 0);
}

编译阶段生成新的code字符串是基于整个vue源代码去生成的,而不是仅仅基于<script setup>模块中的js代码去生成的。我们来看看此时的code代码字符串是什么样的,如下图:
before-move

从上图中可以看到此时的code代码字符串还是和初始的源代码差不多,没什么变化。

首先要找到当前import语句在整个vue源代码中开始位置和结束位置在哪里。node.start为当前import语句在<script setup>模块中的开始位置,startOffset<script setup>模块中的内容在整个vue源码中的开始位置。所以node.start + startOffset就是当前import语句在整个vue源代码中开始位置,将其赋值给start变量。

同理node.end + startOffset就是当前import语句在整个vue源代码中结束位置,将其赋值给end变量。由于import语句后面可能会有空格,所以需要使用while循环将end指向import语句后面非空格前的位置,下一步move的时候将空格一起给move过去。

最后就是调用ctx.s.move方法,这个方法接收三个参数。第一个参数是要移动的字符串开始位置,第二个参数是要移动的字符串结束位置,第三个参数为将字符串移动到的位置。

所以这里的ctx.s.move(start, end, 0)就是将import语句移动到最前面的位置,执行完ctx.s.move方法后,我们在debug终端来看看此时的code代码字符串,如下图:
after-move

从上图中可以看到import语句已经被提升到了最前面去了。

遍历import导入说明符

我们接着来看前面省略的遍历node.specifiers的代码,如下:

function compileScript(sfc, options) {
  // 。。。省略

  for (const node of scriptSetupAst.body) {
    if (node.type === "ImportDeclaration") {
      hoistNode(node);
      for (let i = 0; i < node.specifiers.length; i++) {
        const specifier = node.specifiers[i];
        const local = specifier.local.name;
        const imported = getImportedName(specifier);
        const source2 = node.source.value;
        registerUserImport(
          source2,
          local,
          imported,
          node.importKind === "type" ||
            (specifier.type === "ImportSpecifier" &&
              specifier.importKind === "type"),
          true,
          !options.inlineTemplate
        );
      }
    }
  }

  // 。。。省略
}

我们先在debug终端看看node.specifiers数组是什么样的,如下图:
specifiers

从上图中可以看到node.specifiers数组是一个导入说明符,那么为什么他是一个数组呢?原因是import导入的时候可以一次导入 多个变量进来,比如import {format, parse} from "./util.js"

node.source.value是当前import导入的路径,在我们这里是./child.vue

specifier.local.name是将import导入进来后赋值的变量,这里是赋值为Child变量。

specifier.type是导入的类型,这里是ImportDefaultSpecifier,说明是default导入。

接着调用getImportedName函数,根据导入说明符获取当前导入的name。代码如下:

function getImportedName(specifier) {
  if (specifier.type === "ImportSpecifier")
    return specifier.imported.type === "Identifier"
      ? specifier.imported.name
      : specifier.imported.value;
  else if (specifier.type === "ImportNamespaceSpecifier") return "*";
  return "default";
}

大家都知道import导入有三种写法,分别对应的就是getImportedName函数中的三种情况。如下:

import { format } from "./util.js";	// 命名导入
import * as foo from 'module';	// 命名空间导入
import Child from "./child.vue";	// default导入的方式

如果是命名导入,也就是specifier.type === "ImportSpecifier",就会返回导入的名称。

如果是命名空间导入,也就是specifier.type === "ImportNamespaceSpecifier",就会返回字符串*

否则就是default导入,返回字符串default

最后就是拿着这些import导入相关的信息去调用registerUserImport函数。

registerUserImport函数

将断点走进registerUserImport函数,代码如下:

function registerUserImport(
  source2,
  local,
  imported,
  isType,
  isFromSetup,
  needTemplateUsageCheck
) {
  let isUsedInTemplate = needTemplateUsageCheck;
  if (
    needTemplateUsageCheck &&
    ctx.isTS &&
    sfc.template &&
    !sfc.template.src &&
    !sfc.template.lang
  ) {
    isUsedInTemplate = isImportUsed(local, sfc);
  }
  ctx.userImports[local] = {
    isType,
    imported,
    local,
    source: source2,
    isFromSetup,
    isUsedInTemplate,
  };
}

registerUserImport函数就是将当前import导入收集到ctx.userImports对象中的地方,我们先不看里面的那块if语句,先来在debug终端中来看看ctx.userImports对象中收集了哪些import导入的信息。如下图:
userImports

从上图中可以看到收集到ctx.userImports对象中的key就是import导入进来的变量名称,在这里就是Child变量。

  • imported: 'default':表示当前import导入是个default导入的方式。

  • isFromSetup: true:表示当前import导入是从setup函数中导入的。

  • isType: false:表示当前import导入不是一个ts的类型导入,后面生成return对象时判断是否要将当前import导入加到return对象中,会去读取ctx.userImports[key].isType属性,其实就是这里的isType

  • local: 'Child':表示当前import导入进来的变量名称。

  • source: './child.vue':表示当前import导入进来的路径。

  • isUsedInTemplate: true:表示当前import导入的变量是不是在template中使用。

上面的一堆变量大部分都是在上一步"遍历import导入说明符"时拿到的,除了isUsedInTemplate以外。这个变量是调用isImportUsed函数返回的。

isImportUsed函数

将断点走进isImportUsed函数,代码如下:

function isImportUsed(local, sfc) {
  return resolveTemplateUsedIdentifiers(sfc).has(local);
}

这个local你应该还记得,他的值是Child变量。resolveTemplateUsedIdentifiers(sfc)函数会返回一个set集合,所以has(local)就是返回的set集合中是否有Child变量,也就是template中是否有使用Child组件。

resolveTemplateUsedIdentifiers函数

接着将断点走进resolveTemplateUsedIdentifiers函数,代码如下:

function resolveTemplateUsedIdentifiers(sfc): Set<string> {
  const { ast } = sfc.template!;
  const ids = new Set<string>();
  ast.children.forEach(walk);

  function walk(node) {
    switch (node.type) {
      case NodeTypes.ELEMENT:
        let tag = node.tag;
        if (
          !CompilerDOM.parserOptions.isNativeTag(tag) &&
          !CompilerDOM.parserOptions.isBuiltInComponent(tag)
        ) {
          ids.add(camelize(tag));
          ids.add(capitalize(camelize(tag)));
        }
        node.children.forEach(walk);
        break;
      case NodeTypes.INTERPOLATION:
      // ...省略
    }
  }
  return ids;
}

sfc.template.ast就是vue文件中的template模块对应的AST抽象语法树。遍历AST抽象语法树,如果当前节点类型是一个element元素节点,比如div节点、又或者<Child />这种节点。

node.tag就是当前节点的名称,如果是普通div节点,他的值就是div。如果是<Child />节点,他的值就是Child

然后调用isNativeTag方法和isBuiltInComponent方法,如果当前节点标签既不是原生html标签,也不是vue内置的组件,那么就会执行两行ids.add方法,将当前自定义组件变量收集到名为ids的set集合中。

我们先来看第一个ids.add(camelize(tag))方法,camelize代码如下:

const camelizeRE = /-(\w)/g;
const camelize = (str) => {
  return str.replace(camelizeRE, (_, c) => (c ? c.toUpperCase() : ""));
};

camelize函数使用正则表达式将kebab-case命名法,转换为首字母为小写的驼峰命名法。比如my-component经过camelize函数的处理后就变成了myComponent。这也就是为什么以 myComponent 为名注册的组件,在模板中可以通过 <myComponent> 或 <my-component> 引用。

再来看第二个ids.add(capitalize(camelize(tag)))方法,经过camelize函数的处理后已经变成了首字母为小写的小驼峰命名法,然后执行capitalize函数。代码如下:

const capitalize = (str) => {
  return str.charAt(0).toUpperCase() + str.slice(1);
};

capitalize函数的作用就是将首字母为小写的驼峰命名法转换成首字母为大写的驼峰命名法。这也就是为什么以 MyComponent 为名注册的组件,在模板中可以通过 <myComponent><my-component>或者是 <myComponent> 引用。

我们这个场景中是使用<Child />引用子组件,所以set集合中就会收集Child。再回到isImportUsed函数,代码如下:

function isImportUsed(local, sfc) {
  return resolveTemplateUsedIdentifiers(sfc).has(local);
}

前面讲过了local变量的值是ChildresolveTemplateUsedIdentifiers(sfc)返回的是包含Child的set集合,所以resolveTemplateUsedIdentifiers(sfc).has(local)的值是true。也就是isUsedInTemplate变量的值是true,表示当前import导入变量是在template中使用。后面生成return对象时判断是否要将当前import导入加到return对象中,会去读取ctx.userImports[key].isUsedInTemplate属性,其实就是这个isUsedInTemplate变量。

总结

执行compileScript函数会将setup语法糖编译成setup函数,在compileScript函数中会去遍历<script setup>对应的AST抽象语法树。

如果是顶层变量、函数、类、枚举声明,就会将其收集到setupBindings对象中。

如果是import语句,就会将其收集到ctx.userImports对象中。还会根据import导入的信息判断当前import导入是否是ts的类型导入,并且赋值给isType属性。然后再去递归遍历template模块对应的AST抽象语法树,看import导入的变量是否在template中使用,并且赋值给isUsedInTemplate属性。

遍历setupBindings对象和ctx.userImports对象中收集的所有顶层绑定,生成setup函数中的return对象。在遍历ctx.userImports对象的时候有点不同,会去判断当前import导入不是ts的类型导入并且在还在template中使用了,才会将其加到setup函数的return对象中。在我们这个场景中setup函数会返回{ Child }对象。

在render函数中使用$setup["Child"]将子组件渲染到页面上去,而这个$setup["Child"]就是在setup函数中返回的Child属性,也就是Child子组件的引用。

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

与有点儿神奇,原来vue3的setup语法糖中组件无需注册因为这个相似的内容:

有点儿神奇,原来vue3的setup语法糖中组件无需注册因为这个

前言 众所周知,在vue2的时候使用一个vue组件要么全局注册,要么局部注册。但是在setup语法糖中直接将组件import导入无需注册就可以使用,你知道这是为什么呢?注:本文中使用的vue版本为3.4.19。 关注公众号:【前端欧阳】,给自己一个进阶vue的机会 看个demo 我们先来看个简单的d

AI来实现代码转换!Python转Java,Java转Go不在话下?

今天看到个有趣的网站,给大家分享一下。 该网站的功能很神奇,可以实现编程语言的转化。感觉在一些场景之下还是有点作用的,比如你原来跟我一样是做Java的,因为工作需要突然转Go。这个时候用你Java的经验 + 这个工具,或许可以起到一定的帮助作用。 工具的使用也很简单,只需要在左侧黏贴你想转换的原始代

带你揭开神秘的javascript AST面纱之AST 基础与功能

在前端里面有一个很重要的概念,也是最原子化的内容,就是 AST ,几乎所有的框架,都是基于 AST 进行改造运行,比如:React / Vue /Taro 等等。 多端的运行使用,都离不开 AST 这个概念。在大家理解相关原理和背景后,我们可以通过手写简单的编译器,简单实现一个 Javascript 的代码编译器,编译后在浏览器端正常运行。

prometheus描点原理

> 大家好,我是蓝胖子,关于prometheus的入门教程有很多,拿我之前学prometheus的经历来讲,看了很多教程,还是会对prometheus的描点以及背后的统计原理感到迷惑,所以今天我们就来分析下这部分,来揭开其神秘的面纱。 我们先来看看prometheus里的数据模型是怎么样的,只有知道

给picgo上传的图片加个水印

之前给大家介绍了picgo和免费的图床神器。我们本可以开开心心的进行markdown写作了。 但是总是会有那么一些爬虫网站过来爬你的文章,还把你的文章标明是他们的原著。咋办呢?这里有一个好的办法就是把markdown中上传的图片加上自己的水印不就行了。 说干就干。接下来我会介绍如何在picgo中进行

【动画进阶】神奇的背景,生化危机4日食 Loading 动画还原

最近,在 Steam 玩一款老游戏(生化危机 4 重置版),其中,每当游戏转场的过程中,都有这么一个有趣的 Loading 动画: 整个效果有点类似于日食效果,中间一圈黑色,向外散发着太阳般的光芒。 本文,我们将尝试使用 CSS,还原这个效果。 整个效果做出来,类似于如下两个动画效果这样: 实现主体

使用 shell 脚本自动申请进京证 (六环外) —— debug 过程

写好的自动办理六环外进京证脚本跑不通,总是返回办理业务人数较多 (500) 错误,Charles / VNET 抓包、android 交叉编译 jq、升级 curl…都不起作用,最终还是神奇的 adb shell 帮了大忙,最后定位到根因,居然是用 shell 字符串长度作为数据长度导致的,这错误犯的有点低级……

JS语法让人困惑的点 “==与===”

在JS中有很多神奇的语法,非常让人困惑,我们就先一一道来,相信你在开发中或多或少都踩过这些坑,或者让人无法理解。 今天我们就来说下 "`==`" 和 "`===`" 这题对于很多没有系统学过前端开发的技术人员来说,算个重点,来画起来,我们一起看。

《爆肝整理》保姆级系列教程-玩转Charles抓包神器教程(4)-Charles如何设置捕获会话

1.简介 前边几篇宏哥介绍了Charles界面内容以及作用。今天宏哥就讲解和分享如何设置Charles后,我们就可以愉快地捕获会话,进行抓包了。因为上一篇许多小伙伴看到宏哥的Charles可以分开看到request和response,而自己的却看不到,因此有点蒙,有点疑惑。同样的版本显示的界面却是不

微软的148座坟墓

深夜档分享,给大家介绍一个黑白的、“惊悚”的网站! 从名字来看(killed by microsoft),是不是猜到点端倪了? 这个神奇的网站居然收录了微软寿终正寝的那些软件。这是一个免费的开放源码列表,其中列出了已停产的微软服务、产品、设备和应用程序。网站的目标是成为有关微软已死项目历史的真实信息