组件库工程化踩坑实战-3万吐血长文

899 阅读17分钟

1 简介

自动化工具?npm 发包?样式按需引入?组件文档?单元测试?本篇文章将从工程化角度带你从0到1实现一个组件库,该组件库具有以下特点:

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

另外,还会从实现组件样式按需引入特性角度,介绍如何通过自定义编译流程来解决公共样式重复打包等问题。

全文篇幅较长,部分内容会持续更新,感兴趣的可以订阅专栏:组件库工程化环境设计

文末会贴出代码仓库,欢迎讨论。

2 实现一个组件库该怎么做

image.png

image.png

2.1 构建工具选取 rollup

构建一个组件库着重于 js 处理,rollup 比较擅长处理 js,小巧且配置简单,并且有良好的 tree-shaking 支持,更加适合打包工具类库。

配置一些基本的 plugins 来让 rollup 工作:

  • @rollup/plugin-node-resolve 可以让 rollup 找到外部模块。
  • @rollup/plugin-commonjs rollup 按照 es module 标准来加载模块,现在为了能够兼容 cjs 模块,使用这个 plugin 来将 cjs module 转化为 es module。
build/rollup.base.config.js
export default {
  plugins: [
    nodeResolve(), // 让 Rollup 可以找到外部模块
    commonjs({
      include: ['node_modules/**'],
    }), // 转换 commonjs module 为 es module],
  ]
};

Rollup 常用的模块可以参考:github.com/rollup/awes…

有了这些基本配置,可以开始处理组件代码了。

2.2 编译打包 处理 js/css/vue

2.2.1 处理 js

babel

我们可能用到的 plugin:

  • @rollup/plugin-babel 在 rollup 中使用 babel
  • @babel/core babel core

加入 babel plugin 配置:

// build/rollup.prod.config.js
export default {
  ...base.plugins,
  plugins: [
    babel({
      exclude: ['node_modules/**'], // 排除的文件
      babelHelpers: 'runtime', // 启用 runtime 方案注入 polyfill helper,主要是为了不与 .babelrc 中方案冲突
      extensions: ['.js', '.ts'] // 要处理的文件类型
    }),
  ]
};

babel 配合 browserlist 可以将 js 转换至兼容目标浏览器,对于低版本浏览器不支持的一些 API,我们就需要注入 polyfill 来兼容。

polyfill 方式

可以全局 polyfill 注入:

  • @babel/preset-env 预设插件集
  • core-jspolyfill 库。
// .babelrc
{
  "presets": [
    [
      "@babel/preset-env",
      {
        "useBuiltIns": true
        //"corejs": 3
      }
    ]
  ]
}

可以 runtime 形式注入:

  • @babel/runtime-corejs3提供运行时的 polyfill module,避免全局污染。
  • @babel/plugin-transform-runtime 智能聚合模块中重复的 helper,以模块的方式导入。
// .babelrc
{
  "plugins": [
    [
      "@babel/plugin-transform-runtime",
      {
        "helpers": true, // 默认 true 以模块的方式导入重复的 helper
        "corejs": 3 // corejs version
      }
    ]
  ]
}

工具类库注意不要污染全局环境,这里选取 runtime 方式注入 polyfill。

typescript

要在 rollup 中使用 typescript 需要安装:

  • typescript
  • rollup-plugin-typescript2 rollup ts 插件

同时需要配置 tsconfig.json 文件:

