组件库搭建总结
开始搭建之前要明确需要支持什么能力,再逐个考虑要如何实现。本项目搭建时计划需要支持以下功能:
- 支持组件测试/demo
- 支持不同的引入方式 : 全部引入 / 按需加载
- 支持主题定制
- 支持文档展示
组件测试/demo
本项目是 vue
组件库,组件开发过程中的测试可以直接使用 vue-cli
脚手架,在项目增加了/demos
目录,用来在开发过程中调试组件和开发完成后存放各个组件的例子. 只需要修改在vue.config.js
中入口路径,即可运行 demos
index: {
entry: 'demos/main.ts',
}
"serve": "cross-env BABEL_ENV=dev vue-cli-service serve",
运行时传入了一个 babel 变量 是用来区分 babel 配置的,后面会有详细说明。
打包
js 打包暂时用的还是 webpack
, 样式处理使用的是 gulp
, 考虑支持两种引入方式,全部引入和按需加载,两种场景会有不同的打包需求。
全部引入
支持全部引入,需要有一个入口文件,暴露并可以注册所有的组件。 /src/index.ts
就是全部组件的入口,它导出了所有组件,还有一个install
函数可以遍历注册所有组件(为什么是 install?详见 vue 插件 )。还需要加一些对script
引入情况的处理 —— 直接注册所有组件。
打包的时候需要以入口文件为打包入口,全部组件一起打包。
按需加载
顾名思义,使用者可以只加载使用到的组件的 js 及 css,且不论他通过何种方式来按需引入,就组件库而言,我们需要在打包时将各个组件的代码分开打包,这样是他能够按需引入的前提。这样的话,我们需要以每个组件作为入口来分别打包。
按需加载的实现可以简单的使用require
来实现,虽然有点粗暴,需要使用者require
对应的组件 js 和 css。查看了一些资料和开源库的做法,发现了更人性化的做法,使用 babel 插件辅助,可以帮我们把import
语法转换成require
语法,这样使用者在写法上会更加简单。
比如babel-plugin-component
插件,可以查看文档,会帮我们进行语法转换
import { SectionWrapper } from "xxx";
// 转换成
require("xxx/lib/section-wrapper");
require("xxx/lib/css/section-wrapper.css");
那我们需要在按需加载打包时,按照一定的目录结构来放置组件的 js 和 css 文件,方便使用者用 babel 插件来进行按需加载
样式打包
同样的,全部引入的样式打包和按需加载的样式打包也有所不同。
全部引入时,所有的样式文件(组件样式,公共样式)打包成一份文件,使用时引入一次即可。
按需加载时,样式文件需要分组件来打包,每个组件需要生产一份样式文件,使用时才能分开加载,只引入需要的资源,因为要使用 babel 插件,所以还要控制样式文件的位置。
所以样式在编写时,就需要公共/组件分开文件,这样方便后面打包处理,考虑目录结构如下:
│ └─ themes
│ ├─ src // 公共样式
│ │ ├─ base.less
│ │ ├─ mixins.less
│ │ └─ variable.less
│ ├─ form-factory.less // 组件样式
│ ├─ index.less // 所有样式入口
themes/index.less
会引入所有组件的样式及公共样式
themes/components-x.less
只包含组件的样式
公共资源
组件之间公用的方法/指令/样式,当然希望能在使用时只加载一份。
公共样式
全部引入时没有问题,所有的样式文件都会一起引入。
按需加载时,不能在组件样式文件中都打包进一份公共样式,这样引入多个组件时,重复的样式太多。考虑把公共样式单独打包出来,按需引入的时候,单独引入一次公共样式文件。这次引入也可以通过babel-plugin-component
插件帮我们实现,详见文档中的相关配置。
公共 JS
有些js资源(方法/指令)是多个组件都会用到的,不能直接打包到组件中,否则按需加载多个组件时会出现多份重复的资源。所以考虑让组件不打包这些资源,要用到 webpack.externals
配置,webpack.externals
可以从输出的 bundle 中排除依赖,在运行时会从用户环境中获取,详见文档。
这里需要考虑的时,如何辨别哪些是公共js,以及在用户环境中要去哪里获取? , 这里是参考element-ui
的做法
公共JS通过目录来约定,src/utils/directives
下为公共指令,src/utils/tools
下为公共方法,同样的,引入公共资源的时候也约定好方式,按照配置的webpack.resolve.alias
, 这样在可以方便配置 webpack.externals
// webpack.resolve.alias
{
alias: {
'xxx': resolve('.')
}
}
// 引入资源通过 xxx/src/...
import ClickOutside from 'xxx/src/utils/directives/clickOutside'
// 配置`webpack.externals`
const directivesList = fs.readdirSync(resolve('/src/utils/directives'))
directivesList.forEach(function(file) {
const filename = path.basename(file, '.ts')
externals[`xxx/src/utils/directives/${filename}`] = `yyy/lib/utils/directives/${filename}`
})
至于要如何在用户环境中获取,在打包时会吧utils
中资源也一起打包发布,所以通过 发布的包名(package.json 中的 name)来获取,也就是上面示例代码中的yyy
。
下一步就是要考虑如何处理utils
中的文件?,utils
中的资源也可能会相互应用,比如方法A中使用了方法B,也需要在处理的时候,要避免相互引入,也要每个单独处理(babel)成单个文件,因为使用者会在用户环境中寻找单个的资源。
直接使用bable命令行来处理会更加方便
"build:utils": "cross-env BABEL_ENV=utils babel src/utils --out-dir lib/utils --extensions '.ts'",
会对每个文件进行babel相关的处理,生成的文件会在 lib/utils
中,和上面的webpack.externals
配置时对应的
另外还要使用babel-plugin-module-resolver
插件,查看 文档,这里的作用是让打包之后到新的地方去找文件。比如在 utils/tools/a
中import B from 'xxx/src/utils/b'
,打包之后,会到 'xxx/lib/utils/'
下去找对应的资源
{
plugins: [
['module-resolver', {
root: ['xxx'],
alias: {
'xxx/src': 'xxx/lib'
}
}]
]
}
不需要被打包的依赖
本项目中会使用到ant-design-vue
和vue
库,但是都不需要被打包,这应该是由使用者自己引入的。
webpack.externals
在上面有用到过,在打包时可以排除依赖
peerDependencies
可以保证所需要的依赖被安装,详见文档
这两个配合就可以实现不打包ant-design-vue
和vue
不被打包,也不会影响组件库的运行
实现
综上,简单总结下,我们在打包时需要做的事情
- 全部引入和按需加载需要分开打包
- 支持全部引入需要,以
src/index.ts
为入口进行打包,并且需要打包出一份包含所有样式的 css 文件 - 支持按需加载需要,以每个组件为入口打包出独立的文件,并且需要单独打包出每个组件的样式文件和一份公共样式文件。之后需要按照对应的目录结构放好文件,方便配合 babel 插件实现按需加载
- 排除不需要被打包的依赖
需要两份不同的打包,分别对应全部引入和按需加载的打包
"build:main": "cross-env BABEL_ENV=build webpack --config build/webpack.main.config.js",
"build:components": "cross-env BABEL_ENV=build webpack --config build/webpack.components.config.js",
以下是两种打包方式都需要做的事情
配置 webpack.externals
、 loader
、 plugins
function getUtilsExternals() {
const externals = {}
const directivesList = fs.readdirSync(resolve('/src/utils/directives'))
directivesList.forEach(function(file) {
const filename = path.basename(file, '.ts')
externals[`xxx/src/utils/directives/${filename}`] = `xxx/lib/utils/directives/${filename}`
})
const toolsList = fs.readdirSync(resolve('src/utils/tools'))
toolsList.forEach(function(file) {
const filename = path.basename(file, '.ts')
externals[`xxx/src/utils/tools/${filename}`] = `xxx/lib/utils/tools/${filename}`
})
return externals
}
// webpack配置
{
mode: 'production',
devtool: false,
externals: {
...getUtilsExternals(),
vue: {
root: 'Vue',
commonjs: 'vue',
commonjs2: 'vue',
amd: 'vue'
},
'ant-design-vue': 'ant-design-vue'
},
module:{
// 相关loader
rules: [
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
loaders: {
ts: 'ts-loader',
tsx: 'babel-loader!ts-loader'
}
}
},
{
test: /\.tsx?$/,
exclude: /node_modules/,
use: [
'babel-loader',
{
loader: 'ts-loader',
options: { appendTsxSuffixTo: [/\.vue$/] }
}
]
}
]
},
plugins: [
new ProgressBarPlugin(),
new VueLoaderPlugin() // vue loader的相关插件
]
}
全部引入
以下是全部引入的入口和输出,这里打包输出到lib目录下,lib目录是打包后的目录。
这里需要注意的是同时要配置package.json
中的相关字段(main
,module
),这样发布之后,使用者才知道入口文件是哪个,详见 文档
这里还需要注意output.libraryTarget
的配置,要根据需求来配置对应的值,详见文档
{
entry: {
index: resolve('src/index.ts')
},
output: {
path: resolve('lib'),
filename: '[name].js',
libraryTarget: 'umd',
libraryExport: 'default',
umdNamedDefine: true,
library: 'xxx'
},
}
按需引入
以下是按需的入口和输出,入口是解析到所有的组件路径,output
的 libraryTarget
也不同,因为按需加载没法支持浏览器加载,所以不需要umd
模式
// 解析路径函数
function getComponentEntries(path) {
const files = fs.readdirSync(resolve(path))
const componentEntries = files.reduce((ret, item) => {
if (item === 'themes') {
return ret
}
const itemPath = join(path, item)
const isDir = fs.statSync(itemPath).isDirectory()
if (isDir) {
ret[item] = resolve(join(itemPath, 'index.ts'))
} else {
const [name] = item.split('.')
ret[name] = resolve(`${itemPath}`)
}
return ret
}, {})
return componentEntries
}
// webpack配置
{
entry: {
// 解析每个组件的入口
...getComponentEntries('components')
},
output: {
path: resolve('lib'),
filename: '[name]/index.js',
libraryTarget: 'commonjs2',
chunkFilename: '[id].js'
},
}
样式处理
使用gulp
处理样式,对入口样式(所有样式)/ 组件样式 / 公共样式 进行相关处理(less -> css, 前缀,压缩等等),然后放在对应的目录下
// ./gulpfile.js
function compileComponents() {
return src('./components/themes/*.less') // 入口样式,组件样式
.pipe(less())
.pipe(autoprefixer({
cascade: false
}))
.pipe(cssmin())
.pipe(dest('./lib/css'))
}
function compileBaseClass() {
return src('./components/themes/src/base.less') // 公共样式
.pipe(less())
.pipe(autoprefixer({
cascade: false
}))
.pipe(cssmin())
.pipe(dest('./lib/css'))
}
主题定制
实现主题定制,主要的思路是样式变量覆盖,比如本项目中使用的是less
来书写样式,而在less
中,同名的变量,后面的会覆盖前面的,详见 文档
作为组件库,支持主题定制,需要做两点:
- 会把可能需要变化的样式定义成样式变量,并告诉使用者相关的变量名
- 提供
.less
类型的样式引入方式
项目中的样式本就是通过.less
格式编写的,且定义了部分可修改的变量名 components\themes\src\variable.less
,需要提供引入less样式的方式即可,要将将less
样式整体复制到lib
中
// ./gulpfile.js
function copyLess() {
return src('./components/themes/**')
.pipe(cssmin())
.pipe(dest('./lib/less'))
}
需要自定义样式时,需要使用者,引入less
样式文件。如果此时需要按需引入的话,要require
对应的组件js文件,不能通过babel插件来实现,因为后者会引入默认的组件样式,和less样式相互影响且重复。
文档化
考虑能有一个门户网站,能包含组件库的所有示例和使用文档。
本项目使用了 storybook
来实现,详见 文档。
所有的内容都在.storybook/
目录中,需要为每一个组件都编写一个对应的 story
类型文件
本项目本身是采用ts编写的,本来考虑采用取巧的方式,通过 typescript编译器 自动生成类型文件的
独立有一份tsconfig.json
,配置了需要生成类型文件
"declaration": true,
"declarationDir": "../types",
"outDir": "../temp",
"types": "rimraf types && tsc -p build && rimraf temp"
,运行时会把.ts编译为.js,随便生成类型文件,然后删掉生成的js文件即可,这样就只会留下.d.ts
类型文件。
但是这种方式生成的类型文件有点乱,有的还需要自己调整,所以就还是手写。除了查看 typescript官网外,还可以查看 文档
目录结构
最终,整体的目录结构是
xxx
├─ build webpack配置
│ ├─ config.js
│ ├─ tsconfig.json
│ ├─ utils.js
│ ├─ webpack.components.config.js
│ └─ webpack.main.config.js
├─ components 组件源码
│ ├─ form-factory
│ │ ├─ formFactory.tsx
│ │ └─ index.ts
│ └─ themes 组件样式
│ ├─ src
│ │ ├─ base.less
│ │ ├─ mixins.less
│ │ └─ variable.less
│ ├─ form-factory.less
│ ├─ index.less
├─ demos 调试文件
├─ dist storybook打包目录
├─ lib 组件库打包目录
│ ├─ css
│ │ ├─ base.css
│ │ ├─ form-factory.css
│ │ ├─ index.css
│ ├─ form-factory
│ │ └─ index.js
│ ├─ less
│ │ ├─ src
│ │ │ ├─ base.less
│ │ │ ├─ mixins.less
│ │ │ └─ variable.less
│ │ ├─ form-factory.less
│ │ ├─ index.less
│ ├─ section-wrapper
│ │ └─ index.js
│ └─ index.js
├─ public
├─ src
│ ├─ utils 工具函数
│ │ ├─ directives
│ │ ├─ tools
│ ├─ global.d.ts
│ ├─ index.ts 组件库入口
│ └─ shims-tsx.d.ts
├─ tests 测试文件
├─ types 类型文件
├─ babel.config.js babel配置
├─ gulpfile.js gulp配置
├─ jest.config.js jest配置
├─ package.json
├─ readme.md
├─ tsconfig.json typescript配置
└─ vue.config.js vue-cli配置
发布
发布时需要注意的是package.json
的相关配置,除了上面提到的main
,module
外,还需要配置以下字段
{
"name": "xxx",
"version": "x.x.x",
"typings": "types/index.d.ts", // 类型文件 入口路径
"files": [ // 发布时需要上传的文件
"lib",
"types",
"hcdm-styles"
],
"publishConfig": { //发布地址
"registry": "http://xxx.xx.x/"
}
}
其他
环境变量的使用
通过 cross-env
在执行脚本时可以传入变量来做一些事情,本项目用到了两处
- 通过
BABEL_ENV
来让babel.config.js
配置来区分环境;vue-cli中提供的@vue/cli-plugin-babel/preset
里面配置的东西太多了,导致组件库打包出来体积增大,所以只在变量为dev
的时候使用,build
的时候使用更简单的必要配置,如下:
module.exports = {
env: {
dev: {
presets: [
'@vue/cli-plugin-babel/preset'
]
},
build: {
presets: [
[
'@babel/preset-env',
{
loose: true,
modules: false
}
],
[
'@vue/babel-preset-jsx'
]
]
},
utils: {
presets: [
['@babel/preset-typescript']
],
plugins: [
['module-resolver', {
root: ['xxx'],
alias: {
'xxx/src': 'yyy/lib'
}
}]
]
}
}
}
- 通过
BUILD_TYPE
来控制是否需要引入打包分析插件
if (process.env.BUILD_TYPE !== 'build') {
configs.plugins.push(
new BundleAnalyzerPlugin({
analyzerPort: 8123
})
)
}
&&
串联执行脚本
"build:lib": "npm run clean &&cross-env BUILD_TYPE=build npm run build:main && cross-env BUILD_TYPE=build npm run build:components && gulp",
&&
可以串联执行脚本,前一个命令执行完才会执行下一个脚本,可以将一组有前后关系的脚本组合在一起