前端架构师实战(三):Vue3 业务组件库——LText 组件、Jest 单元测试与 Rollup 多格式打包

前端架构师实战(三):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.jsv20.18.1华为云镜像二进制包安装
npm10.8.2registry 指向国内镜像
git2.43.0系统自带
Vue3.5.42运行时依赖
Jest29.7.0配 @vue/vue3-jest@29.2.6
@vue/test-utils2.4.11Vue3 官方测试工具库
Rollup3.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 是可视化编辑器里出现频率最高的组件——用户在右侧属性面板改字号、颜色、行高,画布上的文本实时变化。它的核心设计有三点:

  1. tag 动态标签:用 <component :is="tag"> 让同一个组件渲染成 h1/p/span/div,编辑器里用户可以切换标题级别;
  2. 样式 props 白名单:把 color/fontSize/fontWidth/lineHeight/letterSpacing/textAlign 声明成 props,用 computed 聚合成 style 对象。这样编辑器的属性面板直接 v-model 绑 props 即可,组件内部再做一层 fontWidth -> font-weight 的语义映射(课程命名习惯,"宽度"指字重粗细);
  3. 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 为什么是三种格式

rollup -c

src/index.js
+ 3 个 .vue 组件

Rollup 打包

qmduo-components.esm.js
ESM

qmduo-components.cjs.js
CJS

qmduo-components.umd.js
UMD

Vite / Webpack5
支持 tree-shaking

Node require
SSR / 测试环境

script 标签直接引入
挂 window.QmduoComponents

qmduo-components.*.css
scoped 样式抽离

  • 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 对应全局的 Vuepostcss({ 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@3package.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/exportsvm.runInContext 验证 UMD 产物时,如果上下文对象里塞了 moduleexports,UMD wrapper 会优先走 CommonJS 分支去 require('vue') 然后炸出 ReferenceError: require is not defined。想验证浏览器全局分支,上下文只留 Vue 全局变量,让 UMD 走 factory(global.QmduoComponents, global.Vue) 分支。

总结

这一篇在华为云服务器上从零走完了业务组件库的完整闭环:

  1. 组件设计:LText 用"样式 props 白名单 + computed 聚合 + $attrs 透传"三件套,把编辑器的属性面板需求和组件渲染彻底解耦;入口用 install + 具名导出双轨制,同时照顾全局注册和 tree-shaking 两种消费方式;
  2. 单元测试:jest@29 与 @vue/vue3-jest@29 严格同主版本,12 个用例覆盖文本渲染、动态标签、样式映射、默认值、事件透传五类合同,0.7 秒全绿;
  3. 多格式打包: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.vuesrc/components/LImage/LImage.vuesrc/components/UploadPhoto/UploadPhoto.vuesrc/index.jstests/unit/*.spec.jsjest.config.jsrollup.config.mjs

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值