
先从我手头一个真实的项目说起吧。前段时间我在负责一个活动页的优化页面不大但静态资源特别多十几个小图标、几段内联样式、一两个首屏脚本单独发请求的话移动端首屏时间很难看。当时的第一反应是引入打包工具但为了一个活动页把整个构建链搬进来实在太重了。无奈之下我给自己写了个小工具名字就叫rea全称是 Resource Embedding Assembly用来把散落在各处的静态资源自动内联进 HTML再统一组装成最终产物。如果你也遇到过类似的场景页面不复杂但资源零碎既要减少 HTTP 请求又不想为一个小项目引入重型构建体系这篇文章应该能帮你省下不少时间。我会把 rea 的立项思路、核心设计、最小可运行版本的完整代码以及我在真实项目里踩过的坑都摊开来讲。内容偏向实操可以直接照着抄。1. 一次手动内联事故逼我写了个rea1.1 活动页性能优化里的“脏活累活”当时的情况是这样的设计稿出来之后前端把样式和脚本拆成了几个文件图片素材也单独放在assets目录里。按常规做法页面引用了大概二十多个静态资源每个都是一次 HTTP 请求。首屏需要等这些资源全部返回白屏时间自然下不来。优化方向其实很明确把小体积的 CSS、JS、图标全部内联进 HTML减少请求数量。问题是项目里没有一个统一的构建工具大家平时的习惯是“发版前手动处理”。一开始我也试过手动合并打开 HTML找到link把 CSS 文件内容复制粘贴进来把img src换成 base64再把script src换成内联代码。听起来不复杂但做了一次之后我就发现问题了。页面模板里有两个小模块是后端动态渲染的CSS 里用到了相对路径引用的字体文件还有两处内联内容是重复的我粘贴的时候没注意导致样式重复加载体积直接翻倍。更要命的是第二次改需求时设计稿换了几个图片我又得把那段长长的 base64 重新生成一遍整个人就是一个人工构建工具效率低还容易出错。那次发版前我盯着合并完的 HTML 文件突然觉得这件事不该这么干。我需要的不是一个完整的打包器而是一个能在构建流程里“轻量插手”的资源内联工具给它一个 HTML 模板它自动找到该内联的东西读取文件内容处理路径最终吐出一个干净的单文件 HTML。我就想这个工具自己写一个可能也不难于是 rea 的雏形就开始了。1.2 从手动合并到自动化先定下三个原则动手之前我先给这个工具定了几条原则避免像其他脚手架一样越写越复杂原则一只做内联和组装不做转译和压缩。压缩、转译有更成熟的工具rea 不需要重复造轮子。它读到的 CSS 就是 CSSJS 就是 JS原样内联。原则二模板里必须能看出内联意图。我不喜欢用了工具之后模板和产物完全是两套东西。所以对内联点要显式声明其他人打开模板也知道这里会变成内联内容。原则三配置文件放在项目里而不是埋在脚本里。不同项目的内联规则不一样硬编码在代码里等于给自己埋坑。这三条原则后来帮了大忙。rea 的定位非常清晰它不试图解决所有构建问题只负责把“资源内联”这一步自动化。后面的实现过程基本都在围绕这三条原则展开。2. rea的核心设计扫描、内联与组装2.1 为什么内联工具要做成三步而不是一个“全自动魔法”很多人刚接触这类工具时会想为什么不直接解析 HTML把所有本地资源全部自动内联省去手动声明我一开始也这么想过但实际做起来你会发现不是所有资源都适合内联。比如一个很大的背景图你把它 base64 化之后生成的 HTML 可能比原来所有文件加起来还大反而拖慢首屏。再比如有些脚本需要异步加载内联之后就破坏了原有加载时序。所以 rea 的架构刻意做成三步保持直观和可控扫描读取入口模板找到所有内联占位符。内联根据配置和文件类型读取资源内容并转换成合适格式。组装把内联结果回填到模板中并根据配置调整路径、顺序等输出最终文件。这三步各管一件事合起来就是 rea 的全部逻辑。如果你之后想扩展成“自动识别哪些资源需要内联”只需要替换第一步的策略后面两步完全不用动。这个拆法也让代码的维护成本变得很低。2.2 占位符约定让模板自己说清楚要内联什么rea 使用的占位符沿用了 HTML 注释的形式类似这样!-- rea:inline src:./assets/app.css --link relstylesheet href./assets/app.css这里我把需要内联的 CSS 文件单独写在注释里后面的link就相当于一个“标记位置”。rea 扫描到注释之后会读取对应的./assets/app.css然后把注释和link一起替换成style标签。这种显式声明的方式看起来比全自动多了一步但它有几个明显的好处可读性好任何人打开 HTML 模板看到注释就知道这里将来会被内联。可控性强哪个资源内联、哪个不内联一眼就清楚。排错容易如果产物有问题直接看模板里的占位符就能定位是哪个资源导致的。当时团队里有人觉得这种写法有点啰嗦但跑过几次真实项目之后大家反而觉得心里有底。比那种“自动发现所有资源”的黑盒方式踏实很多。2.3 产物组装内联之后还要解决路径和顺序问题内联听起来只是“把文件内容粘贴进去”但真正实现时路径和顺序这两个问题很烦。先说路径。假设 CSS 文件里有一句background: url(../images/logo.png)如果你直接把 CSS 内容塞进 HTML这个相对路径就不再相对于原 CSS 文件而是相对于 HTML 所在目录。如果 CSS 在src/styles下HTML 在dist下路径就全错了。所以 rea 在读取这类资源时会先解析出它所在的目录然后把路径换算成以产物文件为基准的绝对路径或者在配置里强制转成 base64 data URI。再说顺序。有些页面的内联脚本之间有依赖关系比如第一个脚本定义了一个全局函数第二个脚本调用了它。如果你的工具只是简单地按模板中出现的位置依次内联那模板里必须保证顺序正确。如果模板里用的是循环或者动态引入顺序可能就乱了。rea 的处理方式是不改变模板中的出现顺序但在组装阶段允许通过配置手动指定某些资源的先后顺序避免因为目录遍历顺序不确定而导致产物不稳定。这一步做好之后rea 输出的 HTML 基本可以直接打开验证不会出现“资源路径失效”这类问题。3. 开始动手用Node实现rea的最小可用版本3.1 初始化工程与目录结构rea 我选择用 Node.js 来实现理由很简单项目本身是前端生态的Node 处理文件、字符串、路径都非常方便而且不需要什么额外依赖。最小可用版本我先建了这样的目录结构rea-demo/ ├── templates/ │ └── index.html ├── assets/ │ ├── app.css │ └── logo.svg ├── src/ │ └── rea.js └── package.json入口脚本是src/rea.js模板放在templates下静态资源放在assets下。我把工具的输入输出参数设计成命令行的形式node src/rea.js --input templates/index.html --output dist/index.html如果之后接入构建流程只需要在构建脚本里调用这一条命令就好了。3.2 第一步从HTML里找出所有内联占位符扫描的核心是一个正则表达式。我需要匹配!-- rea:inline src:xxx --这种注释同时拿到它后面紧跟着的标签。这里有个重要的细节正则不能贪婪匹配否则会把多个占位符合并成一次匹配。我最终用的正则是这样const PLACEHOLDER_PATTERN /!--\s*rea:inline\ssrc:([^\s])\s*--/g;但这个正则只能匹配到注释本身拿不到注释后面的那个标签。所以真正扫描的时候我用的是逐行扫描的方式而不是整个文件一次替换。具体逻辑是这样的按行读取 HTML 模板。遇到rea:inline注释时先记住当前行号和 src 路径。继续往下找第一个link、script或img标签把这段范围标记为“待替换区域”。把所有“待替换区域”收集起来统一处理。为什么不用一个大的正则一次性替换因为 HTML 标签可能出现跨行的情况比如link relstylesheet href./assets/app.css 单个正则会很难处理。按行扫描加状态记忆的方式虽然代码长一点但非常稳定各种格式都能覆盖。扫描完的伪代码如下function scanPlaceholders(htmlLines) { const matches []; for (let i 0; i htmlLines.length; i) { const line htmlLines[i]; const m line.match(/!--\s*rea:inline\ssrc:([^\s])\s*--/); if (m) { matches.push({ startLine: i, src: m[1], endLine: await findTagEnd(htmlLines, i) }); } } return matches; }我在实际代码里把findTagEnd实现为“从当前行往下找直到遇到包含的行”这样就兼容了单行标签和多行标签两种情况。3.3 第二步读取资源并决定用原文还是base64扫描到占位符之后下一步就是根据资源类型做内联处理。rea 的规则是.css和.js文件原样读取文本直接插入。.png、.jpg、.svg等图片文件转成 base64插入data:image/...;base64,前缀。.woff2、.ttf等字体文件同样转成 base64。实现这个逻辑并不复杂import fs from node:fs/promises; import path from node:path; function getInlineType(filePath) { const ext path.extname(filePath).toLowerCase(); if (ext .css || ext .js) return raw; return base64; } async function inlineResource(filePath) { const absolutePath path.resolve(filePath); const type getInlineType(absolutePath); if (type raw) { return await fs.readFile(absolutePath, utf-8); } const buffer await fs.readFile(absolutePath); const mime image/${path.extname(absolutePath).slice(1)}; return data:${mime};base64,${buffer.toString(base64)}; }这里有一个小坑SVG 文件虽然也是文本但如果你直接用utf-8读取也没问题不过要注意其中的 XML 声明和编码头。为了统一我把 SVG 也走 base64 分支省得处理编码问题。3.4 第三步回填内容并输出最终产物组装阶段的核心是把收集到的内联内容替换回模板中。这里我比较推荐的方式是先读全部内容在内容字符串上做替换而不是逐行拼字符串。原因是逐行操作容易丢失原有格式。实际的处理逻辑可以抽象成两步先把所有占位符位置和内容准备好。从后往前替换避免前面的替换导致后面的索引错位。async function assemble(inputPath, outputPath) { const html await fs.readFile(inputPath, utf-8); const matches scanPlaceholders(html); let result html; // 从后往前处理防止索引错位 for (let i matches.length - 1; i 0; i--) { const { startIndex, endIndex, src } matches[i]; const content await inlineResource(src); const tag createInlineTag(src, content); result result.slice(0, startIndex) tag result.slice(endIndex); } await fs.mkdir(path.dirname(outputPath), { recursive: true }); await fs.writeFile(outputPath, result, utf-8); }createInlineTag会根据文件类型生成对应的style、script或者img标签。到这里一个最小可用版的 rea 就完成了。拿刚才的index.html跑一次输出的dist/index.html已经不再包含任何外部静态资源引用打开就能看到完整样式和图片。4. 在真实项目里反复踩过的四个坑4.1 文件编码UTF-8带BOM的HTML让正则匹配整段失效我第一次把 rea 接到真实项目时先在某台 Windows 机器上测试结果扫描结果为零一个占位符都没匹配到。查了半天发现模板文件头部带了 UTF-8 的 BOM 头EF BB BF读取字符串之后第一个字符是个不可见字符虽然不影响后面的内容但我的正则扫描是基于“行首”的逻辑一旦第一行出现 BOM整个匹配状态就乱了。解决方法是读取文件时显式去掉 BOMfunction stripBOM(content) { if (content.charCodeAt(0) 0xFEFF) { return content.slice(1); } return content; }这个细节如果你没实际碰到很难想到。很多编辑器在 Windows 下默认会带 BOM这也导致同一份模板在不同系统上扫描结果不一致。所以 rea 在处理输入文件前会统一先做一次 BOM 清理。4.2 相对路径陷阱内联CSS里的url()需要一起处理CSS 里常见的写法是用相对路径引用图片、字体.logo { background: url(../images/logo.png); }直接把这个 CSS 内容内联到 HTML 后url(../images/logo.png)的解析基准就变成了 HTML 文件所在目录这几乎必定会失效。我最初的处理方案过于暴力——把所有url(...)都转成了 base64。但有些图片是背景图体积不小全部转成 base64 导致 HTML 体积暴增。后来我改了策略增加了配置项让url(...)有两种处理方式方式一如果图片小于阈值比如 4KB转 base64 内联。方式二如果图片较大则重写相对路径改成基于最终产物目录的正确路径。实现的时候我用postcss的 AST 能力来解析 CSS而不是用正则替换url(...)。正则处理url很容易遇到引号、空格、注释等边界情况AST 更可靠。但由于 rea 不想引入太多依赖我只做了一个简化版专门处理url()的内部路径并做路径拼接。这个坑让我再次确认了之前的原则工具不要轻易“全自动”处理资源因为资源体积和路径依赖往往是最容易失控的地方。4.3 重复内联与资源膨胀没有去重机制的后果我在第一个版本里没有做任何去重结果模板里如果同一个 icon 被引用两次rea 就会生成两份一模一样的 base64 字符串页面体积翻了约一倍。更糟的是部分模块是用后端模板循环渲染的同一个资源可能被循环输出 5-10 次这种重复放大效应非常吓人。后来我加了一个全局“内联缓存”const inlineCache new Map(); async function inlineResource(filePath) { if (inlineCache.has(filePath)) { return inlineCache.get(filePath); } const content await readAndTransform(filePath); inlineCache.set(filePath, content); return content; }实现上只加了一个Map但对重复资源的效果立竿见影。这也提醒我任何资源处理工具都应该考虑幂等性同一个输入文件在同一个产物里只应该出现一次。4.4 模板与配置分离千万不要把配置写死在脚本里一开始 rea 的路径规则、内联阈值、是否启用缓存等等全都写在rea.js里。后来项目多了有人想用不同的规则就只能去改脚本源码改完还容易影响到别人的项目。我调整为通过配置文件rea.config.js来传入参数脚本本身保持无状态export default { input: templates/index.html, output: dist/index.html, rules: [ { test: /\.css$/, type: raw }, { test: /\.(png|jpe?g|gif|webp)$/, type: base64, maxSize: 4096 } ] };脚本读取配置后再决定扫描方式、内联规则和输出路径。这样每个项目都有一份自己的配置文件既不互相干扰也方便版本管理。这个变更看似简单但对 rea 的使用体验影响非常大。从那之后rea 从一个“只适合我自己用的脚本”慢慢变成了团队里其他人也能直接上手的小工具。5. rea的下一步从一次性工具变成构建链路上的一环5.1 条件内联区分本地环境、测试环境与生产环境rea 真正被团队接受之后需求就变多了。第一个需求是条件内联。本地开发时我们希望保留外部资源引用方便调试生产环境才做内联。后来我就在占位符里加了条件参数!-- rea:inline src:./assets/app.css env:production -- link relstylesheet href./assets/app.cssrea 在读配置时会拿到当前的env值只有匹配时才执行内联否则保留原始标签。这样本地调试和生产构建用的是同一份模板不会出现“本地好好的上线样式丢了”的问题。5.2 接入现有构建流程在打包前增加一个前置校验实际接入时我们没有让 rea 独立承担“构建工具”的角色而是把它作为构建流程的一步。我们的做法是在 CI 脚本里打包前先执行一次 rea然后对产物做一次校验——检查生成后的 HTML 里是否还残留href./assets/...这类外链引用。校验逻辑很简单但作用很大。它相当于给团队加了一道保险如果有人不小心在模板里写了不支持的资源类型或者某个资源路径写错了构建会直接报错而不是到线上才发现样式没了。这个步骤对 rea 的推广很关键。毕竟一个工具如果只在自己电脑上跑得好别人是不敢随便信的一旦变成构建流程里自动执行的一个环节大家的使用成本就降到最低了。5.3 增量计算与缓存给团队用之前先解决性能问题最开始 rea 每次运行都会重新扫描、重新内联所有资源。模板小的时候没什么感觉但后来某项目模板有几百个占位符图片资源也偏大全量处理后每次构建要十几秒这在 CI 里是不能接受的。于是我给 rea 加了基于文件内容哈希的缓存。思路很简单对每个要被内联的资源计算它的hash如果和上一次构建记录的hash相同就直接复用上次生成的字符串不用重新读取文件。只有内容变了才重新处理。实现时我使用了一个简单的缓存目录.cache/rea/ 资源路径 - hash - 内联结果这样既不占用项目目录空间也不会因为依赖外部数据库而增加维护成本。性能问题解决后整个构建时间恢复到了秒级。就我目前的使用体验来说rea 的定位很明确它不是一个大而全的构建平台也不是要替代现有的插件体系而是在“资源内联”这个具体问题上提供一种轻量、可控的解决思路。你可以直接复制这份代码也可以把它改造成符合自己项目习惯的样子。只要第一步扫描、第二步内联、第三步组装这个骨架在换成什么语言、加什么功能都不会跑偏。