打包发布 React 组件库

打包发布 React 组件库

起因

「代码写了不测等于白写」我总是跟身边的朋友这样调侃。然而我们写前端项目时很难在代码层面进行测试,大部分函数都是基于事件响应,接收用户输入的参数,并对页面组件或数据产生一定副作用,Mock 起来很麻烦。所以前端项目的测试往往都是端到端测试,即模拟用户在页面上进行操作,测试路径越离奇越好,因为无法提前预知用户会如何使用,所以最好在测试时可劲儿造。

曾经还会想着用 Cypress 等自动化工具进行端到端测试,例如用代码定义【打开某页面–>拖拽滑动条至页面下方–>点击输入框使之获取焦点–>输入“Hello world”–>按下回车–>等待页面响应–>观察响应是否符合预期】这个过程,但只要遇到元素稍多的页面,编写测试用例的过程就会变得机械呆板。

如果组件足够小,内容够聚焦,那么测一下也不是不可以。因为想在不同的项目中复用同一套富文本编辑组件(体积比较大,且包含机器构建的 JS),我把它单独提出来作为 NPM 包发布以便各个项目安装使用。这当中编码和测试都遇到了一些问题。

打包经过

目标

既然要在不同项目之间共用,那该组件肯定至少已经应用到一个项目中。所以最终目标就是把项目中原先引入的组件完全替换成为 NPM 包版本的组件后,所有富文本编辑预览功能都照常。

原先组件结构

CKEditorFormFields.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import "./some-styles.css";
import React from "react";
import { CKEditor } from "@ckeditor/ckeditor5-react";
import CustomBuildEditor from "@ckeditor/ckeditor5-custom-build";

export const CKEditorInput: React.FC<ControllableFormFieldProps> = () => {
/*
* ...
*/
};
export const CKEditorRenderer: React.FC<ControllableFormFieldProps> = () => {
/*
* ...
*/
};

原先的组件定义基本上如上面片段所示,其中 CustomBuildEditor 利用了 CKEditor5自定义构建,算是按项目需要选取必要功能构建出来的编辑器母版,它本身的使用方法很 HTML,不太适合直接用在 React 项目里,需要用 @ckeditor/ckeditor5-react 进行包装。

而这个 CustomBuildEditor 是自定义构建工具编译好之后打包好后(后续用 ckeditor-dist 称呼)下载到本地的,如果不用 NPM 包的话需要在几个项目间复制粘贴。或许因为我们项目用的是 TS,无法直接从本地目录下直接引入,所以我们用 package.json 依赖的文件链接定义了一个叫做 @ckeditor/ckeditor5-custom-build 的假包供代码引入使用,但这个方法时而奏效时而报错,或是在张三电脑上能用而李四电脑上用不了。可以确保解决问题的方法是将该 ckeditor-dist 目录复制到 node_modules 当中,但是这样过于原始。于是决定有时间研究一下 NPM 打包。

Hello-richtext

目标富文本组件包名叫做 Hello-richtext,它需要依赖我们自定义构建的富文本编辑器母版,所以首先将 ckeditor-dist 单独作为一个 NPM 包发布到我们团队的私有制品库中,就起名为 ckeditor-custom-build

ckeditor-custom-build 加入到依赖中,原先组件中包含的所有文件都复制到 Hello-richtext 的代码目录中。利用 tsc"target": "ESNest" 的配置将 .tsx 格式的文件编译为 .js.d.ts 文件,或许这也是最终在前端项目中被应用时的引入形式。

随后利用 JestReact Testing Library 写下了如下的测试用例。

render.test.jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import React from "react";
import { render } from "@testing-library/react";
import { Form } from "antd";
import { CKEditorInput, CKEditorRenderer } from "../dist/CKEditorFormFields";
import "@testing-library/jest-dom";

Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // Deprecated
removeListener: jest.fn(), // Deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});

const TestForm = () => (
<Form initialValues={{ renderer: "<p>Hello world!</>" }}>
<Form.Item label="Mock Input" name="input">
<CKEditorInput data-testid="input-field" />
</Form.Item>
<Form.Item label="Mock Renderer" name="renderer">
<CKEditorRenderer data-testid="renderer-field" />
</Form.Item>
</Form>
);

test("test rendering", async () => {
render(<TestForm />).debug();
});

启动测试时遇到的问题

只要富文本编辑器能够正常渲染就成功了,所以首次运行测试我比较保守,只定义了简单的表单并把自定义的两个组件作为表单项置入其中,并尝试将渲染结果用 render().debug() 的方式打印出来看看是否正确渲染。

