本节是渐进式vue3的组件库通关秘籍的第四节 -- 完成最终的组件库打包流程,本节假设你已经完成了第三节的内容:3、样式系统和Design Token -- 渐进式vue3的组件库通关秘籍
注意:本节内容仅供参考,其中的打包流程作者也有一些迷惑的地方,主要学习组件库的构建和打包思路,我们想要的是个什么结果,如何通过工具去实现它。
1、完善打包流程
上一节我们已经完成了基于Less和Design Token的样式系统的引入,为Hello World组件添加了一些样式,得益于vitepress的配置,我们能够在不进行任何配置的情况下,实现对Less能力的开箱即用的支持。
但是,在我们组件库自有的打包流程上,还不支持对Less的解析。
我们运行打包命令 npm run build,可以看到以下报错:
ERROR in ./components/hello-world/styles/index.less 1:0
Module parse failed: Unexpected character '@' (1:0)
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
> @import './token.less';
| .world {
| height: 100px;
@ ./components/hello-world/hello-world.tsx 3:0-29
@ ./components/hello-world/index.ts 1:0-39 2:15-25
@ ./components/components.ts 1:0-54 1:0-54
@ ./components/index.ts 1:0-29 1:0-29
@ ./index.js
webpack 5.91.0 compiled with 1 error in 708 ms
Webpack 提示我们,需要一个合适的loader去处理这种数据类型,针对Less我们可以使用less-loader将其编译成css。
首先,安装 less 和 less-loader :
npm install less less-loader style-loader css-loader --save-dev
修改 config/webpack.base.config.js :
const path = require('node:path');
module.exports = {
...
module: {
rules: [
// ... 新增内容
{
test: /.less$/i,
use: [
// compiles Less to CSS
'style-loader',
'css-loader',
'less-loader',
],
},
// ... 新增内容结束
],
},
...
};
再次执行打包命令:
npm run build
可以看到打包成功了。
在开始正式的打包配置之前呢我们先配置一下组件的开发预览页面,之前在docs中预览组件过于麻烦,也没有代码提示。使用开发预览页面可以方便我们测试各种打包输出。
2、新增组件预览页面
在本节正式开始之前,我们先新增一个组件预留页面,用以试试调试组件,而不再去通过修改文档页面预览组件了。
新建 preview/index.tsx 文件
import { createApp } from 'vue';
import App from './app';
createApp(App).mount('#app');
新建 preview/app.tsx 文件
import { defineComponent } from 'vue';
import HelloWorld from '../components/hello-world';
export default defineComponent({
setup() {
const render = () => {
return (
<>
<HelloWorld />
</>
);
};
return render;
},
});
新建 preview/index.html 文件
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="app"></div>
</body>
</html>
由于我们需要使用webpack启动开发模式,所以需要一个开发模式的配置
新建 config/webpack.dev.config.js
const path = require('path');
const htmlWebpackPlugin = require('html-webpack-plugin');
const { merge } = require('webpack-merge');
const baseConfig = require('./webpack.base.config');
const distfilename = 'fakeui';
const resolveDir = dir => path.join(__dirname, `../${dir}`);
const dev = merge(baseConfig, {
mode: 'development',
entry: './preview/index.tsx',
output: {
path: resolveDir('preview/dist'),
filename: 'bundle.js',
},
devServer: {
static: path.join(__dirname, 'preview'),
compress: true,
port: 9000,
},
plugins: [
new htmlWebpackPlugin({
template: './preview/index.html', //html模板
}),
],
});
module.exports = [dev];
这里引入了htmlWebpackPlugin来实现模板的指定。
由于开发者模式中,vue不能再作为外部依赖了,所以我们需要将 .base.config.js中的externals移出到 .prod.config.js中去。
先删除 config/webpack.base.config.js 中 externals 属性。
新建 config/utils/getExternals.js
const externals = [
{
vue: {
root: 'Vue', //表示在浏览器环境中,全局变量Vue可以直接访问
commonjs2: 'vue', // 表示在CommonJS环境的模块引入方式,通过require(‘vue’)来引入Vue模块。
commonjs: 'vue', // 同样表示在CommonJS环境的模块引入方式,通过require(‘vue’)来引入Vue模块。
amd: 'vue', // 表示在AMD(Asynchronous Module Definition)环境下的模块引入方式。
module: 'vue', // 表示ES Module(ES6模块)的引入方式。
},
},
];
module.exports = externals;
修改 config/webpack.prod.config.js
const path = require('path');
const { merge } = require('webpack-merge');
const baseConfig = require('./webpack.base.config');
const distfilename = 'fakeui';
const resolveDir = dir => path.join(__dirname, `../${dir}`);
const externals = require('./utils/getExternals');// 新增
const es = merge(baseConfig, {
mode: 'production',
entry: {
[distfilename]: ['./index.esm'],
},
experiments: {
outputModule: true,
},
externals, // 新增
output: {
path: resolveDir('dist/es'),
library: {
type: 'module',
},
},
});
const cjs = merge(baseConfig, {
mode: 'production',
entry: {
[distfilename]: ['./index.js'],
},
externals, // 新增
output: {
path: resolveDir('dist/lib'),
library: {
name: distfilename,
type: 'umd',
},
},
});
module.exports = [es, cjs];
修改 package.json 新增一条开发命令
{
...
"scripts": {
...
"dev": "webpack serve -c ./config/webpack.dev.config.js",
...
},
}
修改 tsconfig.json ,支持对preview的ts解析
{
...
"include": ["components/**/*", "preview/**/*"],
...
}
安装所有依赖:
npm i -D html-webpack-plugin webpack-dev-server
最后运行:
npm run dev
可以看到,我们能够直接预览组件了。
接下来我们就能更加直观的测试组件的各种打包输出了。
3、按需加载-分包
目前的打包有一个问题,就是所有的代码都打包到一个文件内部了,不论是打包类型是esm还是umd。
由于组件库可能包含很多的组件,但是用户在使用的时候往往只会使用其中一部分组件,如果按照之前的打包模式,将所有的代码都打包到一个文件中去,那么会使得用户最终的构建产物体积变大。因此我们需要提供给用户按需加载的能力。
可能得使用像这样:
import fakeui from 'fakeui' // 全局引入所有组件
import { HelloWorld } from 'fakeui' //按需引入
import { createApp } from 'vue'
const app = createApp()
app.use(HelloWorld)
针对全局引入非常简单,我们只需要像之前那样打包引入就行了。
目前组件的按需引入的解决方案通常有两个:
- 经典方法:组件单独分包 + 按需导入 +
babel-plugin-component( 自动化按需引入); - 次时代方法:
ESModule+Treeshaking+ 自动按需import(unplugin-vue-components自动化配置)。
这里我们介绍一下经典方案。
- 组件单独分包:将组件库中的每个组件都打包为独立的文件,而不是将所有组件打包到一个巨大的文件中。这样做的好处是,当应用只需要使用其中的部分组件时,可以只加载所需的组件文件,而不必加载整个组件库,从而减少了初始加载时间和资源消耗。
- 按需导入:在应用中,只引入需要使用的组件,而不是一次性导入整个组件库。这样可以减少应用的代码体积,加快应用的加载速度,并且更好地管理依赖关系。
最终我们想要的输出文件结构像这样:
dist/
├── esm/
│ ├── HelloWorld/
│ │ ├── index.js // 按需引入
│ │ ├── index.css // 组件样式
├── index.js // 全局引入
├── index.css // 公共样式
3.1 分包
使用webpack进行分包,相当于把每个组件都当成一个单独的库进行打包,因此需要有多个库的入口
需要先注意的是:之前我们的hello-world组件已经定义了单独的导出文件index.ts,因此可以作为单独分包的入口进行打包。
新建 config/utils/getProdEntry.js
/**
* 分包打包的时候,获取多入口
*/
const path = require('path');
const fs = require('fs');
const componentsDir = path.resolve(__dirname, '../../components');
const getEntry = function (isESM = false, distFileName = 'fakeui') {
const entry = {};
// 获取components文件夹下的所有子文件夹名称
const componentDirs = fs
.readdirSync(componentsDir, { withFileTypes: true })
.filter(dir => dir.isDirectory())
.map(dir => dir.name);
// 生成components/index.ts的入口配置
const indexEntry = isESM ? ['./index.esm'] : ['./index.js'];
entry[distFileName] = indexEntry;
// 遍历每个组件文件夹,生成对应的入口配置
componentDirs.forEach(componentDir => {
entry[componentDir] = `./components/${componentDir}/index.ts`;
});
return entry;
};
module.exports = getEntry;
这里获取components下所有的组件文件夹下的index.ts作为入口。
这里的入口格式类似于这样:
{
'fakeui':'./index.esm.js',
'hello-world':'./components/hello-world/index.ts'
}
由于之前将themes文件放到了components文件夹下,作为公共配置呢,这里我们将themes文件夹移动到根目录下:
移动 components/themes => themes
修改 webpack.prod.config.ts:
const path = require('path');
const { merge } = require('webpack-merge');
const baseConfig = require('./webpack.base.config');
const distfilename = 'fakeui';
const resolveDir = dir => path.join(__dirname, `../${dir}`);
const externals = require('./utils/getExternals');
const getEntry = require('./utils/getProdEntry'); // 新增
// 也可以抽离出去
const fileNameFormatter = function (chunkData) {
return chunkData.chunk.name === distfilename ? 'index.js' : '[name]/index.js';
};
const es = merge(baseConfig, {
mode: 'production',
entry: getEntry(true), // 修改
experiments: {
outputModule: true,
},
externals,
output: {
path: resolveDir('dist/es'),
// 新增,注意这里产物的命名逻辑
filename: fileNameFormatter,
library: {
type: 'module',
},
},
});
const cjs = merge(baseConfig, {
mode: 'production',
entry: getEntry(false), // 修改
externals,
output: {
path: resolveDir('dist/lib'),
// 新增
filename: fileNameFormatter,
library: {
name: distfilename,
type: 'umd',
},
},
});
module.exports = [es, cjs];
运行 npm run build 试试,目前打包内容如下:
OK,先来试试构建的产物能不能正常工作:
修改 preview/app.tsx:
import { defineComponent } from 'vue';
import HelloWorld from '../dist/es/hello-world'; // 修改处,变成了引入构建产物,局部引入
export default defineComponent({
setup() {
const render = () => {
return (
<>
<HelloWorld />
</>
);
};
return render;
},
});
保存运行预览,可以看到组件正常工作。
如果要全局引入,需要组件能够被vue当做插件来使用,需要实现install方法。
所以我们需要先引入一个公共方法,将之前的组件包装成vue的插件。
新建 utils/withInstall.ts:
import type { App, Plugin } from 'vue';
export default <T>(comp: T) => {
const c = comp as any;
c.install = function (app: App) {
app.component(c.displayName || c.name, comp);
};
return comp as typeof comp & Plugin;
};
修改hello-world组件:
import { computed, defineComponent, toRef, type PropType } from 'vue';
import './styles/index.less';
import { WorldType } from './type';
import withInstall from '../../utils/withInstall'; // 新增
const HelloWorld = defineComponent({ // 修改
props: {
// 给组件加入参数type,jsx不能通过defineProps设定参数
type: {
default: WorldType.NORMAL,
type: String as PropType<WorldType>,
},
},
name: 'HelloWorld',
setup(props) {
const worldType = toRef(props.type);
const worldMsg = computed(() => {
switch (worldType.value) {
case WorldType.NORMAL:
return 'boring world';
case WorldType.PEACE:
return 'hello world';
case WorldType.DANGER:
return 'danger world';
case WorldType.BIGGER:
return '广阔天地、大有作为';
default:
return 'world 404~~';
}
});
const render = () => {
return (
<>
<div class={[`world-${worldType.value}`, 'world']}>{worldMsg.value}</div>
</>
);
};
return render;
},
});
export default withInstall(HelloWorld); // 新增
最后修改全局引入入口文件components/index.ts:
import type { App } from 'vue'; 新增
import * as components from './components'; // 新增
export * from './components';
// 新增
// 全局引入调用install方法的时候,自动注入所有组件
export const install = function (app: App) {
Object.keys(components).forEach(key => {
const component = components[key];
if (component.install) {
app.use(component);
}
});
return app;
};
export default { install };
由于webpack的入口文件没有默认导出,所以需要加上默认导出:
修改 index.esm.ts :
export * from './components';
export { default as fakeui } from './components';
修改preview/index.tsx
import { createApp } from 'vue';
import fakeui from '../dist/es/index'; // 新增
import App from './app';
createApp(App).use(fakeui).mount('#app'); // 修改
修改 preview/app.tsx
import { defineComponent } from 'vue';
// 删除此行
export default defineComponent({
setup() {
const render = () => {
return (
<>
<HelloWorld />
</>
);
};
return render;
},
});
然后再重新打包一下:npm run build
然后运行:npm run dev 可以看到组件正常运行。
这里我们完成了按需引入和全局引入打包的配置。值得注意的是,在构建产物里面,dist/esm/index.js里面是包含了所有组件的源码的,而不是对单独组件构建产物的引用。这个时候其实我们是可以这样引用单独组件的:
import { HelloWorld } from 'dist/esm'
因为我们对每一个组件都做了命名导出,只不过这样的引用方式,webpack无法帮我们做tree-shaking,构建产物还是包含了所以的组件的(大家可以实验一下看看,使用dev配置文件对preview工程进行打包,看不同情况下构建产物的大小)。
所以这里打包后,只能通过import HelloWorld from '../dist/es/hello-world'; 这样来做,对于使用者来说很不方便,针对这一个问题,目前有以下解决方案:
-
使用babel-plugin-component 来转换导入
转换之前:
import { HelloWorld } from 'dist/esm'转换之后:
var button = require('dist/esm/hello-world') require('dist/esm/hello-world/style.css')
问题:
-
怎么将 components/index.ts 的打包结果只包含导入导出语句,引用组件的打包结果?希望有大佬赐教。
-
理论上来说components/index.ts的打包结果也包含了命名导出,为什么打包preview的时候,还是将所有的产物都打包进去了?
难倒是因为export default { install } ,使得webpack认为有副作用产生了?希望有大佬赐教
但是呢,总的来说,目前打包流程虽然不完美,还是能够按照预期工作了。
3.2 样式抽离
上一步的打包产物中,组件的样式文件和逻辑代码被打包到一起了,而我们希望的是实现样式分离,所以需要一个webpack插件mini-css-extract-plugin来做一下。
安装依赖:
npm i -D mini-css-extract-plugin
修改webpack.base.config.js
const path = require('node:path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');// 新增
module.exports = {
...
plugins: [
// 新增
new MiniCssExtractPlugin({
filename: chunkData => {
return chunkData.chunk.name === 'fakeui' ? 'style.css' : '[name]/style.css';
}, // 将组件使用的 CSS 输出到 css/components 文件夹中
}),
],
output: {
filename: '[name].js',
},
};
保存重新运行,可以看到打包结果
npm run build
npm run dev
但是无论以哪种方式引入的组件,都可以看到,组件的样式不见了。
因为组件的样式被单独打包了,所以需要我们单独引入样式组件了。
修改preview/index.tsx :
import { createApp } from 'vue';
import '../dist/es/style.css'; // 新增
import App from './app';
createApp(App).mount('#app');
修改config/webpack.base.config.js 添加对css文件解析的支持
const path = require('node:path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
module: {
rules: [
{
test: /.(less|css)$/i, // 增加css的处理
use: [
// compiles Less to CSS
MiniCssExtractPlugin.loader, // 移出了style-loader
// 'style-loader',
'css-loader',
'less-loader',
],
},
],
},
};
保存预览,可以看到,组件的样式又回来了。
其它情况的导入使用,可以自行尝试。比如按需导入单独组的样式。
思考:
- 如何配置babel-plugin-component 来帮我们自动导入按需导入的样式文件,可以尝试配置一下dev的babel-loader。
4、总结
本节完成了组件库的代码和样式的打包,实现了以下效果:
-
能够实现全局导入(导出install方法,自动全局use)和按需导入(借助babel插件的转换能力)的功能。
-
针对不同的开发环境,可以通过package.json的配置,自动加载合适的产物。
{ // 参考第一节这两个配置的作用 "main": "lib/index.js", "module": "es/index.js", } -
实现了组件和样式的打包分离。
参考文献:
- [Vue组件库搭建实践与探索
- 你的Tree-Shaking并没什么卵用 这个时间比较就远了,可以用来了解发展历史
- 实现组件库按需引入功能
- ant-desing-vue
- tree-shaking
本节代码分支:feature_1.2_package_style
关于本节的问题欢迎大佬赐教。
- 怎么将 components/index.ts 的打包结果只包含导入导出语句,引用其它组件的打包结果,而不是包含所有的组件源码
- 如何将组件的公共样式打包成一个单独的文件,目前组件单独的style.css还是包含base.css的所有的内容的。
5、来点八股
- style-loader, css-loader, less-loader的作用是什么,在数组中顺序有要求吗?
- webpack打包的流程?
- module和plugin的区别
- 有没有写过插件呀?
- 讲讲你对webpack的理解,和vite的区别,哪个更好用?