作者: Zeeko

  • 拒绝重复劳动,自动为项目添加 target

    This entry is part 2 of 4 in the series Nx Monorepo Experience

    完成了自制的插件过后,我们得往需要使用 lingui 的项目中添加相关的 target,项目少还好说,项目一多,这就变成纯体力劳动了。还好, nx 提供了 Project Inference 机制,给插件加上几行代码,就可以让 nx 自动为项目添加合适的 target。

    与 executor 不同,project inference 功能需要在 nx.json 中注册:

    {
      "plugins": [
        "my-nx-plugin"
      ]
    }

    Project inference 功能由 my-nx-plugin 模块默认导出的两个变量来实现:

    • projectFilePatterns 主要用来识别项目文件,项目目录中匹配的文件会作为参数传递给 registerProjectTargets
    • registerProjectTargets 是一个用来根据项目文件推断 targets 的函数,它返回一个 Record<string, TargetConfiguration>,即我们在 project.json/targets 中编写的内容。

    一个给项目添加 lingui target 的实现如下:

    import { workspaceRoot } from '@nrwl/devkit';
    import * as path from 'path';  
    import * as fs from 'fs';
    import type { TargetConfiguration } from '@nrwl/devkit';
    
    export const projectFilePatterns = ['package.json'];
    
    export function registerProjectTargets(projectFilePath: string): Record<string, TargetConfiguration> {
     // 通过导入的 workspaceRoot 变量来获取当前 nx workspace 的根目录,这样可以将 projectFilePath 转换为绝对路径
     const projectRoot = path.join(workspaceRoot, path.dirname(projectFilePath));  
     return { 
       ...linguiTargets(projectRoot),  
     };  
    }
    
    function linguiTargets(projectRoot) {
      const packageJSON = fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8');
      // 只要项目依赖了 lingui,就给它添加 lingui target
      if (packageJSON.indexOf('lingui') > 0) {
        return {
          lingui: {
            executor: 'nx-plugin:lingui',
            options: {
              locales: path.join(projectRoot, 'locales'),
            }
          }
        }
      }
    }

    在实现这个功能的时候需要注意几点 registerProjectTargets 必须是个同步函数,所以不能使用任何异步 API,也没法用之前提到的方法来引用 ESM。另外,nx 出于性能考量,会缓存 registerProjectTargets 的结果,所以在 Debug 的时候,一定要记得设置环境变量 NX_CACHE_PROJECT_GRAPH=false

    在处理文件路径的时候,建议尽量使用绝对路径,使用相对路径需要思考是相对于 workspace root 还是 cwd,用绝对路径可以减少这方面的心智负担。

    虽然功能实现了,但是现在还不能立马使用,需要将插件源码转译成 CJS 代码。前面的文章提到过用 postinstall 来编译自定义 nx 插件,在实现了今天的功能后,编译自定义插件的命令必须不能用 nx 来执行,因为 nx 执行任何命令都会加载插件模块,如果我们的插件没有编译成 CJS,通过 nx 调用的编译插件命令也会失败。

    Project Inference 功能非常强大,可以很大地减少我们维护项目 targets 的负担,在我看来,这算得上 nx 的旗舰功能了,后面我也会再演示一些其他使用 Project Reference 的典型场景。

  • 使用这个技巧,再也不会搞乱 Git 用户信息了

    在使用 GitHub 的时候,经常在公司帐户跟个人帐户之间切换,难免会造成这样的困扰:个人项目中的代码不小心用公司帐户提交了。

    Commit with wrong git user

    等到发现的时候,才发现已经来不及修改了——代码已经被推送到了远程仓库,强行修改历史会带来很多冲突。

    经过一番寻找,发现了这个答案,原来 Git 2.13 引入了一项新功能“Conditional Includes”,可以实现对不同路径下的仓库引用不同 gitconfig 的功能。

    我习惯把公司项目放在 ~/projects/company 目录下,个人项目随缘摆放。所以我给 ~/.gitconfig 添加了如下配置:

    [includeIf "gitdir:company/**"]
     path = ~/.gitconfig.company
    • gitdir: 表示按照 .git 文件夹的路径来匹配 gitconfig
    • company/** 是一个 glob 表达式,在 includeIf 中会自动展开为 **/company/**/.git,用来匹配 /any/path/to/company/any/project/.git
    • path 则是指要引用的 gitconfig 的路径,被引用的文件内容会覆盖 ~/.gitconfig 的配置,从而拥有更高的优先级。

    接着,只需要在 ~/.gitconfig.company 添加公司帐户的相关信息就好了:

    [user]  
     name = your-name
     email = your-email

    掌握了这个小技巧,再也不会用错误的帐户提交正确的代码了。

  • 年轻人的第一个 nx 插件

    This entry is part 1 of 4 in the series Nx Monorepo Experience

    我们的项目中使用 lingui 作为 i18n 方案,在之前的实践中,每个项目都在 package.json 中维护了调用 lingui 提取 i18n 文本的命令,除此之外,在每个项目的 CI 中也包含了检查 i18n 文本是否存在遗漏的脚本。

    把项目迁移到 nx 管理的 monorepo 后,就可以着手把各个项目中重复维护的 lingui 相关脚本交给 nx 插件来实现,减少维护 lingui 相关脚本的负担。

    添加 lingui executor

    lingui 并没有提供官方的 nx 插件,但我们自己在 monorepo 中创建一个 nx plugin 来实现相关的功能。一般来说,nx 插件能够提供三种能力:

    1. executors,用来执行工具命令,例如,编译、运行测试
    2. generators,用来创建模板文件
    3. 自动推断项目类型,可以根据项目类型自动地为项目添加相关的开发脚本

    对于 lingui 插件来说,只需要用到其中的 executor,在项目的 project.json 中添加 lingui executor,就可以通过 nx lingui 来调用 lingui cli

    借助 nx 提供的 nx-plugin 插件,可以很快的把实现这些功能的基本模板搭建起来。

    实现 Lingui Executor

    Lingui Executor 的内容非常简单,只需要调用 lingui cli 就行了。这里我推荐使用 zx 来执行命令,zx 开箱即用,帮你处理好了调用命令行程序的一切工作,包括参数解析、命令行输出捕获、glob 匹配。唯一需要注意的是,zx 目前只提供 ESM 模块,而 nx 暂时无法调用 ESM 模块插件。为了兼容这两者之间的模块格式,我们的插件只能编译为 CJS 模块,通过 import('zx') 来引用 zx 中的函数。方便起见,我封装了 zx 中最常用的 $ 函数,使之可以自然地在 CJS 中使用:

    import { logger } from '@nrwl/devkit';
    export async function $$(template: TemplateStringsArray, ...args: any) {  
     const { $ } = await import('zx');  
     try {
       // 强制终端彩色输出
       $.env.FORCE_COLOR = '1';  
       // 关闭命令回显
       $.verbose = false;  
       if ($$.cwd) {  
         $.cwd = $$.cwd;  
       }  
       const process = $(template, ...args);  
       process.stdout.on('data', (data) => {  
         logger.info(data.toString());  
       });  
       process.stderr.on('data', (data) => {  
         logger.info(data.toString());  
       });  
       return await process;  
     } catch (e) {  
       logger.error(e.stdout);  
       logger.error(e.stderr);  
       throw e;  
     }  
    }  
      
    $$.cwd = undefined as string | undefined;

    需要注意的是,zx 执行命令的 stdio 必须通过 @nrwl/devkit 输出到终端,否则 nx 的缓存机制将无法取得 executor 执行过程中产生的命令行输出内容。

    export default async function runExecutor(options: ILinguiExecutorSchema, context: ExecutorContext) {    
     if (!options.localesDir) {  
       logger.error('options.localesDir is required');  
       return { success: false };  
     }  
     const localesDirPath = path.resolve(context.root, options.localesDir);  
     if (!context.projectName) {  
       return {  
         success: true,  
       };  
     }  
     const projectCwd = await getProjectCwd(context);  
     $$.cwd = projectCwd;
     try {
       await $$`yarn exec lingui extract --clean`;
     } catch (e) {
       return { success: false };
     } 
     return {  
       success: true,  
     };  
    }

    编译插件

    在运行 nx 命令时,nx 插件会被执行,所以需要在开发者调用任何 nx 命令之前就把本地项目中的插件编译成 nx 可以加载的 CJS 模块。我的做法是给本地插件加上一个 postinstall 脚本,这样在运行 yarn install 的时候,nx 插件就会被自动地编译,不需要手动干预。

    nx 默认生成的模板会使用 TypeScript 将代码编译为 CJS 模块,但 tsc 的编译速度比较慢,测试下来稍微有点影响实际的开发体验,我们可以用 swc 作为 tsc 的替代品,编译速度会快上很多。

    另一个用来编译插件的选项是 esbuild,但 esbuild 的目标并不是替代 tsc,在某些行为上跟 tsc 有不少差异,需要对项目做很多改动,才能适配 nx 执行 executor 的机制。对于 nx 插件来说,executor 跟 generator 都是插件模块的入口(entry),而 esbuild 是设计来将代码打包成一个 Bundle 的。

    最后

    给自己的项目开发 nx 插件是一件非常简单的事情,但是由于 nx 加载插件的限制,需要注意插件的编译方式以及 ESM 的引用方式。

  • 修改 WordPress 主题默认字体

    我很喜欢 WordPress 2022 主题的设计,但是这款主题默认使用的是衬线字体,我看着很不舒服,经过断断续续的折腾,终于把找到了最合适的方法来修改这款主题的默认字体。

    TL;DR

    在不使用第三方插件的前提下,为了让主题的修改在展示页面跟编辑页面同时生效,必须要借助主题文件编辑器。打开编辑器,选中 WordPress 2022 主题,在右边找到 theme.json

    Theme File Editor in Menu
    theme.json

    接着将文件中所有的 var(--wp--preset--font-family--source-serif-pro) 替换为 var(--wp-preset--font-family--system-font) 即可。system-font 默认使用的就是用户操作系统的非衬线字体。

    为什么不用自定义 CSS?自定义 CSS 需要同时覆盖展示页面跟管理页面的样式,而且还需要针对不同的 Block 单独编写样式,太麻烦了,直接编辑主题文件可以在所有看得到的地方起效。

  • 让 v2ray 透明代理的 DNS 更聪明一些

    透明代理(TPROXY) | 新 V2Ray 白话文指南 中,作者通过 v2ray 内置的 DNS 功能实现 DNS 查询分流的功能,但 v2ray 内置的 DNS 功能并不完善,实际体验下来速度并不快,于是参考其他网友的文章,试了一下用 SmartDNS 来接管全局网络的 DNS。

    SmartDNS 是一个运行在本地的 DNS 服务器软件,它支持缓存、分流、测速等功能,可以极大地提高 DNS 使用体验。SmartDNS 提供了 OpenWRT 的预编译包,参考官方文档就可以很容易的在 OpenWRT 上完成安装配置。

    启动 SmartDNS

    在 v2ray 的 TProxy 的网络环境下,v2ray 会接管来自局域网的 DNS 查询请求,为了让 SmartDNS 能够正常工作,需要修改之前设置的 iptables 规则,让来自 53 端口的 UDP 请求不再被转发到 v2ray:

    - iptables -t mangle -A V2RAY -d 192.168.0.0/16 -p udp ! --dport 53 -j RETURN # 直连局域网,53 端口除外(因为要使用 V2Ray 的 DNS)
    + iptables -t mangle -A V2RAY -d 192.168.0.0/16 -p udp -j RETURN # 直连局域网

    再添加一些上游 DNS 服务器地址:

    # 国内域名服务器,设置分组名为 cn
    server-tls 233.5.5.5  -group cn
    # 国外域名服务器,使用默认分组 default
    server-tls 1.1.1.1
    server-tls 8.8.8.8

    接着,设置 SmartDNS 监听 53 端口,并关闭 dnsmasq 服务:

    启用 SmartDNS

    如果一切顺利,你应该可以通过命令查询到 smartdns 这个域名:

    $ dog smartdns
    # A smartdns. 10m00s   192.168.68.4

    实现 DNS 查询分流

    为了能够在不造成 DNS 污染的情况下尽可能地使用国内 CDN,需要借助 dnsmasq-china-list 项目提供的域名列表实现查询分流。

    先 clone 这个项目到本地,然后执行下面的命令来生成 SmartDNS 规则:

    $ make smartdns-domain-rules SERVER=cn
    $ ls *.domain.smartdns.conf
    # accelerated-domains.china.domain.smartdns.conf  apple.china.domain.smartdns.conf  google.china.domain.smartdns.conf

    再把这些生成出来的文件复制到 OpenWRT 中,并修改 SmartDNS 的自定义设置:

    conf-file /etc/smartdns/conf.d/accelerated-domains.china.domain.smartdns.conf
    conf-file /etc/smartdns/conf.d/apple.china.domain.smartdns.conf
    conf-file /etc/smartdns/conf.d/bogus-nxdomain.china.smartdns.conf
    conf-file /etc/smartdns/conf.d/google.china.domain.smartdns.conf

    设置 v2ray 路由规则

    在完成 DNS 查询分流后,还需要更新一下 v2ray 的路由规则,让 v2ray 能够正确的把流量发送到正确的 outbound,以下是一个国内直连白名单策略的设置:

    {
      "outbounds": [
        {
          // 配置省略,请参考白话文
          "tag": "proxy"
        },
        {
          // 配置省略,请参考白话文
          "tag": "direct"
        }
      ],
      "routing": {
        // 优先按域名匹配规则,无匹配时再将域名解析为 ip 进行匹配
        "domainStrategy": "IPIfNonMatch",
        "rules": [
          {
            // 国外 DNS 需要代理
            "ip": [
              "8.8.8.8",
              "1.1.1.1"
            ],
            "outboundTag": "proxy",
            "type": "field"
          },
          {
            // 国内 DNS 必须直连
            "ip": [
              "223.5.5.5"
            ],
            "outboundTag": "direct",
            "type": "field"
          },
          {
            // 需要直连的域名白名单:国内域名、国内 CDN 等
            "domain": [
              "geosite:private",
              "geosite:apple-cn",
              "geosite:google-cn",
              "geosite:category-games@cn",
              "geosite:cn",
              "ntp.org",
              "domain:mi.com",
              "domain:ls.apple.com",
              // steam 优先选择国内 CDN,如果无效可以删掉
              "domain:cm.steampowered.com",
              "YOUR_VPS_ADDRESS"
            ],
            "outboundTag": "direct",
            "type": "field"
          },
          {
            // 需要直连的 IP 地址白名单:局域网地址、国内地址
            "ip": [
              "geoip:private",
              "geoip:cn",
              "YOUR_VPS_IP"
            ],
            "outboundTag": "direct",
            "type": "field"
          }
        ]
      }
    }
    

    这份配置文件中用到的 geosite.dat 来自 Loyalsoldier/v2ray-rules-dat,其中的国内域名列表也是基于上面的 dnsmasq-china-list 项目生成的。这样就可以保证 SmartDNS 解析出来的国内域名也一定会被 v2ray 发送到 direct outbound。

  • 什么时候应该避免 barrel import

    TL;DR
    不要从导出当前模块的 barrel file 导入其他模块。

    什么是 Barrel file

    用来重新导出当前目录下的子模块,缩短外部 import 路径,通常命名为 index.ts

    DO NOT

    上图存在循环依赖,如果 pluign-c 尝试在顶层访问 PluginB,有可能会得到 undefined,这取决于 plugin-b、plugin-c 在 barrel 中的顺序

    DO

  • 给 cypress 添加 todo

    jest 提供的 test.todo 功能在实践 TDD 的时候非常实用,但是 cypress 却没有提供类似的功能。经过简单的搜索,找到了一个 workaround。

    const test = it;
    test.todo = it.skip;
    global.test = it;

    接着就可以在测试代码中愉快地使用 test.todo 了。

  • 年轻人的第一个前端 monorepo 应该怎么选型

    如果你正在调研 monorepo 的技术选型,那你一定不能错过 nx。nx 是一个用来管理 monorepo 的构建系统,它提供了一系列的工具来帮助你实现代码的模块化、标准化,让开发人员在管理任意规模的 monorepo 时仍能够游刃有余。

    nx 提供了哪些功能?

    代码生成器
    通过生成器,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-app

    任务执行器

    swc?esbuild?vite?vitest?playwright?放手去尝试这些新的工具,你可以先在单个项目中试点,当你做好准备后,就可以把单个项目中的实践封装为任务执行器,接下来 nx 就可以帮助你快速地将新的工具链应用到各个项目中。

    任务编排

    你可以放心地把工具库、业务模块提取成 monorepo 中独立的项目,nx 通过依赖分析能力,能够自动地理清项目之间的依赖关系,正确地帮你编排构建、测试脚本的执行顺序,提高执行工具构建、测试的执行效率。

    nx 适用于新项目吗?

    我非常推荐新的项目采用 nx,你可以利用 nx 插件,快速地创建各种新项目必需的各种样板代码、设置好各种开发工具的配置文件,再也不用去翻看 「XXX Get Started」文档。工具链的最佳实践由 nx 搞定,你只需要在功能实现上展示专业水平。

    nx 适用于已有的小型项目吗?

    处于代码量茁壮成长阶段的小型项目,需要注意代码的模块化、标准化,避免让代码库成长为超大的面条怪物。

    在拆分模块的时候,可以先用生成器定制符合项目需求的代码样板,减少提取项目模块的工作量。接着,你还可以为项目中使用的开发工具(Storybook、Cypress 等)创建执行器,方便在提取出来的项目模块中复用同样的工具链。

    nx 适用于已有的大型单体项目吗?

    大型项目往往会遇到编译缓慢的问题,通过将大型项目拆分成多个模块,利用 nx 的构建缓存以及增量构建功能,可以提高项目整体构建速度,极大地提升开发体验。

    nx 适用于将已有的多仓库项目改造成 monorepo 吗?

    nx 提供的 project.json 能够帮助你抹平不同项目使用的工具脚本之间的差异,让你可以用尽量小的代价使整个 monorepo 工作起来。项目成功运行之后,你就可以考虑使用执行器来替换掉各个项目中的开发脚本,减少维护各种工具脚本的负担。

    需要更换包管理器吗?

    简单来说,nx 跟任何包管理工具都能够很好的工作,它并不依赖于某种独特的包管理机制。但这也是 nx 的不足之处,它没法帮你解决管理第三方依赖时可能会遇到的问题(NPM 中的 phatom 与 doppelgangers 问题),你可能需要针对这种问题去选择适合自己的包管理工具。

    该用怎样的方式管理 monorepo ?

    我的 nx 实践体验可以总结为三点,希望对你有所启发:

    1. 拆分模块,利用构建缓存、任务编排、增量构建提高构建效率
    2. 用样板代码生成器,保持各个模块代码结构上的一致性,减少创建、更新样板代码的手动操作,在规模庞大的 monorepo 中,这点尤其重要
    3. 复用执行器,为 monorepo 中所有的模块提供标准化的开发体验
  • 「推敲代码」如何为回调函数设计 API

    TLDR

    在设计回调函数形式的扩展点时,扩展点的默认行为也要提供给调用方,方便基于默认行为进行扩展。达到这一目的的模式有:

    1. 把回调函数提取成单独的类,把默认行为作为基类方法提供给调用方
    2. 把默认行为作为回调函数的参数提供给调用方

    推敲过程

    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 的具体实现才知道「fooBehavioronChangeState 的默认实现,这显然是不合理的。

    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:好的,最后来总结一下:

    • 回调函数的默认行为需要作为 API 的一部分提供给调用方,这样调用方可以在不查看具体实现的情况下,基于默认行为做扩展。
    • 把回调函数提取成类,通过类的继承,可以将默认行为作为基类的方法提供给调用方。
    • 一个不使用类的更轻量的做法,将默认行为作为回调函数的参数提供给调用方。
  • 吐槽一下 Testing Library / Jest

    有点鸡肋的 Query API

    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

    Playwright 是近来非常流行的 e2e 测试框架,它使用魔改版的浏览器来执行测试,在 playwright 中我们几乎可以实现任何真实的用户操作,甚至是多开标签页。借助 playwright-ct,组件的单元测试也可以使用 playwright 编写。