jest 提供的 test.todo 功能在实践 TDD 的时候非常实用,但是 cypress 却没有提供类似的功能。经过简单的搜索,找到了一个 workaround。
const test = it; test.todo = it.skip; global.test = it;
接着就可以在测试代码中愉快地使用 test.todo 了。


jest 提供的 test.todo 功能在实践 TDD 的时候非常实用,但是 cypress 却没有提供类似的功能。经过简单的搜索,找到了一个 workaround。
const test = it; test.todo = it.skip; global.test = it;
接着就可以在测试代码中愉快地使用 test.todo 了。


如果你正在调研 monorepo 的技术选型,那你一定不能错过 nx。nx 是一个用来管理 monorepo 的构建系统,它提供了一系列的工具来帮助你实现代码的模块化、标准化,让开发人员在管理任意规模的 monorepo 时仍能够游刃有余。
代码生成器
通过生成器,nx 可以为你自动地创建、修改各种样板代码:
# 替代 create-react-app 创建 react 项目
$ nx g @nrwl/react:application my-app
# 创建一个新的组件
$ nx g component my-component --project=my-app
# 给项目添加 tailwind
$ nx g setup-tailwind --project=my-appswc?esbuild?vite?vitest?playwright?放手去尝试这些新的工具,你可以先在单个项目中试点,当你做好准备后,就可以把单个项目中的实践封装为任务执行器,接下来 nx 就可以帮助你快速地将新的工具链应用到各个项目中。
你可以放心地把工具库、业务模块提取成 monorepo 中独立的项目,nx 通过依赖分析能力,能够自动地理清项目之间的依赖关系,正确地帮你编排构建、测试脚本的执行顺序,提高执行工具构建、测试的执行效率。
我非常推荐新的项目采用 nx,你可以利用 nx 插件,快速地创建各种新项目必需的各种样板代码、设置好各种开发工具的配置文件,再也不用去翻看 「XXX Get Started」文档。工具链的最佳实践由 nx 搞定,你只需要在功能实现上展示专业水平。
处于代码量茁壮成长阶段的小型项目,需要注意代码的模块化、标准化,避免让代码库成长为超大的面条怪物。
在拆分模块的时候,可以先用生成器定制符合项目需求的代码样板,减少提取项目模块的工作量。接着,你还可以为项目中使用的开发工具(Storybook、Cypress 等)创建执行器,方便在提取出来的项目模块中复用同样的工具链。
大型项目往往会遇到编译缓慢的问题,通过将大型项目拆分成多个模块,利用 nx 的构建缓存以及增量构建功能,可以提高项目整体构建速度,极大地提升开发体验。
nx 提供的 project.json 能够帮助你抹平不同项目使用的工具脚本之间的差异,让你可以用尽量小的代价使整个 monorepo 工作起来。项目成功运行之后,你就可以考虑使用执行器来替换掉各个项目中的开发脚本,减少维护各种工具脚本的负担。
简单来说,nx 跟任何包管理工具都能够很好的工作,它并不依赖于某种独特的包管理机制。但这也是 nx 的不足之处,它没法帮你解决管理第三方依赖时可能会遇到的问题(NPM 中的 phatom 与 doppelgangers 问题),你可能需要针对这种问题去选择适合自己的包管理工具。
我的 nx 实践体验可以总结为三点,希望对你有所启发:
在设计回调函数形式的扩展点时,扩展点的默认行为也要提供给调用方,方便基于默认行为进行扩展。达到这一目的的模式有:
Alice:Hi,Bob,我有一个重构代码的需求,能帮我看看吗?
Bob:没问题,Show me your code 。
Alice:之前的代码是这样的
class MyClass {
public update() {}
public fooBehavior() {}
public changeState(params) {
// do something
this.fooBehavior();
// finally
this.update();
}
}现在,我需要允许调用方修改 changeState 要执行的行为
class MyClass {
public update() {}
public fooBehavior() {}
public changeState(params, behaviorType?: string) {
// do something
if (behaviorType === 'A') {
// do behavior A
} else if (behaviorType === 'B') {
// do behavior B
} else {
this.fooBehavior();
}
// finally
this.update();
}
}Bob:根据以往的经验,这里的 if-else 很可能会变成叠 buff 的屎山,建议把 behaviorType 换成一个回调函数,让调用方决定这里应该干些啥。
Alice:改好了,你再看看?
class MyClass {
public update() {}
public fooBehavior() {}
public changeState(params, onChangeState?: () => void) {
// do something
if (onChangeState) {
this.onChangeState()
} else {
this.fooBehavior();
}
// finally
this.update();
}
}Bob:这里还有些问题,调用方只能覆盖默认行为,并不能在默认行为的基础之上做扩展。
Alice:调用方也可以自己手动调用 fooBehavior 啊,这也是个公共 API 。
Bob:但是调用方必须得翻看 changeState 的具体实现才知道「fooBehavior 是 onChangeState 的默认实现,这显然是不合理的。
Alice:在使用这个类时翻看源码很正常,因为这是一个非常高级晦涩的 API,不翻看源码根本不清楚要用它来干什么。
Bob:OK,就算调用方在拓展这个地方的时候来查看了 onChangeState 的默认实现,然后把这里的代码复制过去来基于默认行为做扩展。但是,随着版本更新迭代,MyClass 中的默认实现可能会发生变更,这个时候,要怎么通知这些依赖默认行为的调用方更新呢?
Alice:好吧,这样看来确实是个问题。
Bob:我觉得这个时候可以把 onChangeState 提取成一个单独的类,它是这个扩展点的基类。调用方在进行扩展的时候,可以通过基类的方法来调用默认行为。
class MyClass {
public update() {}
public fooBehavior() {}
public changeState(params, changeStateHook: ChangeStateHook = new ChangeStateHook()) {
// do something
changeStateHook.after(this);
// finally
this.update();
}
}
class ChangeStateHook {
after(instance: MyClass) {
instance.fooBehabior();
}
}
class ConsumerChangeStateHook extends ChangeStateHook {
after(instance: MyClass) {
if(/* some case */) {
super.after();
} else {
// do something else
}
}
}Alice:这样倒是可以满足你在上面提到的需求,但是代码看起来也太阵仗了。为了自定义这个回调函数,还得专门声明一个类!我觉得用个字段来存储默认行为,然后在注释上标明就足够了。
class MyClass {
public update() {}
public fooBehavior() {}
public defaultOnChangeState() {
this.fooBehavior();
}
/**
* 默认 onChangeState 是 defaultOnChangeState
*/
public changeState(params, onChangeState?: () => void) {
// do something
if (onChangeState) {
this.onChangeState();
} else {
this.defaultOnChangeState();
}
// finally
this.update();
}
}Bob:这样做确实轻量很多,但并不是一个好的「模式」。如果后面这种拥有默认行为的回调函数越来越多, MyClass 上就会有很多 defaultOnXxxx ,看起来就很糟心。不如把默认行为作为回调函数的参数提供给调用方。
class MyClass {
public update() {}
public fooBehavior() {}
public changeState(params, onChangeState?: (defaultOnChangeState: () => void) => void) {
// do something
const defaultOnChangeState = () => {
this.fooBehavior();
}
if (onChangeState) {
this.onChangeState(defaultOnChangeState);
} else {
defaultOnChangeState();
}
// finally
this.update();
}
}Alice:看起来很不错,跟我写的第一版回调函数形式 API 相比没有多几行代码,但是却有更加灵活的扩展空间。
Bob:好的,最后来总结一下:
Testing Library 推荐使用 queryByRole、queryByText 这类 API,因为这类 API 更加符合用户视角,相较于 DOM 结构更加稳定。但是这些 API 存在一个非常恼人的限制,不能让我自由自在地编写稳定的测试代码,例如:
<button> <div>Option 1</div> <button> <span>Option 1</span>
如果我想要点击上面例子中的按钮,就必须得先用 findAllByText 找出所有的内容为「Option 1」的元素,然后再用 DOM 属性筛选出「按钮」。Testing Library 甚至还有一个 eslint 规则禁止使用 DOM 属性。
同样的需求在 Playwright 中的查询语法就非常简单:button:has-text(Option 1)。
Jest 几乎不再推荐使用真实浏览器来执行测试,所有的类真实浏览器环境 test-runner 都不再有官方的支持。这时我就非常怀念 Angular 提供的 Jasmine 测试框架,可以选择使用真实浏览器执行,完全不用像使用 JSDOM 一样需要顾虑是否用到了一些有限制的 API。
也是因为 JSDOM 的原因,一些在真实浏览器上工作正常的代码很容易在 JSDOM 上翻车,如果你比较注重用户体验,想要整一些酷炫的动画或者类似 Drag、Hover 等交互操作,那么 JSDOM 可以让你的复杂交互逻辑反复翻车。
Playwright 是近来非常流行的 e2e 测试框架,它使用魔改版的浏览器来执行测试,在 playwright 中我们几乎可以实现任何真实的用户操作,甚至是多开标签页。借助 playwright-ct,组件的单元测试也可以使用 playwright 编写。
因为种种原因,你可能想要将 antd 组件从另一个模块中重新导出,但是这么做并没有像看上去那么简单。
重新导出 antd 的类型可能是整个需求中最简单的部分,因为我们只需要简单的使用 export type { xxx } from 'antd' 就行了。
首先我们需要使用 require.resolve 获取 antd 的安装路径,然后用正则表达式解析一下 antd/index.d.ts 的内容,其中以 export type 开头的就是 antd 导出的类型。获取到这些类型名称后,就可以生成代码,在我们自己的模块中重新导出。
与类型信息不同,想要重新导出组件还需要考虑支持「按需引用」功能。虽然目前的 antd 文档中写着 JS 组件默认支持基于 ESM 按需引用功能,但是组件对应的样式依赖 babel-plugin-import 实现按需引用。
然而, babel-plugin-import 并不支持 export { xxx } from 'antd' 的语法,它甚至会报错。
考虑到 babel-plugin-import 目前并没有人在积极地维护,而且我个人也比较讨厌这种依赖 babel 魔改代码的手段,我决定迂回地解决这个问题——先导入,再导出。
使用简单的 import 语句让 babel-plugin-import 正常地工作,然后再将导入的变量重新 export 出去。不过这里涉及到一个问题,如果我在同一个文件中将 antd 所有的组件全部导入的话,由于 babel-plugin-import 的原因,所有组件的样式将在加载这个文件的时候被加载。这样就破坏了按需加载的特性。
为了避免上述问题,我们得把 babel-plugin-import 的副作用隔离到独立的模块中,以 Button 组件为例:
import { Button as _Button } from 'antd';
export const Button: typeof _Button = _Button;export * from './lib/button';
如此一来, index.ts 就完全变成用来按需引用子模块的入口,在加载 index.ts 的时候就不会加载所有组件的样式了。
除此之外,你可能还发现了我在导出组件的时候特意标注了变量的类型,这是因为 TypeScript 要求我们给导出的变量类型起一个稳定的名字,否则编译会报错。
我叒换博客平台了。
我的博客之路是从博客园开始的,最早的一篇文章发表于 2015 年 7 月,那时刚刚高考结束,离大学开学还有不到两个月。现在的我只能从文章的标题来推测当时写博客的动机——应该是为了学习记录。就这么断断续续的记录了大概四年,持续到大学毕业,找到了人生中的第一份工作——博客园后端开发。
虽然入职了一个开发博客平台的公司,但是写博客慢慢变少了,更多的是用来测试博客后台功能正确性的文章。也差不多是刚入职的这个时间,我写出了我的个人博客软件。这个项目是博客园的笔试题,但是笔试结束后我并没有把它丢到一旁,在上班的空余时间,慢慢地给它提交代码,一点点的丰富功能。直到我换两份工作,我的个人博客都是记录在自己的这个网站上的。
在这个网站上,我实现了一些有意思的功能,比如「服务端高亮代码」、「服务端渲染数学公式」。不过这些小成就并没能继续推动我写博客,在这期间的博客很多都是在繁忙工作的喘息中写下来的。除了需要写博客之外,还得继续维护博客平台本身——服务商跑路了,想办法恢复数据,迁移博客;dotnet 更新了,得升级项目;不支持 RSS,得自己实现 RSS 功能,诸如此类。这些事务也像写博客一样,抢占着我的休息时间。让我的博客更新看起来就像便秘一样。
同样也是这段时间,我也变得越来越沉默,不想在社交网络上发言,不再往感兴趣的论坛灌水,不再打开群组闲聊。闭而不言让我感到舒适,我不用思考该如何讲话,我只需要接收我喜欢的信息就好。但是最近我也发现了长久沉默带来的副作用——一旦看文字信息,就只想快速的扫视,马上了解大致内容,就像是大脑被训练出来了快速阅读的「肌肉记忆」。我想大概是我的大脑已经习惯了长时间开启的「只读」模式,针对这种情况进行了 JIT 优化吧。
现在回想起来这种情况已经出现了很久,大概得有四五年了,但真正意识到这个问题却是在去年全职远程之后。全职远程在家,每天能够说话的人就更少了,连着十几个小时不说一句话可能并不夸张。不过一到线上会议,我总是能够感觉到自己已经不太会说话了,这让我感到了一丝不妙。会不会真的有某一天我完全丧失了口头交流的能力呢?
不知道哪天,一个观点进入了我的脑子:无法向外输出内容,是因为没有经过自己的思考将输入的信息融会贯通。大部分常用的信息表达方式都是线性的,例如聊天工具中的一段对话、网站评论区的一段留言、博客里面的一篇文章,不管篇幅如何,其中蕴含的观点与知识都是文字这样线性的方式传递的。而知识在思维之中却是以复杂精巧的网状结构存在的,想要通过文字传递原创的、有价值的信息,就得先将输入的信息反序列化成自己知识脉络的一部分,然后再通过合适的技巧将自己的知识序列化成线性的结构通过文字表达出来。
从这个角度来看的话,我遇到的问题其实是「看的太多,想的太少」。就像是 Epic 免费送的游戏一样,虽然可以很容易的加入到自己游戏库中,但是从来不会去玩,更不要说体验游戏的乐趣了「所谓的电子阳痿」。写作则是我准备进行的第一个康复训练,让大脑在编辑文字的过程中不断地整理之前输入的信息,不断地推敲文章的论述,不断地构思行文的思路,重新找回脚踏知识大地的坚实感觉。
如同是游戏开启了新存档准备用全新的职业来攻略一般,我的新博客平台切换到了托管的 WordPress 这样我就不用总是给自己的博客做运维了。虽然并非新年伊始,但这于我个人的数字生活而言算得上是个新的开始,这一次就以每周三篇为目标吧,Hello World!