阅读视图

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

探索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 项目中的路由管理架构。如果你有任何问题或建议,欢迎在评论区留言讨论!

❌