Vue3组件库打包指南,一次生成esm、esm-bundle、commonjs、umd四种格式

本文为Varlet组件库源码主题阅读系列第二篇,读完本篇,你可以了解到如何将一个Vue3组件库打包成各种格式

上一篇里提到了启动服务前会先进行一下组件库的打包,运行的命令为:

varlet-cli compile

显然是varlet-cli包提供的一个命令:

Vue3组件库打包指南,一次生成esm、esm-bundle、commonjs、umd四种格式_第1张图片

处理函数为compile,接下来我们详细看一下这个函数都做了什么。

// varlet-cli/src/commands/compile.ts
export async function compile(cmd: { noUmd: boolean }) {
    process.env.NODE_ENV = 'compile'
    await removeDir()
    // ...
}

// varlet-cli/src/commands/compile.ts
export function removeDir() {
    // ES_DIR:varlet-ui/es
    // LIB_DIR:varlet-ui/lib
    // HL_DIR:varlet-ui/highlight
    // UMD_DIR:varlet-ui/umd
    return Promise.all([remove(ES_DIR), remove(LIB_DIR), remove(HL_DIR), remove(UMD_DIR)])
}

首先设置了一下当前的环境变量,然后清空相关的输出目录。

// varlet-cli/src/commands/compile.ts
export async function compile(cmd: { noUmd: boolean }) {
    // ...
    process.env.TARGET_MODULE = 'module'
    await runTask('module', compileModule)

    process.env.TARGET_MODULE = 'esm-bundle'
    await runTask('esm bundle', () => compileModule('esm-bundle'))

    process.env.TARGET_MODULE = 'commonjs'
    await runTask('commonjs', () => compileModule('commonjs'))

    process.env.TARGET_MODULE = 'umd'
    !cmd.noUmd && (await runTask('umd', () => compileModule('umd')))
}

接下来依次打包了四种类型的产物,方法都是同一个compileModule,这个方法后面会详细分析。

组件的基本组成

Button组件为例看一下未打包前的组件结构:

Vue3组件库打包指南,一次生成esm、esm-bundle、commonjs、umd四种格式_第2张图片

一个典型组件的构成主要是四个文件:

.less:样式

.vue:组件

index.ts:导出组件,提供组件注册方法

props.ts:组件的props定义

样式部分Varlet使用的是less语言,样式比较少的话会直接内联写到Vue单文件的style块中,否则会单独创建一个样式文件,比如图中的button.less,每个组件除了引入自己本身的样式外,还会引入一些基本样式、其他组件的样式:

Vue3组件库打包指南,一次生成esm、esm-bundle、commonjs、umd四种格式_第3张图片

index.ts文件用来导出组件,提供组件的注册方法:

Vue3组件库打包指南,一次生成esm、esm-bundle、commonjs、umd四种格式_第4张图片

props.ts文件用来声明组件的props类型:

Vue3组件库打包指南,一次生成esm、esm-bundle、commonjs、umd四种格式_第5张图片

有的组件没有使用.vue,而是.tsx,也有些组件会存在其他文件,比如有些组件就还存在一个provide.ts文件,用于向子孙组件注入数据。

打包的整体流程

首先大致过一遍整体的打包流程,主要函数为compileModule

