组件库工程化环境设计(三):组件库编译硬核优化,三万字带你手写 compiler

588 阅读12分钟

前言

自动化工具?npm 发包?样式按需引入?组件及样式自动引入?组件文档?单元测试?听着有点熟悉又带点陌生😱。。。别急,阅读本系列文章:将从工程化角度带你从0到1实现一个组件库,一站到底!

源码地址:github.com/Devil-Train…

上篇:组件库工程化环境设计(二):样式按需引入?真的没那么简单

阅读完本篇,你的组件库将具有以下特点:

  • 使用 rollup 作为打包工具 ✅
  • 支持 babel 和 esbuild 两种构建方式✅🆕
  • 支持 cjs、esm 和浏览器直接引入✅
  • 支持组件样式按需引入✅🆕
  • 自动引入☑️
  • 接入eslint、commitlint 等静态检测工具✅
  • 能够进行 npm 发包和产出 changelog☑️
  • 提供组件文档和组件示例☑️
  • 接入单元测试☑️

流程

先简单回顾上篇出现的三个问题:

image.png

打包速度慢还有优化空间,组件库内部公共组件chunk问题却无法解决,公共 css 重复打包方案还有待确认。

决定着手写一个 compiler,思路如下:

  1. 组件库会提供两种 esm / cjs 两种模块化方案的按需引入,为了方便文件路径区分,在 build 之前将 src 目录分别复制到 es / lib 目录。
  2. 组件库需要打包成 esm / cjs / umd 格式,同时样式需要单独打包,所以要依据 src/packages下组件目录来生成打包入口
  3. 生成类型文件可以使用 vue-tsc 生成 .d.ts 文件。
  4. 样式文件要支持按需引入, 每一个组件都需要生成单独样式入口文件。
  5. 编译整个目录下的文件。
  6. 依据 2 生成的打包入口打包整个组件库。

image.png

复制源文件

  • 组件库会提供两种 esm / cjs 两种模块化方案的按需引入,为了方便文件路径区分,在 build 之前将 src 目录分别复制到 es / lib 目录。

这里可以思考一下为什么需要复制

  • 不用处理产物路径转换,后续编译时,css / js / .d.ts 等文件可以换下扩展名直接原路径输出。
  • 不用担心污染源文件。
  • 可以杜绝中间产物命名冲突。

fs-extra库复制源文件夹,考虑到后续可能会接入组件文档和测试,并不是目录下所有文件都需要编译,需要过滤掉一些文件:

import { copy, remove } from 'fs-extra';
import { CJS_DIR, ES_DIR, IGNORE_DIRS_RE, SRC_DIR } from '../common/constant.js';
async function copySource() {
  await Promise.all([remove(ES_DIR), remove(CJS_DIR)]);
  await Promise.all(
    [copy(SRC_DIR, ES_DIR, { filter: copyFilter })],
    copy(SRC_DIR, CJS_DIR, { filter: copyFilter }),
  );
}
function copyFilter(src) {
  return ! IGNORE_DIRS_RE.test(src);
}

这里的 CJS_DIR, ES_DIR, IGNORE_DIRS_RE, SRC_DIR都是些常量,可能会在整个编译过程中用到:

// build/common/constants.js
export const ES_DIR = 'es';
export const CJS_DIR = 'lib';
export const SRC_DIR = 'src';
export const PACKAGE_NAME = 'virtual-scroll-list';
export const GLOBAL_NAME = 'VirtualScrollList';
export const IGNORE_DIRS = ['docs', 'example'];
export const IGNORE_DIRS_RE = new RegExp(`(\/|\\)(${IGNORE_DIRS.join('|')})`);
CJS_DIRES_DIRIGNORE_DIRS_RESRC_DIR
cjs 目录,这里为 libesm 目录,这里为 es过滤不需要复制文件的RE源目录

产出

src 下的资源将复制到 es 目录,后续打包都会以 es 目录来示例

生成整体打包入口

  • 组件库需要打包成 esm / cjs / umd 格式,同时样式需要单独打包,所以要依据 src/packages下组件目录来生成打包入口。

生成样式整体打包入口

核心思路就是遍历 es/packages 目录导入每一个组件样式入口文件:

import { glob } from 'glob';
async function genESModuleEntryTemplate(options) {
  const { dir, ext } = options;
  const styleImports = [];
  const componentPaths = await glob(`${dir}/packages/*/`);

  componentPaths.forEach(componentPath => {
    const componentName = basename(componentPath);
    styleImports.push(`import './packages/${componentName}/style/index${ext}';`);
  });
  const styleTemplate = `
${styleImports.join('\n')}
  `;
  return {
    styleTemplate,
  };
}

