简介
随着esmodule规范的发展以及浏览器环境和node环境的支持,我们在构建vue组件时,可以通过将vue组件构建成对应的esm提供到外部使用。然而我们如何自行构建输出esmodule呢?
我们可以通过编写node脚本的方式调用@vue/compilersfc将vue文件输出的module转为esm.
下面我们就来一起看看如何编写node脚本将vue组件输出esm
脚本环境配置
一般我们执行脚本需要使用 node来支持运行构建,需要提前配置node环境,node环境配置大家可自行查找配置,目前我这边使用ts和esm语法编写node脚本的,但是nodejs 不支持typescript语法,node在v12后开始支持esm,node支持ts和执行esm需要做如下配置:
- 安装ts-node
npm install ts-node -D
- 添加node执行命令
// package.json
{
"scripts": {
"build:esm": "node --loader ts-node/esm /xxx/index.ts"
}
}
- 运行esm语法
如果执行的脚本是esm的语法,运行时将脚本所在目录中的package.json做如下配置:
也可将脚本单独打包,作为一个脚手架单独运行
{
"type":"module",
"private": true
}
任务流程创建
整个脚本的执行过程,我们可以按任务流的形式进行处理,整个任务流包括清空输出文件夹(ES_DIR),生成ts的类型信息文件.d.ts,构建module,生成esm 这四个部分;其他更多的执行流程大家可根据自身项目需要自行定义添加:
任务执行的入口需要添加在package.json中我们配置的指定文件中:
// xxx/index.ts
export async function start() {
await removeDir() // 清空输出文件夹
await Promise.all([runTask('types', compileTypes)]) // 生成.d.ts文件
await runTask('module', compileModule) // 生成module转为esm
}
start()
清空输出文件夹
清空输出文件夹用于清除原有的输出的文件内容,用于重新输出。
import fse from 'fs-extra'
const { remove } = fse
export function removeDir() {
/**ES_DIR 输出esm的文件夹路径**/
return Promise.all([remove(ES_DIR)])
}
生成.d.ts文件
基于 Typescript 开发的时候,很麻烦的一个问题就是类型定义。导致在编译的时候,经常会看到一连串的找不到类型的提示。“d.ts”文件用于为 TypeScript 提供有关用 JavaScript 编写的 API 的类型信息。简单讲,就是你可以在 ts 中调用的 js 的声明文件。TS的核心在于静态类型,我们在编写 TS 的时候会定义很多的类型,但是主流的库都是 JS编写的,并不支持类型系统。这个时候你不能用TS重写主流的库,这个时候我们只需要编写仅包含类型注释的 d.ts 文件,然后从您的 TS 代码中,可以在仍然使用纯 JS 库的同时,获得静态类型检查的 TS 优势。
以下代码可供参考:
/*
引入必要的文件读取依赖包
*/
export async function compileTypes() {
await ensureDir(TYPES_DIR)
const { name: libraryName } = readJSONSync(UI_PACKAGE_JSON)
const filenames = await readdir(TYPES_DIR)
const includeFilenames = filenames.filter((filename) => filename !== 'index.d.ts' && filename !== 'global.d.ts')
const exports: string[] = []
const componentDeclares: string[] = []
const directiveDeclares: string[] = []
/** 类型申明收集**/
includeFilenames.forEach((filename) => {
const folder = filename.slice(0, filename.indexOf('.d.ts'))
const name = bigCamelize(folder)
exports.push(`export * from './${folder}'`)
if (directives.includes(folder)) {
directiveDeclares.push(`v${filename}: typeof import('${libraryName}')['_${filename}Component']`)
} else {
componentDeclares.push(`${filename}: typeof import('${libraryName}')['_${filename}Component']`)
}
})
/**组件申明模板**/
const vueDeclares = `\
declare module 'vue' {
export interface GlobalComponents {
${componentDeclares.join('\n ')}
}
/**组件类型定义模板**/
export interface ComponentCustomProperties {
${directiveDeclares.join('\n ')}
}
}`
/** 输出申明d.ts文件 **/
const template = `\
import type { App } from 'vue'
export const version: string
export const install: (app: App) => void
${exports.join('\n')}
${vueDeclares}
`
await writeFile(resolve(TYPES_DIR, `${filename}.d.ts`), template)
}
创建block
创建block主要依赖@vue/compile-sfc模块,下面我们可以看一下sfc处理机制
-
整体流程
image.png
我们知道一个vue文件,通常包括template, script, style这三大部分,然而vue文件是不能被直接执行的,这就需要将vue文件通过编译处理,vue文件编译通常包括三大部分
- @vue/compiler-sfc:该包是用于处理单文件组件(SFC)的编译器。它依赖于 @vue/compiler-core 和 @vue/compiler-dom,可以将 SFC 中的模板、脚本和样式分别编译成渲染函数、模块和 CSS 代码。
- @vue/compiler-dom:该包提供了特定于浏览器的编译器,将 AST 抽象语法树转换为浏览器可执行的代码,以生成 DOM 元素。相对于 @vue/compiler-core,它包含浏览器特定的逻辑和属性处理,例如事件处理和样式绑定等。
- @vue/compiler-ssr: 该包提供了特定于服务端渲染的编译器,依赖于vue/compiler-dom提供的基础编译功能
- @vue/compiler-core:是 Vue.js 编译器的核心包,与平台无关,提供了编译器的核心逻辑和 AST 抽象语法树的数据结构。
下边我们可以用一下代码作为示例,看下输出的block结果
<template>
<div class="red">{{ helloWorld }}</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
const helloWorld = ref('hello world!')
</script>
<style lang="less" scoped>
.red {
color: red;
}
</style>
- 入口解析
sfc主要包括了compileTemplate , compileScript, compileStyle 这几部分,然而它的入口是从parse开始的
parse是将一个vue文件拆按 template, script, style 三部分进行拆分生成 templateBlock,scriptBlock, styleBlock,将Block处理的结果添加到descriptor进行返回
- Parse是整体的入口,通过调用parseChildren,createBlock
- parseChildren的作用是将 模板字符串截取匹配一步步生成标签,文本,注释,vue模板语法{{}}或者属性等节点,然后将其抽象转换成 各自对应的AST,最终标记区分出一个vue文件的各个部分组成,以及所在位置 loc。具体的parseChildren的实现细节大家可以去看(@vue/compiler-core/parse)文件学习
- AST是抽象语法树 和 Vnode类似,都是使用JavaScript对象来描述节点的树状表现形式,
以上述示例,生成的AST基本结构如下:
// AST结构
{
type: 0,
children: [{
type: 1,
ns: 0,
tag: 'template',
tagType: 0,
props: [],
isSelfClosing: false,
children: [Array],
loc: [Object],
codegenNode: undefined
},{
type: 1,
ns: 0,
tag: 'script',
tagType: 0,
props: [Array],
isSelfClosing: false,
children: [Array],
loc: [Object],
codegenNode: undefined
},{
type: 1,
ns: 0,
tag: 'style',
tagType: 0,
props: [Array],
isSelfClosing: false,
children: [Array],
loc: [Object],
codegenNode: undefined
}
],
helpers: Set(0) {},
components: [],
directives: [],
hoists: [],
imports: [],
cached: 0,
temps: 0,
codegenNode: undefined,
loc: {
start: { column: 1, line: 1, offset: 0 },
end: { column: 9, line: 15, offset: 277 },
source: '<template>\r\n' +
' <div class="red">\r\n' +
' {{ helloWorld }}\r\n' +
' </div>\r\n' +
'</template>\r\n' +
'\r\n' +
'<script lang="ts" setup>\r\n' +
" import { ref } from 'vue';\r\n" +
" const helloWorld = ref('hello world!')\r\n" +
'</script>\r\n' +
'<style lang="less" scoped>\r\n' +
' .red {\r\n' +
' color: red;\r\n' +
' }\r\n' +
'</style>'
}
}
- createBlock 根据ast的loc定位,从source截取content,然后针对block对节点属性props处理,比如style中用了scoped,script用了setup
- descriptor D是最终block的挂载目标对象,里包含了 script, scriptSetup, template, styles几个部分,最终descriptor需要通过compilerTemplate, compilerScript,compilerStyle处理
以下为parse入口的简化后代码
export function parse(
source: string,
{}
) {
const ast = compiler.parse(source, {}) // 最终调用parseChildren
ast.children.forEach(node => {
switch (node.tag) {
case 'template':
const templateBlock = (descriptor.template = createBlock())
templateBlock.ast = node
case 'script':
const scriptBlock = createBlock(node, source, pad) as SFCScriptBlock
descriptor.scriptSetup = scriptBlock
case 'style':
const styleBlock = createBlock(node, source, pad) as SFCStyleBlock
descriptor.styles.push(styleBlock)
break
default:
descriptor.customBlocks.push(createBlock(node, source, pad))
break
}
})
// parse CSS vars
descriptor.cssVars = parseCssVars(descriptor)
// check if the SFC uses :slotted
const slottedRE = /(?:::v-|:)slotted\(/
descriptor.slotted = descriptor.styles.some(
s => s.scoped && slottedRE.test(s.content)
)
const result = {descriptor,}
return result
}
- 生成产物
通过入口parse->ast->createBlock,最终生成了vue文件中的各个部分的block,以上述提供的示例,我们可以看到最终生成的各个部分block
- templateBlock
// templateBlock
{
type: 'template',
content: '\r\n<div class="red">\r\n{{ helloWorld }}\r\n</div>\r\n',
loc: {
source: '\r\n <div class="red">\r\n {{ helloWorld }}\r\n</div>\r\n',
start: { column: 11, line: 1, offset: 10 },
end: { column: 1, line: 5, offset: 73 }
},
attrs: {}
}
- scriptBlock
// scriptBlock
{
type: 'script',
content: '\r\n' +
" import { ref } from 'vue';\r\n" +
" const helloWorld = ref('hello world!')\r\n",
loc: {
source: '\r\n' +
" import { ref } from 'vue';\r\n" +
" const helloWorld = ref('hello world!')\r\n",
start: { column: 25, line: 7, offset: 112 },
end: { column: 1, line: 10, offset: 190 }
},
attrs: { lang: 'ts', setup: true },
lang: 'ts',
setup: true
}
- styleBlock
// styleBlock
{
type: 'style',
content: '\r\n .red {\r\n color: red;\r\n }\r\n',
loc: {
source: '\r\n .red {\r\n color: red;\r\n }\r\n',
start: { column: 27, line: 11, offset: 227 },
end: { column: 1, line: 15, offset: 269 }
},
attrs: { lang: 'less', scoped: true },
lang: 'less',
scoped: true
}
block合成输出esm
从上面我们知道了compile-sfc处理后的产物是四种类型的block,其中block挂载到了descriptor对象上,
下面我们就来看如何将block处理成可以运行的esm文件,
将block编译成esm的主要方法包括
compileScript, compileTemplate, compileStyle ,
从上边流程图中我们可以看到,
templateblock通过compileTemplate调用transform和generate最终生成render函数注入到scriptContent
- transform 阶段,核心的逻辑就是识别一个个的 Vue的语法,并且进行编译器的优化,我们经常提到的静态标记就是这一步完成的,最后生成一个带codegenNode字段的 AST
- generate代码生成器的作用是通过 AST 语法树生成代码字符串,代码字符串被包装进render函数返回。
styleblock通过compileStyle最终生成styles列表,在styles列表中检测是否有scoped属性,如果有就将其注入到scriptContent中实现单文件组件的样式属性关联,同时输出css文件
scriptblock通过descriptort获取到scriptContent,接注入scopedId和render函数,最终通过compileScript 调用 babel/transform将其编译成可运行的js代码,在babel/transform中可以配置相关插件来实现语言转换,例如将ts转为js可以在这里配置babel插件
将通过babel输出后的code,最后通过依赖替换和样式依赖替换最终输出esm,完成单文件组件编译和打包
代码脚本基本结构如下:
export async function compileSFC(sfc: string) {
const sources: string = await readFile(sfc, 'utf-8')
const id = hash(sources)
const { descriptor } = parseSFC(sources, { sourceMap: false })
const { script, scriptSetup, template, styles } = descriptor
let scriptContent
let bindingMetadata
if (script || scriptSetup) {
scriptContent = script!.content
}
}
// scoped
const hasScope = styles.some((style) => style.scoped)
const scopeId = hasScope ? `data-v-${id}` : ''
if (template) {
const render = compileTemplate({id,source: template.content,}).code
scriptContent = injectRender(scriptContent, render)
}
if (scopeId) {
scriptContent = injectScopeId(scriptContent, scopeId)
}
scriptContent = injectExport(scriptContent)
await compileScript(scriptContent, sfc)
// style
for (let index = 0; index < styles.length; index++) {
let { code } = compileStyle({source: style.content,filename: file,
id: scopeId,scoped: style.scoped,})
code = extractStyleDependencies(file, code, STYLE_IMPORT_RE)
writeFileSync(file, clearEmptyLine(code), 'utf-8')
if (style.lang === 'less') {
await compileLess(file)
}
}
}
总结
通过以上node构建vue单文件组件包的学习,对vue单文件的运行原理有了一些认识,我们能够通过自己编写node脚本来实现vue组件输出模式,以上按esm的方式输出,大家可以参考以上流程构建自己的node脚本,输出自己需要的组件包,以上流程存在不正确的地方,望大家指正!