{
  "include": ["**/*.ts", "**/*.d.ts"], // 包含的文件
  "exclude": ["./node_modules/*", "./dist/*"], // 排除的文件
  "compilerOptions": {
    "target": "ES2019", // 编译后的目标
    "lib": ["ES2020", "DOM", "DOM.Iterable"], // 指定一组描述目标运行时环境的捆绑库声明文件
    "module": "ESNext", // 指定生成代码的模块化方式
    "esModuleInterop": true, // 简化对导入CommonJS模块的支持。这使得`允许合成默认导入`以实现类型兼容性。
    "moduleResolution": "node", // 模块解析方式
    "resolveJsonModule": true, // 支持解析 json 模块
    "strict": true, // 严格类型检测
    // "noUnusedLocals": true, // 在未读取局部变量时报错。
    "noImplicitAny": false, // 无隐式 any
    // "noImplicitThis": true, // 当`this`具有类型`any`时,启用错误报告
    "typeRoots": ["./node_modules/@types"], // 指定一组类型文件夹
    "isolatedModules": true, // 确保每个文件都可以安全地传输,而不依赖于其他导入
    "declaration": true, // 自动生成声明文件
    "outDir": "./dist", // 输出文件夹
    "declarationDir": "./dist/types", // 输出时声明文件的文件夹
    "skipLibCheck": true, // 跳过类型检查 .d.ts类型脚本中包含的ts文件
    "allowJs": true // 允许 js
    // "rootDirs": ["src", "demo"]
  }
}

更多的配置说明可以参考:juejin.cn/post/707866…

同时还需要加入 rollup plugin 配置:

// build/rollup.base.config.js
export default {
  plugins: [
    typescript({
      verbosity: 2,
      check: true,
      useTsconfigDeclarationDir: true, // 使用 tsconfig.json 中的 declarationDir,而不是依据 output.file
    }),
  ]
};

压缩优化

生产环境下进一步压缩代码体积,这里采用 terser 来压缩 es6 的代码,如果目标环境需要支持 es5,那么可以使用 uglify 来压缩。

需要安装 plugin

  • @rollup/plugin-terser

然后在 production 环境配置:

// build/rollup.prod.config.js
export default {
  ...base.plugins,
  plugins: [
    terser(), // 压缩 es6+ 代码 / uglify 压缩 es5
  ]
};

2.2.2 处理 css

组件库使用 scss 预处理器来处理 css,使用 postcss 的 plugin 来提供 autoprefixer 和 压缩 css 的能力。

需要安装的 plugin:

  • rollup-plugin-postcss rollup postcss 插件

    • extract:可导出单文件 css
    • modules:可以配置 css module
  • sass

  • postcss

  • autoprefixer 依据 browserslist 来自动为目标环境 css 属性添加浏览器私有前缀

  • cssnano 压缩 css

只需要在 production 环境配置:

// build/rollup.prod.config.js
export default {
  ...base.plugins,
  plugins: [
    postcss({
      plugins: [
        autoprefixer(), // 依据 browserlist 自动加浏览器私有前缀
        cssnanoPlugin(), // 压缩 css
      ],
      extract: 'virtual-scroll-list.css', // 导出 css 为单文件
    })
  ]
};

postcss-preset-env

这是一个 postcss plugin,用来兼容 modern CSS 语法,依据 browserslist添加 postcss plugin。浏览器是否支持现代 css 语法的依据是 MDN 和 Can I Use。

它内置了很多 polyfill plugin,包括 autoprefixer,可以配置 feature 来自定义 plugin 需求:

export default {
  ...base.plugins,
  plugins: [
    postcss({
      plugins: [
        postcssPresetEnv({
            /* pluginOptions */
            // features: {'nesting-rules': {noIsPseudoSelector: false,},},
        }),
        cssnanoPlugin(), // 压缩 css
      ],
      extract: 'virtual-scroll-list.css', // 导出 css 为单文件
    })
  ]
};

browserlist

在 package.json 中配置:

{
    "browserslist": [
        "> 1%",
        "last 2 versions",
        "not dead"
      ]
}

或者在 root 新建 .browserslistrc,这些目标环境配置会被 babel polyfill / postcss-preset-env 等共享。

2.2.3 处理 vue

我们需要让 rollup 能够处理 .vue 文件:

  • rollup-plugin-vue + @vue/compiler-sfc:vue3.x
  • rollup-plugin-vue + vue-template-compiler:vue2.x
  • @vitejs/plugin-vue + @vue/compiler-sfc: rollup-plugin-vue 已经不再维护了,但 vite 插件是大部分兼容 rollup 的,实测可行。

