阅读视图

发现新文章,点击刷新页面。

别再被 `npx` 骗了:Debug 纪实 —— 为什么总是找不到文件?

做全栈开发,最让人抓狂的往往不是复杂的业务逻辑,而是各种匪夷所思的 “环境玄学”

  • “为什么教学视频里敲 npx xxx 秒开,我一敲就报错?”
  • “为什么我昨天在这台电脑上敲就没事,今天怎么突然就不行了?”
  • “按照控制台弹出的方案重试了 3 次,为什么一行能在 Windows 跑通的都没有?”

今天,我们就以开发用到的 Inngest CLI 为例(同样适用于 Prisma, esbuild, sharp 等工具),彻底扒开前端包管理器的底层黑盒,讲透这个恶心无数开发者的 Binary not found 现象。


💥 案发现场

当你在本地输入 npx inngest-cli@latest dev 时,满心欢喜地等待面板启动,结果迎面砸来这样一段报错:

Error: Inngest CLI binary not found.
This happened because install scripts were skipped.
To fix this, use the method most appropriate for your setup:
  NPM_CONFIG_CACHE=$(mktemp -d) npx --ignore-scripts=false inngest-cli@latest
  ...

你尝试复制了报错提示里的命令,然后发现它在 Windows 的 PowerShell 里连语法都不对! 这是为什么?


🕵️‍♂️ 剥开黑盒探寻本质:为什么找不到肉身?

这个报错并不是说你断网了没装上包,而是说你装下来的包**“少了灵魂”**。

1. 挂羊头卖狗肉的 NPM 包装戏法

现代的开发工具链(如 Inngest、esbuild、Prisma 等)由于对性能有极致要求,它们底层的引擎绝大多数是用 Go、C++ 或 Rust 写的。 为了能兼容前端庞大的 npm 生态,开发者通常会在 npm 仓库里发一个纯粹由 JS 构成的 “空壳子”

它的真实运作机制是: 触发安装 -> 下载 JS 空壳 -> 触发 postinstall 钩子脚本 -> 脚本自动从 Github Releases 拉取对应系统(Win/Mac/Linux)的 .exe 可执行文件。

一旦这个 postinstall 脚本因为任何原因(网络超时、没有权限)没有跑成功,你的包里就只剩下一个没用的 JS 空壳。这就叫 Binary not found

2. 拦路虎:pnpm v10 的“安全铁腕”

你可能会问:“我的网络有魔法代理,为什么还会失败?” 真相隐藏在你的包管理器里。如果你升级到了 pnpm v10,由于它引入了极其严格的“受信任依赖”机制,默认会悄悄拦截一切第三方包在后台执行构建脚本(postinstall)的行为

你的命令行里大概会有这样一行一闪而过的高大上的警告:

Ignored build scripts: inngest-cli@1.16.1. Run "pnpm approve-builds" to pick ...

是的,是 pnpm 觉得这个包不安全,亲手把下载 .exe 的途径给掐断了。

3. NPX 的“就近连坐”病毒(解释时灵时不灵)

这是最魔幻的一点:为什么昨天能行,今天装完反而坏了?

  • 当你没安装时(昨天):运行 npx 时,它去自己干净的全局临时目录下载了一个包,刚好没受困于安全拦截,顺利拿到了二进制文件,成功运行。
  • 当你在本地项目里安装了它但被拦截时(今天):你的项目 node_modules 里多了一个“没有二进制文件的空壳包”。
  • 致命的偷懒机制:当你再次敲击 npx inngest-cli 时,npx 会自作聪明地优先使用本地项目中已有的坏包,而不是去全局深究。

这就造成了:只要你的项目里混进了一个“太监版”的依赖,无论你敲多少次全局 npx,它都会被就近传染,当场暴毙。


🛠️ 解法:做防弹的工程底座

搞懂了原理,我们就绝不能像“脚本小子”一样,每次报错就去删除 %LOCALAPPDATA%\npm-cache\_npx 缓存。在正规的全栈商业级项目中,所有基建都必须是绝对受控且确定的。

彻底杜绝玄学的标准动作:将隐式全局依赖,转变为显式本地依赖。

Step 1: 签署白名单 (pnpm.onlyBuiltDependencies)

不要让 pnpm 盲猜,直接在你的 package.json 中明确发给 Inngest 发“放行条”:

{
  "pnpm": {
    "onlyBuiltDependencies": [
      "inngest-cli",
      "prisma",
      "esbuild",
      "sharp"
    ]
  }
}

🔥 Tips: 另一个快捷写法是在终端执行 pnpm approve-builds --save-bundle,它会自动把被拦截的包扫进信任名单。

Step 2: 固化到开发依赖

将不靠谱的 npx 游击战术转编为正规军:

# 保证当前终端顺畅访问 Github 的前提下
pnpm add -D inngest-cli

这时候你再看日志,必定能看到真正的 .exe 安稳落地。

Step 3: 固定项目启动快捷键

打开 package.jsonscripts

"scripts": {
  "dev:inngest": "inngest dev"
}

以后只需优雅地执行 pnpm run dev:inngest,把复杂的事情彻底封装在项目内部。不管换谁接手、换什么电脑拉下代码,都不再需要承受你昨天吃过的苦!


🎯 总结与认知升级

全栈开发往往就是在和这些看似无聊的“基建脏活”抗争。当你能够把“这破电脑怎么又抽风了”,转变为“哦,这显然是 pnpm 包提取钩子被跳过导致的本地模块污染”,你的水平就已经跟初级搬砖工拉开了真正的身位。

下次如果有人对你说“这机器跑不起来,但我本地没问题”,记得用这套理论降维打击他。👨‍💻

前端架构演进:基于AST的常量模块自动化迁移实践

前端架构演进:基于AST的常量模块自动化迁移实践

从“硬编码”到“全自动”:一次常量模块重构的工程化探索

在这里插入图片描述

一、背景与痛点

在许多中大型前端项目中,常量管理常常是一个被忽视但又十分重要的环节。随着业务迭代,常量定义方式可能发生变化,历史代码中也可能沉淀出各种“不规范”的模式。

在我们的项目中,常量定义最初采用了一种集中式导出方式:

// src/constants/Constants_expert.ts
export default {
  STATUS_PENDING: 0,
  STATUS_APPROVED: 1,
  // ... 数十个常量
}

而在业务代码中,这些常量通过一个“万能”的 @/locales 模块统一导入,并以 Constants_expert.default.STATUS_PENDING 的形式使用:

// 旧代码片段
import { Constants_expert } from '@/locales';

if (status === Constants_expert.default.STATUS_APPROVED) { ... }

这种模式存在几个严重问题:

  1. Tree Shaking 失效export default 对象导致整个常量对象被打包,无法按需剔除。
  2. 命名空间冗余:每次使用都要写 .default,代码冗长且容易出错。
  3. 模块职责混乱@/locales 本应是国际化模块,却承担了常量聚合的职责。
  4. 可维护性差:新增常量文件需要手动修改 @/locales 的导出,极易遗漏。

为了彻底解决这些问题,我们决定进行两项重构:

  • 常量文件:将 export default { ... } 拆解为多个 export const,实现具名导出。
  • 业务代码:将所有 Constants_xxx.default.PROP 替换为直接使用 PROP,并添加对应的具名导入。

项目涉及 30+ 个常量文件200+ 个业务文件,手工修改不仅耗时,而且极易出错。于是,我们开发了两个基于 AST(抽象语法树) 的自动化迁移脚本,实现了零人工干预的平滑过渡。

本文将从技术实现、难点攻克、工程化落地三个维度,深度剖析这次自动化重构的全过程。


二、整体方案设计

整个迁移流程分为两个独立的阶段,必须严格按顺序执行

