做者 光生 蚂蚁金服·数据体验技术团队javascript
最近参与了一个 React + Typescript 组件项目,这个项目后期会开源,对代码的质量和工程化上有比较高的要求,所以须要进行工程化治理。经过此次工程化治理,笔者算是梳理清楚了一个 React + Typescript 第三方组件所须要的一些工程化方面的基础设施,在这里总结并分享给你们。html
此次的工程化治理主要分如下几个方面:前端
TS 和 ESLint 这些工具本质上是对代码作静态检查,尽早发现隐藏的 bug。在 TS 出现以后,TS 有 ESLint 没有的类型检查,而且也具有 ESLint 具备的语法错误检查的能力,因此目前咱们用 ESLint 主要是利用社区中数量庞大的 Lint 规则来对代码风格作一个规范,利用工具的方式去推行一些最佳实践。TS 则主要负责对代码语法和语义上的错误进行静态检查。另外,TS 自己是一个全新的语言,使用 TS 能够享受到一些 JS 没有的语言特性。
java
用 TS,一个很重要的区别就是有没有在配置中打开 strict 选项。若是没有的话,那其实你用的就是 AnyScript,在类型上基本没有约束,和 JS 没有太大的区别。若是是从 JS 迁移到 TS 的项目,这个选项应该关闭,由于老的 JS 代码没有写类型。但若是是全新的纯 TS 项目,strict 是必定要打开的。如今 CRA 这样的脚手架建立的项目也是默认开启了 strict 模式的。node
打开 strict 模式其实很简单,难的是如何在 strict 模式下优雅的写 TS 代码。下面说说一些 strict 模式下的常见问题以及一些类型的技巧:react
这个问题出现的最多见的场景就是函数的参数。若是习惯了写 JS,在写函数参数的时候很大可能会忘记写类型。虽然 TS 能够推断出函数的返回值类型,但不能推断出函数的参数类型。若是不写参数类型,那参数的类型默认就是 any,这个时候就会报 noImplictAny 的错误,由于 TS 的 strict 模式下不容许这种隐式 any 的存在。webpack
解决这个问题,就要养成给函数参数加类型的习惯,而且不能直接加个显式的 any 就完事了 😂。该定义新类型就定义,若是已经定义的就引用一下。不是很是规的场景,是不该该出现 any 的,这个后面还会再讲到。any 自己是一个绕过类型检查的 escape hatch,用了 any 就会致使这个地方的类型检查被绕过,这样一来使用 TS 的意义就不大了。git
话说回来,若是写代码的同窗的背景是写静态类型语言的,那是绝对不会忘了加类型的。这个问题在习惯写弱类型的 JS 的前端同窗身上比较常见,更可能是一个习惯和类型思惟的养成问题。github
让咱们再来看一个场景:web
const props = {
foo: "bar"
};
props["foo"] = "baz"; // Element implicitly has an 'any' type because expression of type 'string' can't be used to index type
复制代码
这种场景下也会报 noImplicitAny 的错误。这是由于咱们没有显式声明这个对象的 index signiture。解决方案就是:
interface Props {
foo: string;
[key: string]: Props[keyof Props];
}
const props: Props = {
foo: "bar"
};
props["foo"] = "baz"; // ok
props["bar"] = "baz"; // error
复制代码
这里原本能够直接写 [key:string]: any;
的,但若是在 key 肯定的状况下,能够用 keyof 来获取一个接口的全部 key 组成的联合类型,而后用 index types 获取 value 的类型。这样相比 any 能对 value 的类型有一个限制。
在默认(非 strict 模式)下,undefined 和 null 能够被赋值给任意的值,因此去调用一个可能为 undefined 的属性上的方法也是被容许的。在 strict 模式下,null 和 undefined 被做为单独的类型处理,不能赋值给其余的类型。所以若是一个值多是 null 或者 undefined,咱们必须采起措施,为类型检查提供信息。
好比有以下的场景:
class Component extends React.Component<{}, {}> {
graph: Graph?;
componentDidMount() {
this.graph = new Graph()
this.init(this.graph)
}
init() {
this.graph.on("click", () => {}) // Object is possibly 'undefined'
}
render() {
return <div>foo</div>
}
}
复制代码
在 GUI 的场景中,不少的成员变量是会在组件初始化以后才有值的,好比这个场景下的 this.graph
。在变量参数或者属性的类型后面加一个 ? 是 Optional 的意思。表名这个属性或者参数有多是 undefined。
要想绕过这个报错,咱们须要用 type guards:
init() {
if (this.graph) this.graph.on("click", () => {}) // ok
}
复制代码
但若是调用 this.graph
的地方比较多,写 if 就很麻烦,并且影响代码的阅读。这个时候在确保 this.graph
必定有值的状况下,咱们能够用 type assertion:
init() {
this.graph!.on("click", () => {}) // ok
}
复制代码
在近期的 TS 3.7 版本中新推出的 Optional Chaining,是更好的解决方案:
init() {
this.graph?.on("click", () => {}) // ok
}
复制代码
! 和 ? 这些操做符的用法,写过 Swift 的同窗应该会很熟悉。如今 TS 里面基本有了一整套的 Nullable Value 的操做方案。
总结一下,对于 strict 模式下的 strictNullChecks,咱们能够用 type guards,type assertion,optional chaining 三种办法去告诉编译器,这里的操做是安全的。
关键是,Optional 的值在 GUI 编程中是很正常的,咱们要学会去处理和面对这些状况,把 undefined 和 null 做为一个单独的类型来对待。
在引用第三方库时,咱们须要注意一点,就是这个库有没有提供类型定义文件。类型定义文件通常在项目 package.json 的 types 字段中有说明。对于 JS 写的库,类型定义也多是一个单独的包,好比 @types/react 这样的。
有类型定义文件,那咱们在调用 API 的时候就能够引入对应的类型定义,调用的时候也会有类型检查和代码提示,来提高咱们使用第三方库的效率,提前发现可能的 bug。
若是没有,就要考虑是否自行维护一个定义文件,但这样的成本是很大的。因此,一个没有定义文件的第三方库,咱们要仔细的考虑是否要在 TS 项目中使用这个库。
TS 文档中有一章叫 “高级类型”。里面提到的都是一些高级的类型特性。除了 Type Guard,交叉类型,联合类型和上文提到的能够为 null 的类型以外,最关键的是
在使用泛型时,这些技巧可让咱们对类型进行“编程”,想象一下对类型变量使用 ?三元表达式或者 Array.prototype.map
这样的方法。
举个例子,条件类型的用法是这样的:
T extends U ? X : Y
复制代码
若是 T 和 U 兼容(T 包含 U 有的全部属性,T 能够被赋值给 U),这个类型就是 X,不然就是 Y。
看一下条件类型的实际用途。好比有以下的函数,可能返回 string,也多是 null:
function process(text: string | null): string | null {
return text && text.replace(/f/g, "p");
}
复制代码
但这样的类型写法是有问题的,由于返回值有多是 null,没有 toUpperCase 这个方法。
// ⌄ Type Error! :(
process("foo").toUpperCase();
复制代码
这个时候咱们能够用条件类型来解决:
function process<T extends string | null>( text: T ): T extends string ? string : null {
...
}
process("foo").toUpperCase() // ok
process().toUpperCase() // error
复制代码
在写 TS 代码时,咱们在掌握了这些高级类型技巧时,就能够适时的去用这些技巧来让代码的类型检查变的更健壮,避免重复定义类型,写更优雅的代码。
由于本文不是 TS 的专题文章,上文中没有提到的 TS 使用技巧,好比映射类型等等,还能够参考:巧用 TypeScript 和 巧用 Typescript (二) 以及 TS 学习总结:编译选项 && 类型相关技巧
ESLint 和 Prettier 是更流行,普及程度更高的工具。这里就不讲太多细节,主要说的是 ESLint 如何支持 TypeScript。
TSLint 在 2019 年宣布将来项目将会废弃。TS 官方推荐 ESLint 做为 Linter。咱们能够经过 @typescript-eslint/parser 让 ESLint 支持解析 TS 文件。配套的还有 @typescript-eslint/eslint-plugin 做为 ESLint 下针对 TS 订制的 Lint 规则。
ESLint + TS 这方面的资料不少,Using ESLint and Prettier in a TypeScript Project 这篇文章讲了如何从 TSLint 迁移到 ESLint。
还有如下的文章,都讲解了相关的配置(ESLint + TS):
关于 TSLint 到 ESLint 的切换的背景,能够看 typescript-eslint 这个项目的 README,讲的很是详细
使用 ESLint 的好处就是:能够背靠 ESLint 的生态,像 Airbnb 这样的规则集就能够直接用于 TS 项目。上面列举的博客就有讲如何配置 Airbnb + typescript-eslint + prettier 三种规则集。让项目能够用 typescript-eslint 来规范 TS 代码(TS 特有的 Lint 规则),用 Airbnb 来规范 React 和 JS 代码(TS 是 JS 的超集),用 Prettier 相关规则来关闭前两个规则中和 Prettier 代码风格冲突的规则。三者集合就是目前比较完善,好用的 Lint 规则了。
Airbnb 中有一些规则,好比要求 React 组件声明 PropTypes,是不适用于 TS 项目的,因此须要在 ESLint 配置文件里关掉。其余相似的配置有不少,咱们不用死板的遵照 Lint 规则,而是关闭不合适的规则,只取其精华。
在又有 JS 又有 TS 文件的状况下,ESLint 须要只在 TS 文件上,执行 TS 相关规则的校验,否则在校验 JS 时不少 TS 规则也会生效,这样就形成了困扰。
解决方案就是使用 ESLint 的 override:
"overrides": [
{
"files": "**/*.ts",
"extends": [
"eslint-config-airbnb",
"plugin:@typescript-eslint/recommended",
"prettier/@typescript-eslint",
"prettier",
"prettier/react"
],
}
],
复制代码
只在处理 TS 文件时才加入 TS 的相关规则。
与这个问题有关的 issue。
另外,有一些 JS 规则在 TS 文件上使用时也会出现问题,好比: github.com/eslint/esli…。解决方案也是使用 override。
Pre commit hook 是指设置一个 Git hook,在提交以前运行。前端项目通常利用这个机会运行静态代码检查和代码格式化,好比 ESLint,Prettier。也能够运行测试或者 TS 编译等等检查。
这里特别提到 Commit hook 是由于这个环节是必不可少的。若是没有 commit hook,那 ESLint 和 Prettier 等因而形同虚设了。
具体设置的流程能够参考:Configuring Pre-commit Hooks for Prettier and Linting on a TypeScript Project。
commit hook 也能够用 -n 跳过,因此还应该在 CI 时加上 ESLint,来保证不规范的代码提交被马上发现。
打包工具也好,mono-repo 也好,这些基础设施其实提高的是开发人员的体验。开发的时候省心省力,方便快捷,一键配置,一键升级,这是如今前端开发体验升级的方向。在选择一个 React 组件的构建工具链时,开发体验是值得咱们关注的一个重要要素。
关于模块的格式,咱们听过 AMD,CommonJS,UMD,ES Module 等等。因为模块标准一开始大规模应用是 Nodejs 的 CommonJS,因此几年前咱们写的 JS 模块都是以 CommonJS 的格式。Webpack 这样的打包工具也只兼容 CommonJS 模块。后来 ES 2015 中提出了 ES Module,这个标准是将来浏览器支持的标准,Nodejs 也会支持。而且从功能上来讲,ES Module 语法上更简洁,支持 multiple exports,而且可让构建工具进行静态的依赖分析,让 Tree-shaking 成为可能。
目前的构建工具都支持原生的 ES Module 格式(以前须要用 babel 转为 CommonJS)。咱们写的组件源代码就是 ES Module。在输出方面,如今的 JS 库通常都会提供 ES Module 版本。因此咱们须要寻找合适的打包方式。
咱们只须要把 package.json 的 module 字段指向打包出的 es module 格式的文件,构建工具就会使用 module 字段而不是 main 字段进行构建了。
接下来咱们要选择打包工具,Webpack 目前不支持输出 ES module,可能在 Webpack 5 会支持。因此先排除 Webpack。
Rollup 和 Babel 是可行的两种方案。
Rollup 是目前最流行 JS 库打包工具,React,Vue 之类的开源项目都在使用 Rollup。Rollup 支持输出 CommonJS,UMD,ES Module 在内的主流格式,并能够经过插件支持 CSS 等静态资源的处理。Rollup 和 Webpack 的主要区别就是 Rollup 是以构建 JS 为核心的,而且从一开始就是基于 ES Module 的,若是要兼容 CommonJS 代码,须要引入额外的插件。Webpack 更关注的是全部资源的构建,而且强调 Code Splitting 的能力,专一于 Web 应用的打包。Rollup 更轻量和专一,并且支持 ES Module 的输出,全部在 JS 库打包这个方面 Rollup 是首选。
Babel 其实自己只是一个转译工具。但 Babel 能够经过插件支持 TS 代码的转译,还有 JSX 的转译(老本行),因此若是是简单的 TS 库,能够直接用 Babel 进行转译,输出的就是原汁原味的 ES Module(由于 Babel 压根没有去解析模块,只是单纯的转译代码)。须要注意的是 Babel 的 TS 转译只是转译,不是编译,因此类型错误是不会报出的,须要额外跑 tsc 来对 TS 代码进行类型校验。其余的静态资源也是同样的,须要单独跑 task。
上面两种工具均可以用,但这里不打算讲如何配置,由于如今的趋势就是构建工具链下沉,封装为一个统一的入口。只要跑一个命令就能够构建,而且只须要配置一些简单的必要的参数。底下的工具链升级也只要更新一个入口工具就行,不用花时间去维护整个构建体系。Umijs,Create React App,和 Vue-cli 都是这样的例子。
这里向你们安利一款专一于 JS 库打包的工具:Father。Father 能够简单理解为是 JS 库领域的 CRA 或者 Umi。Father 封装了 Rollup 和 Babel 两套工具链。
在最简单的状况下:咱们只须要告诉 Father 须要什么格式的输出就能够构建成功,好比:
father build --esm --cjs --umd --file bar src/foo.js
复制代码
所以笔者在项目中就使用了 Father 来对 React 组件进行打包。若是对 Rollup 和 Babel 构建流程有兴趣的同窗,能够看一下 Father 的源码,仍是很容易看懂的。
Lerna 是用于管理拥有多个 npm package 的 mono repo 的工具。mono repo 就是指多个项目的源码放在同一个仓库下进行管理。
简单的说,Lerna 的功能就是一键在多个 package 中同时运行一些命令。并且运行的时候还会根据 package 之间的依赖拓扑关系,对命令的启动顺序进行编排。同时 Lerna 的 bootstrap 命令能够把 package 之间相互的依赖,自动 link 到 package 本身的 node_modules 里面。这能够说是最大的一个卖点。Lerna 以前若是要在本地开发多个相互依赖的 npm 包,那就要敲一堆的 npm link,并且还容易出问题。
mono-repo 这种方式自己也是为了提高多个 npm package 的状况下,管理源代码的效率,以及共享基础设施。所以 Lerna 实际上是提高了开发者开发基于 mono repo 的前端项目的体验。
前端的组件库一类的项目,用 Lerna 是很是合适的。
其实以前提到的静态检查也是用于保证代码质量的,这里的代码质量主要是指测试。
React 组件的测试框架有不少,我选的是 Jest。由于这是 FB 自家的工具,也是一个很流行的测试框架。除了测试框架,咱们还须要一个 DOM Util,用于组件渲染和 DOM 的操做。比较流行的就是 Enzyme 和 react-testing-library。
在 React 16 下,Enzyme 有一些问题,好比 shallow 模式下不支持 useEffect。详见:github.com/airbnb/enzy…。react testing library 是在 React 官方的 test util 基础上包装的,要更轻量一些。他的 FAQ 中写了对于 Enzyme 的见解:
What about enzyme is "bloated with complexity and features" and "encourage poor testing practices"? Most of the damaging features have to do with encouraging testing implementation details. Primarily, these are shallow rendering, APIs which allow selecting rendered elements by component constructors, and APIs which allow you to get and interact with component instances (and their state/properties) (most of enzyme's wrapper APIs allow this). The guiding principle for this library is:
The more your tests resemble the way your software is used, the more confidence they can give you. - 17 Feb 2018
做者以为测试应该模仿用户使用你的产品时的操做,而不该该鼓励对实现细节进行测试。
综合各类因素,笔者选用了 react testing library。总的来讲其实这种 Util 库,选用哪个的差异不大。写起来更方便的就是好的。
若是对 react testing library 不熟悉,能够看官网和这篇教程。
testingjavascript.com/ 这个测试教程网站,能够了解到测试相关技术的大图,若是对测试的分类和做用不太清楚能够看一下这个网站。
使用 react testing library 测试是很简单的,咱们只须要调用 render
,把组件渲染出来就好了:
const { asFragment, queryByText, rerender } = render(
<Graphin data={data} layout={layout}> <div>foo</div> </Graphin>
);
expect(queryByText(/foo/)).toBeTruthy();
复制代码
比较有意思的就是 render
以后会返回一个 render result,里面是一些 DOM query util 和一些其余的 util。好比 queryByText
就是根据元素里的文本做为选择器来获取 DOM 元素。其余的 DOM query API 能够从这里看到。其中用的比较多的一个是 queryByTestId,在 React 元素上加 data-test-id
以后就能够直接经过 queryByTextId
获取到这个元素。
能够看出,react testing library 鼓励的是根据元素的 text 这样的属性来获取元素,进行断言。这就是这个库的哲学,但愿开发者从用户怎么使用产品的角度去测试。而不是经过 DOM 结构之类实现细节的来判断。
除了对渲染的 UI 进行测试,咱们还须要触发事件,这个过程当中须要用 act
和 fireEvent
这样的 API:
act(() => {
fireEvent.click(getByText(/Click Me/), {});
});
复制代码
须要用 act 包装的缘由是,浏览器中 React 的渲染是有必定的周期的,会有 batch update。所以把会修改 state 的调用写在 act 中能够保证这个调用会完整的走完渲染周期。
若是想对组件的 props 进行更新,咱们须要使用 render
结果里返回的 rerender
:
data = { id: "1" } // update props.data
rerender(<Graphin data={data} layout={layout}>/Graphin>)
复制代码
以后就能够继续使用第一次调用返回的那几个函数进行断言。
最后一个函数是 asFragment
,调用 asFragment
能够返回组件的 DOM 结构。这让咱们可使用 Jest 的 Snapshot 对组件进行测试:
expect(asFragment()).toMatchSnapShot();
复制代码
测试中,常常会遇到须要 mock 函数或者其余对象的状况。mock 函数能够用 Jest 的 Mock Functions。比较麻烦的是一些浏览器事件的 mock。由于 Jest 的 DOM 实现使用的是 JSDom,并非真实的浏览器环境。这里举一个例子,若是须要模拟浏览器的 resize 事件,能够这么作:
act(() => {
// Change the viewport to 500px.
(window as any).innerWidth = 500;
(window as any).innerHeight = 500;
});
fireEvent(window, new Event("resize"));
复制代码
若是测试的目标中有 Canvas,状况分两种:
若是是前者,咱们能够 Mock 掉 Canvas,使用 jest-canvas-mock 能够很方便的一键 Mock。
若是是后者,咱们能够用 jest-electron 去运行一个真实的浏览器,来测试 Canvas 的绘制结果。
使用 jest-canvas-mock 的时候,咱们还能够经过 Mock 的 Canvas 对象上附加的 API 来获取 Canvas 上的绘制调用的信息:
let canvas = getByTestId("custom-element").firstChild as HTMLCanvasElement;
let ctx = canvas.getContext("2d") as any;
ctx.__getPath(); // 获取路径信息
ctx.__getEvents(); // 获取事件记录
ctx.__getDrawCalls(); // 获取绘制调用信息
复制代码
这样咱们就能够经过这些信息来看图表的绘制接口是否有被调用,从而看出调用了图表渲染 API 的 React 组件自己的逻辑是否正确。
Jest 配置了 collectCoverage: true
以后就会在本地生成测试覆盖率报表。用 http-server 起一个本地服务器就能够看到,相似以下的表格:
覆盖率分为语句,行,分支,函数四个部分。咱们通常说的覆盖率通常是指行覆盖率,就是代码自己有百分之几是被测试跑到的。但分支覆盖率也很重要,这意味着咱们有没有把全部的 case 都测试到。覆盖率是否是要 100% 要看状况,若是是 lodash 这样的工具库,那就要有这样的指标。若是是比较复杂的 React 组件,那主要先保证核心链路是被覆盖的。
若是测试自己写的很差,覆盖率很高其实也没有用。好比只是把代码跑一遍但没有对结果作任何验证的话,就算代码逻辑出现了问题,测试也是 pass,覆盖率也很高,但这样的测试是没有用的。
总结一下,就是不能一味的追求数字的好看。覆盖率报表是帮助咱们看测试是否漏掉了应该测试的函数,分支等等,起一个辅助的做用。评价测试的标准仍是看测试能不能帮助咱们在以后每一次提交代码时发现是否有 regression 的状况。
本文是对一次 React + TypeScript 组件的工程化治理过程所作的总结。若是你的项目也是 React + TypeScript 组件,而且会发布为 NPM package 给其余人使用,那本文应该能够为工程化方面的建设提供一些参考。
由于篇幅缘由,里面一些具体的流程须要读者自行看连接中的教程和博客,那些文章更专一,更有深度。本文主要介绍的仍是 React + TypeScript 组件工程化的主要几个方向(静态检查,开发体验和代码质量)和其中一些须要解决的问题。
github blog 原文链接