// varlet-cli/src/compiler/compileModule.ts
export async function compileModule(modules: 'umd' | 'commonjs' | 'esm-bundle' | boolean = false) {
  if (modules === 'umd') {
    // 打包umd格式
    await compileUMD()
    return
  }

  if (modules === 'esm-bundle') {
    // 打包esm-bundle格式
    await compileESMBundle()
    return
  }
    
  // 打包commonjs和module格式
  // 打包前设置一下环境变量
  process.env.BABEL_MODULE = modules === 'commonjs' ? 'commonjs' : 'module'
  // 输出目录
  // ES_DIR:varlet-ui/es
  // LIB_DIR:varlet-ui/lib
  const dest = modules === 'commonjs' ? LIB_DIR : ES_DIR
  // SRC_DIR:varlet-ui/src,直接将组件的源码目录复制到输出目录
  await copy(SRC_DIR, dest)
  // 读取输出目录
  const moduleDir: string[] = await readdir(dest)
  // 遍历打包每个组件
  await Promise.all(
    // 遍历每个组件目录
    moduleDir.map((filename: string) => {
      const file: string = resolve(dest, filename)
      if (isDir(file)) {
        // 在每个组件目录下新建两个样式入口文件
        ensureFileSync(resolve(file, './style/index.js'))
        ensureFileSync(resolve(file, './style/less.js'))
      }
      // 打包组件
      return isDir(file) ? compileDir(file) : null
    })
  )
  // 遍历varlet-ui/src/目录,找出所有存在['index.vue', 'index.tsx', 'index.ts', 'index.jsx', 'index.js']这些文件之一的目录
  const publicDirs = await getPublicDirs()
  // 生成整体的入口文件
  await (modules === 'commonjs' ? compileCommonJSEntry(dest, publicDirs) : compileESEntry(dest, publicDirs))
}

umdesm-bundle两种格式都会把所有内容都打包到一个文件,用的是Vite提供的方法进行打包。

commonjsmodule是单独打包每个组件,不会把所有组件的内容都打包到一起,Vite没有提供这个能力,所以需要自行处理,具体操作为:

  • 先把组件源码目录varlet/src/下的所有组件文件都复制到对应的输出目录下;
  • 然后在输出目录遍历每个组件目录:

    • 创建两个样式的导出文件;
    • 删除不需要的目录、文件(测试、示例、文档);
    • 分别编译Vue单文件、ts文件、less文件;
  • 全部打包完成后,遍历所有组件,动态生成整体的导出文件;

compileESEntry方法为例看一下整体导出文件的生成:

// varlet-cli/src/compiler/compileScript.ts
export async function compileESEntry(dir: string, publicDirs: string[]) {
  const imports: string[] = []
  const plugins: string[] = []
  const constInternalComponents: string[] = []
  const cssImports: string[] = []
  const lessImports: string[] = []
  const publicComponents: string[] = []
  // 遍历组件目录名称
  publicDirs.forEach((dirname: string) => {
    // 连字符转驼峰式
    const publicComponent = bigCamelize(dirname)
    // 收集组件名称
    publicComponents.push(publicComponent)
    // 收集组件导入语句
    imports.push(`import ${publicComponent}, * as ${publicComponent}Module from './${dirname}'`)
    // 收集内部组件导入语句
    constInternalComponents.push(
      `export const _${publicComponent}Component = ${publicComponent}Module._${publicComponent}Component || {}`
    )
    // 收集插件注册语句
    plugins.push(`${publicComponent}.install && app.use(${publicComponent})`)
    // 收集样式导入语句
    cssImports.push(`import './${dirname}/style'`)
    lessImports.push(`import './${dirname}/style/less'`)
  })

  // 拼接组件注册方法
  const install = `
function install(app) {
  ${plugins.join('\n  ')}
}
`

  // 拼接导出入口index.js文件的内容,注意它是不包含样式的
  const indexTemplate = `\
${imports.join('\n')}\n
${constInternalComponents.join('\n')}\n
${install}
export {
  install,
  ${publicComponents.join(',\n  ')}
}

export default {
  install,
  ${publicComponents.join(',\n  ')}
}
`
  
  // 拼接css导入语句
  const styleTemplate = `\
${cssImports.join('\n')}
`

  // 拼接umdIndex.js文件,这个文件是用于后续打包umd和esm-bundle格式时作为打包入口,注意它是包含样式导入语句的
  const umdTemplate = `\
${imports.join('\n')}\n
${cssImports.join('\n')}\n
${install}
export {
  install,
  ${publicComponents.join(',\n  ')}
}

export default {
  install,
  ${publicComponents.join(',\n  ')}
}
`

  // 拼接less导入语句
  const lessTemplate = `\
${lessImports.join('\n')}
`
  // 将拼接的内容写入到对应文件
  await Promise.all([
    writeFile(resolve(dir, 'index.js'), indexTemplate, 'utf-8'),
    writeFile(resolve(dir, 'umdIndex.js'), umdTemplate, 'utf-8'),
    writeFile(resolve(dir, 'style.js'), styleTemplate, 'utf-8'),
    writeFile(resolve(dir, 'less.js'), lessTemplate, 'utf-8'),
  ])
}