graph LR
    A[常量文件] -->|transform-const.js| B[具名导出常量]
    C[业务代码] -->|transform-project.js| D[直接引用+具名导入]
    B -.->|提供导出变量列表| D
  • 第一阶段:扫描 src/constants/*.ts,将每个文件中的 export default 对象转换为多个 export const 语句。
  • 第二阶段:扫描 src/views 下的所有 .vue.ts.js 文件,识别旧的导入模式,分析实际使用的常量,删除旧导入,生成新的具名导入,并替换代码中的引用。

两个脚本均支持 --dry-run 预览模式,并在修改前自动创建 .bak 备份文件,确保操作可逆。


三、第一阶段:常量文件格式转换(transform-const.js)

3.1 核心目标

将这样的代码:

// Constants_expert.ts
export default {
  STATUS_PENDING: 0,
  STATUS_APPROVED: 1,
}

转换为:

export const STATUS_PENDING = 0;
export const STATUS_APPROVED = 1;

同时保留所有注释(文件头注释、属性上方注释等)。

3.2 AST 操作流程

我们使用 Babel 全家桶完成这次转换:

  • @babel/parser:将源码解析为 AST
  • @babel/traverse:遍历和修改 AST 节点
  • @babel/types:构建新的 AST 节点
  • @babel/generator:将 AST 还原为代码

核心步骤:

  1. 解析源码,指定 sourceType: 'module'plugins: ['typescript'] 以支持 TS 语法。
  2. 遍历 AST,找到 ExportDefaultDeclaration 节点,并判断其声明是否为 ObjectExpression
  3. 移除该默认导出节点
  4. 遍历对象的每个属性,对每个属性构建一个 ExportNamedDeclaration 节点,内部包裹 VariableDeclaration 类型为 const
  5. 保留注释:将原属性的 leadingCommentstrailingComments 赋值给新节点。
  6. 重新生成代码,并写回原文件。

关键代码片段:

traverse(ast, {
  ExportDefaultDeclaration(path) {
    if (t.isObjectExpression(path.node.declaration)) {
      defaultExportObject = path.node.declaration;
      path.remove(); // 移除整个 export default
    }
  },
});

defaultExportObject.properties.forEach((prop) => {
  const propName = prop.key.name;
  const propValue = prop.value;
  const exportDecl = t.exportNamedDeclaration(
    t.variableDeclaration('const', [
      t.variableDeclarator(t.identifier(propName), propValue),
    ])
  );
  // 保留注释
  if (prop.leadingComments) exportDecl.leadingComments = prop.leadingComments;
  exportConstNodes.push(exportDecl);
});

3.3 易错点与防御

  • 非对象默认导出:某些常量文件可能已经是 export const 格式,或者导出一个函数。脚本会检测并跳过,避免破坏已有代码。
  • 属性名非标识符:如果对象的键是字符串字面量(如 "my-const": 123),则无法转换为合法的变量名,脚本会给出警告并跳过该属性。
  • 文件备份:转换前自动创建 .bak 文件,防止误操作导致代码丢失。

四、第二阶段:业务代码引用迁移(transform-project.js)

这是整个方案中最复杂的部分,需要同时处理 JavaScript/TypeScriptVue SFC 文件,并且要保证转换后的代码语法正确、依赖完整。

4.1 动态发现常量文件

第一阶段完成后,src/constants 下的每个 .ts 文件都导出了一批具名常量。我们需要知道每个常量文件导出了哪些变量名,以便在第二阶段验证引用的有效性。

function loadAllConstantFiles() {
  const constantFiles = glob.sync(path.join(CONSTANTS_DIR, '*.ts'), { absolute: true });
  const constantMap = new Map(); // key: 文件名(如 Constants_expert), value: { filePath, exportedNames }

  for (const filePath of constantFiles) {
    const ast = parser.parse(fs.readFileSync(filePath, 'utf-8'), { plugins: ['typescript'] });
    const exportedNames = new Set();
    traverse(ast, {
      ExportNamedDeclaration(path) {
        if (t.isVariableDeclaration(path.node.declaration) && path.node.declaration.kind === 'const') {
          path.node.declaration.declarations.forEach(d => {
            if (t.isIdentifier(d.id)) exportedNames.add(d.id.name);
          });
        }
      },
    });
    constantMap.set(path.basename(filePath, '.ts'), { filePath, exportedNames });
  }
  return constantMap;
}

这样我们就获得了所有常量文件的“导出变量白名单”。

4.2 识别旧的导入模式

在业务代码中,旧的导入语句通常长这样:

import { Constants_expert, Constants_supplier_portrait } from '@/locales';

我们需要找到这些导入,并记录每个本地标识符对应的常量集合名(例如 Constants_expert 对应 Constants_expert 集合)。

使用 AST 遍历 ImportDeclaration,匹配 source.value === '@/locales',然后遍历 specifiers,只处理 ImportSpecifier 类型:

traverse(ast, {
  ImportDeclaration(path) {
    if (path.node.source.value === OLD_IMPORT_SOURCE) {
      path.node.specifiers.forEach(spec => {
        if (t.isImportSpecifier(spec)) {
          const importedName = spec.imported.name;
          const localName = spec.local.name;
          if (constantMap.has(importedName)) {
            oldLocalToConstantMap.set(localName, importedName);
            shouldRemove = true;
          }
        }
      });
      if (shouldRemove) path.remove(); // 删除整条导入语句
    }
  },
});

4.3 替换成员访问表达式

旧的引用方式有两种常见形态:

  • Constants_expert.default.STATUS_PENDING
  • Constants_expert.STATUS_PENDING(某些早期代码省略了 .default

我们需要将它们统一替换为 STATUS_PENDING,并记录下该常量名被使用了。

通过 AST 遍历 MemberExpression,找到根标识符,判断是否在 oldLocalToConstantMap 中,然后解析属性链,提取出最终属性名:

traverse(ast, {
  MemberExpression(path) {
    const root = findRootIdentifier(path.node);
    if (!root) return;
    const localName = root.name;
    if (!oldLocalToConstantMap.has(localName)) return;

    const constantSetName = oldLocalToConstantMap.get(localName);
    const chain = getPropertyChain(path.node);
    let propName = null;
    if (chain.length >= 3 && chain[1] === 'default') {
      propName = chain[2];
    } else if (chain.length >= 2) {
      propName = chain[1];
    }

    if (propName && constantMap.get(constantSetName).exportedNames.has(propName)) {
      // 记录需要导入的变量
      neededImports.get(constantSetName).add(propName);
      // 替换整个节点为一个简单的标识符
      path.replaceWith(t.identifier(propName));
    }
  },
});

4.4 Vue SFC 的特殊处理

Vue 单文件组件包含 <template><script><script setup> 等多个块,需要分别处理。

Script 块:将块内的代码提取出来,调用上述的 transformScript 函数,得到新的代码和需要的导入变量。注意一个 SFC 可能同时存在 <script><script setup>,需要分别处理并合并导入变量。

Template 块:模板中也可能直接使用 Constants_expert.default.STATUS_PENDING 表达式。由于模板不是完整的 JavaScript,用 AST 解析成本较高,我们采用正则替换的方式。

但正则替换有几个坑:

  • 常量名可能包含正则元字符(如 +.),需要转义。
  • 需要同时匹配 .default 和没有 .default 的情况。
  • 替换后要记录使用了哪些变量,以便生成导入。

我们构建动态正则:

const safeName = escapeRegExp(constName);
const regexWithDefault = new RegExp(`\\b${safeName}\\.default\\.([a-zA-Z_][a-zA-Z0-9_]*)\\b`, 'g');
const regexWithoutDefault = new RegExp(`\\b${safeName}\\.([a-zA-Z_][a-zA-Z0-9_]*)\\b`, 'g');

匹配后,将 Constants_expert.default.STATUS 替换为 STATUS,并将 STATUS 加入 neededImports。

4.5 生成新的导入语句

经过上述分析,我们得到了每个常量集合需要导入的具名变量列表。但这里有一个隐蔽的坑:不同常量文件可能导出同名的变量(例如 Constants_expertConstants_supplier 都导出了 STATUS),如果直接生成 import { STATUS } from ... 两次,会产生语法错误。

因此,我们必须先检测冲突:

const varToConstMap = new Map();
for (const [constName, vars] of neededImportsTotal) {
  for (const v of vars) {
    if (varToConstMap.has(v) && varToConstMap.get(v) !== constName) {
      throw new Error(`变量名冲突: "${v}" 同时出现在 "${varToConstMap.get(v)}" 和 "${constName}" 中,请手动重命名其中一个导出变量`);
    }
    varToConstMap.set(v, constName);
  }
}

如果没有冲突,再生成导入语句。导入路径需要将绝对路径转换为 @/ 开头的别名:

const srcDir = path.join(rootDir, 'src');
let importPath = constantFilePath.replace(srcDir, '@/').replace(/\.ts$/, '');
importPath = importPath.replace(/\\/g, '/');

最后,将导入语句插入到文件顶部(如果有 script 块则插入到第一个 script 块的开始位置)。


五、技术难点与解决方案

5.1 路径别名动态转换

最初我们使用 path.relative 然后替换 ../@/,但当文件深度超过两层时,会出现 @/../../constants/xxx 的错误路径。解决方案:基于项目根目录的 src 进行绝对路径替换,直接构造 @/constants/xxx,简单可靠。

5.2 多个 <script> 块的替换位置

Vue SFC 可能同时存在 <script><script setup>,它们的起始和结束偏移量不同。我们需要记录每个块的 loc.start.offsetloc.end.offset,分别替换。并且由于替换后文件长度会变化,必须从后往前依次替换,避免位置偏移错误。

5.3 模板正则的精确匹配

模板中可能包含字符串字面量,例如:

<div :title="'Constants_expert.default.STATUS'"></div>

我们不应该替换引号内的内容。由于 Vue 模板语法的复杂性,完全避免误判需要解析模板 AST,成本过高。我们采用了一个折中方案:只替换独立表达式中的匹配,通过正则的单词边界 \b 来减少误判。在实际项目中,常量名很少出现在字符串内部,因此风险可控。

5.4 保留代码格式与注释

AST 转换后重新生成的代码会丢失原格式(空行、缩进等)。为了最小化 diff,我们使用了 generate{ retainLines: true, comments: true } 选项,尽可能保留原始行号和注释位置。对于 template 的正则替换,我们只替换匹配部分,其余原样保留。


六、工程化落地与自动化流程

为了确保迁移过程平滑、可回滚,我们设计了一套完整的执行流程:

# 1. 全量备份(使用 git 分支)
git checkout -b feature/migrate-constants

# 2. 执行常量文件转换(dry-run 预览)
node scripts/transform-const.js --dry-run
node scripts/transform-const.js

# 3. 执行项目引用迁移(dry-run 预览)
node scripts/transform-project.js --dry-run
node scripts/transform-project.js

# 4. 运行类型检查、单元测试,确保无报错
npm run type-check
npm run test

# 5. 提交变更
git add .
git commit -m "refactor: migrate constants to named exports"

两个脚本都内置了 --dry-run 模式和自动 .bak 备份。即便转换出现问题,也可以快速恢复:

# 恢复所有备份文件
find src -name "*.bak" | while read bak; do mv "$bak" "${bak%.bak}"; done

七、成果与思考

通过这两个脚本,我们在 10 分钟内完成了原本需要 2 人天 的手工重构工作,且零失误。转换后的代码:

  • Tree Shaking 友好:打包体积减少约 15%(未使用的常量被自动剔除)。
  • 可读性提升:代码中直接使用 STATUS_PENDING 而非冗长的 Constants_expert.default.STATUS_PENDING
  • 维护成本降低:新增常量文件无需任何额外配置,脚本自动发现。

更重要的是,这次实践让我们深刻体会到 AST 驱动重构 的巨大威力。无论是代码格式化、框架升级,还是架构调整,只要存在“模式化的代码变换”,都可以借助 AST 工具实现自动化。

未来拓展方向

  • 支持更复杂的引用模式:如 Constants_expert['default'].STATUSConstants_expert[someVar].STATUS,这些可以通过增强 MemberExpression 的递归分析来支持。
  • 集成到 CI 流水线:当常量文件结构发生变化时,自动触发迁移脚本,确保代码库始终保持统一风格。
  • 可视化迁移报告:输出每个文件转换前后的 diff,以及冲突变量列表,便于人工审核。

八、总结

本文详细介绍了如何利用 Babel AST 和 Vue 编译器,完成一次大型常量模块的重构迁移。从最初的痛点分析,到两个阶段脚本的设计,再到各种技术坑点的解决方案,我们不仅解决了实际问题,也沉淀了一套可复用的自动化重构方法论。

如果你也面临类似的“技术债务”清理任务,不妨尝试用 AST 武装自己——让机器去处理那些重复、枯燥的代码变换,把人解放出来做更有创造性的工作。

欢迎交流讨论,共同提升前端工程化水平。更多文章

探索Vite深入 Rollup 分块插件:从零实现一个智能分包工具

探索Vite深入 Rollup 分块插件:从零实现一个智能分包工具

告别正则匹配的硬编码,用规则引擎优雅管理代码分割

引言

在 Rollup 打包配置中,manualChunks 是最强大也最容易被误用的选项之一。社区常见的做法是写一堆 if (id.includes('node_modules')) 或正则表达式,把第三方库一股脑打入 vendor 块。这种方案在项目初期看似简单,但随着迭代,很容易出现:

  • chunk 体积失控:一个 vendor 文件动辄几 MB。
  • 缓存失效频繁:任何依赖更新都会导致整个 vendor 重新下载。
  • 代码复用不佳:被多个入口共享的公共模块无法独立拆分。

为了解决这些问题,我们开发了 rollup.plugin.robin-build 插件(纯 JS/TS 版本,下文简称“本插件”)。它提供了一套声明式的分块规则配置,支持路径匹配、引用次数阈值、优先级排序等高级特性,让代码分割变得可预测、可维护。

插件概览

本插件导出两个主要部分:

  1. output 对象:标准的 Rollup 输出配置,定义了文件命名与分类规则。
  2. createSplitChunks 函数:接收用户配置,返回一个符合 manualChunks 签名 (moduleId, { getModuleInfo }) => string | void 的函数。

插件本身不依赖任何外部库,仅使用 Node.js 内置模块 path。其核心思路是:用户以对象形式定义多个“规则组”,每个规则组包含匹配条件(路径字符串或正则)、目标 chunk 名称、优先级以及最小引用次数。插件在构建时遍历每个模块,按优先级匹配规则,决定模块归属的 chunk。

第一部分:输出配置(output)

export const output = {
    entryFileNames: 'js/robin-[hash].js',
    hashCharacters: 'hex', // 减少字符集,见下图1
    experimentalMinChunkSize: 20 * 1024,
    chunkFileNames: (chunkInfo) => {
        if(chunkInfo.name && chunkInfo.name.startsWith('vendor-')){
            return 'js/[name]-[hash].js'
        }
        return 'js/chunk-[hash].js'
    },
    assetFileNames: (info) => { ... }
}

在这里插入图片描述

1.1 entryFileNames 与 hash 配置

  • entryFileNames:入口 chunk 的文件名模板。这里使用 app-[hash].js,并放入 js/ 目录。
  • hashCharacters: 'hex':指定 hash 编码方式为十六进制(Rollup 5.0+ 支持)。
  • experimentalMinChunkSize:设置最小 chunk 大小(20KB),Rollup 会尝试合并小于此阈值的 chunk,减少 HTTP 请求数量。

1.2 chunkFileNames 动态命名

chunkFileNames 可以是函数,接收 chunkInfo 对象。插件判断如果 chunk 名称以 vendor- 开头(通常是通过规则生成的 vendor 块),则保留原名称,例如 vendor-react-[hash].js;否则统一命名为 chunk-[hash].js

这样做的好处是:vendor 块名称可读性高,便于调试和 CDN 缓存策略区分。

1.3 assetFileNames 按扩展名分类

assetFileNames 根据文件扩展名将静态资源归类到不同子目录:

扩展名类型 输出目录
.css asset/css/
.wasm asset/wasm/
.json, .map asset/data/
.txt, .xml, .pdf asset/docs/
图片格式 asset/img/
音视频格式 asset/media/
字体格式 asset/fonts/
其他 asset/other/

这种细粒度分类对于大型项目尤其重要:运维可以针对不同资源类型设置不同的 CDN 缓存头(例如图片缓存一年,JSON 缓存五分钟)。

第二部分:核心分块引擎 createSplitChunks

createSplitChunks 是整个插件的灵魂。它接收一个配置对象,返回 manualChunks 函数。我们先看它的完整实现:

export const createSplitChunks = (config = {}) => {
    if(!isObject(config)) return null

    const list = []
    Object.keys(config).forEach((key) => {
        const test = config[`${key}`].test

        if(!(isRegExp(test) || isString(test))) {
            throw new Error('test 必须为正则表达式或字符串')
        }

        if (isString(test) && !path.isAbsolute(test)) {
            throw new Error(`test 路径必须为绝对路径,实际获取到的是: ${test}`)
        }

        if (isRegExp(test) && test.global) {
            throw new Error('正则表达式测试不得使用 /g 标志')
        }

        list.push({
            ...config[key],
            chunk_name: `${key.startsWith('vendor') ? key : `vendor-${key}`}`,
            type: isRegExp(test) ? 'regexp' : 'string'
        })
    })
    list.sort((a, b) => (b.priority || 0) - (a.priority || 0))

    return (disk_path, { getModuleInfo }) => {
        const moduleInfo = getModuleInfo(disk_path)

        const target = list.find(item=> {
            if(item['minChunks'] && moduleInfo){
                const static_count = moduleInfo['importers'] ? moduleInfo['importers'].length : 0
                const dynamic_count = moduleInfo['dynamicImporters'] ? moduleInfo['dynamicImporters'].length : 0
                const total = static_count + dynamic_count
                if (total < item['minChunks']) return false
            }
            if(item.type === 'regexp') return item.test.test(disk_path)
            return disk_path.startsWith(item.test)
        })

        if(target && isNull(target.name)) return null

        if(target) return target.name || target.chunk_name

        return null
    }
}

2.1 配置解析与校验

插件期望 config 是一个对象,其每个 key 代表一个规则组的名称,value 必须包含 test 字段(字符串绝对路径或正则表达式)。此外还可以包含:

  • name:自定义 chunk 名称(如果未提供,会自动生成 vendor-${key})。
  • priority:优先级(数字越大越先匹配)。
  • minChunks:最小引用次数,只有模块被引用的总次数 ≥ 该值时才匹配。

首先进行严格的类型校验:

  • 使用 toString.call 来判断数据类型(因为 typeof null === 'object',需要区分)。
  • 对于字符串类型的 test,要求必须是绝对路径(通过 path.isAbsolute 验证)。这确保了匹配的确定性,避免相对路径在不同工作目录下产生歧义。
  • 对于正则表达式,禁止使用 g 全局标志,因为 test 方法在全局标志下会有状态残留,导致不可预期的行为。

2.2 构建规则列表与优先级排序

解析后的每个规则对象会被扩展两个内部字段:

  • chunk_name:自动生成的备用名称(如 vendor-react)。
  • type:标记匹配方式('regexp''string')。

然后将规则数组按 priority 降序排序。没有指定优先级的规则默认为 0。排序保证了高优先级规则先被匹配,避免低优先级规则“抢走”本应归属高优先级规则的模块。

2.3 manualChunks 回调逻辑

manualChunks 接收两个参数:disk_path(模块在磁盘上的绝对路径)和上下文对象 { getModuleInfo }getModuleInfo 可以获取模块的依赖关系信息。

对于每个模块,插件会:

  1. 获取模块的引用信息moduleInfo.importers(静态导入该模块的模块列表)和 dynamicImporters(动态导入该模块的模块列表)。两者的长度之和就是该模块被其他模块引用的总次数 total
  2. 遍历规则列表:按照优先级顺序查找第一个匹配的规则。
    • 如果规则定义了 minChunks,则检查 total >= minChunks,不满足则跳过该规则。
    • 根据规则类型,用 test 匹配 disk_path
  3. 决定 chunk 名称
    • 如果匹配到的规则中 name 字段为 null,则返回 null(表示不强制放入任何特定 chunk,由 Rollup 默认处理)。
    • 否则返回 name 或自动生成的 chunk_name
  4. 未匹配任何规则则返回 null,让 Rollup 按照默认算法处理(通常是基于模块共享度自动拆分)。

2.4 设计亮点

优先级机制解决规则冲突

当多个规则都能匹配同一个模块时,优先级决定了最终归属。例如:

{
  "vue-vendor": {
    test: /[\\/]node_modules[\\/](vue|vue-router|vue-i18n)[\\/]/,
    priority: 10
  },
  "node-vendor": {
    test: /[\\/]node_modules[\\/]/,
    priority: 0
  }
}

vuevue-i18nvue-router 会进入 vue-vendor 块,而其他 npm 包则进入 node-vendor。如果没有优先级,node-vendor 可能会先匹配,导致 Vue 也被打入通用 vendor。

minChunks 避免过度拆分

一个模块如果被很多地方引用(例如工具函数 debounce),独立成 chunk 是有益的;但如果只被一个入口使用,则应该合并到该入口的 chunk 中,减少 HTTP 请求。minChunks 参数允许开发者设置阈值,只有达到引用次数的模块才独立打包。

路径匹配的两种模式
  • 字符串绝对路径:适用于明确知道模块所在目录的场景,例如 '/app/shared/utils'
  • 正则表达式:更灵活,可以匹配 node_modules 中的特定包名,例如 /node_modules\/lodash-es/
自定义 chunk 名称与 null 返回值

允许规则返回 null 可以让某些模块“逃逸”出规则体系,由 Rollup 默认算法处理。这在使用第三方插件或有特殊分块需求时非常有用。

第三部分:类型判断辅助函数

插件开头定义了几个类型判断函数:没有引入loadsh-es,个人感觉没有必要,所以简化写一下

const toString = Object.prototype.toString
const isObject = (data) => toString.call(data) === '[object Object]'
const isNull = (data) => toString.call(data) === '[object Null]'
const isRegExp = (data) => toString.call(data) === '[object RegExp]'
const isString = (data) => toString.call(data) === '[object String]'

为什么不直接用 typeofinstanceof

  • typeof null === 'object',无法区分 null 和普通对象。
  • 在 Rollup 插件环境中,moduleInfo 等对象可能来自不同的上下文,instanceof 可能失效。而 Object.prototype.toString 返回的是内部 [[Class]] 属性,跨框架可靠。

第四部分:使用示例

4.1 基础配置

// rollup.config.js
import { createSplitChunks, output } from 'rollup-plugin-robin-build';

export default {
  input: 'src/main.js',
  output: {
    dir: 'dist',
    ...output,
    manualChunks: createSplitChunks({
      // 规则1:vue 全家桶单独打包
      vue: {
        test: /[\\/]node_modules[\\/](vue|vue-router|vue-i18n)[\\/]/,
        priority: 10,
        minChunks: 1
      },
      // 规则2:antd 组件库单独打包
      antd: {
        test: /[\\/]node_modules[\\/]antd[\\/]/,
        priority: 9
      },
      // 规则3:其他 node_modules 打入 vendor
      vendor: {
        test: /[\\/]node_modules[\\/]/,
        priority: 0
      },
      // 规则4:src/utils 下的公共工具,引用次数 >=3 时独立
      utils: {
        test: path.resolve(__dirname, 'src/utils'),
        minChunks: 3,
        name: 'shared-utils'
      }
    })
  }
}

4.2 配合动态导入

Rollup 能识别动态导入(import()),getModuleInfo 中的 dynamicImporters 会记录哪些模块动态引入了当前模块。因此,minChunks 同样适用于动态导入的场景。

4.3 高级:跳过某些模块

如果某个模块我们不想受任何规则影响,可以在规则中设置 name: null

{
  exclude: {
    test: /[\\/]src[\\/]legacy[\\/]/,
    name: null,   // 让 Rollup 默认处理
    priority: 100
  }
}

第五部分:性能考量

5.1 时间复杂度

每个模块都会遍历规则列表(最坏 O(m·n)),其中 m 为模块数,n 为规则数。对于大型项目(几千个模块,几十条规则),遍历开销仍然可控。但如果规则数量膨胀到上百条,可以考虑将正则表达式编译一次并缓存结果,或使用 Trie 树优化字符串前缀匹配。

5.2 使用 getModuleInfo 的开销

getModuleInfo 是 Rollup 内部维护的模块图查询函数,调用开销极小。我们只在需要检查 minChunks 时才调用,且仅访问 importersdynamicImporters 属性,性能影响可忽略。

5.3 避免重复计算

插件没有做缓存,因为 manualChunks 函数在构建过程中会被多次调用(每个模块一次)。如果规则中包含复杂的自定义函数,可以考虑在外层 memoize。不过本插件完全基于配置,没有用户自定义函数,所以不需要缓存。

第六部分:与其他方案的对比

方案 优点 缺点
原生 manualChunks + 硬编码 简单直接 规则硬编码,难以维护;无法基于引用次数动态拆分
SplitChunksPlugin (Webpack) 功能强大,支持 cacheGroups 配置复杂,且 Webpack 与 Rollup 生态不同
本插件 声明式配置,支持优先级、minChunks,轻量无依赖 需要手动编写规则;无法像 Webpack 那样自动提取公共模块

本插件更适合那些希望将分块规则集中管理、且对 Rollup 生态有强依赖的项目。配合 experimentalMinChunkSize 和 Rollup 自带的 Tree Shaking,可以获得接近 Webpack 的代码分割体验。

第七部分:扩展建议

虽然当前插件已经满足大多数场景,但还可以进一步增强:

  • 支持函数形式的 test:允许用户传入 (id) => boolean,实现更灵活的匹配逻辑。
  • 支持异步规则:例如根据模块内容的大小或依赖关系动态决策。
  • 提供内置预设:例如 preset: 'vue' 自动配置 Vue 相关的分块规则。
  • 集成 bundle 分析器:生成分块报告,帮助用户调整规则。

结语

代码分割是前端性能优化的核心环节之一,但往往被忽视或粗暴处理。通过本插件,我们可以用声明式的规则引擎精细控制每个模块的去向,实现:

  • 更合理的缓存策略:稳定依赖单独 chunk,业务代码频繁更新不影响第三方库缓存。
  • 更快的首屏加载:避免一次性加载巨大的 vendor 文件。
  • 更清晰的构建产物:每个 chunk 有明确的命名和用途。

希望这篇文章能帮助你理解 manualChunks 的高级用法,并启发你构建属于自己的分块工具。如果你对插件有任何疑问或改进建议,欢迎在评论区交流。

对比

vue 全家桶单独打包 我在项目中走CDN啦! 默认分包 在这里插入图片描述 只用插件分包 在这里插入图片描述 很明显有很大的差别!

重新思考模板语言与 TypeScript 的结合:一条可落地的新路径

前端框架语法大致可以分为两类:模板语言框架(如 Vue、Svelte、Qingkuai)和 JSX/TSX 框架(如 React、Solid)。

在模板语言中,开发者通常可以在嵌入脚本块里获得接近原生 JS/TS 的编写体验,同时借助更简洁的模板语法完成常见渲染逻辑;代价是组件文件的灵活性会受到一定约束。JSX/TSX 则几乎让你在整份文件里都处在 JS/TS 的表达体系中,灵活性更高,但也会让 HTML 标签、CSS 样式与 JavaScript 代码深度交织,语法边界相对模糊。

以上只是对两类语法核心差异的简化描述,具体体验因人而异。本文聚焦一个长期存在的痛点:模板语言如何更好地支持 TypeScript

一、组件中的类型声明

在日常使用模板语言时,我一直有一个明显感受:主流框架对 TypeScript 的支持虽然已经很强,但在关键场景仍有不小门槛。最典型的就是几乎所有组件化框架都会遇到的 props 类型声明。

在这件事上,VueSvelte 采用了相近思路:通过编译标记(不同框架术语略有区别,例如 Vue 常称为编译器宏)声明类型。对于简单 props 这套方案基本够用;但进入泛型场景后,通常需要在 <script> 标签上额外声明泛型作用域,例如:

<script generics="T extends { id: number; name: string }"></script>

这在一定程度上背离了模板语言的核心优势:在嵌入脚本里提供一个纯净的 JS/TS 编程环境,让开发者专注业务逻辑,而不是额外语法细节。

更现实的问题是隐性成本。比如在 generics 属性中,是否可以访问嵌入脚本块内声明的类型?经过测试,Vue 与 Svelte 的表现一致但并不理想:

对于导入的外部类型,generics 可以访问;对于脚本块内部声明的类型,则无法访问。

导入类型.png

内部类型.png

我推测这与泛型组件的导出形态有关:语言服务可能需要将组件默认导出处理为函数,而 import 声明只能位于模块顶层,因此需要提升到函数外部,进而产生这种可见性差异。无论具体实现原因如何,这都会增加开发成本,并削弱模板语言应有的流畅体验。

这也是我在 Qingkuai 中做的一个核心取舍:保留 Props 作为组件全局类型声明。只要声明了 Props,就等于声明了 props 类型。这样一来,嵌入脚本块的编写体验和普通 JS/TS 基本一致。

props类型声明.png

这个设计还有一个额外收益:在非 TypeScript 项目中,仍可通过 JSDoc 注释声明 Props 类型,从而获得类型检查与补全能力。

jsdoc定义组件类型.png

二、泛型实参的传递

除了 props 声明之外,另一个高频痛点是:无法为组件泛型参数传递实参

在 Vue 与 Svelte 中,目前都缺少一套明确机制来向组件泛型传入实参。这会导致调用方即使具备明确的业务上下文,也无法通过显式传入泛型实参来收窄并主动约束组件类型。

组件泛型实参.png

三、插槽上下文类型推导

在插槽上下文类型推导上,模板语言相较 JSX/TSX 其实有天然优势:多数模板语言通过 slot 标签声明插槽出口,并可在标签上直接绑定要传递给插槽的数据。这为自动推导提供了明确入口,不必强迫开发者在组件内部增加额外类型标注。

反过来看 JSX/TSX(如 React),其并没有原生插槽概念,通常只能通过 children 模拟类似能力。这样一来,类型推导会明显更难,往往需要开发者手工声明函数类型来描述 children 的参数与返回值。

遗憾的是,当前主流模板语言仍未实现插槽上下文自动推导。Vue 支持手动标注插槽上下文类型;Svelte v4 使用 slot 定义插槽但不能标注其类型,v5 虽引入 Snippet 机制,仍需要开发者手动标注片段上下文类型,二者都不支持自动推导。

但从可行性看,这件事并不遥远。通过编译期静态分析、IR 标记与 TypeScript 语言服务提取类型的组合,模板语言完全可以实现插槽上下文自动推导。例如下面两个组件中,组件内部没有额外类型标注,调用方仍可获得完整推导与补全,甚至在纯 JavaScript 项目中也能自动推导插槽上下文类型:

插槽上下文类型推导.png

插槽上下文自动推导的价值不只在于减少类型声明成本,更在于 IDE 交互质量。借助 查找定义查找引用,开发者可以直接跳转到上下文定义源头,而不是落在类型定义中转层。

qingkuai插槽跳转.gif

vue插槽跳转.gif

在复杂组件里,这个差异非常直观。没有自动推导时,你往往需要先定位 <slot>,再分析绑定字段,最后回溯字段定义;有自动推导时,只需在插槽内容处执行一次 查找定义,即可直达源头,开发效率和可维护性都会明显提升。

四、组件类型导出

目前几乎所有模板语言都不要求手工定义组件导出类型,语言服务会根据组件内部声明自动推导默认组件类型。这本身是合理且高效的设计。

但另一个问题是:推导出的导出类型是否足够可读。Vue 可能因兼容历史语法而导致类型展示偏冗长;Svelte 虽然更简洁一些,但仍会暴露部分内部类型细节,容易增加理解成本。

vue组件导出类型.png

svelte组件导出类型.png

通过更清晰的导出类型结构设计,这个问题是可以优化的:

qingkuai组件导出类型.png

另外,很多开发者在写组件时都会习惯把鼠标悬停在组件标签上查看类型,但 Vue 与 Svelte 对这一体验的支持仍不理想:

svelte组件标签查看类型.png

vue组件标签查看类型.png

如果通过 TypeScript 语言服务的 TypeChecker 提取组件导出类型,并在标签悬停中返回该类型,落地并不复杂:

qingkuai组件标签查看类型.png

五、总结

本文从四个问题展开:组件内类型声明、泛型实参传递、插槽上下文类型推导,以及组件导出类型可读性。它们看似分散,本质上都指向同一个目标:让模板语言中的 TypeScript 体验尽可能接近“普通 TypeScript 文件”的直觉与效率。

从工程实践看,真正决定体验的往往不是语法表层,而是类型流是否连续。只要类型信息在“组件定义 -> 编译产物 -> 语言服务 -> IDE 交互”链路上断裂,开发者就会被迫用额外声明、注释和心智记忆去补洞。

围绕这一点,Qingkuai 采取了两项关键策略:

  1. 减少模板内额外语法负担:通过内置 Props / Refs 约定,将组件属性类型声明收敛到标准 TS 类型定义。
  2. 增强语言服务侧类型恢复能力:在编译期保留足够结构化标记,再由 TypeScript 语言服务提取并回填类型,用于补全、跳转与错误检查。

以插槽上下文为例,采用“编译期 IR 标记 + LSP TypeChecker 提取”路径后,类型推导不再依赖开发者逐处手工维护,IDE 也能把定义关系直接连接回真实源头。这不仅降低了类型维护成本,也显著改善了代码阅读与重构体验。

最终结论可以归纳为三点:

  1. 模板语言并不天然弱于 TS 体验:关键在于是否将类型系统纳入语言与工具链的一体化设计。
  2. 编译器与语言服务应协同设计:编译器负责可追踪标记,语言服务负责语义恢复与交互反馈。
  3. 高质量类型体验可以工程化落地:只要类型链路闭环,补全、跳转、诊断与可维护性就能同步提升。

这也意味着,这套思路并不局限于 Qingkuai。本质上,它为其他模板语言也提供了一条可行路线:在尽量保持模板语法简洁的前提下,通过编译器与语言服务协同设计,持续提升 TypeScript 体验。Qingkuai 的后续工作也可以沿着这条路径推进:补齐更多边界场景(复杂泛型、条件类型、跨文件符号映射),并以真实项目数据验证这套机制在大型代码库中的稳定性与性能表现。若你想进一步了解实现细节或直接上手验证,可以参考qingkuai文档在线体验qingkuai

pretext实现余力深度解析

Pretext 实现原理深度分析

Pretext 是一个纯 JavaScript/TypeScript 的多行文本测量和布局库,能够不依赖 DOM 回流就计算出文本高度。

核心价值

问题: 传统的 getBoundingClientRectoffsetHeight 会触发同步布局回流,当页面有 500 个文本块时,每帧可能要花 30ms+ 在测量上。

解决方案: Pretext 使用 Canvas API + Intl.Segmenter 实现纯 JS 测量,避免了 DOM 回流。

prepare()  → 一次性预计算(~19ms/500文本)
layout()   → 纯算术计算高度(~0.09ms/500文本)

核心架构:两阶段测量

prepare(text, font) → 预计算(一次性)
    ↓
layout(prepared, width, lineHeight) → 纯算术(每次 resize

1. prepare() 做什么?

输入: 文本 + 字体配置

输出: 预计算的数据结构

const text = "Hello 世界!"
const prepared = prepare(text, "16px Inter")

内部处理流程:

原始文本: "Hello 世界!"
    ↓ 1. 分段(Intl.Segmenter)
分段结果: ["Hello", " ", "世", "界", "!"]
分段类型: ["text", "space", "text", "text", "text"]
    ↓ 2. 测量宽度(Canvas measureText)
宽度数据: [42.5, 4.4, 16.0, 16.0, 5.2]

2. layout() 做什么?

输入: prepared 对象 + 容器宽度 + 行高

输出: { height, lineCount }

const { height, lineCount } = layout(prepared, 100, 20)
// height = lineCount * lineHeight

关键:layout() 是纯算术,不调用任何测量 API!

// 简化版 layout 逻辑
function layout(prepared, maxWidth, lineHeight) {
    let lineWidth = 0
    let lineCount = 1
    
    for (let i = 0; i < prepared.widths.length; i++) {
        const segWidth = prepared.widths[i]  // 直接从数组读
        
        if (lineWidth + segWidth > maxWidth) {
            lineCount++
            lineWidth = segWidth
        } else {
            lineWidth += segWidth
        }
    }
    
    return { height: lineCount * lineHeight, lineCount }
}

PreparedText 数据结构详解

type PreparedCore = {
  // === 核心数据(每个分段一个值)===
  widths: number[]              // 每个分段的宽度(像素)
  kinds: SegmentBreakKind[]     // 每个分段的类型(决定能否换行)
  lineEndFitAdvances: number[]  // 行尾时的宽度贡献
  lineEndPaintAdvances: number[] // 行尾时的绘制宽度
  
  // === 可断词的额外数据 ===
  breakableWidths: (number[] | null)[]      // 每个字符的宽度(用于 overflow-wrap)
  breakablePrefixWidths: (number[] | null)[] // 累计宽度(二分查找用)
  
  // === 特殊情况 ===
  discretionaryHyphenWidth: number  // 软连字符 "-" 的宽度
  tabStopAdvance: number            // Tab 停止位间隔
  
  // === 分块(遇到 \n 分开)===
  chunks: PreparedLineChunk[]       // 预编译的硬换行块
  
  // === 优化标记 ===
  simpleLineWalkFastPath: boolean   // 普通文本可用简化算法
  
  // === 双向文本(阿拉伯语等)===
  segLevels: Int8Array | null       // Bidi 元数据
}

// 分段类型
type SegmentBreakKind =
  | 'text'           // 普通文本
  | 'space'          // 可折叠空格
  | 'preserved-space' // pre-wrap 保留空格
  | 'tab'            // 制表符
  | 'glue'           // 粘连标点
  | 'zero-width-break' // 零宽断点
  | 'soft-hyphen'    // 软连字符
  | 'hard-break'     // 强制换行(\n)

具体例子

const text = "Hello 世界! How are\nyou?"
const prepared = prepare(text, "16px Inter")

分段结果

原文: "Hello 世界! How are\nyou?"
       
分段: ["Hello", " ", "世", "界", "!", " ", "How", " ", "are", "\n", "you", "?"]
索引:    0      1    2    3     4    5     6     7    8      9    10    11

prepared 内部数据

{
  widths: [
    42.5,  // "Hello"
    4.4,   // " "
    16.0,  // "世"
    16.0,  // "界"
    5.2,   // "!"
    4.4,   // " "
    28.8,  // "How"
    4.4,   // " "
    22.4,  // "are"
    0,     // "\n" - 硬换行
    28.8,  // "you"
    5.2    // "?"
  ],
  
  kinds: [
    'text',           // "Hello"
    'space',          // " "
    'text',           // "世"
    'text',           // "界"
    'text',           // "!"
    'space',          // " "
    'text',           // "How"
    'space',          // " "
    'text',           // "are"
    'hard-break',     // "\n"
    'text',           // "you"
    'text'            // "?"
  ],
  
  breakableWidths: [
    null,             // "Hello" - 英文单词
    null,
    [16.0, 16.0],     // "世界" - 中文每个字符可断
    null,
    // ...
  ],
  
  chunks: [
    { startSegmentIndex: 0, endSegmentIndex: 9 },
    { startSegmentIndex: 10, endSegmentIndex: 12 }
  ]
}

换行算法

换行不是靠换行符,而是靠累加宽度判断!

文本: "Hello world test"
宽度: [42.5, 4.4, 37.2, 4.4, 28.0]

maxWidth = 80

第1步: lineWidth = 0 + 42.5 = 42.5  (Hello) ✓
第2步: lineWidth = 42.5 + 4.4 = 46.9  (空格) ✓
第3步: lineWidth = 46.9 + 37.2 = 84.1 > 80 ❌ 换行!
       lineCount = 2, lineWidth = 37.2 (world)
第4步: lineWidth = 37.2 + 4.4 = 41.6  (空格) ✓
第5步: lineWidth = 41.6 + 28.0 = 69.6 (test) ✓

结果: 2行

换行点由什么决定?

  1. 空格 - kinds[i] === 'space' 后可换行
  2. CJK 字符 - 中文每个字符都是独立分段,随时可换
  3. 软连字符 - kinds[i] === 'soft-hyphen' 可断开并加 -
  4. overflow-wrap - 单词太长时按 breakableWidths 断开
  5. 硬换行 - \n 强制换行

多语言支持

分段(Intl.Segmenter)

浏览器原生分段器自动处理所有语言:

  • 中文/日文/韩文 → 按字符分段
  • 英语 → 按单词分段
  • 泰语 → 按词分段(泰语没有空格)
  • 阿拉伯语 → 正确处理双向文本
  • Emoji → 识别为单个 grapheme
const segmenter = new Intl.Segmenter(locale, { granularity: 'word' })
for (const segment of segmenter.segment(text)) {
    // 自动处理各种语言
}

Emoji 修正

Chrome/Firefox 在字号 <24px 时,Canvas 测量的 emoji 比实际 DOM 宽:

if (textMayContainEmoji(seg)) {
    const canvasWidth = ctx.measureText(seg).width
    const domWidth = measureDOM(seg)  // 一次性 DOM 读取
    const correction = domWidth - canvasWidth
    // 缓存修正值,后续只用 Canvas
}

渲染方式

Pretext 不负责渲染,只告诉你高度!

1. DOM 渲染

const { height } = layout(prepared, 300, 24)

const div = document.createElement('div')
div.style.width = '300px'
div.style.height = `${height}px`  // ← Pretext 告诉你高度
div.style.lineHeight = '24px'
div.style.font = '16px Inter'
div.textContent = text

2. Canvas 渲染

const prepared = prepareWithSegments("Hello world", "16px Inter")
const { lines } = layoutWithLines(prepared, 100, 20)

lines.forEach((line, i) => {
    ctx.fillText(line.text, 0, i * 20 + 16)
})

3. 虚拟列表

const items = data.map(text => {
    const prepared = prepare(text, "16px Inter")
    const { height } = layout(prepared, containerWidth, 20)
    return { text, prepared, height }
})

// 总高度
const totalHeight = items.reduce((sum, item) => sum + item.height, 0)

// 只渲染可见项
for (let i = visibleStart; i < visibleEnd; i++) {
    renderItem(items[i])
}

使用场景

  1. 虚拟列表 - 知道文本高度才能做虚拟滚动
  2. Canvas 渲染 - 游戏/WebGL 中的文本布局
  3. 自定义布局 - 瀑布流、自适应宽度
  4. 服务端渲染 - 不需要浏览器就能算布局
  5. 开发时验证 - AI 生成代码时检查文本是否溢出

完整流程图

┌─────────────────────────────────────────────────────────────┐
│                        Pretext 流程                          │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. prepare()  ← 文本 + 字体                               │
│       ↓                                                     │
│  [widths, kinds, ...]  ← 预计算的分段数据                  │
│                                                             │
│  2. layout()   ← prepared + 容器宽度 + 行高                │
│       ↓                                                     │
│  { height, lineCount }  ← 纯算术,瞬间完成                 │
│                                                             │
│  3. 你自己渲染                                              │
│       ↓                                                     │
│  DOM: <div style="height: 120px">文本...</div>             │
│  Canvas: ctx.fillText(line.text, x, y)                     │
│  SVG: <text y="20">每行文本</text>                          │
│                                                             │
└─────────────────────────────────────────────────────────────┘

总结

Pretext 的核心创新是把文本测量从 DOM 中剥离出来:

  1. prepare() - 一次性做昂贵操作(分段 + Canvas 测量)
  2. layout() - 变成纯算术(数组遍历 + 加法)

从而实现高性能的文本布局计算,特别适合虚拟列表、Canvas 渲染等场景。

SwiftUI路由管理架构揭秘:从混乱到优雅的蜕变

引言

想象一下:当你打开一个 App,点击不同标签页,切换页面时,所有导航状态都能完美保持;当你从详情页返回时,TabBar 能智能地重新出现;当你需要传递数据时,类型安全的导航能让你告别字符串硬编码的烦恼。这一切,都离不开一个优秀的路由管理架构。

在现代 iOS 应用开发中,路由管理常常被视为"基础设施"而被忽视,但其重要性却不亚于任何核心功能。一个设计良好的路由系统,不仅能让代码结构更清晰,还能显著提升用户体验。今天,我将带大家深入剖析我项目中的路由管理架构,分享从设计到实现的全过程,希望能为你的项目带来启发。

路由架构概览

我项目的路由管理基于 SwiftUI 的 NavigationStackNavigationPath,采用了集中式的路由管理方案。核心组件包括:

  • Router 类:全局导航路由器,管理所有 Tab 的导航路径
  • MainTab 枚举:定义应用的标签页结构
  • MainContainerView:主容器视图,负责整合标签页和导航逻辑
  • App 启动注入:在应用启动时将 Router 注入到环境中

路由的启动注入

EviApp.swift 中,我们通过 @StateObject 创建 Router 实例,并通过 environmentObject 将其注入到应用环境中:

import SwiftUI

@main
struct EviApp: App {
    // 把 AppDelegate 接进来,系统会照常调用 didFinishLaunchingWithOptions 等
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    
    // 全局弹框管理器
    @StateObject private var overlay = GlobalOverlayManager.shared
    // 全局导航路由器
    @StateObject private var router = Router()
    
    var body: some Scene {
        WindowGroup {
            MainContainerView()
                .environmentObject(overlay)
                .environmentObject(router)
        }
    }
}

这样,在应用的任何视图中,都可以通过 @EnvironmentObject 来访问 Router 实例,实现全局路由管理。

核心组件分析

1. Router 类:路由管理的核心

import SwiftUI

/// 全局导航路由器,管理所有Tab的导航路径
class Router: ObservableObject {
    
    // 当前选中的Tab
    @Published var selectedTab: MainTab = .home
    
    // 为每个tab单独存储NavigationPath
    @Published var homePath = NavigationPath()
    @Published var hotPath = NavigationPath()
    @Published var creationPath = NavigationPath()
    @Published var stylePath = NavigationPath()
    @Published var profilePath = NavigationPath()
    
    // MARK: - 获取导航路径
    
    /// 获取指定tab的导航路径
    func getNavigationPath(for tab: MainTab) -> NavigationPath {
        switch tab {
        case .home: return homePath
        case .hot: return hotPath
        case .creation: return creationPath
        case .style: return stylePath
        case .profile: return profilePath
        }
    }
    
    /// 获取指定tab的导航路径绑定
    func getNavigationPathBinding(for tab: MainTab) -> Binding<NavigationPath> {
        switch tab {
        case .home: return binding(for: \.homePath)
        case .hot: return binding(for: \.hotPath)
        case .creation: return binding(for: \.creationPath)
        case .style: return binding(for: \.stylePath)
        case .profile: return binding(for: \.profilePath)
        }
    }
    
    // MARK: - 清空导航路径
    
    /// 清空指定tab的导航路径
    func clearPath(for tab: MainTab) {
        switch tab {
        case .home: clear(\.homePath)
        case .hot: clear(\.hotPath)
        case .creation: clear(\.creationPath)
        case .style: clear(\.stylePath)
        case .profile: clear(\.profilePath)
        }
    }
    
    /// 清空所有导航路径
    func clearAllPaths() {
        clear(\.homePath)
        clear(\.hotPath)
        clear(\.creationPath)
        clear(\.stylePath)
        clear(\.profilePath)
    }
    
    // MARK: - 当前Tab操作
    
    /// 获取当前选中Tab的导航路径
    func getCurrentNavigationPath() -> NavigationPath {
        return getNavigationPath(for: selectedTab)
    }
    
    /// 获取当前选中Tab的导航路径绑定
    func getCurrentNavigationPathBinding() -> Binding<NavigationPath> {
        return getNavigationPathBinding(for: selectedTab)
    }
    
    /// 清空当前选中Tab的导航路径
    func clearCurrentPath() {
        clearPath(for: selectedTab)
    }
    
    // MARK: - 私有辅助方法
    
    /// 创建导航路径的绑定
    private func binding(for keyPath: ReferenceWritableKeyPath<Router, NavigationPath>) -> Binding<NavigationPath> {
        Binding {
            self[keyPath: keyPath]
        } set: {
            self[keyPath: keyPath] = $0
        }
    }
    
    /// 清空指定的导航路径
    private func clear(_ keyPath: ReferenceWritableKeyPath<Router, NavigationPath>) {
        self[keyPath: keyPath].removeLast(self[keyPath: keyPath].count)
    }
}

设计亮点

  • 集中管理:所有路由逻辑集中在一个类中,便于统一管理
  • Tab 隔离:为每个标签页维护独立的导航路径,确保切换标签时不会影响其他标签的导航状态
  • 响应式设计:使用 @Published 修饰符,实现路由状态的自动更新
  • 便捷方法:提供了丰富的方法来操作导航路径,如获取路径、清空路径等

2. MainTab 枚举:标签页定义

import SwiftUI

/// 主标签栏枚举
enum MainTab {
    case home
    case hot
    case creation
    case style
    case profile
}

extension MainTab {
    
    /// 根据选中状态返回对应的图标名称
    func iconName(isSelected: Bool) -> String {
        switch self {
        case .home:
            return isSelected ? "tabbar_home_sel" : "tabbar_home_nor"
        case .hot:
            return isSelected ? "tabbar_hot_sel" : "tabbar_hot_nor"
        case .creation:
            return "tabbar_add"
        case .style:
            return isSelected ? "tabbar_style_sel" : "tabbar_style_nor"
        case .profile:
            return isSelected ? "tabbar_me_sel" : "tabbar_me_nor"
        }
    }
}

设计亮点

  • 类型安全:使用枚举定义标签页,避免了字符串硬编码
  • 扩展功能:通过扩展为枚举添加了获取图标名称的功能,使代码更整洁

3. MainContainerView:路由的实际应用

import SwiftUI

/// 主容器视图,包含悬浮TabBar
struct MainContainerView: View {
    
    // 获取指定tab的导航路径
    private func getNavigationPath(for tab: MainTab) -> NavigationPath {
        return router.getNavigationPath(for: tab)
    }
    
    /// 创建带有NavigationStack的标签页视图
    private func tabView(_ tab: MainTab) -> some View {
        NavigationStack(path: router.getNavigationPathBinding(for: tab)) {
            switch tab {
            case .home:
                HomeView()
            case .hot:
                HotHomeView()
            case .creation:
                CreationHomeView()
            case .style:
                StyleHomeView()
            case .profile:
                ProfileHomeView()
            }
        }
        .tag(tab)
    }
    
    @StateObject private var appConfigManager = AppConfigManager.shared
    
    @EnvironmentObject private var overlay: GlobalOverlayManager
    @EnvironmentObject private var router: Router
    
    var body: some View {
        if appConfigManager.appConfig != nil {
            ZStack {
                
                // 真正负责页面生命周期的容器
                TabView(selection: $router.selectedTab) {
                    tabView(.home)
                    tabView(.hot)
                    tabView(.creation)
                    tabView(.style)
                    tabView(.profile)
                }
                
                // 你的悬浮TabBar,根据当前选中标签的导航路径长度控制显示
                if isTabBarVisible {
                    VStack {
                        Spacer()
                        FloatingTabBar(selectedTab: $router.selectedTab)
                            .padding(.horizontal, 16)
                            .padding(.bottom, 20)
                    }
                }
                
                // 全局弹框显示
                if let current = overlay.current {
                    
                    // 遮罩
                    Color.black.opacity(0.4)
                        .ignoresSafeArea()
                        .onTapGesture {
                            overlay.dismiss()
                        }
                    
                    switch current {
                    case .login:
                        LoginOverlayView(onClose: {
                            overlay.dismiss()
                        })
                        .transition(.flipFromBottom)
                    }
                }
                
            }
            .animation(.easeInOut(duration: 0.25), value: overlay.current)
        } else {
            // 显示空View
            EmptyView()
                .background(ThemeManager.Background.global)
        }
    }
    
    var isTabBarVisible: Bool {
        return getNavigationPath(for: router.selectedTab).count == 0
    }
}

设计亮点

  • NavigationStack 集成:为每个标签页创建独立的 NavigationStack
  • TabBar 智能显示:根据当前导航路径长度控制 TabBar 的显示/隐藏
  • 环境对象注入:使用 @EnvironmentObject 注入 Router,实现全局访问
  • 动画效果:添加了平滑的过渡动画,提升用户体验

路由管理的实现细节

1. 路径管理机制

路由系统的核心是 NavigationPath 的管理。NavigationPath 是 SwiftUI 4.0+ 引入的类型,它是一个类型擦除的容器,可以存储任意类型的导航目的地。

在我们的实现中:

  • 每个标签页都有自己的 NavigationPath 实例
  • 通过 getNavigationPathBinding 方法获取路径的绑定,用于 NavigationStack
  • 提供了 clearPathclearAllPaths 方法来清空导航路径

2. 标签页切换逻辑

当用户切换标签页时:

  1. router.selectedTab 的值会更新
  2. TabView 会根据新的 selectedTab 显示对应的标签页
  3. 由于每个标签页有独立的 NavigationPath,切换标签不会影响其他标签的导航状态

3. 导航路径的实际使用

在具体的视图中,可以通过以下方式使用路由:

// 在视图中注入 Router
@EnvironmentObject private var router: Router

// 使用全局路由管理进行导航
let currentPath = router.getCurrentNavigationPathBinding()
// 向当前路径添加新页面
currentPath.wrappedValue.append(AppNavigationDestination.materialDetail(material))

// 清空当前标签页的导航路径
router.clearCurrentPath()

4. 导航目的地定义

项目使用 AppNavigationDestination 枚举来定义导航目的地:

import Foundation
import SwiftUI

/// 导航目标枚举
enum AppNavigationDestination: Hashable {
    case accountLogin
    case materialDetail(MaterialListDTOElement)
}

这种方式的优势:

  • 类型安全:使用枚举定义导航目的地,避免了字符串硬编码
  • 参数传递:可以在导航时传递相关数据,如 materialDetail 中的 MaterialListDTOElement
  • 可扩展性:可以轻松添加新的导航目的地

5. NavigationStack 中处理导航目的地

在使用 NavigationStack 时,需要处理导航目的地的显示逻辑。通常在根视图中添加 navigationDestination 修饰符:

NavigationStack(path: router.getNavigationPathBinding(for: tab)) {
    HomeView()
        .navigationDestination(for: AppNavigationDestination.self) { destination in
            switch destination {
            case .accountLogin:
                AccountLoginView()
            case .materialDetail(let material):
                MaterialDetailView(material: material)
            }
        }
}

这样,当我们通过 currentPath.wrappedValue.append(AppNavigationDestination.materialDetail(material)) 导航时,NavigationStack 会自动显示对应的目标视图。

6. 完整导航流程示例

下面是一个完整的导航流程示例,展示从触发导航到显示目标页面的全过程:

// 1. 在视图中注入 Router
@EnvironmentObject private var router: Router

// 2. 定义导航触发事件
Button("查看素材详情") {
    // 3. 获取当前路径绑定
    let currentPath = router.getCurrentNavigationPathBinding()
    // 4. 向路径添加导航目的地
    currentPath.wrappedValue.append(AppNavigationDestination.materialDetail(selectedMaterial))
}

// 5. 在根视图中处理导航目的地
NavigationStack(path: router.getNavigationPathBinding(for: .home)) {
    HomeView()
        .navigationDestination(for: AppNavigationDestination.self) { destination in
            switch destination {
            case .materialDetail(let material):
                MaterialDetailView(material: material)
            default:
                EmptyView()
            }
        }
}

// 6. 从详情页返回
Button("返回") {
    // 清空当前路径,返回根视图
    router.clearCurrentPath()
}

7. 导航路径与 TabBar 显示的关联

MainContainerView 中,通过 isTabBarVisible 计算属性控制 TabBar 的显示:

var isTabBarVisible: Bool {
    return getNavigationPath(for: router.selectedTab).count == 0
}

当导航路径为空时(即处于标签页的根视图),显示 TabBar;当导航路径不为空时(即进入了子页面),隐藏 TabBar,为用户提供更大的内容显示区域。

优势与最佳实践

优势

  1. 清晰的职责分离:路由逻辑与 UI 逻辑分离,使代码更易于维护
  2. 类型安全:使用枚举和类型化的导航路径,减少运行时错误
  3. 状态管理:集中管理路由状态,避免状态分散
  4. 灵活性:可以轻松添加新的标签页和导航目的地
  5. 用户体验:标签页切换时保持各自的导航状态,提升用户体验

最佳实践

  1. 统一的路由入口:所有导航操作都通过 Router 进行,避免直接操作 NavigationPath
  2. 合理的路径清理:在适当的时机清理导航路径,避免内存占用过高
  3. 导航目的地的类型定义:为导航目的地创建明确的类型,提高代码可读性
  4. 错误处理:添加适当的错误处理,确保导航操作的稳定性
  5. 测试:为路由逻辑编写单元测试,确保其正确性

代码优化建议

  1. 导航目的地类型化

    // 建议为每个标签页创建导航目的地枚举
    enum HomeDestination {
        case detail(id: String)
        case search
    }
    
    // 然后在导航时使用
    router.homePath.append(HomeDestination.detail(id: "123"))
    
  2. 添加导航日志

    // 添加导航日志,便于调试和分析用户行为
    func appendToPath(_ value: some Hashable, for tab: MainTab) {
        let path = getNavigationPathBinding(for: tab)
        path.wrappedValue.append(value)
        print("Navigate to \(value) in tab \(tab)")
    }
    
  3. 导航路径持久化

    // 可以考虑在应用进入后台时保存导航状态,在应用启动时恢复
    func saveNavigationState() {
        // 保存导航状态到 UserDefaults 或其他存储
    }
    
    func restoreNavigationState() {
        // 从存储中恢复导航状态
    }
    
  4. 添加路由拦截器

    // 可以添加路由拦截器,用于处理登录验证等场景
    func appendToPath(_ value: some Hashable, for tab: MainTab) {
        if needsAuthentication(for: value) {
            // 显示登录界面
            overlay.present(.login)
        } else {
            let path = getNavigationPathBinding(for: tab)
            path.wrappedValue.append(value)
        }
    }
    

总结

通过以上分析,我们可以看到,一个良好的路由管理架构对于 iOS 应用的重要性。我项目中的路由架构采用了集中式管理、Tab 隔离、响应式设计等原则,通过 Router 类、MainTab 枚举和 MainContainerView 的配合,实现了清晰、灵活、用户友好的导航体验。

这种路由架构不仅适用于当前项目,也可以作为其他 SwiftUI 项目的参考。通过不断优化和扩展,可以构建更加完善的路由系统,为用户提供更加流畅的应用体验。

希望这篇文章能够帮助大家更好地理解和实现 iOS 项目中的路由管理架构。如果你有任何问题或建议,欢迎在评论区留言讨论!

❌