但是启动 Jest 之后遇到了一系列问题,下面列举了我遇到的问题以及相关的解决方案。

  1. 无法解析 .jsx 格式文件,通过安装 @babel/preset-react 插件并创建 babel.config.js 应用该插件解决;
  2. 提示 ‘react’ 这个包没有导出 default,通过安装 @types/react@babel/preset-env 解决,其中 babel 插件同样需要应用到配置文件中;
  3. 提示没有 window.matchMedia 方法,直接通过 Object.definePropertywindow 打上补丁(官方建议);
  4. 无法解析 CKEditorFormField 中引入的 .css 文件,通过安装 identity-obj-proxy 依赖,并在 Jest 配置文件的 moduleNameMapper 属性中加入 "\\.(css|less)$": "identity-obj-proxy" 解决;

稍微麻烦些的就是上面 4 点,当然还有一些其他的必要的依赖也是需要安装的,这里给出局部 package.json,Jest 和 Babel 的配置分别如下:

package.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
{
"name": "Hello-richtext",
"version": "0.0.1",
"scripts": {
"test": "jest",
"build": "tsc"
},
"keywords": ["richtext"],
"dependencies": {
"react": "^17.0.0",
"@ckeditor/ckeditor5-react": "^3.0.3",
"@ckeditor/ckeditor5-build-classic": "^31.0.0",
"ckeditor5-custom-build": "0.0.2",
"antd": "^4.16.13"
},
"devDependencies": {
"@babel/core": "^7.17.5",
"@babel/preset-env": "^7.16.4",
"@babel/preset-react": "^7.16.7",
"@testing-library/dom": "^8.11.3",
"@testing-library/jest-dom": "^5.16.2",
"@testing-library/react": "^12.1.4",
"@types/jest": "^27.4.1",
"@types/react": "^17.0.39",
"babel-jest": "^27.5.1",
"identity-obj-proxy": "^3.0.0",
"jest": "^27.5.1",
"react-dom": "^17.0.2",
"react-test-renderer": "^17.0.2",
"ts-node": "^10.7.0",
"typescript": "^4.1.2"
}
}
jest.config.ts
1
2
3
4
5
6
7
8
export default {
coverageProvider: "v8",
moduleNameMapper: {
"\\.(css|less)$": "identity-obj-proxy",
},
testEnvironment: "jsdom",
// 其他均为默认
};
babel.config.js
1
2
3
4
5
6
module.exports = {
presets: [
["@babel/preset-env", { targets: { node: "current" } }],
["@babel/preset-react", { targets: { node: "current" } }],
],
};

测试补全

解决上一节遇到的问题之后,测试脚本可以顺利运行了,需要稍微补全一下测试用例。因为 CKEditorInputckeditor-custom-build 中加载 CKEditor 时采用了异步加载,所以用 rlr 的 render() 第一时间拿到的页面源码内显示该方法仍在加载中。通过 screen.logTestingPlaygroundURL() 方法可以获取 Debug Playground 的访问链接,用浏览器打开可以看到下图的内容,清楚明了。利用它还能获取如何查询页面元素的提示。

我期望两个组件都正常渲染,如果 CKEditorInput 组件需要异步加载,那么设置等待即可。测试用例补充为下面的样子:

render.test.jsx (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 防止加载时间稍长引发 jest timeout 的问题
jest.setTimeout(60000);
// ...

test("test rendering", async () => {
render(<TestForm />).debug();
screen.logTestingPlaygroundURL();
await waitFor(() => screen.getByText(/hello world!/i), {
timeout: 30000,
});
await waitFor(
() => {
// 根据 debug() 的返回结果发现:
// 可以通过获取 "段落" 这个工具栏提示字样来判断是否已经渲染出富文本编辑器
screen.debug();
screen.getByText(/段落/i);
},
{
timeout: 30000,
}
);
});

打包、发布完成

测试完成后,利用 npm publish 命令将 Hello-richtext 发布到团队的私有制品库中,完成打包流程。在两个实际项目中安装 Hello-richtext@0.0.1 后,组件切实可用。

这次打包发布 React “组件库”的经历给了我几点体会,

  1. 首先是学习和实践了 React 组件的测试流程,加深了自己对于前端项目测试的理解;
  2. 其次让自己构建 NPM 包的流程更规范,之前发布的几个 NPM 包因为图方便求速度都没有写测试,没测等于没写
  3. 在目前两个可能未来更多的项目开发中事实上地提升了代码复用度和开发体验,原先富文本编辑在各个项目间维护…挺麻烦的。
作者

PowerfooI

发布于

2022-03-12

更新于

2024-08-04

许可协议

评论