前言
自动化工具?npm 发包?样式按需引入?组件及样式自动引入?组件文档?单元测试?听着有点熟悉又带点陌生😱。。。别急,阅读本系列文章:将从工程化角度带你从0到1实现一个组件库,一站到底!
上篇:组件库工程化环境设计(二):样式按需引入?真的没那么简单
阅读完本篇,你的组件库将具有以下特点:
- 使用 rollup 作为打包工具 ✅
- 支持 babel 和 esbuild 两种构建方式✅🆕
- 支持 cjs、esm 和浏览器直接引入✅
- 支持组件样式按需引入✅🆕
- 自动引入☑️
- 接入eslint、commitlint 等静态检测工具✅
- 能够进行 npm 发包和产出 changelog☑️
- 提供组件文档和组件示例☑️
- 接入单元测试☑️
流程
先简单回顾上篇出现的三个问题:
打包速度慢还有优化空间,组件库内部公共组件chunk问题却无法解决,公共 css 重复打包方案还有待确认。
决定着手写一个 compiler,思路如下:
- 组件库会提供两种 esm / cjs 两种模块化方案的按需引入,为了方便文件路径区分,在 build 之前将 src 目录分别复制到 es / lib 目录。
- 组件库需要打包成 esm / cjs / umd 格式,同时样式需要单独打包,所以要依据
src/packages下组件目录来生成打包入口。 - 生成类型文件可以使用
vue-tsc生成 .d.ts 文件。 - 样式文件要支持按需引入, 每一个组件都需要生成单独样式入口文件。
- 编译整个目录下的文件。
- 依据 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_DIR | ES_DIR | IGNORE_DIRS_RE | SRC_DIR |
|---|---|---|---|
| cjs 目录,这里为 lib | esm 目录,这里为 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 加速构建。
总结
至此,整个组件库编译工作完成。
组件库编译器部分代码结构如下,实现过程中,Varlet 和 Vant 这些优秀业界案例给我带来很大启发。
按需引入支持情况:
| 方式 | 手动引入 | auto import | tree shaking |
|---|---|---|---|
| 组件按需引入 | 支持 | 支持 | 支持 |
| 组件样式按需引入 | 支持 | 支持 | 不支持 |
| 说明 | 按照组件库目录引用即可 | 后续实现 | esm 方案打包后的 js 支持 tree shaking |
当你做完这些,你的组件库具有以下特点:
- 使用 rollup 作为打包工具 ✅
- 支持 babel 和 esbuild 两种构建方式✅🆕
- 支持 cjs、esm 和浏览器直接引入✅
- 支持组件样式按需引入✅🆕
- 自动引入☑️
- 接入eslint、commitlint 等静态检测工具✅
- 能够进行 npm 发包和产出 changelog☑️
- 提供组件文档和组件示例☑️
- 接入单元测试☑️
如果你觉得阅读后有所感悟,不妨帮我点个赞和 github star。
核心编译工作完成,下一步来看看如何搭建组件库文档: