对组件库的构想
搭建一个MVP(最小可用的)组件库,该从何入手呢?
我想首先是基于团队当前的实际情况,考虑兼容性、需要支持一些什么使用场景、需要实现到什么程度。可以先实现一个简单的组件库,能先用起来,然后再后续慢慢一点点做优化,以下是我想到的实现一个MVP组件库需要做的事:
- 当前项目使用的是Vue2.6.14,而我想在不改动当前项目中 Vue2 版本的情况下实现一个公共组件库,把当前项目的改动放到最小,直接引入一个组件库以插件的形式使用即可(其实用vue-demi可以实现Vue版本的兼容性,后续有时间的话会踩踩坑试下)
- 既然是Vue2.6.14,可以选择使用 vue-cli 来快速生成项目模板,但 Vite 的开发体验明显是更好的。(一开始我以为 Vite 仅支持 Vue2.7(
@vitejs/plugin-vue2),后来了解到通过vite-plugin-vue2插件是可以支持 Vue2.6及更早的版本的),后续考虑改用 Vite 做优化 - 考虑打包产物,一般组件库打包后,需要支持的格式有cjs、umd、iife、esm
- 希望封装的组件能有一个demo示例可以查看效果,所以我打算写一个docs文档以支持demo的展示,考虑使用 Vitepress 搭建文档,但踩过坑发现 Vitepress 不支持对 Vue2 打包,后续考虑改用 Vuepress
- 可以支持
git submodule的方式引入组件库使用,在git submodule的情况下组件库最终也是以打包产物(插件)的形式被使用 - 因为有两个项目:一个组件库,一个docs文档,所以我打算使用 monorepo 的代码管理方式实现组件库与文档的二合一。对于组件库来说,使用 monorepo 的好处之一是,在docs以插件的形式引入组件库的打包文件,可以即时查看到组件的编辑效果,这样免去了组件库发包或者
npm link软链接操作、无 HMR 的麻烦
以上是我对一个公共组件库的简单构想,后面遇到更多实际问题的时候再一一完善。
说再多也不够写代码来得实在,so 接下来我会把我搭建组件库的流程过一遍,文末附有源码,有需自取。
monorepo
1、初始化项目
pnpm init
2、根目录新建 pnpm-workspace.yaml 文件
touch pnpm-workspace.yaml
文件内容:
packages:
- packages/*
3、根目录新建 packages 文件夹
该文件夹下,分别放置两个项目:
- /packages/components —— 组件库项目
- /packages/docs —— demo文档项目,先不创建,后面搭建docs文档时自动生成
搭建组件库
基于 vue-cli 搭建
初始化
mkdir packages
先确保安装了vue-cli
npm install -g @vue/cli
vue --version
# @vue/cli 5.0.8
然后创建项目:
cd packages
vue create components
Vue CLI v5.0.8
? Please pick a preset: Manually select features
? Check the features needed for your project: Babel, TS, CSS Pre-processors
? Choose a version of Vue.js that you want to start the project with 2.x
? Use class-style component syntax? Yes
? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? Yes
? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): Less
? Where do you prefer placing config for Babel, ESLint, etc.? In dedicated config files
? Save this as a preset for future projects? No
🎉 Successfully created project components.
👉 Get started with the following commands:
$ cd components
$ npm run serve
运行项目成功:
组件库目录设计
接着改造项目目录:
├── README.md
├── babel.config.js
├── package-lock.json
├── package.json
├── public
│ ├── favicon.ico
│ └── index.html
├── src
│ ├── _util
│ │ └── index.ts
│ ├── components
│ │ ├── MyButton
│ │ │ ├── index.ts
│ │ │ ├── index.vue
│ │ │ └── style
│ │ │ └── index.less
│ │ └── index.ts
│ ├── main.ts
│ ├── shims-tsx.d.ts
│ └── shims-vue.d.ts
├── tsconfig.json
└── vue.config.js
因为组件库最终要以Vue插件的形式被引入使用,所以改造入口文件如下:
- src/main.ts
import { VueConstructor } from 'vue';
import * as components from './components';
export * from './components';
export const install = function(app: VueConstructor) {
Object.keys(components).forEach((key) => {
const component = (components as any)[key];
if (component.install) {
app.use(component);
}
});
};
export default {
install,
};
- src/components.ts
以 Button 组件为例
export { default as MyButton } from './MyButton';
- src/components/MyButton/index.vue
<template>
<div>Hello, this is my custom button. </div>
</template>
<script>
export default {
name: 'MyButton'
}
</script>
<style lang="less" scoped>
</style>
- src/components/MyButton/index.ts
import './style/index.less';
import MyButton from './index.vue';
import { withInstall } from '../_util';
export default withInstall(MyButton);
- src/_util.ts
import { VueConstructor } from 'vue';
function toUpperCase(str: string) {
return str.slice(0, 1).toUpperCase() + str.slice(1);
}
export const withInstall = (comp: any) => {
const c = comp as any;
c.install = function (app: VueConstructor) {
app.component(toUpperCase(c.name), comp);
};
return comp;
};
vue-cli 打包
- 基于 vue-cli 打包,可以使用以下这个命令:
vue-cli-service build --target lib --name myLib src/main.ts
- 配置打包输出文件夹名:
const { defineConfig } = require('@vue/cli-service');
module.exports = defineConfig({
transpileDependencies: true,
outputDir: 'vue-cli-dist',
});
打包结果:
如图所示,在项目根目录下会生成一个名为 vue-cli-dist 的包。
很遗憾地,vue-cli 没有生成 ESM 格式的产物。这是因为 vue-cli 是基于 webpack 实现的,而 webpack 不支持 ESM 格式的输出产物,但是有支持了 UMD。
UMD
UMD,一种适用于任何环境使用的规范,兼容了 AMD 和CommonJS、IIFE。所以即使 webpack 不支持 ESM,其实也可以引入 UMD 格式文件在浏览器端使用。
UMD的实现原理:
- 先判断是否支持Node.js模块格式(exports是否存在),存在则使用Node.js模块格式。
- 再判断是否支持AMD(define是否存在),存在则使用AMD方式加载模块。
- 前两个都不存在,则将模块公开到全局(window或global)。
AMD
AMD,异步模块定义,一种基于 RequireJS 工具实现的前端模块化规范,适用于浏览器。AMD 是作为 CommonJS 的同步加载不适用于浏览器而出现的。在浏览器中使用同步加载可能会遇到性能问题,因为同步加载可能会阻塞页面渲染。
define('myModule', ['jquery'], function($) {
// $ 是 jquery 模块的输出
$('body').text('hello world'); });
// 使用
require(['myModule'], function(myModule) {});
CJS
CJS(CommonJs) ,一种适用于后端 Node 环境的规范,使用同步加载的方式。
CommonJs 是引入对象的一个拷贝,可以直接运行在后端环境中。故 CommonJs 在浏览器环境中是无效的,必须要经过编译和打包后才能在浏览器环境中执行。
前端模块化趋势,探讨CJS、AMD、UMD和ESM的演进之路
ESM
ESM(ES Module)是 Javascript 提出的实现一个标准模块系统的方案,从 ES6(ES2015)开始引入,采用import和export语法来导入和导出模块,与现代浏览器和 Node.js 兼容。
通过设置 script 标签属性 type="module" 能够引入 ESM 模块文件使用。
<script src="./main.js" type="module"></script>
既然有了 UMD 为啥还用 ESM 呢?
ESM 相较于 UMD,有以下优点:
- ESM 可以替代 CJS 与 AMD,并且兼备 UMD 任何环境都可使用的特性。
- 自身的静态化特点,在编译时加载,使得页面加载速度快。
- 真正意义上做到了按需使用。使用 import 并不会直接执行模块,而是生成一个动态的只读引用,等到真的需要用到时,才会到模块里面去读取。
前端模块化趋势,探讨CJS、AMD、UMD和ESM的演进之路
打包
webpack 与 rollup
一般来说,webpack 更适用于组件库的打包、大型复杂应用的打包,而 rollup 更适用于 JS 类库的打包。
webpack 特点:
- 天然支持 CommonJS 模块打包,不需要额外配置插件
- 生态丰富,支持 HMR
- 支持生成 CJS、UMD、IIFE 格式产物,不支持 ESM 格式产物
rollup 的优势在于:
-
打包速度快
rollup 得益于 ESM 的支持,它的打包速度更快,ESModule 可以在编译时进行静态优化,还支持 Tree shaking,可以在代码打包时删除未使用的代码,从而减少打包后代码的体积和加载时间,这也是 ESModule 比 CommonJS 更快的原因之一。
-
引入 CommonJS 模块使用,需要配置插件支持
-
对于更复杂的使用场景,需要通过配置插件以支持
-
支持生成 ESM、CJS、UMD、IIFE 格式产物
-
虽然没有 HMR 支持,但是后续通过 monorepo workspace 的方式引入组件库到 docs 文档上使用的时候,其实是可以做到边写边查看更新效果的,所以对此来说,跟有没 HMR 也没太大关系
选 webpack 还是 rollup
虽然一般来说 “Use Webpack for Apps, Use Rollup for Libries”,但其实我个人是更倾向于使用 rollup 的,因为打包速度更快不用等太久,打包产物体积更小,而且能输出 ESM 格式产物。缺点是为了支持更复杂的场景,需要花点时间配置插件。
而 webpack 虽然打包慢,但更强大,不需要额外配置太多的插件就可以成功打包,在复杂场景下,还是使用 webpack 来更稳妥点吧,就当作是 rollup 打包不成功的一个后备方案。
因此,最终这两种打包方式我都实现了,选择用哪个都行,只是如果在使用 rollup 打包不成功的时候尝试自己去配置一下插件使用或者选择 webpack 的打包方式。
webpack 打包
- vue.config.ts
const { defineConfig } = require('@vue/cli-service');
module.exports = defineConfig({
transpileDependencies: true,
outputDir: 'vue-cli-dist',
});
- package.json
{
"scripts": {
"vue-cli:build": "vue-cli-service build --target lib --name myLib src/main.ts && npm run vue-cli-dist:copy",
"vue-cli-dist:copy": "rm -rf ./../../output/ && copyfiles -u 1 ./vue-cli-dist/* ./../../output/"
},
}
pnpm i copyfiles -w
rollup 打包
- rollup.config.js
import { defineConfig } from 'rollup';
import typescript from '@rollup/plugin-typescript';
import vue from 'rollup-plugin-vue';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const pkg = require('./package.json');
export default defineConfig({
plugins: [
typescript(),
vue({
css: true,
})
],
input: './src/main.ts',
output: [
{
file: 'dist/myLib.common.js',
format: 'cjs',
name: 'myLib',
sourcemap: true
},
{
file: 'dist/myLib.umd.js',
format: 'umd',
name: 'myLib',
sourcemap: true
},
{
file: 'dist/myLib.esm.js',
format: 'es',
name: 'myLib',
sourcemap: true
},
{
file: 'dist/myLib.js',
format: 'iife',
name: 'myLib',
sourcemap: true,
globals: {
vue: 'Vue',
}
}
],
external: Object.keys(pkg.dependencies || {}),
});
- package.json
{
"scripts": {
"build": "rm -rf dist && rollup -c rollup.config.js && npm run copy",
"copy": "rm -rf ./../../output/ && copyfiles -u 1 ./dist/* ./../../output/",
},
}
打包结果:
dist
├── myLib.common.js
├── myLib.common.js.map
├── myLib.esm.js
├── myLib.esm.js.map
├── myLib.js
├── myLib.js.map
├── myLib.umd.js
└── myLib.umd.js.map
基于 Vite 搭建-优化
上面是基于 vue-cli 搭建的组件库项目,现在打算改用 vite 来搭建项目并完成打包。
选择 vite 的原因:
- 能够兼容处理 vue2.6.14
- 构建速度快
- vite 内置了 rollup,不需要额外安装 rollup 和 配置太多的 rollup 插件,直接在 vite.config.ts 写打包配置就可以
初始化
npm init vite@latest
√ Project name: ... components
? Select a framework: » - Use arrow-keys. Return to submit.
? Select a framework: » - Use arrow-keys. Return to submit.
> Vanilla
? Select a variant: » - Use arrow-keys. Return to submit.
> TypeScript
Done. Now run:
cd components
npm install
npm run dev
修改
完成项目搭建后,把前面写好的组件库 src 目录的内容拷贝到这个新建的项目覆盖掉。
需要注意的问题:
-
添加依赖,注意版本号
- "vite": "^4.0.0"
- "vue-template-compiler": "2.6.14"
- "vue": "^2.6.14"
{
"devDependencies": {
"less": "^4.2.0",
"typescript": "^5.2.2",
"vite": "^4.0.0",
"vue": "^2.6.14"
},
"dependencies": {
"vite-plugin-vue2": "^2.0.3",
"vue-template-compiler": "2.6.14"
}
}
- 添加入口
// package.json
{
"main": "./src/main.ts",
"module": "./src/main.ts",
}
- 打包配置
// vite.config.ts
import { createVuePlugin } from 'vite-plugin-vue2';
export default {
plugins: [
createVuePlugin(),
],
build: {
sourcemap: true,
lib: {
entry: './src/main.ts',
name: 'MyLibUI',
formats: ['es', 'umd', 'iife', 'cjs'],
fileName: 'my-lib-ui'
}
}
}
打包
修改打包命令
"scripts": {
"build": "tsc && vite build && npm run copy",
"copy": "rm -rf ./../../output/ && copyfiles -u 1 ./dist/* ./../../output/"
}
搭建docs项目
基于Vuepress 搭建
使用 create-vuepress 可以快速创建项目模板。
有 vite 和 webpack 这两种打包器可以选择,我这里选择了 vite。
初始化
$ pnpm create vuepress docs
? Select a language to display / 选择显示语言 简体中文
? 选择包管理器 pnpm
? 你想要使用哪个打包器? vite
? 你想要创建什么类型的项目? docs
生成 package.json...
? 设置应用名称 my-lib-docs
? 设置应用版本号 0.0.1
? 设置应用描述 A VuePress project
? 设置协议 MIT
? 是否需要一个自动部署文档到 GitHub Pages 的工作流? No
配置
目录
docs
├── docs
│ ├── README.md
│ └── pages
│ ├── Button
│ │ └── README.md
│ ├── Icon
│ │ └── README.md
│ └── index.md
└── package.json
侧边栏
import { defaultTheme } from '@vuepress/theme-default'
import { defineUserConfig } from 'vuepress/cli'
import { viteBundler } from '@vuepress/bundler-vite'
export default defineUserConfig({
lang: 'en-US',
title: 'MyLibUI',
description: '组件库文档',
theme: defaultTheme({
navbar: [
{
text: '快速上手',
link: '/', // TODO
},
{
text: '组件',
link: '/pages/Button/',
}
],
sidebar: {
'/pages/': [
{
text: 'Button',
link: '/pages/Button/',
},
'/pages/Icon/',
]
}
}),
bundler: viteBundler(),
})
workspace 安装组件库
添加 .gitignore
docs/.vuepress/.cache
docs/.vuepress/.temp
dist
node_modules
package.json name 属性值
// my-lib-ui/package.json
{
"name": "my-lib-ui",
}
// my-lib-ui/packages/components/package.json
{
"name": "@my-lib-ui/components"
}
// my-lib-ui/packages/docs/package.json
{
"name": "@my-lib-ui/docs"
}
安装
注意一定要加上 --workspace,不然安装的时候会误以为你要装的是线上包
pnpm i @my-lib-ui/components -F @my-lib-ui/docs --workspace
安装完成,就会在 docs 项目中看到多出了一个 dependencies
{
"dependencies": {
"@my-lib-ui/components": "workspace:^"
}
}
使用组件库
全局引入
在 docs 项目中,新建 client.js
// docs/.vuepress/client.js
import { defineClientConfig } from 'vuepress/client'
import MyLibUI from '@my-lib-ui/components'
export default defineClientConfig({
enhance({ app, router, siteData }) {
app.use(MyLibUI); // 以插件的形式,全局安装使用
},
setup() {},
rootComponents: [],
})
这样,在 md 文件中就可以使用全局注册组件了:
## Button
<script setup>
import { ref } from 'vue'
const count = ref(false)
</script>
<div>
<MyButton />
</div>
看下效果,可以看到在组件库项目中写的 Button demo 组件内容正常显示在了 docs:
按需引入
<script setup>
import { ref } from 'vue'
import { MyButton } from '@my-lib-ui/components'
const count = ref(false)
</script>
<div>
<MyButton />
</div>
局部注册也是可以正常显示的。
当组件非常多的时候,还是使用局部注册的方式比较好,按需加载。
路径别名配置
import animationData from '@assets/data.json'
如上,假如我要在 md 文件引入 json 资源,vuepress 会报错,提示在 .temp 文件夹下找不到资源,那么就可以通过配置别名的方式,让vuepress能够识别正确的路径。
还有,由于 @my-lib-ui/components 名字太长,也可以通过别名的方式简化书写。
// docs/.vuepress/config.js
import { defaultTheme } from '@vuepress/theme-default'
import { defineUserConfig } from 'vuepress/cli'
import { getDirname, path } from 'vuepress/utils'
const __dirname = getDirname(import.meta.url);
export default defineUserConfig({
lang: 'zh-CN',
alias: {
'@assets': path.resolve(__dirname, '../src/assets'),
'@components': path.resolve(process.cwd(), 'node_modules/@my-lib-ui/components'),
},
})
这样就可以使用别名的方式引入了
import animationData from '@assets/data.json'
import { MyButton } from '@components'
调试组件库
可以尝试下修改组件库项目中的Button组件内容,可以看到在docs项目中会实时地展示了更新的内容。
打开 node_modules,你会发现,整个 @my-lib-ui/components 项目被 “拷贝” 到了这里。
当 import @my-lib-ui/components 使用的时候,那么就会找到 @my-lib-ui/components 目录下的 package.json 的 module 属性,它的属性值 .src/main.ts 指向了入口文件。
所以,当 import @my-lib-ui/components 的时候,引入的是 node_modules/@my-lib-ui/components/src/main.ts export 出来的成员。
So,此处回答以上所说:虽然没有 HMR 支持,但是后续通过 monorepo workspace 的方式引入组件库到 docs 文档上使用的时候,其实是可以做到边写边查看更新效果的,所以对此来说,跟有没 HMR 也没太大关系。