标签: nx

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

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

  • 如何使用 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 的引用方式。