<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函数外面去了。
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函数。
经过前面的处理allBindings对象中已经收集了setup语法糖中的所有顶层绑定,然后遍历allBindings对象生成setup函数中的return对象。我们在debug终端来看看生成的return对象,如下图:
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++) { // 堆代码 duidaima.com } } } // 。。。省略 }遍历scriptSetupAst.body也就是<script setup>模块中的code代码字符串对应的AST抽象语法树,如果当前节点类型是import导入,就会执行hoistNode函数将当前import导入提升到setup函数外面去。
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代码字符串是什么样的,如下图:
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数组是什么样的,如下图:
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",就会返回导入的名称。
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导入的信息。如下图:
function isImportUsed(local, sfc) { return resolveTemplateUsedIdentifiers(sfc).has(local); }这个local你应该还记得,他的值是Child变量。resolveTemplateUsedIdentifiers(sfc)函数会返回一个set集合,所以has(local)就是返回的set集合中是否有Child变量,也就是template中是否有使用Child组件。
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。
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> 引用。
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变量的值是Child,resolveTemplateUsedIdentifiers(sfc)返回的是包含Child的set集合,所以resolveTemplateUsedIdentifiers(sfc).has(local)的值是true。也就是isUsedInTemplate变量的值是true,表示当前import导入变量是在template中使用。后面生成return对象时判断是否要将当前import导入加到return对象中,会去读取ctx.userImports[key].isUsedInTemplate属性,其实就是这个isUsedInTemplate变量。