陪伴前端开发日常的一定是组件了吧,能提高开发效率,降低代码重复度等,在我司后端,ui,产品都经常说这不就是一个组件么拿来用就好了,但是堆砌的组件成了我们中后台打开性能的最大阻碍,gzip前体积高达4mb的js + 700kb的css,究其根本就是我们内部的组件库不支持按需导入,在入口就加载了这个巨大无比的组件库,但是在使用一些社区的组件库时并没有这些阻碍,我们先来看看他们是如何实现的
我司内部基于vue@2.7+antd@1.7.8开发,主要参考antd element的实现方案
element
在package.json可以看到,element提供了dist指令完成发布前的一些操作,我们拆解看一下
npm run clean // 清空发布目录
npm run build:file(
node build/bin/iconInit.js & // 大概是处理图标
node build/bin/build-entry.js & // 在根据根目录的components.json文件生成umd的入口文件, 包括umd入口注册组件的一些模版代码
node build/bin/i18n.js & // 多语言相关的逻辑
node build/bin/version.js)
npm run lint
webpack --config build/webpack.conf.js // 重点关注 !!!
webpack --config build/webpack.common.js // 生成commonjs版本
webpack --config build/webpack.component.js // 重点关注 !!!
npm run build:utils // 使用babel降级src/目录中的公共文件
npm run build:umd // 使用babel降级多语言的js源文件
npm run build:theme // 根据默认的主题处理sass文件
看到这里element内部组件的打包使用webpack完成,接下来我们详细看一下如何生成es,umd的组件库代码
上面多个脚本都依赖到了根目录的components.json, 我们看一下这个文件的缩略版
{
"pagination": "./packages/pagination/index.js",
"dialog": "./packages/dialog/index.js",
}
看到这里我们也能大概猜到是如何生成es版本的组件库了吧,在webpack中的entry不就是提供一个入口文件么,哪对于umd格式,刚才有个脚本生成了所有compents的模版文件(node build/bin/build-entry.js),我们继续看一下element的实现方式
webpack --config build/webpack.conf.js
指定了entry(上文中提到了根据components.json渲染的模版文件,包括导入组件,注册组件) eg
// 精简版
import Pagination from '../packages/pagination/index.js';
import Dialog from '../packages/dialog/index.js';
const components = [
Pagination,
Dialog,
]
const install = function(Vue, opts = {}) {
components.forEach(component => {
Vue.component(component.name, component);
});
};
export default {
version: '2.15.12',
install,
Pagination,
Dialog,
}
所以总结一下umd的生成
最后附上完整的webpack配置
const path = require('path');
const ProgressBarPlugin = require('progress-bar-webpack-plugin');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const TerserPlugin = require('terser-webpack-plugin');
const config = require('./config');
module.exports = {
mode: 'production',
entry: {
app: ['./src/index.js']
},
output: {
path: path.resolve(process.cwd(), './lib'),
publicPath: '/dist/',
filename: 'index.js',
chunkFilename: '[id].js',
libraryTarget: 'umd',
libraryExport: 'default',
library: 'ELEMENT',
umdNamedDefine: true,
globalObject: 'typeof self !== 'undefined' ? self : this'
},
resolve: {
extensions: ['.js', '.vue', '.json'],
alias: config.alias
},
externals: {
vue: config.vue
},
optimization: {
minimizer: [
new TerserPlugin({
terserOptions: {
output: {
comments: false
}
}
})
]
},
performance: {
hints: false
},
stats: {
children: false
},
module: {
rules: [
{
test: /.(jsx?|babel|es6)$/,
include: process.cwd(),
exclude: config.jsexclude,
loader: 'babel-loader'
},
{
test: /.vue$/,
loader: 'vue-loader',
options: {
compilerOptions: {
preserveWhitespace: false
}
}
}
]
},
plugins: [
new ProgressBarPlugin(),
new VueLoaderPlugin()
]
};
我们继续看一下es版本的生成过程
有了每个组件的入口,我们是不是就能一一对应的生成出构建结果,但是事情还没那么简单,例如el-pagination导入了el-button,我们在项目中使用到了el-pagination,el-button那么构建的结果是不是就包含了两份el-button的代码,可能有同学说有treeshark,但是这个场景下很明显不可以,大家可以想一下为什么
如果这样做,哪我们的按需导入不就没有意义了么,继续看看element是如何解决的,答案就是webpack 的externals。只要将内部的组件,代码引用全部externals掉不就解决重复导入了么
我们看一下具体的webpack配置
const path = require('path');
const ProgressBarPlugin = require('progress-bar-webpack-plugin');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const Components = require('../components.json');
const config = require('./config');
const webpackConfig = {
mode: 'production',
entry: Components,
output: {
path: path.resolve(process.cwd(), './lib'),
publicPath: '/dist/',
filename: '[name].js',
chunkFilename: '[id].js',
libraryTarget: 'commonjs2'
},
resolve: {
extensions: ['.js', '.vue', '.json'],
alias: config.alias,
modules: ['node_modules']
},
externals: config.externals,
performance: {
hints: false
},
stats: 'none',
optimization: {
minimize: false
},
module: {
rules: [
{
test: /.(jsx?|babel|es6)$/,
include: process.cwd(),
exclude: config.jsexclude,
loader: 'babel-loader'
},
{
test: /.vue$/,
loader: 'vue-loader',
options: {
compilerOptions: {
preserveWhitespace: false
}
}
},
{
test: /.css$/,
loaders: ['style-loader', 'css-loader']
},
{
test: /.(svg|otf|ttf|woff2?|eot|gif|png|jpe?g)(?\S*)?$/,
loader: 'url-loader',
query: {
limit: 10000,
name: path.posix.join('static', '[name].[hash:7].[ext]')
}
}
]
},
plugins: [
new ProgressBarPlugin(),
new VueLoaderPlugin()
]
};
module.exports = webpackConfig;
除了基础的配置外,我们看一下externals是如何做的
如我们想的一样,确实是枚举了element内部的组件+公共文件。
最后看一下es版本的整体流程
这个时候js的构建逻辑基本就完成了,css的构建在element中使用了gulp, 换个目录直出即可,就不展开说了
ant-design
在来看一下大名鼎鼎的ant-design,老规矩还是先看package.json, antd的发布脚本使用antd-tools compile,这里antd-tools应该是一个内部的库,我们转移视线来到antd-tools的仓库,看看他是如何提供构建的。
antd-tools run dist
antd-tools run compile // 主要编译的逻辑
antd-tools run clean
antd-tools run pub
antd-tools run guard
antd-tools run deps-lint
antd-tools run sort-api-table
antd-tools run api-collection
有了上面element的经验, 我们大概知道要实现按需,必须拆分打包结果,做到一个结果对应一个组件,在element中他手写json配置的方式给出了每个组件的路径,在antd中并没有找到类似的配置,哪他是咋样实现的呢,答案是目录结构,约定一种文件结构使用glob来读取所有关联的文件。了解了基本的思路我们在看看antd是如何做es版本的输出的。
看一下核心compile task,antd内部用了gulp来承载这些操作, 有了目录结构,我们是不是就可以只做transform不做bundle了, 看一下antd es的处理逻辑,他主要做了文件的transform,包括js降级,类型文件生成
compile逻辑
- 拿到目标文件夹下所有的文件
- 不同类型匹配对应的翻译器eg js使用babel ts使用tsc
- 处理完毕按照原文件路径输出到指定目录
这不就有了es的版本么,还顺便知道了为什么使用了gulp来做
我们思考一下,我们一个组件库发布到npm, 要做到开箱即用,必须要做哪些事情,拍脑袋一想可能有
-
提供不同格式 比如 es umd
-
尽可能降低对编译速度的影响,比如将一些原本不支持的文件做编译 .vue ==> js ts ==> js
-
type
对比上面的两种方式,在antd 肯定更加适合在2022年使用, 毕竟element的构建脚本已经停留在了5年前,哈哈😄
transform方案 | bundle方案 | |
---|---|---|
发布构建速度 | 🌟🌟🌟 | 🌟🌟 |
编译后源代码可读性 | 🌟🌟🌟 | 🌟 |
type文件生成 | 🌟🌟🌟 | 🌟 |
对下游编译性能的影响 | 🌟🌟 | 🌟🌟🌟 |
看完知道如何抄作业了吧, 现在流行的还transform方案,比如unbuild
unbuild
大概看了一下指引,使用成本还是比较高的,对目录结构有比较高的要求,比如会把一些内部使用的文件,暴露出去给外部使用,这个时候加入某次迭代改了内部文件目录,或者删除了,哪下游岂不是要emmmm。
总结一下,三种构建模式:
- mkdist文件到文件
- rollup打包模式,
- untyped(补充无类型的文件的类型)
我们是如何做的
社区的组件库一般都会去做0依赖,但是我们内部的业务组件库不可能做到如此纯粹,比如我们内部依赖了antd, lodash, echarts等依赖, 所以处理起来还是有点区别的,还有留下的历史债🤑️
背景
基于vue2.x,主要依赖antd,目前已经累计1700多次commit, 130个正式的版本,累计了93个业务组件覆盖基础,表单,业务选择器,数据报表等,工程方面基于vue/cli 文档基于vue-styleguidist
留下的历史债
- 内部使用的组件没有引用关系;比如a,b都是组件库中的组件,a中使用了b,但是没有做导入,因为以前产出的umd会在入口把所有组件注册进去
- api请求使用的axios居然没有create,和业务代码中的axios实例冲突了
- 部分代码里混用了大量的jsx代码。
改造的技术选型
- vite 构建js ts,less sass等
- esno 执行过程编译ts
- babel 做一些代码查找,模版替换的ast工具
- unplugin-vue-components 业务中使用组件库的实践,内部找到使用但是未导入的组件
接下来就开干了
首先上文说到要找到我们要导出哪些组件/工具给外部使用
我们使用babel 分析原来入口文件的components列表及其对应的文件路径
export const getUiExport = () => {
const indexContent = readFileSync(resolve(process.cwd(), 'src/index.js'))
// 解析成ast
const indexAst = parse(indexContent.toString(), {
sourceType: 'module',
})
// 记录出每个import 文件导出了哪些变量
const importsDecs: { fileName: string, exports: string[], name: string }[] = [];
// 组件列表
const packageComponents = []
traverse(indexAst, {
ImportDeclaration(path) {
const exportsArr = path.node.specifiers.map(ele => {
if (t.isImportDefaultSpecifier(ele)) return 'default'
return ele.local.name
})
const name = path.node.source.value.split('/').pop().replace(/.(js|vue)$/, '') || ''
importsDecs.push({
fileName: path.node.source.value,
exports: exportsArr,
name
})
},
ExportNamedDeclaration(path) {
if (t.isExportNamedDeclaration(path.node) && path.node?.declaration?.declarations[0]?.id.name === 'packageComponents') {
packageComponents.push(...path.node.declaration.declarations[0].init.elements.map(ele => ele.name))
}
},
})
// 过滤出来需要导出组件
const components = importsDecs.filter(ele => {
return ele.exports.some(exp => {
const name = exp === 'default' ? ele.name.replace(/.(js|vue)$/, '') : exp;
return (packageComponents as string[]).includes(name)
})
})
return components
}
我们拿到这样的结果
{
"com-a": "src/xx/index.vue",
"com-b": "src/xx/b.js",
}
拿到路径后,我们把每一个组件作为vite打包的资源入口。
到这里基础es版本已经差不多ok了,但是我们这里还有几个历史债要完成,
丢掉的依赖关系怎么补
- 手动review代码补充
- 通过ast 在编译过程找到未导入的内部组件
懒人当然用懒办法,unplugin-vue-components
Components({
dirs: [],
resolvers: [
(componentName) => {
// 看一下当前的组件名是否属于组件库内部组件
const index = privateDepkeys.findIndex(
(ele) => ele === componentName.toLowerCase()
);
if (index < 0) return;
// 有的话就记录一下组件名
const key = Object.keys(privateDep)[index];
comDep[key] = privateDep[key];
},
],
dts: false,
})
拿到当前组件的丢失依赖后,重新生成一下组件的入口文件
export * from './com-a'
import './com-a.css'
import Vue from 'vue';
import ComB from './com-b/index'
Vue.component('ComB', ComB)
这样就可以把丢失的组件在入口重新注册进去
externals的处理
const externals = [
"vue",
"ant-design-vue",
"lodash",
"axios",
"echarts",
"moment",
"vue-video-player",
"qrcodejs2",
'vuex',
'vue-color',
'html2canvas',
'clw-vue-easy-tree',
'clw-vue-easy-tree/src/assets/index.scss',
'js-cookie',
'awesome-qr',
/lodash/.+/, // 注意一下 匹配 lodash/get 这种写法
/echarts/.+/,
'vuedraggable',
'xe-utils',
];
至此我们内部组件库的按需导入也就基本完成了,我们在回顾一下这个过程
结束语
这样就完成了组件库es版本的构建,最后看一下改造的结果
umd版本
es版本
如此巨大的提升,是不是又可以找老板加鸡腿了🍗🍗🍗