打包成module和commonjs格式

打包成umdesm-bundle两种格式依赖module格式的打包产物,而打包成modulecommonjs两种格式是同一套逻辑,所以我们先来看看是如何打包成这两种格式的。

这两种格式就是单独打包每个组件,生成单独的入口文件和样式文件,然后再生成一个统一的导出入口,不会把所有组件的内容都打包到同一个文件,方便按需引入,去除不需要的内容,减少文件体积。

打包每个组件的compileDir方法:

// varlet-cli/src/compiler/compileModule.ts
export async function compileDir(dir: string) {
  // 读取组件目录
  const dirs = await readdir(dir)
  // 遍历组件目录下的文件
  await Promise.all(
    dirs.map((filename) => {
      const file = resolve(dir, filename)
      // 删除组件目录下的__test__目录、example目录、docs目录
      ;[TESTS_DIR_NAME, EXAMPLE_DIR_NAME, DOCS_DIR_NAME].includes(filename) && removeSync(file)
      // 如果是.d.ts文件或者是style目录(前面为样式入口文件创建的目录)直接返回
      if (isDTS(file) || filename === STYLE_DIR_NAME) {
        return Promise.resolve()
      }
      // 编译文件
      return compileFile(file)
    })
  )
}

删除了不需要的目录,然后针对需要编译的文件调用了compileFile方法:

// varlet-cli/src/compiler/compileModule.ts
export async function compileFile(file: string) {
  isSFC(file) && (await compileSFC(file))// 编译vue文件
  isScript(file) && (await compileScriptFile(file))// 编译js文件
  isLess(file) && (await compileLess(file))// 编译less文件
  isDir(file) && (await compileDir(file))// 如果是目录则进行递归
}

分别处理三种文件,让我们一一来看。

编译Vue单文件

// varlet-cli/src/compiler/compileSFC.ts
import { parse } from '@vue/compiler-sfc'

export async function compileSFC(sfc: string) {
    // 读取Vue单文件内容
    const sources: string = await readFile(sfc, 'utf-8')
    // 使用@vue/compiler-sfc包解析单文件
    const { descriptor } = parse(sources, { sourceMap: false })
    // 取出单文件的每部分内容
    const { script, scriptSetup, template, styles } = descriptor
    // Varlet暂时不支持setup语法
    if (scriptSetup) {
        logger.warning(
            `\n Varlet Cli does not support compiling script setup syntax\
\n  The error in ${sfc}`
        )
        return
    }
    // ...
}

使用@vue/compiler-sfc包来解析Vue单文件,parse方法可以解析出Vue单文件中的各个块,针对各个块,@vue/compiler-sfc包都提供了相应的编译方法,后续都会涉及到。

// varlet-cli/src/compiler/compileSFC.ts
import hash from 'hash-sum'

export async function compileSFC(sfc: string) {
    // ...
    // scoped
    // 检查是否存在scoped作用域的样式块
    const hasScope = styles.some((style) => style.scoped)
    // 将单文件的内容进行hash生成id
    const id = hash(sources)
    // 生成样式的scopeId
    const scopeId = hasScope ? `data-v-${id}` : ''
    // ...
}

这一步主要是检查style块是否存在作用域块,存在的话会生成一个作用域id,作为css的作用域,防止和其他样式冲突,这两个id相关的编译方法需要用到。

// varlet-cli/src/compiler/compileSFC.ts
import { compileTemplate } from '@vue/compiler-sfc'

export async function compileSFC(sfc: string) {
    // ...
    if (script) {
        // template
        // 编译模板为渲染函数
        const render =
              template &&
              compileTemplate({
                  id,
                  source: template.content,
                  filename: sfc,
                  compilerOptions: {
                      scopeId,
                  },
              })
    // 注入render函数
        let { content } = script
        if (render) {
            const { code } = render
            content = injectRender(content, code)
        }
        // ...
    }
}

使用@vue/compiler-sfc包的compileTemplate方法将解析出的模板部分编译为渲染函数,然后调用injectRender方法将渲染函数注入到script中:

// varlet-cli/src/compiler/compileSFC.ts
const NORMAL_EXPORT_START_RE = /export\s+default\s+{/
const DEFINE_EXPORT_START_RE = /export\s+default\s+defineComponent\s*\(\s*{/

export function injectRender(script: string, render: string): string {
  if (DEFINE_EXPORT_START_RE.test(script.trim())) {
    return script.trim().replace(
      DEFINE_EXPORT_START_RE,
      `${render}\nexport default defineComponent({
  render,\
    `
    )
  }
  if (NORMAL_EXPORT_START_RE.test(script.trim())) {
    return script.trim().replace(
      NORMAL_EXPORT_START_RE,
      `${render}\nexport default {
  render,\
    `
    )
  }
  return script
}

兼容两种导出方式,以一个小例子来看一下,比如生成的渲染函数为:

export function render(_ctx, _cache) {
    // ...
}

script的内容为:

export default defineComponent({
    name: 'VarButton',
    // ...
})

注入renderscript的内容变成了:

export function render(_ctx, _cache) {
    // ...
}
export default defineComponent({
    render,
    name: 'VarButton',
    /// ...
})

其实就是把渲染函数的内容和script的内容合并了,script其实就是组件的选项对象,所以同时也把组件的渲染函数添加到组件对象上。

继续compileSFC方法:

// varlet-cli/src/compiler/compileSFC.ts
import { compileStyle } from '@vue/compiler-sfc'

export async function compileSFC(sfc: string) {
    // ...
    if (script) {
        // ...
        // script
        // 编译js
        await compileScript(content, sfc)
        // style
        // 编译样式
        for (let index = 0; index < styles.length; index++) {
          const style: SFCStyleBlock = styles[index]
          // replaceExt方法接收文件名称,比如xxx.vue,然后使用第二个参数替换文件名称的扩展名,比如处理完会返回xxxSfc.less
          const file = replaceExt(sfc, `Sfc${index || ''}.${style.lang || 'css'}`)
          // 编译样式块
          let { code } = compileStyle({
            source: style.content,
            filename: file,
            id: scopeId,
            scoped: style.scoped,
          })
          // 去除样式中的导入语句
          code = extractStyleDependencies(file, code, STYLE_IMPORT_RE, style.lang as 'css' | 'less', true)
          // 将解析后的样式写入文件
          writeFileSync(file, clearEmptyLine(code), 'utf-8')
          // 如果样式块是less语言,那么同时也编译成css文件
          style.lang === 'less' && (await compileLess(file))
        }
    }
}

调用了compileScript方法编译script内容,这个方法我们下一小节再说。然后遍历style块,每个块都会生成相应的样式文件,比如Button.vue组件存在一个less语言的style

Vue3组件库打包指南,一次生成esm、esm-bundle、commonjs、umd四种格式_第6张图片

那么会生成一个ButtonSfc.less,因为是less,所以同时也会再编译生成一个ButtonSfc.css文件,当然这两个样式文件里只包括内联在Vue单文件中的样式,不包括使用@import导入的样式,所以生成的这两个样式文件都是空的:

Vue3组件库打包指南,一次生成esm、esm-bundle、commonjs、umd四种格式_第7张图片

编译样式块使用的是@vue/compiler-sfccompileStyle方法,它会帮我们处理