@vitejs/plugin-vue 做到开箱即用:

  • @vue/compiler-sfc 使用了 postcss 来处理 css,所以集成 postcss 相关 plugin 就很容易,比如 autoprefixer / cssnano,只要 install 即可。
  • 针对 js / ts / jsx / tsx@vue/compiler-sfc 会自动 load 对应的 plugin,然后结合我们自己配置的 plugin,使用 @babel/parser 来 parse。
export default {
  ...base.plugins,
  plugins: [
    vue()
  ]
};

2.2.4 产出 umd、cjs、esm

组件库支持 cjs / esm 两种模块化方案,产出 umd 格式来支持浏览器直接引入。

  • 配置 external: ['vue']将 vue 视为外部模块。
export default {
  ... base,
  input: 'src/index.ts',
  output: [
    {
      format: 'es',
      file: 'dist/virtual-scroll-list.esm.js',
      exports: 'named',
    },
    {
      format: 'cjs',
      file: 'dist/virtual-scroll-list.cjs.js',
      exports: 'named',
    },
    {
      format: 'umd',
      name: 'VirtualScrollList',
      file: 'dist/virtual-scroll-list.umd.js',
      globals: {
        vue: 'Vue',
      },
      exports: 'named', // 消除 export named 和 export default 同时存在警告
    },
    {
      format: 'umd',
      name: 'VirtualScrollList',
      file: 'dist/virtual-scroll-list.umd.min.js',
      exports: 'named',
      globals: {
        vue: 'Vue',
      },
      plugins: [minify()],
    },
  ],
  external: ['vue'],
  plugins: [
    ... base.plugins,
    babel({
      exclude: ['node_modules/**'],
      babelHelpers: 'runtime',
    }),
    typescript({
      verbosity: 2,
      check: true,
      useTsconfigDeclarationDir: true, // 使用 tsconfig.json 中的 declarationDir,而不是依据 output.file
    }),
    vue(),
    postcss({
      plugins: [
        autoprefixer(), // 依据 browserlist 自动加浏览器私有前缀
        postcssPresetEnv(),
        cssnanoPlugin(), // 压缩 css
      ],
      extract: 'virtual-scroll-list.css', // 导出 css 为单文件
    }),
  ],
};

打包后 dist 目录下:

2.3 自动化工具

2.3.1 静态检查工具

篇幅原因,静态检测工具可以看我另外一篇文章组件库工程化环境设计(一):自动化工具,编译构建打包?一篇搞定

  • eslint

  • stylelint

  • prettier

  • commitlint + cz

  • lslint

  • husky + lint-staged

  • IDE plugin

  • vscode settings

2.3.2 发布

  • release-it
  • changelog + git tag + npm 版本管理 + npm 发包

2.4 总结

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

  • 使用 rollup 作为打包工具
  • 支持 cjs、esm 和浏览器直接引入
  • 接入eslint、commitlint 等静态检测工具
  • 能够进行 npm 发包和产出 changelog

3 为组件库增加一些特性

3.1 一个组件库有哪些特性?

一个优秀的组件库可能有以下优点:

体积小、组件丰富、单元测试覆盖率、丰富的中英文文档和示例、主题定制、按需引入和 tree sharking、服务端渲染、国际化...

相比之下我们的组件库还比较简陋。

4 从按需引入入手-需求

组件库支持按需引入很常见,我们可以尝试从按需引入入手,丰富组件库的功能。

我设想的打包后组件目录结构应该如下:

D:.
├─dynamic-list
│  │  dynamic-list.css
│  │  dynamic-list.mjs
│  │  index.d.ts
│  │  index.mjs
│  │  useRO.d.ts
│  │  useRO.mjs
│  │

这样我们就可以单独引入某一个组件的 js 和 css。

4.1 组件按需引入-rollup 多入口

