node脚本构建vue3组件输出esm

简介

随着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进行返回

image.png
  • 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脚本,输出自己需要的组件包,以上流程存在不正确的地方,望大家指正!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,406评论 6 503
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,732评论 3 393
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,711评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,380评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,432评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,301评论 1 301
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,145评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,008评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,443评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,649评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,795评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,501评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,119评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,731评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,865评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,899评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,724评论 2 354

推荐阅读更多精彩内容