这里的组件样式入口文件现在还不存在,将在第四步生成,路径会在组件同级目录下的 style/index.xx。如./packages/${componentName}/style/index${ext}'

其中 style.mjs

生成组件整体打包入口

其实 js 入口文件应该动态生成比较合适,生成思路和样式入口文件类似。我这里 js 入口文件是手动维护的,es 目录下的 index.ts 即入口:

import type { Component, App } from 'vue';

import { DynamicList } from './packages/dynamic-list';
import { FixedSizeList } from './packages/fixed-size-list';
import type { WithInstall } from './utils';

const components : Component[] = [FixedSizeList, DynamicList];

export const install = (app : unknown) => {
  components.forEach((component : Component) => {
    (component as WithInstall<Component>).install(app as App);
  });
};

export * from './packages/fixed-size-list';
export * from './packages/dynamic-list';
export default {
  install,
};

这里的 install 会去调用每一个组件的 install 方法来全局注册组件:

export type WithInstall<T> = T & {
  install(app : App) : void;
};
// 组件 install 方法
export function withInstall<T extends Component>(options : T) : WithInstall<T> {
  (options as WithInstall<T>).install = (app : App) => {
    const { name } = options;
    if ( ! name) return;
    app.component(name, options);
    app.component(camelize(name), options);
  };
  return options as WithInstall<T>;
}

产出

当完成这一步后 es 目录下会有 js 和 css 两个打包入口文件:

后续我们就可以利用这两个入口来整体打包组件库了。

声明类型

  • 生成类型文件可以使用 vue-tsc 生成 .d.ts 文件。

这一步借助 vue-tsc来完成,在项目根目录创建专门用来生成声明文件的 tsconfig.declaration.json

{
  "extends": "./tsconfig.json",
  "include": [
    "es/**/*.ts",
    "lib/**/*.ts",
  ], // 包含的文件, 
  "exclude": [], // 排除的文件
  "compilerOptions": {
    // "rootDir": "./", // 指定代码的根目录,默认情况下编译后文件的目录结构会以最长的公共目录为根目录,通过rootDir可以手动指定根目录
    "declaration": true, // 自动生成声明文件
    "declarationDir": ".", // 输出时声明文件的文件夹
    "outDir": ".",
    "emitDeclarationOnly": true, // 只输出声明文件
    "allowJs": false,
  }
}

注意配置:

  • "declarationDir": ".":原路径产出声明文件, 得益于 复制源文件 的复制策略,不需要为 es 和 lib 单独配置。
  • "emitDeclarationOnly": true:只输出声明文件。

使用 node 子进程来执行 vue-tsc命令:

import { execSync } from 'child_process';
// 生成类型
export async function compileTypes() {
  const decPath = './tsconfig.declaration.json';
  execSync(`vue-tsc -p ${decPath}`, {
    stdio: 'inherit',
    shell: true,
  });
}

产出

这一步产出 .d.ts 类型声明文件,vue-tsc 是对 tsc 的封装,ts 文件也会随之一起处理。

组件样式入口

  • 样式文件要支持按需引入,每一个组件都需要生成单独样式入口文件。

为了解决公共样式 chunk 问题,每一个组件都要有独立的样式入口文件才行,一个组件的样式可能有几种情况:

  • 引用公共样式
  • 组件本身样式
  • 引用其他组件样式

需要将这三种情况包含的样式全部在入口文件引用,需注意:公共样式应该能被组件样式覆盖,应该优先引用

如何知道该组件到底需要引用那些样式呢?

公共样式引入

公共样式在组件入口文件里引入:

import { withInstall } from '../../utils';

import _DynamicList from './dynamic-list.vue';
import '../../styles/base.scss';
import '../../styles/animate.css';

export const DynamicList = withInstall(_DynamicList);
export default DynamicList;

declare module 'vue' {
  export interface GlobalComponents {
    DynamicList : typeof DynamicList;
  }
}

我们需要做的就是在处理入口 ts 时匹配 css 导入语句,然后写入 css 入口文件,最后在入口 ts 里移除,这样就实现了样式和组件打包互不干扰,公共样式也就不会和组件样式被打包在一起了,公共样式 chunk 问题得以解决。

提取样式导入语句

提取的样式导入语句会写入当前组件目录下的 style/index.mjs:

export const IMPORT_STYLE_RE =
  /(?<!['"`])import\s+['"](\.{1,2}\/.+((\.css)|(\.scss)))['"]\s*;?(?!\s*['"`])/g;

export function extractStyleDependencies(filePath, code, styleReg, format) {
  const cssFilePath = `${path.dirname(filePath)}/style/index${path.extname(filePath)}`;
  if (!existsSync(cssFilePath)) { // 如果样式入口不存在,即跳过
    return code;
  }
  let cssFile = readFileSync(cssFilePath, 'utf-8');
  const styleImports = code.match(styleReg) ?? [];
  const newImports = [];
  styleImports.forEach(styleImport => {
    const normalizePath = normalizeStyleDependency(styleImport, styleReg); // 标准化样式导入,styleReg == IMPORT_STYLE_RE 用来匹配导入语句
    newImports.push(
      format === 'es' ? `import '${normalizePath}.css';\n` : `require('${normalizePath}.css');\n`,
    );
  });
  cssFile = newImports.join('') + cssFile;
  writeFileSync(cssFilePath, cssFile); // 将匹配到的 样式导入语句 写入 样式入口文件
  return code.replace(IMPORT_STYLE_RE, ''); // 移除 js入口文件 中的 样式导入语句
}

normalizeStyleDependency 来匹配导入语句并标准化:

export const IMPORT_STYLE_RE =
  /(?<!['"`])import\s+['"](\.{1,2}\/.+((\.css)|(\.scss)))['"]\s*;?(?!\s*['"`])/g;

export function normalizeStyleDependency(styleImport, styleReg) {
  styleImport = styleImport.replace(styleReg, '$1');
  styleImport = styleImport.replace(/(.scss) | (.css)/, '');
  styleImport = '../' + styleImport;
  return styleImport;
}

import '../../styles/base.scss' ------> '../../../styles/base'

在经过这一步转化后,js 入口样式导入语句被移除,生成了 style/index.mjs 样式入口并写入了公共样式导入语句:

index.ts

import { withInstall } from '../../utils';

import _DynamicList from './dynamic-list.vue';

export const DynamicList = withInstall(_DynamicList);
export default DynamicList;

declare module 'vue' {
  export interface GlobalComponents {
    DynamicList : typeof DynamicList;
  }
}

style/index.mjs

import '../../../styles/base.css';
import '../../../styles/animate.css';

其他组件样式引入

组件内引用其他组件可能是这样的:

  import { ListItem } from '../list-item';
  import type { FixedSizeListEmits } from '../fixed-size-list/props';

扩展名可以被省略,因此很难去分辨是否是一个组件导入语句,我们可以这样约定,在 style 代码块里通过 @import 形式引用所有样式文件,包括组件本身样式:

<style scoped>
  @import  url (../list-item/style/index);
</style>

但是我没有选择这种方式,@import 为原生 css 的引入方式,导入的样式文件不会受 scoped 影响,很容易被误解。

最终选择维护一个字典:

{
  "dynamic-list": ["list-item"],
  "fixed-size-list": ["list-item"],
  "list-item": []
}

依据字典在 style/index.mjs 添加对应组件样式导入语句。

async function genComponentStyle(dir, format) {
  const componentPaths = await glob(`${dir}/packages/*/`);
  componentPaths.forEach(async line => {
    const component = path.basename(line);
    const deps = getDeps(component); // 获取自字典
    let content = deps
      .map(dep =>
        format === 'es'
          ? `import '../../${dep}/${dep}.css';\n`
          : `require('../../${dep}/${dep}.css');\n`,
      )
      .join('');
    await outputFile(`${line}/style/index${jsFileExt(format)}`, content);
  });
}

这一步后,被引用组件的样式加入 style/index.mjs:

import  '../../../styles/base.css'; 
import  '../../../styles/animate.css'; 
import  '../../list-item/list-item.css'; 

组件本身样式引入

组件本身样式可能写在 .vue 文件中,也可能在组件的同级目录,在处理引用其他组件样式时,在字典里加入组件本身:

function getDeps(component) {
  const deps = styleDeps[component].slice(0);
  deps.push(component);
  return deps;
}
async function genComponentStyle(dir, format) {
  const componentPaths = await glob(`${dir}/packages/*/`);
  componentPaths.forEach(async line => {
    const component = path.basename(line);
    const deps = getDeps(component);
    let content = deps
      .map(dep =>
        format === 'es'
          ? `import '../../${dep}/${dep}.css';\n`
          : `require('../../${dep}/${dep}.css');\n`,
      )
      .join('');
    content = content.replace(`../${component}/`, ''); // 注意要替换一下 组件本身样式路径
    await outputFile(`${line}/style/index${jsFileExt(format)}`, content);
  });
}

组件本身样式加入 style/index.js:

import '../../../styles/base.css';
import '../../../styles/animate.css';
import '../../list-item/list-item.css';
import '../dynamic-list.css';

产出

最终

  • 组件入口 index.ts 里的公共样式导入语句被提取至 style/index.mjs。
  • 引用其他组件的样式导入语句写入 style/index.mjs。
  • 组件本身样式导入语句写入 style/index.mjs。
import '../../../styles/base.css';
import '../../../styles/animate.css';
import '../../list-item/list-item.css';
import '../dynamic-list.css';

注意保证这样一种引入顺序,公共样式权重最小要最先引入

解决了什么问题-公共 css 重复打包问题

经过这一步,组件有了单独样式入口,且入口是 js 方式,也就能够被tree-shaking公共 css 重复打包问题得以解决,样式导入和组件本身彼此隔离。

编译

  • 编译整个目录下的文件。

入口文件生成等准备工作都已就绪,接下来就是最重要的编译部分,当前项目结构:

需要编译的文件有 .ts、.vue、.scss、.css、.(m)js,递归遍历当前目录,分别处理不同文件类型:

async function complieFile(filePath, format) {
  if (isSfc(filePath)) {
    await compileSfc(filePath, format);
  }
  if (isScript(filePath)) {
    await compileScript(filePath, format);
  }
  if (isStyle(filePath)) {
    await compileStyle(filePath);
  }
  // await remove(filePath);
}
async function compileDir(dir, format) {
  // 构建 es
  const entries = await glob(`${dir}/**/*`, {
    nodir: true,
  });
  for (const filePath of entries) {
    await complieFile(filePath, format);
  }
}

css / scss - postcss

css 和 scss 的处理相对简单,使用 sass处理 scss,postcss处理 css:

export async function compileStyle(filePath) {
  const ext = path.extname(filePath);
  try {
    let css;
    switch (ext) {
      case '.scss':
        css = await compileSass(filePath); // 是 scss 先交给 compileSass 处理
        break;

      default:
        css = await readFile(filePath, 'utf-8');
        break;
    } 
    const code = await compileCss(css); // 最后都要经过 compileCss 处理
    await remove(filePath);
    await outputFile(replaceExt(filePath, '.css'), code);
  } catch (error) {
    console.log(error);
    logger.error('Compile style failed: ' + filePath);
  }
}

处理 scss

import { readFile } from 'fs/promises';

import { compileStringAsync } from 'sass';

// 编译 scss
export async function compileSass(filePath) {
  const code = await readFile(filePath, 'utf-8');
  const { css } = await compileStringAsync(code);
  return css;
}

处理 css,考虑到最终还要整体打包,这里无需 postcss 各种 plugin。

import postcss from 'postcss';

// postcss 打包压缩 css
export async function compileCss(code) {
  const { css } = await postcss().process(code, {
    from: undefined,
  });
  return css;
}

原路径输出编译后的 css 产物:

ts / js - esbuild

由于输出的不是最终产物,使用 esbuild 来 transform 更快,并且 esbuild 也可以转换 ts,extractStyleDependencies 即是提取样式导入语句章节里提取公共样式导入语句的函数。

// 编译 js
export async function compileScript(filePath, format) {
  if (filePath.includes('.d.ts')) {
    return;
  }
  let script = await readFile(filePath, 'utf-8');
  const ext = jsFileExt(format);
  const outputFilePath = replaceExt(filePath, ext);
  if (script) { // 这里就是提取 js/ts 当中的 css 导入语句
    script = extractStyleDependencies(outputFilePath, script, IMPORT_STYLE_RE, format);
  }
  script = resolveDependences(script, filePath, ext);
  let { code } = await esbuild.transform(script, {
    loader: 'ts',
    format: format === 'es' ? 'esm' : format,
  });
  removeSync(filePath);
  await outputFile(outputFilePath, code, 'utf-8');
  // console.dir(code, { depth: 1 });
}

原路径输出转化后的 js:

处理依赖扩展名

这里需要注意,我们源代码模块导入导出语句可能有多种情况,我们要对其进行相应处理:

  • 类型导入语句:会被 esbuild 处理掉,无需处理。

    • import type or export type -> import type or export type
  • 第三方模块或者 node 内置模块:无需处理。

    • 'vue' -> 'vue'
  • 带扩展名的自定义模块:需要完成转换。

    • .vue -> .mjs
  • 省略掉 index.${ext}自定义模块:应当完成以下转换。

    • ../utils -> ../utils/index.mjs
  • 省略扩展名的自定义模块:应当加上扩展名。

    • ./props -> ./props.mjs
// src/packages/index.ts
import type { Component, App } from 'vue';

import { DynamicList } from './packages/dynamic-list';
import { FixedSizeList } from './packages/fixed-size-list';
import type { WithInstall } from './utils';

export const components : Component[] = [FixedSizeList, DynamicList];

export const install = (app : unknown) => {
  components.forEach((component : Component) => {
    (component as WithInstall<Component>).install(app as App);
  });
};

export * from './packages/fixed-size-list';
export * from './packages/dynamic-list';
export default {
  install,
};

上面提到的 resolveDependences 函数即用来处理:

const IMPORT_RE = /import\s+?[\w\s{},$*]+\s+from\s+?(".*?"|'.*?')/g;
const EXPORT_RE = /export\s+?[\w\s{},$*]+\s+from\s+?(".*?"|'.*?')/g;
const scriptExtNames = ['.vue', '.ts', '.tsx', '.mjs', '.js', '.jsx'];
export function resolveDependences(code, filePath, targetExt) {
  const resolver = (source, dependence) => { // source 匹配到的语句,dependence 模块路径
    dependence = dependence.slice(1, dependence.length - 1);
    // import type or export type -> import type or export type
    if (source.includes('import type') || source.includes('export type')) {
      return source;
    }
    // 'vue' -> 'vue'
    if (!dependence.startsWith('.')) {
      return source;
    }
    const sourcePath = path.resolve(path.dirname(filePath), dependence);
    const ext = path.extname(sourcePath);
    const update = target => source.replace(dependence, target);
    if (ext) {
      // .vue -> .mjs
      if (scriptExtNames.includes(ext)) {
        return update(dependence.replace(ext, targetExt));
      }
    }
    const hasIndexFile = matchIndexFile(sourcePath, scriptExtNames);
    // ../utils -> ../utils/index.mjs
    if (hasIndexFile) {
      return update(`${dependence}/index${targetExt}`);
    }
    // ./props -> ./props.mjs
    return update(`${dependence}${targetExt}`);
  };
  return code.replace(IMPORT_RE, resolver).replace(EXPORT_RE, resolver);
}
function matchIndexFile(filePath, extNames) {
  return extNames.some(ext => {
    const pathName = `${filePath}/index${ext}`;
    return existsSync(pathName);
  });
}

转换后的文件如下,无论导入还是导出语句都会处理:

// es/packages/index.mjs
import { DynamicList } from "./packages/dynamic-list/index.mjs";
import { FixedSizeList } from "./packages/fixed-size-list/index.mjs";
const components = [FixedSizeList, DynamicList];
const install = (app) => {
  components.forEach((component) => {
    component.install(app);
  });
};
export * from "./packages/fixed-size-list/index.mjs";
export * from "./packages/dynamic-list/index.mjs";
var stdin_default = {
  install
};
export {
  components,
  stdin_default as default,
  install
};

vue - @vue/compiler-sfc

.vue 文件的处理较麻烦,我们先用 vue/compiler-sfc parse 解析文件:

// 注意这里 要传入 filename 否则 parse 将无法正确的解析路径
const { descriptor } = parse(source, { filename: filePath, sourceMap: false });
let { styles, template, script, scriptSetup } = descriptor;

特别注意:这里必须传入 filePath,defineProps 宏的 props 是外部支持导入的,如果没有 filePath,props 路径无法解析。

这里 descriptor 能解构出 styles, template, script, scriptSetup,这些是 .vue 中各种代码块转后的结果,其中,scriptSetup 是 setup 语法糖的产出。

这些代码块需要分别处理,除此之外我们需要考虑 scoped。

scoped

scoped 和 template、styles、script 都相关:

  • template 需要 scopedId 在元素上设置特殊的 attr。
  • styles 需要组合 attr 带上特殊的属性选择器。
  • script 需要保留 scopedId。

@vitejs/plugin-vue / vue-loader一些插件的做法是根据当前文件路径和文件内容来生成唯一的 hush,我们也这样做:

import hash_sum from 'hash-sum';
// hash 单文件路径生成 id
const id = hash_sum(source + filePath);
// 检查是否存在 scoped 作用域的样式块
const hasScope = styles.some(style => style.scoped);
// 生成 scopeId
const scopeId = hasScope ? `data-v-${id}` : '';

拿到 scopedId。

template

使用 vue/compiler-sfc 的 compileTemplate 函数将 template 转化为 render 函数:

import {
  parse,
  compileStyle as compileSfcStyle,
  compileTemplate,
  compileScript as compileSfcScript,
} from 'vue/compiler-sfc';
// 处理 template
  if (template) {
    const { code } = compileTemplate({
      id, // scopedId
      source: template.content,
      filename: filePath,
      compilerOptions: {
        scopeId,
        bindingMetadata: bindings, // 在 setup 中暴露的变量
      },
    });
  }

script

scirpt 部分较复杂,可以分为以下几个步骤:

  • 替换组件默认导出
  • 注入 render 函数
  • 挂载 scopedId
  • 注入导出
  • 再次编译

替换组件默认导出

正常编译后的组件是直接 export default 的,我们需要加工组件,所以不能直接导出,我们替换导出语句,声明 __SFC__ 变量来保存组件:

const  SFC_COMPONENT_NAME = '__SFC__' ;
const SFC_DECLAREION = `const ${SFC_COMPONENT_NAME} =`;
// 替换掉 script 编译后的 导出声明 为 变量声明
function replaceExportToDeclaration(script) {
  return script.replace('export default', SFC_DECLAREION);
}

注入 render 函数

注入 template 编译后的 render 函数。

const SFC_COMPONENT_NAME = '__SFC__';
const SFC_RENDER_NAME = '__render__';
const SFC_DECLAREION = `const ${SFC_COMPONENT_NAME} =`;
const SFC_EXPORT = `export default`;
// 将 template 编译后的 render 函数注入 script 中,同时替换名称
function injectRender(script, render) {
  script = script.trim();
  render = render.replace(`export function render`, `function ${SFC_RENDER_NAME}`); // 见下
  script = script.replace(`${SFC_DECLAREION}`, `${render}\n${SFC_DECLAREION}`); // 见下
  script += `\n${SFC_COMPONENT_NAME}.render = ${SFC_RENDER_NAME}`;
  return script;
}

这里编译后的 template 同样是 export render 的,所以我们替换导出为 const __render__,同时插入到组件声明之前:

render = render.replace(`export function render`, `function ${SFC_RENDER_NAME}`);
script = script.replace(`${SFC_DECLAREION}`, `${render}\n${SFC_DECLAREION}`);

然后挂载 render 函数到组件上:

script += `\n${SFC_COMPONENT_NAME}.render = ${SFC_RENDER_NAME}`;

挂载 scopedId

const SFC_COMPONENT_NAME = '__SFC__';
// 注入 scopeId
if (scopeId) {
  scriptContent = injectScopeId(scriptContent, scopeId);
}
 // 注入 scopeId
function injectScopeId(script, scopeId) {
  return script + `\n${SFC_COMPONENT_NAME}.__scopeId = "${scopeId}"`;
}

注入导出

默认导出组件声明 __SFC__

const SFC_COMPONENT_NAME = '__SFC__';
const SFC_EXPORT = `export default`;
// 注入 导出语句
function injectExport(script) {
  return script + `\n${SFC_EXPORT} ${SFC_COMPONENT_NAME};`;
}
// 注入 导出语句
  scriptContent = injectExport(scriptContent);

再次编译

这里的 compileScript 即前面ts / js - esbuild 章节处理 ts / js 的函数,经由 compileScript 再次编译成最终结果。

const scriptFilePath = replaceExt(filePath, `.${script?.lang || scriptSetup?.lang || 'js'}`);
await outputFile(scriptFilePath, scriptContent);
// 编译 script
await compileScript(scriptFilePath, format);

产出

经由以上转化,一个 .vue 文件编译后的 script 产出概览如下:

import { defineComponent as _defineComponent } from "vue";
import { watchEffect } from "vue";
import { renderList as _renderList, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock, normalizeProps as _normalizeProps, guardReactiveProps as _guardReactiveProps, renderSlot as _renderSlot, normalizeStyle as _normalizeStyle, withCtx as _withCtx, createBlock as _createBlock, createElementVNode as _createElementVNode } from "vue";
function __render__(_ctx, _cache, $props, $setup, $data, $options) {
  ...
}
const __SFC__ = /* @__PURE__ */ _defineComponent({
  ...
});
__SFC__.render = __render__;
__SFC__.__scopeId = "data-v-4e290164";
var stdin_default = __SFC__;
export {
  stdin_default as default
};

style

style 处理就相对简单:

  • 处理多个 style 块
  • 再次编译

处理多个 style 块

使用 vue/compiler-sfc 的 compileStyle 函数将 styles 转化为样式文件,传入 scopedId,注意一个 .vue 文件可以包含多个 style 块,需要拼接:

// 处理 css
  let styleCode = '';
  const cssFilePath = replaceExt(filePath, `.scss`);
  for (const { content, scoped } of styles) {
    // vue 编译 css
    let { code } = compileSfcStyle({
      source: content,
      filename: cssFilePath,
      id: scopeId, // 传入 scodeId
      scoped,
    });
    styleCode += code;
  }

再次编译

这里的 compileStyle 即前面6.6.1处理 css / scss 的函数,将 .vue 的样式部分输出然后经由 compileStyle 再次编译成最终结果。

await outputFile(cssFilePath, styleCode.trim(), 'utf-8'); // 输出为 scss 文件
await compileStyle(cssFilePath); // 当成 scss 文件转化

产出

一个有多个 style 块的组件:

<style scoped>
  .virtual-list-container {
    overflow-y: auto;
  }
</style>
<style scoped lang="scss">
  .virtual-list {
    position: relative;

    .list-item {
      position: absolute;
    }
  }
</style>

最终被编译成我们熟悉的样子:

 .virtual-list-container [data-v-1256af42] {
  overflow-y: auto;
}

.virtual-list [data-v-1256af42] {
  position: relative;
}
.virtual-list [data-v-1256af42]  .list-item [data-v-1256af42] {
  position: absolute;
}

整体编译后产出

本流程实现将需要编译的 .ts、.vue、.scss、.css、.(m)js 文件,转化为 .(m)js、.css 文件,同时 js、css 编译互不干扰。

编译前:

编译后:

解决了什么问题-组件库内部公共组件 chunk、公共 css 重复打包

至此,最核心部分编译工作完成,组件按需引入,样式按需引入实现。

整体打包

  • 依据 2 生成的打包入口打包整个组件库。

整体打包需要支持 esm / cjs / umd 三种模块化方案,组件入口和组件样式入口(在第二步生成)都已生成。

使用 babel 编译 js 比较慢,而 esbuild 不能转换 js 到 es6 以下,所以我想兼容两种打包方式,为此我需要一个配置文件来保存配置,并且需要提供命令行参数:

import { rollup } from 'rollup';
export async function compileBundle() {
  const config = await getBuildConfig(); // 获取 config
  const tasks = [];
  const { esbuildOptions, styleOptions, babelOptions } = await import(
    '../config/rollup.prod.config.js' // 动态导入 rollup 配置文件
  );
  const jsOptions = config.modern ? esbuildOptions : babelOptions; // 根据 modern 的值改变打包策略
  const rollupTasks = [jsOptions, styleOptions].map(options => {
    return async () => {
      const bundle = await rollup(options);
      return Promise.all(options.output.map(bundle.write));
    };
  });
  tasks.push( ... rollupTasks);
  await Promise.all(tasks.map(task => task()));
  );
}

定义配置文件

modern 参数为 true 时,使用 esbuild 来转换 js。

暂无细致的 merge strategy,合并时直接覆盖配置项:

import defaultConfig from './default.config.js';
let config = {
  ... defaultConfig,
};

export function setBuildConfig(options) {
  Object.assign(config, options);
}
export async function getBuildConfig() {
  return config;
}

// default.config.js
export default {
  modern: false,
};

命令行

build 命令可以使用 -m --modern参数来改变 modern 值

#!/usr/bin/env node
import { Command } from 'commander';
const program = new Command();

program
  .command('build')
  .description('Compile components')
  .option('-m --modern', 'Build with esbuild')
  .action(async options => {
    const { build } = await import('./commands/build.js');
    build(options); // 打包入口函数
  });

rollup 配置

和之前相比 rollup 配置大同小异,只需要配置整体打包,无需多入口打包组件。样式和组件分开打包。

esbuild

import esbuild, { minify } from 'rollup-plugin-esbuild';
export const esbuildOptions = {
  ... base,
  input: `${ES_DIR}/index.mjs`,
  output: [
    {
      format: 'es',
      dir: ES_DIR,
      entryFileNames: `${PACKAGE_NAME}.esm.js`,
    },
    {
      format: 'cjs',
      dir: CJS_DIR,
      entryFileNames: `${PACKAGE_NAME}.cjs.js`,
      exports: 'named',
    },
    {
      format: 'umd',
      name: GLOBAL_NAME,
      dir: CJS_DIR,
      entryFileNames: `${PACKAGE_NAME}.js`,
      exports: 'named',
      globals: {
        vue: 'Vue',
      },
    },
    {
      format: 'umd',
      name: GLOBAL_NAME,
      dir: CJS_DIR,
      entryFileNames: `${PACKAGE_NAME}.min.js`,
      exports: 'named',
      globals: {
        vue: 'Vue',
      },
      plugins: [minify()],
    },
  ],
  external: ['vue'],
  plugins: [ ... base.plugins, esbuild()],
};

babel

使用 esbuild 来打包压缩代码,babel 只需要负责语法转换:

import { babel } from '@rollup/plugin-babel';
import esbuild, { minify } from 'rollup-plugin-esbuild';
export const babelOptions = {
  ... base,
  input: `${ES_DIR}/index.mjs`,
  output: [
    {
      format: 'es',
      dir: ES_DIR,
      entryFileNames: `${PACKAGE_NAME}.esm.js`,
    },
    {
      format: 'cjs',
      dir: CJS_DIR,
      entryFileNames: `${PACKAGE_NAME}.cjs.js`,
      exports: 'named',
    },
    {
      format: 'umd',
      name: GLOBAL_NAME,
      dir: CJS_DIR,
      entryFileNames: `${PACKAGE_NAME}.js`,
      exports: 'named',
      globals: {
        vue: 'Vue',
      },
    },
    {
      format: 'umd',
      name: GLOBAL_NAME,
      dir: CJS_DIR,
      entryFileNames: `${PACKAGE_NAME}.min.js`,
      exports: 'named',
      globals: {
        vue: 'Vue',
      },
      plugins: [minify()],
    },
  ],
  external: ['vue'],
  plugins: [
    ... base.plugins,
    babel({
      exclude: ['node_modules/**'],
      babelHelpers: 'runtime',
    }),
    esbuild(),
  ],
};

style

使用第二步生成的整体样式入口文件 style.mjs打包 css:

import styles from 'rollup-plugin-styles';
export const styleOptions = {
  ... base,
  input: `${ES_DIR}/style.mjs`,
  output: [
    {
      format: 'es',
      dir: ES_DIR,
      entryFileNames: `[name].bundle.mjs`,
      assetFileNames: '[name][extname]',
    },
    {
      format: 'cjs',
      dir: CJS_DIR,
      entryFileNames: `[name].bundle.js`,
      assetFileNames: '[name][extname]',
    },
  ],
  plugins: [
    ... base.plugins,
    styles({
      // 遵从 assetFileNames 路径
      mode: 'extract',
      plugins: [
        // 依据 browserlist 自动加浏览器私有前缀
        autoprefixer(),
        postcssPresetEnv(),
        // // 压缩 css
        cssnanoPlugin(),
      ],
    }),
  ],
  logLevel: 'silent',
};

产出

组件库整体打包完成,最终结果如下:

解决了什么问题-打包速度慢

兼容了两种打包方式,esbuild 加速构建。

总结

至此,整个组件库编译工作完成。

组件库编译器部分代码结构如下,实现过程中,VarletVant 这些优秀业界案例给我带来很大启发。

按需引入支持情况:

方式手动引入auto importtree shaking
组件按需引入支持支持支持
组件样式按需引入支持支持不支持
说明按照组件库目录引用即可后续实现esm 方案打包后的 js 支持 tree shaking

当你做完这些,你的组件库具有以下特点:

  • 使用 rollup 作为打包工具 ✅
  • 支持 babel 和 esbuild 两种构建方式✅🆕
  • 支持 cjs、esm 和浏览器直接引入✅
  • 支持组件样式按需引入✅🆕
  • 自动引入☑️
  • 接入eslint、commitlint 等静态检测工具✅
  • 能够进行 npm 发包和产出 changelog☑️
  • 提供组件文档和组件示例☑️
  • 接入单元测试☑️

如果你觉得阅读后有所感悟,不妨帮我点个赞和 github star。

github.com/Devil-Train…

核心编译工作完成,下一步来看看如何搭建组件库文档:

组件库工程化环境设计(四):组件示例原理剖析?轻松搭建组件库文档