初步设想用 rollup 多 input entry 来分别 build 每一个组件:

{
    ...base,
    input: getComponentEntries(),
    output: [
      {
        format: 'es', // 产物 js 的格式
        dir: 'dist/es', // 产物所在的目录
        entryFileNames: '[name].mjs', // 产物 js 的名称,这里的 name 是 input entry 的 key
        assetFileNames: '[name][extname]', // 产出资源的名称
      }
    ],
}

这里的 input 通过 getComponentEntries 方法来获取:

const resolve = dir => {
  const __filename = fileURLToPath(import.meta.url);
  const __dirname = path.dirname(__filename);
  return path.resolve(__dirname, '../', dir);
};

// 获取导出入口
export function getComponentEntries() {
  return Object.fromEntries(
    glob
      .sync('src/**/*.ts')
      .map(file => [
        path.relative('src', file.slice(0, file.length - path.extname(file).length)),
        resolve(file),
      ]),
  );
}

获取的 input 会类似于,src 每个组件文件夹会有一条 entry:

{
    'fixed-size-list/index': '绝对路径/src/fixed-size-list/index.ts'
}

上述配置的作用下,组件按照以下输出:

src/fixed-size-list/index.ts -----> dist/es/fixed-size-list/index.mjs

4.2 样式按需引入

rollup-plugin-postcss 开启 extract 后能导出外部样式文件,但不能定义样式文件路径。

使用 rollup-plugin-styles代替,他的mode: 'extract'会遵循 assetFileNames,最终会产出到正确的位置:

D:.
├─dynamic-list
│  │  dynamic-list.css
│  │  dynamic-list.mjs
│  │  index.d.ts
│  │  index.mjs
│  │  useRO.d.ts
│  │  useRO.mjs
│  │

4.3 问题

这貌似就实现了按需引入,我们引入组件测试一下,组件能够正常运行。但是打包速度和产物却有待改善。

4.3.1 打包速度慢

roullp + babel 初次构建需要近 21.6s,重复构建也要 4.5s,这还只是几个虚拟列表组件。能不能想办法提升构建速度,比如接入 esbuild / SWC 来提升构建速度?

4.3.2 组件库内部公共组件 chunk

组件库内部组件相互引用时,被引用的组件会被 rollup chunk。

如组件fixed-size-list引用了list-item,假设有如下配置:

export const esbuildOptions = {
  ... base,
  input: {
    'packages/fixed-size-list/fixed-size-list': 'src/packages/fixed-size-list/index.ts',
    'packages/list-item/list-item': 'src/packages/list-item/index.ts',
  },
  output: [
    {
      format: 'es',
      dir: 'dist',
      entryFileNames: `[name].mjs`,
      assetFileNames: '[name][extname]',
    },
  ],
  external: ['vue'],
  plugins: [
    ... base.plugins,
    del({ targets: 'dist/*' }), // 每次 build 之前删除 dist
    vue(),
    esbuild(),
    styles({
      // 遵从 assetFileNames 路径
      mode: 'extract',
      plugins: [
        // 依据 browserlist 自动加浏览器私有前缀
        autoprefixer(),
        postcssPresetEnv(),
        // // 压缩 css
        cssnanoPlugin(),
      ],
    }),
  ],
};

我所预期的 build 后目录结构如下:

├─packages
│  ├─fixed-size-list
│  │  │  fixed-size-list.css
│  │  │  fixed-size-list.mjs
│  └─list-item
│      │  list-item.css
│      │  list-item.mjs

实际上得到的目录结构:

│  index-18712f89.js
│  index.css
└─packages
    ├─fixed-size-list
    │      fixed-size-list.css
    │      fixed-size-list.mjs
    └─list-item
            list-item.mjs

rollup 会将 list-item组件作为公共 chunk 输出到项目根目录。

4.3.3 公共 css 重复打包

