分类: frontend

  • 跟 LLM 一起折腾 Web Audio

    最近在开发一个与 TTS(Text-to-Speech)相关的功能,其中需要对播放的人声音频进行加速或减速处理。这个领域我之前完全没有接触过,DeepSeek 推荐我使用 Howler.js 和 Tone.js 这样的库来播放音频。

    在实现加速功能时,我发现需要修改 playbackRate,但通常 playbackRate 和音调(Pitch)的修改是联动的。然而,Howler.js 并没有提供直接修改 Pitch 的方法。在折腾了很久之后,DeepSeek 也没能解决 Tone.js 带来的噪音问题。使用在线处理(Pitch Shift 效果器)时,会产生“dadadada”的噪音;而使用离线处理(GrainPlayer)时,又会出现类似回声的杂音。

    最终,我查到 HTML5 的 Audio Element 提供了 preservePitch 的功能,这似乎是一个比较理想的解决方案。

    一些经验总结

    1. 代码生成与抽象
      即使通过 LLM 生成代码的成本较低,也尽量让它生成易于拓展和复用的代码。在我的尝试中,即便切换了多个不同的音频库,我都不需要修改业务代码,这正是因为我在最初就让 LLM 对音频处理部分进行了足够的抽象。
    2. LLM 在处理开放性问题时的局限性
      如果待解决的开放性问题足够普遍,LLM 通常能给出令人满意的答案。但对于像 Web Audio 这样 API 复杂且不太常见的技术领域,LLM 可能就很难解决问题了。例如,在解决 Pitch Shift 效果器的噪音问题时,DeepSeek 很难坚定地指出 Tone.js 的实现本身就存在缺陷(事实上也是如此)。这大概率是因为 LLM 只是一个互联网文本预测器,容易产生“幻觉”,尤其是在处理小众领域的问题时。

    总结

    在开发过程中,抽象和代码复用是非常重要的,尤其是在处理新技术领域时。虽然 LLM 在很多常见问题上表现出色,但在处理复杂或小众领域的问题时,仍需要结合人工经验和深入的技术调研来找到更优的解决方案。

  • 如何为 CodeMirror Widget 添加 lint marker

    CodeMirror6 中可以创建 Atomic Rranges 用自定义 Widget 替换文本,从而实现更加丰富的展示效果。但是当文本被 Widget 替换后,lint 功能的 markClass 并不会被添加到生成的 Widget 元素上,这就会导致用户无法在 Widget 上看到 linter 生成的错误标记。

    CodeMirror 并没有提供直接地解决方案,我们需要自己将 lint 结果绑定到 Widget 上。由于 Widget 的渲染时机跟 linter 的运行时间都是黑盒,可以假设这俩都会在编辑器内容更新后被异步并发调用。为了方便将 linter 异步产生的结果同步给 Widget ,可以用 rxjs 的 BehaviorSubject 存储 linter 的结果。

    import { Diagnostic } from '@codemirror/lint';
    import { BehaviorSubject } from 'rxjs';
    
    const diagnostics$ = new BehaviorSubject<Diagnostic[]>([]);

    在实现 linter 时,除了直接返回 Diagnostic 外,还需要将结果写入 diagnostics$

    const invalidMetric = linter(async view => {
      const diagnostics: Diagnostic[] = await yourLinter(view);
      diagnostics$.next(diagnostics);
      return diagnostics;
    });

    接下来,我们在创建 Widget 时就可以订阅 Diagnostic 的变化来更新 UI 状态:

    class PlaceholderWidget extends WidgetType {
      constructor(string, private from: number, private to: number) {
        super();
      }
    
      toDOM(view: EditorView): HTMLElement {
        const span = document.createElement('span');
        span.innerHTML = renderYourWidget(view);
        // @ts-expect-error add destroy$ to span
        span.destory$ = diagnostics$.subscribe(diagnostics => {
          const error = diagnostics.find(d => d.from >= this.from && d.to <= this.to);
          if (error) {
            span.classList.add('cm-widget-error');
          } else {
            span.classList.remove('cm-widget-error');
          }
        });
        return span;
      }
    
      destroy(el: HTMLSpanElement) {
        // @ts-ignore
        if (el.destory$) {
          // @ts-ignore
          el.destory$.unsubscribe();
        }
      }
    }

    注意 PlaceholderWidget 的构造函数,我们需要在创建 Widget 的时候,传入 Widget 的插入位置(fromto)。

    const placeholderMatcher = new MatchDecorator({
      regexp: /#[0-9A-Za-z_]+/g,
      decorate: (add, from, to, match) => {
        const deco = Decoration.replace({
          widget: new PlaceholderWidget(from, to),
        });
        add(from, to, deco);
      },
    });
    const placeholders = ViewPlugin.fromClass(class {
      placeholders: DecorationSet;
    
      constructor(view: EditorView) {
        this.placeholders = placeholderMatcher.createDeco(view);
      }
    
      update(update: ViewUpdate) {
        this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders);
      }
    }, {
      decorations: instance => instance.placeholders,
      provide: plugin => EditorView.atomicRanges.of(view => {
        return view.plugin(plugin)?.placeholders || Decoration.none;
      }),
    });

    最终的效果如下:

  • 密码保护:pre-bundle dependencies for vite

    此内容受密码保护。如需查阅,请在下列字段中输入您的密码。

  • 给 Email 写样式真的很难

    虽然 IE 退出了浏览器的历史舞台,但是 Windows 平台上的 Outlook 依然继承了 IE 的衣钵,继续在邮件客户端界发光发热。

    This ranks email clients based on their support among the 298 HTML and CSS features listed on Can I email.

    最好用内联样式

    很多情况下,使用外部样式表并不靠谱,比如 GMail 以及 Outlook 就会在特定情况下移除邮件 HTML 中的 style 标签,一个更保守的做法是使用元素上的内联样式。对于简单的通知邮件来说,完全够用了。

    如果嫌手搓样式很麻烦,可以先用 TailWind CSS 编写,然后让 ChatGPT 帮忙转换成内联样式。

    避免使用 base64 内联图片

    虽然 Base64 图片 URL 很简单易用,但是很多邮件客户端会把它们当作外部图片一样默认屏蔽。

    一个对展示效果更加友好的做法是通过 cid 引用内联图片,这时,图片会被作为邮件的附件一起发送给收件人,如果不希望正文图片被客户端识别为可下载的附件,则需要将附件的 Content-Disposition 设置为 inline,同时,还需要设置正确的 Content-Type

    Quick summary

    1. Inline only attachments: use multipart/related
    2. Non-inline only attachments: use multipart/mixed
    3. Inline and non-inline attachments use multipart/mixed and multipart/related
    mime – HTML-Email with inline attachments and non-inline attachments – Stack Overflow

    我的 Email 模板工作流

    考虑到 EMail HTML 本身的复杂性,为了简化 Email 模板的开发,我采用了下面的工作流:

    首先,使用 degit 初始化一个集成了 TailWind CSS 跟 Vite 的项目模板:

    npx degit kometolabs/vite-tailwind-nojs-starter email-template

    修改其中的 vite.config.mjs:

    import { resolve } from "path";
    import { defineConfig } from "vite";
    import { ViteImageOptimizer } from "vite-plugin-image-optimizer";
    
    function optimizeSvgPlugin() {
      if (!!process.env.OPTIMIZE_IMAGES) {
        return ViteImageOptimizer({
          svgo: {
            plugins: [{ removeViewBox: false }, { removeDimensions: true }],
          },
        });
      }
    }
    
    export default defineConfig(() => ({
      build: {
        outDir: "../dist",
        emptyOutDir: true,
        // avoid inlining images
        assetsInlineLimit: () => false,
        rollupOptions: {
          input: {
            page1: resolve(__dirname, "src", "page1.html"),
            page2: resolve(__dirname, "src", "page2.html"),
          },
          output: {
            // avoid hash in assets filename
            assetFileNames: (assetInfo) => {
              const info = assetInfo.name.split(".");
              let extType = info[info.length - 1];
    
              // Explicitly handle image files
              if (/png|jpe?g|svg|gif|tiff|bmp|ico/i.test(extType)) {
                extType = "img";
                // Return the original filename without hashing
                return `assets/${extType}/[name][extname]`;
              }
    
              // For other asset types, you can keep the default hashing behavior
              return `assets/${extType}/[name]-[hash][extname]`;
            },
          },
        },
      },
      root: "src",
      plugins: [optimizeSvgPlugin()],
    }));

    在编写 HTML 时需要手动创建一个引入 TailWind CSS 的 style 标签:

    <style>
    @tailwind base;
    @tailwind components;
    @tailwind utilities;
    </style>

    接下来就可以按照常规的方法用 HTML 画页面了,不过需要注意的是,大部分邮件客户端对 flex-box 的支持都比较差,在编写样式时需要注意。

    完成了 HTML 页面以及样式后,就可以借助 ChatGPT 将 TailWind 转换成内联样式的代码。最后,借助下面的脚本,即可将构建出来的 HTML 文件转换成 ejs 模板:

    // convert all `{{ foo }}` to `<%= foo %>`
    
    function convertToEjs(content: string) {
      return content.replace(/{{\s*(\w+?)\s*}}/g, "<%= $1 %>");
    }
    
    /**
     * Convert /assets/img/foo-bar.svg to cid:foo-bar
     * @param content
     */
    function toCidReference(content: string): string {
      return content.replace(/\/assets\/img\/([a-zA-Z0-9-)]+?)\.(svg|png|jpg|jpeg)/g, "cid:$1");
    }
    
    const files = await glob(dir + "/**/*.html");
    
    for (const filename of files) {
      const content = await fs.readFile(filename, "utf-8");
      const dirOfFile = path.dirname(filename);
      const newContent = toCidReference(convertToEjs(content));
      const newFilename = path.join(
        dirOfFile,
        path.basename(filename, ".html") + ".ejs",
      );
    
      await fs.writeFile(newFilename, newContent);
      echo(chalk.green(`Converted ${filename} to ${newFilename}`));
    }

    用来生成邮件模板的命令行操作如下:

    #!/usr/bin/bash
    OPTIMIZE_IMAGES=true yarn build
    cp ./dist/assets/img/* ./assets/img/
    npx tsx ./scripts/to-ejs-template.ts --dir ./dist

    参考链接

    How to Embed Images in Your Emails (CID, HTML Inline & More) | SendGrid

    mime – HTML-Email with inline attachments and non-inline attachments – Stack Overflow

  • 前端处理响应式图片

    MDN: Responsive images 介绍了在前端实现响应式图片的几种方法:

    • 为不同尺寸的屏幕加载不同大小的图片(srcset 配合 sizes
    • 固定图片大小,为不同 DPI 的屏幕加载不同 DPI 的图片(srcset
    • 为不同尺寸的屏幕加载不同的图片(picture 配合多个 source

    WordPress 采用 srcset/sizes 方案实现响应式图片,默认的设置为 sizes="(max-width: {{image-width}}px) 100vw, {{image-width}}px"。当页面宽度小于图片原始宽度时,以页面宽度(100vw)作为图片的显示宽度,当页面宽度大于图片宽度时,总会以图片原始宽度作为显示宽度。但实际上,不是所有的图片都会以原始宽度在页面上展示,由于排版样式的影响,图片的展示宽度往往跟原始宽度不一致。

    为了解决这个问题,需要使用 Enhanced Responsive Images 插件,这个插件会基于当前主题的 Content Width, Wide Width 为图片元素设置 srcset/sizes,相较于默认的实现,可以为大屏设备在图片宽度小于窗口宽度时提供更加合适的缩略图尺寸。

    Performance Lab 提供的 Enhanced Responsive Images 插件

    启用后的效果如下图:

    可以看到,虽然 img 元素的大小只有 800×312 px,但是浏览器选择了展示原图(1341 x 523 px),这是因为我的屏幕启用了 HiDPI,1 css px 约等于 2 显示器 px,这种情况下,浏览器使用更大尺寸的图片是合理的。

  • 使用 cloudflare tunnel 在线上预览本地的开发服务器

    Cloudflare Tunnel 是一项服务,允许开发者将其本地开发服务器通过安全的隧道连接到 Cloudflare 的服务器,并发布到互联网上。这意味着:

    1. 其他人可以直接通过互联网访问你本机的开发服务器
    2. 本地的 HTTP 服务可以自动获得由 Cloudflare 提供的 https 证书

    Cloudflare 在这篇文章中介绍了如何将本地项目发布到互联网,但是对于前端开发服务器的场景来说,缺少一些必要的配置说明,例如,稳定的域名、网页缓存策略等。如果你遇到了开发服务器通过 Cloudflare tunnel 访问后出现了一些诡异的问题,不妨参考下面的步骤来检查一下自己的配置。

    创建本地管理的 tunnel

    在开始之前,请确保你准备好了这些东西:

    • 在本地安装 cloudflared
    • 在 cloudflare 上绑定了自己的域名

    接着,参考这里的说明,创建一个本地管理的 tunnel。完成这里的步骤后,你应该会得到一份 config.yaml 文件,默认存储在 ~/.cloudflared 目录下。

    Cloudflare 的文档中使用了 url 字段配置反向代理,但我建议使用下面的 ingress 配置,这样你可以在同一个配置文件中声明多个服务。

    tunnel: <Tunnel ID>
    credentials-file: /path/to/<Tunnel ID>.json
    
    ingress:
      - hostname: ng-serve.zeeko.dev
        path: /api/.*
        service: http://localhost:3000
      - hostname: ng-serve.zeeko.dev
        service: http://localhost:4200
      # this is a required fallback rule
      - service: http_status:503

    禁用 Cloudfare 缓存

    你的 Cloudflare 帐户很可能默认开启了请求缓存功能,在一些使用场景下,例如,webpack dev server,这个自带的缓存功能会让 dev server 变得很鬼畜,我们需要手动在 Cloudflare 控制面板禁用缓存。

    添加 webpack dev server 白名单

    如果你在使用 webpack dev server,记得把绑定的域名添加到 allowedHosts 中,避免 HMR 失败。

  • 给大代码库的类型检查提速

    这是一系列博文中的一篇,用来记录将我将公司的前端项目改造为 monorepo 的过程。

    4 / 4 of Nx Monorepo Experience

    当代码库达到一定规模后,在每次提交前进行完整的 TypeScript 类型检查会非常耗时,就像下面的代码库,执行完整的检查需要 47s,如果每次代码提交时执行类型检查,开发者都得等上接近 1 分钟才能完成 git commit,极大的拉低了开发体验。

    ───────────────────────────────────────────────────────────────────────────────
    Language                 Files     Lines   Blanks  Comments     Code Complexity
    ───────────────────────────────────────────────────────────────────────────────
    TypeScript                3721    271374    23335     21340   226699      16639
    
     nx run-many -t check --all
    
          nx run icons:check (3s)
          nx run visual:check (4s)
          nx run eslint-plugin-merico:check (4s)
          nx run vdev-api:check (176ms)
          nx run nx-plugin:check (3s)
          nx run illustrations:check (4s)
          nx run shared-lib_temp:check (4s)
          nx run fekit:check (12s)
          nx run charts:check (14s)
          nx run main:check (39s)
    
     —————————————————————————————
    
     >  NX   Successfully ran target check for 10 projects (47s)
    

    那么为什么会这么慢呢?责任真的全在 nodejs 的性能上?

    为什么这么慢

    TypeScript 文档中这样写道:

    Rather than doing a full check of all d.ts files, TypeScript will type check the code you specifically refer to in your app’s source code.

    所以当你的 monorepo 中存在项目间引用的时候,执行 tsc -p lib-bob/tsconfig.json 会同时在 lib-bob 及其依赖的 lib-alice 上运行类型检查,这意味着,如果你在 monorepo 中对所有项目分别执行 tsc -p xx/tsconfig.json,被其他项目依赖的项目,会被重复检查多次!

    为了避免这种问题,TypeScript 的 build mode 提供了一种基于增量构建的解决方案。启用 build mode 后,庞大的代码库可以被拆分成多个项目分别被 tsc 构建,tsc 会缓存每个项目的构建结果,当一个项目被其他项目引用的时候,这个项目不会被重新构建而是使用之前的构建结果。在后续的执行中,只有发生变动的项目才会被重新构建。

    如何配置 build mode

    启用 build mode 的方法比较简单,你只需要调整 tsconfig.json 中以下几个选项即可:

    • compilerOptions.composite: true,用来标记这个项目可以被增量构建
    • compilerOptions.noEmit: false,启用增量构建必须允许 tsc 生成文件
    • compilerOptions.outDir: path/to/tsc-out,设置生成文件(包括缓存文件)的路径,记得加入 gitignore
    • compilerOptions.emitDeclarationOnly: true,如果你使用 tsc 构建 js 文件,可以不设置这个选项
    • references,把依赖项目的 tsconfig 路径添加到这里,让 tsc 可以使用这些依赖项目的构建缓存
    • compilerOptions.rootDir,不建议设置,避免构建缓存生成到 outDir 目录之外

    现在,你就可以使用 tsc -b (注意,不是 tsc -p) 来构建你的项目了,同样还是开头展示的代码库,现在重新运行类型检查:

     time nx run-many -t check --all --skip-nx-cache
    
          nx run illustrations:check (3s)
          nx run icons:check (5s)
          nx run visual:check (5s)
          nx run shared-lib_temp:check (781ms)
          nx run vdev-api:check (5s)
          nx run eslint-plugin-merico:check (832ms)
          nx run nx-plugin:check (2s)
          nx run fekit:check (10s)
          nx run fekit-e2e:check (2s)
          nx run charts:check (12s)
          nx run main:check (27s)
    
     ——————————————————————————————
    
     >  NX   Successfully ran target check for 11 projects (44s)
    
    
    ________________________________________________________
    Executed in   44.50 secs    fish           external
       usr time  119.40 secs  213.00 micros  119.40 secs
       sys time    6.17 secs   67.00 micros    6.17 secs 
    

    看起来似乎长进不大,再运行一遍试试?

     time nx run-many -t check --all --skip-nx-cache
    
          nx run illustrations:check (752ms)
          nx run visual:check (760ms)
          nx run icons:check (768ms)
          nx run shared-lib_temp:check (732ms)
          nx run fekit:check (880ms)
          nx run vdev-api:check (1s)
          nx run eslint-plugin-merico:check (731ms)
          nx run charts:check (900ms)
          nx run nx-plugin:check (756ms)
          nx run fekit-e2e:check (773ms)
          nx run main:check (1s)
    
     —————————————————————————————
     >  NX   Successfully ran target check for 11 projects (4s)
     
    
    ________________________________________________________
    Executed in    4.49 secs    fish           external
       usr time   13.51 secs    0.00 micros   13.51 secs
       sys time    1.86 secs  369.00 micros    1.86 secs
    

    类型检查速度变得飞快,这是因为 tsc 复用了第一次生成的构建缓存。如果你这时检查 outDir,你就会发现除了 d.ts 之外,tsc 还生成了 tsbuildinfo 文件,这就是 tsc 用来判断是否可以复用 d.ts 的关键。

     ls dist/out-tsc/icons
    components  tsconfig.build.tsbuildinfo
    

    可以想象的是,在平常开发过程中,每个 commit 通常只会修改一小部分文件,如果我们把代码库中较大的项目(例如上面的 main 项目,代码量有 18w 行)进一步拆分,就可以更加充分地利用增量构建的特性,运行类型检查的范围就会更小,速度就会更快。

    维护 monorepo 中的项目依赖

    细粒度的拆分项目在提高类型检查速度的同时,又会带来一个新的问题,由开发者人工维护项目之间的 references 字段会变得很麻烦,而且容易出错。为了减少人工操作的负担,我开发了一个小工具 —— ts-sync-ref,它会分析项目的 monorepo 内依赖,进而更新 tsconfig.json 的 references 字段。

    # 安装 ts-sync-ref
    npm install -g @zeeko/ts-sync-ref
    # 更新 my-lib 的依赖到 tsconfig.json 中的 references 字段
    cd /path/to/my-monorepo
    ts-sync-ref -p packages/my-lib/tsconfig.json -f 'packages/my-lib/src/**/*.ts'
    

    你可以阅读 ts-sync-ref 的说明文档、代码,进一步了解使用方式及其实现原理。

    结论

    Monorepo 的组织方式有很多种,不管你在用什么工具管理你的 TypeScript monorepo,不妨试试通过 TypeScript 的 Project Reference 将大代码库拆分成多个项目,不仅可以提升类型检查、构建速度,还可以减少编辑器的内存占用,极大地提高开发体验。

    不过,需要注意的是,直接启用 Project Reference 可能会给你的项目带来一些影响,导致现有的构建脚本出错,例如:

    最后,如果你有兴趣的话,也欢迎你在评论区分享你的项目执行类型检查的方案。

  • 如何使用 nx 缓存构建产物

    这是一系列博文中的一篇,用来记录将我将公司的前端项目改造为 monorepo 的过程。

    3 / 4 of Nx Monorepo Experience

    There are only two hard things in Computer Science: cache invalidation and naming things.

    Phil Karlton

    nx 的缓存机制如何工作

    How Caching Works 这篇文章中非常详细地说明了 nx 缓存构建结果的机制:
    nx 会计算当前执行的 target 的 Hash 作为 cache key,在 target 执行完成后把构建输出(包括终端输出、构建结果文件)作为缓存内容存储起来。

    通过配置 project.json 中 target 的 inputs 选项,我们可以调整能够让缓存失效的文件、环境变量,具体的配置方法可以查看这里

    在编写文件 glob 模式的时候,有两个特殊的变量:

    • {projectRoot} :当前项目的根目录
    • {workspaceRoot}:nx 工作区的根目录

    然而不管是在 inputs 还是 outputs 配置中,路径都是相对于 workspace root 的,所以 {workspaceRoot} 变量基本上没啥作用。

    如何为项目添加构建缓存

    缓存失效一直都是计算机领域的一个难题,在配置 nx 构建缓存的时候,我有以下两点建议:

    • inputs 配置应该宁滥毋缺,因为缓存不同步的严重性远大于缓存命中率低
    • outputs 配置必须不多不少,避免无关文件被错误地覆盖或者构建结果缺失

    要做到上面两点需要精确地控制 inputs 跟 outputs 的文件匹配模式,这里推荐几种帮你减少工作量方法:

    在拆分模块需要注意,尽量采用类似 Module Federation 或者 Webpack DLLPlugin 等机制,这种构建方式能够真正实现分模块打包。如果没有采用类似的机制,所有的模块都会在入口模块处被重新处理,所以除了入口模块之外,其他的模块没必要 minify ,这样可以加快打包速度。

    共享缓存执行结果

    nx 缓存默认存储在本地,独乐乐不如众乐乐,通过添加 nx-remotecache-minio,我们可以把 nx 缓存存储在任意与 S3 兼容的对象存储服务器上。如此,只要项目中的任意一位成员甚至 CI 服务器构建过某个模块,其他人都可以跳过构建直接从远程服务器拉取构建结果。

    需要注意的是,nx-remotecache-minio 没有处理访问对象存储超时的情况,所以一旦遇到对象存储服务器不可用的情况,记得及时终止构建,并将 NX_CACHE_MINIO_URL 环境变量设置成一个无法解析的地址,避免构建命令卡住。

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

    这是一系列博文中的一篇,用来记录将我将公司的前端项目改造为 monorepo 的过程。

    2 / 4 of 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 的典型场景。

  • 年轻人的第一个 nx 插件

    这是一系列博文中的一篇,用来记录将我将公司的前端项目改造为 monorepo 的过程。

    1 / 4 of 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 的引用方式。