前端架构师实战(三):Vue3 业务组件库——LText 组件、Jest 单元测试与 Rollup 多格式打包
系列上一篇我们把可视化编辑器的"画布"跑起来了,这一篇进入课程的另一个硬核阶段:从零开发一个可测试、可打包、可被三种模块规范消费的 Vue3 业务组件库。全程在一台华为云 Ubuntu 24.04 服务器上真实实操,所有命令输出均为实机抓取,源码可直接复用。
前言
做业务组件库和写业务页面是两种完全不同的思维:
- 写业务组件,你要考虑的不是"这个页面怎么好看",而是"这个组件在不知道被谁用的情况下,如何保持正确"——props 校验、样式可配置、事件透传、副作用隔离;
- 做工程化,你要考虑的不是"代码能不能跑",而是"别人怎么用"——ESM 给打包器做 tree-shaking,CJS 给老的 Node 工具链 require,UMD 给
<script>直接引用。
本篇以慕课网前端架构师课程的 qmduo-components 组件库为蓝本,在云服务器上完成:LText / LImage / UploadPhoto 三个组件开发 → Jest + @vue/test-utils 单元测试 → Rollup 打出 esm/cjs/umd 三格式产物 → Node 实测三种产物可用。
环境信息
| 项目 | 版本/配置 | 备注 |
|---|---|---|
| 服务器 | 华为云 ECS(8C/16G/40G) | Ubuntu 24.04,内核 6.8.0-106-generic |
| Node.js | v20.18.1 | 华为云镜像二进制包安装 |
| npm | 10.8.2 | registry 指向国内镜像 |
| git | 2.43.0 | 系统自带 |
| Vue | 3.5.42 | 运行时依赖 |
| Jest | 29.7.0 | 配 @vue/vue3-jest@29.2.6 |
| @vue/test-utils | 2.4.11 | Vue3 官方测试工具库 |
| Rollup | 3.30.0 | 配 rollup-plugin-vue@6.0.0 |
$ node -v && npm -v && git --version
v20.18.1
10.8.2
git version 2.43.0
Node 通过华为云镜像安装,比 apt 里的发行版源快得多,且版本可控:
$ cd /tmp && curl -sSL -O https://repo.huaweicloud.com/nodejs/v20.18.1/node-v20.18.1-linux-x64.tar.xz
$ tar -xf node-v20.18.1-linux-x64.tar.xz -C /usr/local/ && mv /usr/local/node-v20.18.1-linux-x64 /usr/local/node
$ ln -sf /usr/local/node/bin/node /usr/local/bin/node # npm/npx 同样软链
$ npm config set registry https://repo.huaweicloud.com/repository/npm/
一、项目初始化与依赖决策
$ mkdir -p /root/labs/qmduo-components && cd /root/labs/qmduo-components
$ npm init -y && npm install vue@3 --save
$ node -e 'console.log(require("vue/package.json").version)'
3.5.42
原课程里组件库依赖了 ant-design-vue,我按预案先试装一次,华为云镜像下 36 秒完成:
$ timeout 200 npm install ant-design-vue --save
added 29 packages in 36s
$ node -e 'console.log(require("ant-design-vue/package.json").version)'
4.2.6
装是装上了,但我做了一个工程化决策:LText/LImage 核心组件保持零第三方 UI 依赖,用 h()/模板 + 属性透传纯手写。原因很直接——组件库的产物会被下游项目消费,引入 ant-design-vue 意味着每个使用方都要背上它的体积和样式体系;而 LText 这类原子组件,纯 Vue3 API 三十行就能写完,还能让 Rollup 的 external 只排除 vue 一个包,产物干净到 5KB。(如果后续做复杂表单组件,再按需引 antd 也不迟。)
二、核心组件开发:LText 的"属性即样式"设计
2.1 设计原理
LText 是可视化编辑器里出现频率最高的组件——用户在右侧属性面板改字号、颜色、行高,画布上的文本实时变化。它的核心设计有三点:
tag动态标签:用<component :is="tag">让同一个组件渲染成 h1/p/span/div,编辑器里用户可以切换标题级别;- 样式 props 白名单:把
color/fontSize/fontWidth/lineHeight/letterSpacing/textAlign声明成 props,用computed聚合成 style 对象。这样编辑器的属性面板直接 v-model 绑 props 即可,组件内部再做一层fontWidth -> font-weight的语义映射(课程命名习惯,"宽度"指字重粗细); v-bind="$attrs"+inheritAttrs: false:显式把 attrs(包括@click监听器)绑定到真实 DOM 元素上,实现事件透传——这是后面单元测试能trigger('click')的基础。
2.2 完整源码
<!-- src/components/LText/LText.vue -->
<script>
import { computed } from 'vue'
// 样式属性白名单:允许业务方通过 props 控制文本样式
// fontWidth 是课程里的自定义命名,对应 CSS 的 font-weight
const stylePropKeys = [
'color', 'fontSize', 'fontWidth', 'lineHeight', 'letterSpacing', 'textAlign'
]
const styleProps = {}
stylePropKeys.forEach((key) => {
styleProps[key] = { type: String, default: '' }
})
export default {
name: 'LText',
inheritAttrs: false,
props: {
tag: { type: String, default: 'div' },
text: { type: String, default: '' },
...styleProps
},
setup(props) {
// 把样式 props 过滤成 style 对象,空值直接忽略
const stylePropsObj = computed(() => {
const style = {}
stylePropKeys.forEach((key) => {
if (props[key]) {
style[key === 'fontWidth' ? 'fontWeight' : key] = props[key]
}
})
return style
})
return { stylePropsObj }
}
}
</script>
<template>
<component :is="tag" class="l-text" :style="stylePropsObj" v-bind="$attrs">
{{ text }}<slot />
</component>
</template>
<style scoped>
.l-text {
margin: 0;
word-break: break-word;
}
</style>
一个细节:style 对象里的 fontSize 交给 Vue 后会自动转成 kebab-case 的 font-size,所以只需要对 fontWidth 做一次手工映射。
LImage 和 UploadPhoto 思路类似(完整源码见文末目录):LImage 用 computed 把数字型 width/height 自动补 px 单位;UploadPhoto 用原生 input[type=file] + FileReader 做纯前端预览,change/error 双事件向外抛,不绑定任何上传后端——上传地址由使用方注入,这才叫"通用"。
2.3 入口:install + 具名导出双轨制
// src/index.js
import LText from './components/LText/LText.vue'
import LImage from './components/LImage/LImage.vue'
import UploadPhoto from './components/UploadPhoto/UploadPhoto.vue'
const components = [LText, LImage, UploadPhoto]
// 支持 app.use(QmduoComponents) 全局注册
const install = (app) => {
components.forEach((c) => app.component(c.name, c))
}
// tree-shakable 的具名导出 + 默认导出(带 install)
export { LText, LImage, UploadPhoto, install }
export default { install, version: '0.1.0' }
这是 Element Plus 等主流库的标准姿势:具名导出让 Vite/Webpack 用户 import { LText } 后能 tree-shaking;默认导出带 install 支持 app.use() 一把全局注册。
三、Jest 单元测试:版本匹配是第一关
3.1 依赖版本矩阵
Vue3 + Jest 的坑 90% 出在版本上,关键约束是:@vue/vue3-jest 的主版本必须和 jest 主版本一致。我用的是经过验证的组合:
$ npm install --save-dev jest@29 jest-environment-jsdom@29 @vue/vue3-jest@29 \
babel-jest@29 @babel/preset-env @vue/test-utils
$ npm ls --depth=0
├── @babel/preset-env@7.29.7
├── @vue/test-utils@2.4.11
├── @vue/vue3-jest@29.2.6
├── babel-jest@29.7.0
├── jest-environment-jsdom@29.7.0
└── jest@29.7.0
另一个隐性坑:Jest 28 起 jest-environment-jsdom 不再内置,必须显式安装,否则报 Test environment "jsdom" cannot be found。
3.2 配置文件
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
transform: {
'^.+\\.vue$': '@vue/vue3-jest', // .vue 单文件组件交给 vue3-jest
'^.+\\.js$': 'babel-jest' // .js 交给 babel 转译
},
moduleFileExtensions: ['vue', 'js', 'json'],
testMatch: ['**/tests/unit/**/*.spec.js']
}
// babel.config.js
module.exports = {
presets: [['@babel/preset-env', { targets: { node: 'current' } }]]
}
targets: node current 是单测场景的惯用配置——测试跑在 Node 里,不需要转译成 ES5,能大幅加快 transform 速度。
3.3 LText 的 6 个测试用例
测试组件就是测试它的"合同":传入什么,渲染什么,承诺什么行为。
// tests/unit/LText.spec.js
import { mount } from '@vue/test-utils'
import LText from '../../src/components/LText/LText.vue'
describe('LText.vue', () => {
// 1. 渲染传入的文本内容
it('renders props.text when passed', () => {
const wrapper = mount(LText, { props: { text: 'hello qmduo' } })
expect(wrapper.text()).toBe('hello qmduo')
})
// 2. tag 属性决定渲染的 HTML 标签
it('renders h1 tag when tag="h1"', () => {
const wrapper = mount(LText, { props: { tag: 'h1', text: 'title' } })
expect(wrapper.element.tagName).toBe('H1')
})
// 3. 样式属性落到元素 style 上
it('applies fontSize and color to style', () => {
const wrapper = mount(LText, {
props: { text: 'styled', fontSize: '32px', color: '#f00' }
})
const style = wrapper.attributes('style')
expect(style).toContain('font-size: 32px')
expect(style).toContain('color: rgb(255, 0, 0)')
})
// 4. 自定义 fontWidth prop 映射为 font-weight
it('maps fontWidth prop to font-weight', () => {
const wrapper = mount(LText, { props: { text: 'bold', fontWidth: 'bold' } })
expect(wrapper.attributes('style')).toContain('font-weight: bold')
})
// 5. 默认值:不传 tag 时渲染为 div
it('renders div by default when tag not passed', () => {
const wrapper = mount(LText, { props: { text: 'default' } })
expect(wrapper.element.tagName).toBe('DIV')
})
// 6. click 事件透传(v-bind="$attrs")
it('emits click event when triggered', async () => {
const onClick = jest.fn()
const wrapper = mount(LText, {
props: { text: 'click me' },
attrs: { onClick }
})
await wrapper.trigger('click')
expect(onClick).toHaveBeenCalledTimes(1)
})
})
三个值得说的断言细节:
- 用例 3 的颜色断言写的是
rgb(255, 0, 0)而不是#f00——jsdom 会把浏览器侧 style 归一化成 rgb 格式,断言#f00会直接挂掉,这是 jsdom 的经典初学者坑; - 用例 6 的
await不能省,trigger返回 Promise,事件处理器是异步 flush 的; - mock 函数通过
attrs: { onClick }传入而不是写在 props 里,因为onClick在 Vue3 中属于 attrs 透传机制,这正好验证了$attrs透传链路是通的。
3.4 实机运行
$ npx jest
PASS tests/unit/LText.spec.js
PASS tests/unit/UploadPhoto.spec.js
PASS tests/unit/LImage.spec.js
Test Suites: 3 passed, 3 total
Tests: 12 passed, 12 total
Snapshots: 0 total
Time: 0.739 s, estimated 1 s
Ran all test suites.
12 个用例一次全绿,0.7 秒跑完。
四、Rollup 多格式打包
4.1 为什么是三种格式
- ESM:现代打包器的首选,
import { LText }后未引用的组件会被摇掉; - CJS:服务端渲染、Jest、老版本工具链的保底格式;
- UMD:
<script src>引入后挂到window.QmduoComponents,demo 页、CodePen 场景零构建可用。
4.2 打包配置
// rollup.config.mjs
import vue from 'rollup-plugin-vue'
import resolve from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
import postcss from 'rollup-plugin-postcss'
export default {
input: 'src/index.js',
// vue 作为 peerDependency,不打进产物
external: ['vue'],
output: [
{ file: 'dist/qmduo-components.esm.js', format: 'esm' },
{ file: 'dist/qmduo-components.cjs.js', format: 'cjs', exports: 'named' },
{
file: 'dist/qmduo-components.umd.js',
format: 'umd',
name: 'QmduoComponents',
globals: { vue: 'Vue' }
}
],
plugins: [
vue(), // 编译 .vue 单文件组件
resolve(), // 解析 node_modules 依赖
commonjs(), // cjs -> esm 转换
postcss({ extract: true }) // 抽离组件内样式到独立 css
]
}
三个关键点:external: ['vue'] 保证 Vue 不被打进产物(使用方自己装,避免双 Vue 实例);UMD 的 globals: { vue: 'Vue' } 告诉 Rollup 浏览器里 vue 对应全局的 Vue;postcss({ extract: true }) 把每个组件的 scoped 样式抽成独立 css 文件,由使用方按需引入。
4.3 实机打包
$ npx rollup -c rollup.config.mjs
src/index.js → dist/qmduo-components.esm.js, dist/qmduo-components.cjs.js, dist/qmduo-components.umd.js...
(!) Mixing named and default exports
The following entry modules are using named and default exports together:
src/index.js
Consumers of your bundle will have to use chunk.default to access their default export...
created dist/qmduo-components.esm.js, dist/qmduo-components.cjs.js, dist/qmduo-components.umd.js in 122ms
$ ls -lh dist/
total 36K
-rw-r--r-- 1 root root 619 Aug 27 15:51 qmduo-components.cjs.css
-rw-r--r-- 1 root root 5.5K Aug 27 15:51 qmduo-components.cjs.js
-rw-r--r-- 1 root root 619 Aug 27 15:51 qmduo-components.esm.css
-rw-r--r-- 1 root root 5.0K Aug 27 15:51 qmduo-components.esm.js
-rw-r--r-- 1 root root 619 Aug 27 15:51 qmduo-components.umd.css
-rw-r--r-- 1 root root 5.6K Aug 27 15:51 qmduo-components.umd.js
Mixing named and default exports 警告来自入口同时有具名导出和默认导出,对 CJS 输出我已经显式加了 exports: 'named',下游 require('./qmduo-components.cjs.js').LText 和 .default 都能取到,可以放心忽略。
4.4 三种格式产物逐一实测
CJS 直接 require:
$ node -e 'const { LText, LImage, UploadPhoto, install } = require("./dist/qmduo-components.cjs.js"); console.log("LText:", typeof LText, "| install:", typeof install)'
cjs -> LText: object | LImage: object | UploadPhoto: object | install: function
ESM 用动态 import(产物是 .js 后缀而项目未声明 type: module,Node 会按 CJS 解析它,所以复制成 .mjs 再验证——真实使用场景中 ESM 产物由 Vite/Webpack 消费,不受此影响):
$ cp dist/qmduo-components.esm.js dist/_verify.esm.mjs
$ node --input-type=module -e 'import { LText, install } from "file:///root/labs/qmduo-components/dist/_verify.esm.mjs"; console.log("esm LText:", typeof LText, "| install:", typeof install)'
esm -> LText: object | install: function
UMD 用 vm 模块模拟浏览器全局环境,验证它挂到 window.QmduoComponents:
$ node -e 'const fs=require("fs"),vm=require("vm");const code=fs.readFileSync("./dist/qmduo-components.umd.js","utf8");const ctx={Vue:{h:()=>{},createApp:()=>({})}};vm.createContext(ctx);vm.runInContext(code,ctx);const lib=ctx.QmduoComponents;console.log("umd install:",typeof lib.install,"| LText:",typeof lib.LText)'
umd -> install: function | LText: object | LImage: object
三种格式全部实测可用。scoped 样式也完整抽离,带着编译期生成的 data-v hash:
/* dist/qmduo-components.esm.css */
.l-text[data-v-6bf95b7a] {
margin: 0;
word-break: break-word;
}
五、踩坑记录
这一节全是本次实机实操中真实撞上的坑,每一个都有报错日志为证。
坑 1:@rollup/plugin-vue 这个包根本不存在。 按不少旧教程的写法 npm i @rollup/plugin-vue,华为云镜像和 npmmirror 双双 404:
$ npm install --save-dev @rollup/plugin-vue ...
npm error 404 Not Found - GET https://registry.npmmirror.com/@rollup/plugin-vue
npm error 404 '@rollup/plugin-vue@*' is not in this registry.
一开始我以为是镜像同步缺失,切了 registry 还是一样——最后确认是包名本身就错了:@rollup 官方组织下从来没有 plugin-vue,Vue3 SFC 的 Rollup 编译插件实际叫 rollup-plugin-vue@6(Vue 官方生态维护,6.x 才支持 Vue3)。换成 rollup-plugin-vue@^6 一发入魂。同时注意它的 peer 依赖是 rollup 2/3,所以我锁了 rollup@^3 而不是最新 rollup 4。
坑 2:ESM 语法的 rollup 配置文件必须用 .mjs 后缀。 配置里写 import vue from 'rollup-plugin-vue',直接 npx rollup -c 报:
RollupError: Node tried to load your configuration file as CommonJS even though it
is likely an ES module. ... Original error: Cannot use import statement outside a module
项目 package.json 没有 "type": "module",Node 按 CJS 解析 rollup.config.js。三选一:改 .mjs 后缀 / 加 type:module(会影响 jest 配置文件的解析)/ 加 --bundleConfigAsCjs 参数。我选了最无副作用的改名 rollup.config.mjs。
坑 3:rollup@3 的 package.json 没有 exports ./package.json 子路径。 我习惯用 require('rollup/package.json').version 打印版本,rollup 3 直接抛 ERR_PACKAGE_PATH_NOT_EXPORTED,改用 npm ls rollup --depth=0 查版本即可。
坑 4:jsdom 会把颜色归一化。 测试断言 color: #f00 失败,实际渲染出来是 color: rgb(255, 0, 0)。断言样式类属性时要么按浏览器归一化格式写,要么只断言部分子串。
坑 5:UMD 验证时上下文别乱给 module/exports。 用 vm.runInContext 验证 UMD 产物时,如果上下文对象里塞了 module 和 exports,UMD wrapper 会优先走 CommonJS 分支去 require('vue') 然后炸出 ReferenceError: require is not defined。想验证浏览器全局分支,上下文只留 Vue 全局变量,让 UMD 走 factory(global.QmduoComponents, global.Vue) 分支。
总结
这一篇在华为云服务器上从零走完了业务组件库的完整闭环:
- 组件设计:LText 用"样式 props 白名单 + computed 聚合 +
$attrs透传"三件套,把编辑器的属性面板需求和组件渲染彻底解耦;入口用 install + 具名导出双轨制,同时照顾全局注册和 tree-shaking 两种消费方式; - 单元测试:jest@29 与 @vue/vue3-jest@29 严格同主版本,12 个用例覆盖文本渲染、动态标签、样式映射、默认值、事件透传五类合同,0.7 秒全绿;
- 多格式打包:rollup@3 + rollup-plugin-vue@6 一次产出 esm/cjs/umd + css 共 6 个文件、合计 17KB,三种格式在 Node 里逐一实测 require/import/全局挂载可用。
组件库的地基打完,编辑器前端的"渲染层"就齐了。下篇预告:进入后端篇——用 egg.js + MongoDB 搭建可视化编辑器的服务端,实现模板与作品的持久化存储、RESTful 接口设计,以及 Mongoose 数据建模的实战,把"画好的作品"真正存进数据库。敬请关注。
本文全部命令与输出均来自华为云 ECS(Ubuntu 24.04,Node v20.18.1)真实执行记录,配套源码与 evidence 文件已归档,关键文件清单:
src/components/LText/LText.vue、src/components/LImage/LImage.vue、src/components/UploadPhoto/UploadPhoto.vue、src/index.js、tests/unit/*.spec.js、jest.config.js、rollup.config.mjs
:Vue3 业务组件库——LText 组件、Jest 单元测试与 Rollup 多格式打包&spm=1001.2101.3001.5002&articleId=164168738&d=1&t=3&u=388a552806a7410f82a3d5c3ecbb5d11)
5506

被折叠的 条评论
为什么被折叠?