rollup-plugin-postcss或一些类似的 css 处理插件虽然可以配置 extract 将样式导出为外部文件,但是当我在多个组件中引入公共 css 时,会将公共的 css 分别打包进每一个组件样式,无法自动模块分割,造成代码体积增大。

5 优化

image.png

5.1 打包速度慢

5.1.1 方案:esbuild 作为 bundler 和 transformer ?

如果目标浏览器是现代浏览器支持 es6,esbuild 作为 bundler 和 transformer,效率是 babel 的几十倍,同时也能当作 minifier,组件库是支持打包成 esm 的,那么就可以使用 rollup-plugin-esbuild:

  • 来代替 babel 进行语法转换。
  • 同时它还兼具压缩代码的能力,可以代替 rollup-plugin-terser
  • 同时支持 ts 转换,可以代替rollup-plugin-typescript2@rollup/plugin-typescript
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()],
};

5.1.1 结果

如上面配置我一共输出 4 种产物:

  • es module
  • cjs module
  • umd
  • umd 压缩

实际测试打包效率,初次启动时大概是 18.7s,后续打包是 1.7s。

场景/工具babel + rollupesbuild + rollup
初次启动21.6s18.7s
后续打包4.5s1.7s

初次启动速度较慢是因为 rollup 会在第一次构建时生成缓存文件,后续的构建过程会根据缓存文件进行增量构建,速度会更快。

后续打包方面 esbuild 比 babel 快了 1.6 倍,还是快不少,当前项目组件较少,并不能体现出很大的差距,当项目足够大时更能体现差距。

5.2 组件库内部公共组件chunk

5.2.1 方案:output.preserveModules

中的各种尝试,事实上 rollup 还有一个 output.preserveModules 配置,该选项将使用原始模块名作为文件名,为所有模块创建单独的 chunk,而不是创建尽可能少的 chunk。

如组件fixed-size-list dynamic-list引用了list-item, 开启preserveModules

export const esbuildOptions = {
  ... base,
  input: {
    index: 'src/index.ts',
  },
  output: [
    {
      format: 'es',
      dir: 'dist',
      entryFileNames: `[name].mjs`,
      assetFileNames: '[name][extname]',
      preserveModules: true,
    },
  ],
  external: ['vue'],
  plugins: [
    ... base.plugins,
    del({ targets: 'dist/*' }), // 每次 build 之前删除 dist
    vue(),
    esbuild(),
    styles({
      // 遵从 assetFileNames 路径
      mode: 'extract',
      plugins: [
        // 依据 browserlist 自动加浏览器私有前缀
        autoprefixer(),
        postcssPresetEnv(),
        // // 压缩 css
        cssnanoPlugin(),
      ],
    }),
  ],
};

产出:

  • 打包后多出了_virtual目录,@vitejs/plugin-vue等一些 plugin 为实现某些结果而产生了额外的“虚拟”文件。
  • 组件库按需引入功能实现,但目录结构上不理想。
  • 公共样式依然被打包在组件里。

5.2.2 方案:output.manualChunks

使用 manualChunks 来自定义公共 chunk 时:

output: [
    {
      format: 'es',
      dir: 'dist',
      entryFileNames: `[name].mjs`,
      assetFileNames: '[name][extname]',
      manualChunks(id) {
        if (id.includes('packages\list-item')) {
          return path.join('packages', 'list-item', 'list-item');
        }
      },
    },
  ],

再次打包:

└─packages
    ├─fixed-size-list
    │      fixed-size-list.css        
    │      fixed-size-list.mjs        
    └─list-item
            list-item-3ac03190.js     
            list-item.css
            list-item.mjs

chunk 的名称、位置、大小、分包规则都需要处理,并且组件库组件被内部其他组件引用很常见,很难为这些有可能被引用的组件去创建特殊的 manualChunks。

5.2.2 结果:目录结构依然不理想

光靠 rollup 配置和插件无法满足我们组件库的需求。

5.3 公共 css 重复打包

公共 css 会在需要的组件中单独引入,同时还需要考虑到组件样式隔离,可以从 css 引入方案做出改变。

5.3.1 方案一:私有前缀

组件和样式互不干涉,css 不写在 .vue文件中,组件中不引用公共 css 文件。

CSS Module、CSS In JS 方案需要引用样式文件可以排除,使用带私有前缀的 class 来进行样式隔离,样式文件单独打包可以满足要求。

5.3.2 方案二:动态提取 css 导入语句

编写 babel plugin 或者自己写一个 compiler 来高度自定义打包流程。

可以在处理 js 前将 css 导入语句提取出,随后再写入,以此避免公共 css 随组件样式打包在一起。

5.3.3 结果

我选择的是方案二。

方案一比较适合 JSX 这种 HTML In JS,Vue SFC 自带 Scoped 样式隔离方案,选择方案二更合适。

5.4 最终结论:编写 compiler 自定义打包流程

综合以上,编写一个自己的组件库 compiler,在自定义打包流程中解决上述问题。

6 compiler

image.png

6.1 流程

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

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

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

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

  5. 编译整个目录下的文件。

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

6.2 复制源文件

  • 组件库会提供两种 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源目录

6.2.1 产出

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

6.3 生成整体打包入口

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

6.3.1 生成样式整体打包入口

核心思路就是遍历 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

6.3.2 生成组件整体打包入口

其实 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>;
}

6.3.3 产出

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

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

6.4 声明类型

  • 生成类型文件可以使用 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": ".":原路径产出声明文件, 得益于 6.2 的复制策略,不需要为 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,
  });
}

6.4.1 产出

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

6.5 组件样式入口

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

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

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

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

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

6.5.1 公共样式引入

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

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 问题得以解决。

6.5.1.1 提取样式导入语句

提取的样式导入语句会写入当前组件目录下的 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';

6.5.2 其他组件样式引入

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

  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'; 

6.5.3 组件本身样式引入

组件本身样式可能写在 .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';

6.5.4 产出

最终

  • 组件入口 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';

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

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

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

6.6 编译

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

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

需要编译的文件有 .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);
  }
}

6.6.1 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 产物:

6.6.2 ts / js - esbuild

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

// 编译 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:

6.6.2.1 处理依赖扩展名

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

  • 类型导入语句:会被 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);
  });
}

转换后的文件如:

// src/packages/index.ts
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
};

6.6.3 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。

6.6.3.1 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。

6.6.3.2 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 中暴露的变量
      },
    });
  }

6.6.3.3 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 即前面6.6.2处理 ts / js 的函数,经由 compileScript 再次编译成最终结果。

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

经由以上转化,一个 .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
};

6.6.3.4 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 文件转化
6.6.3.4.1 产出

一个有多个 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;
}

6.6.4 整体编译后产出

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

编译前:

编译后:

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

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

6.7 整体打包

  • 依据 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()));
  );
}

6.7.1 定义配置文件

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,
};

6.7.2 命令行

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); // 打包入口函数
  });

6.7.3 rollup 配置

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

7.1.3.1 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()],
};

7.1.3.2 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(),
  ],
};

7.1.3.3 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',
};

6.7.4 产出

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

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

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

6.8 总结

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

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

按需引入支持情况:

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

7 总结一下

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

  • 使用 rollup 作为打包工具
  • 支持 babel 和 esbuild 两种构建方式
  • 支持 cjs、esm 和浏览器直接引入
  • 接入eslint、commitlint 等静态检测工具
  • 能够进行 npm 发包和产出 changelog
  • 支持组件样式按需引入

7.1 代码仓库

文章中部分内容限于篇幅原因,后续更新。如果你觉得阅读后有所感悟,不妨帮我点个赞和 github star。

github.com/Devil-Train…

8 更多

内容会持续更新,感兴趣的可以订阅专栏:组件库工程化环境设计

8.1 样式 auto import

待更新

8.2 组件库文档

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

8.3 测试

组件库工程化环境设计(五):测试方案选型及实战