先从我手头一个真实的项目说起吧。前段时间我在负责一个活动页的优化,页面不大,但静态资源特别多,十几个小图标、几段内联样式、一两个首屏脚本,单独发请求的话,移动端首屏时间很难看。当时的第一反应是引入打包工具,但为了一个活动页把整个构建链搬进来,实在太重了。无奈之下,我给自己写了个小工具,名字就叫rea,全称是 Resource Embedding & Assembly,用来把散落在各处的静态资源自动内联进 HTML,再统一组装成最终产物。
如果你也遇到过类似的场景:页面不复杂,但资源零碎,既要减少 HTTP 请求,又不想为一个小项目引入重型构建体系,这篇文章应该能帮你省下不少时间。我会把 rea 的立项思路、核心设计、最小可运行版本的完整代码,以及我在真实项目里踩过的坑,都摊开来讲。内容偏向实操,可以直接照着抄。
1. 一次手动内联事故,逼我写了个rea
1.1 活动页性能优化里的“脏活累活”
当时的情况是这样的:设计稿出来之后,前端把样式和脚本拆成了几个文件,图片素材也单独放在assets目录里。按常规做法,页面引用了大概二十多个静态资源,每个都是一次 HTTP 请求。首屏需要等这些资源全部返回,白屏时间自然下不来。
优化方向其实很明确:把小体积的 CSS、JS、图标全部内联进 HTML,减少请求数量。问题是,项目里没有一个统一的构建工具,大家平时的习惯是“发版前手动处理”。一开始我也试过手动合并:打开 HTML,找到<link>,把 CSS 文件内容复制粘贴进来,把<img src>换成 base64,再把<script src>换成内联代码。
听起来不复杂,但做了一次之后我就发现问题了。页面模板里有两个小模块是后端动态渲染的,CSS 里用到了相对路径引用的字体文件,还有两处内联内容是重复的,我粘贴的时候没注意,导致样式重复加载,体积直接翻倍。更要命的是,第二次改需求时,设计稿换了几个图片,我又得把那段长长的 base64 重新生成一遍,整个人就是一个人工构建工具,效率低还容易出错。
那次发版前,我盯着合并完的 HTML 文件,突然觉得这件事不该这么干。我需要的不是一个完整的打包器,而是一个能在构建流程里“轻量插手”的资源内联工具:给它一个 HTML 模板,它自动找到该内联的东西,读取文件内容,处理路径,最终吐出一个干净的单文件 HTML。我就想,这个工具自己写一个可能也不难,于是 rea 的雏形就开始了。
1.2 从手动合并到自动化:先定下三个原则
动手之前,我先给这个工具定了几条原则,避免像其他脚手架一样越写越复杂:
- 原则一:只做内联和组装,不做转译和压缩。压缩、转译有更成熟的工具,rea 不需要重复造轮子。它读到的 CSS 就是 CSS,JS 就是 JS,原样内联。
- 原则二:模板里必须能看出内联意图。我不喜欢用了工具之后,模板和产物完全是两套东西。所以对内联点要显式声明,其他人打开模板也知道这里会变成内联内容。
- 原则三:配置文件放在项目里,而不是埋在脚本里。不同项目的内联规则不一样,硬编码在代码里等于给自己埋坑。
这三条原则后来帮了大忙。rea 的定位非常清晰,它不试图解决所有构建问题,只负责把“资源内联”这一步自动化。后面的实现过程,基本都在围绕这三条原则展开。
2. rea的核心设计:扫描、内联与组装
2.1 为什么内联工具要做成三步而不是一个“全自动魔法”
很多人刚接触这类工具时会想:为什么不直接解析 HTML,把所有本地资源全部自动内联,省去手动声明?我一开始也这么想过,但实际做起来你会发现,不是所有资源都适合内联。比如一个很大的背景图,你把它 base64 化之后,生成的 HTML 可能比原来所有文件加起来还大,反而拖慢首屏。再比如有些脚本需要异步加载,内联之后就破坏了原有加载时序。
所以 rea 的架构刻意做成三步,保持直观和可控:
- 扫描:读取入口模板,找到所有内联占位符。
- 内联:根据配置和文件类型,读取资源内容并转换成合适格式。
- 组装:把内联结果回填到模板中,并根据配置调整路径、顺序等,输出最终文件。
这三步各管一件事,合起来就是 rea 的全部逻辑。如果你之后想扩展成“自动识别哪些资源需要内联”,只需要替换第一步的策略,后面两步完全不用动。这个拆法也让代码的维护成本变得很低。
2.2 占位符约定:让模板自己说清楚要内联什么
rea 使用的占位符沿用了 HTML 注释的形式,类似这样:
<!-- rea:inline src:./assets/app.css --><link rel="stylesheet" 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\s+src:([^\s]+)\s*-->/g;但这个正则只能匹配到注释本身,拿不到注释后面的那个标签。所以真正扫描的时候,我用的是逐行扫描的方式,而不是整个文件一次替换。具体逻辑是这样的:
- 按行读取 HTML 模板。
- 遇到
rea:inline注释时,先记住当前行号和 src 路径。 - 继续往下找第一个
<link>、<script>或<img>标签,把这段范围标记为“待替换区域”。 - 把所有“待替换区域”收集起来,统一处理。
为什么不用一个大的正则一次性替换?因为 HTML 标签可能出现跨行的情况,比如:
<link rel="stylesheet" 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\s+src:([^\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,整个匹配状态就乱了。
解决方法是读取文件时显式去掉 BOM:
function 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 rel="stylesheet" href="./assets/app.css">rea 在读配置时会拿到当前的env值,只有匹配时才执行内联,否则保留原始标签。这样本地调试和生产构建用的是同一份模板,不会出现“本地好好的,上线样式丢了”的问题。
5.2 接入现有构建流程:在打包前增加一个前置校验
实际接入时,我们没有让 rea 独立承担“构建工具”的角色,而是把它作为构建流程的一步。我们的做法是:在 CI 脚本里,打包前先执行一次 rea,然后对产物做一次校验——检查生成后的 HTML 里是否还残留href="./assets/...这类外链引用。
校验逻辑很简单,但作用很大。它相当于给团队加了一道保险,如果有人不小心在模板里写了不支持的资源类型,或者某个资源路径写错了,构建会直接报错,而不是到线上才发现样式没了。
这个步骤对 rea 的推广很关键。毕竟一个工具如果只在自己电脑上跑得好,别人是不敢随便信的;一旦变成构建流程里自动执行的一个环节,大家的使用成本就降到最低了。
5.3 增量计算与缓存:给团队用之前先解决性能问题
最开始 rea 每次运行都会重新扫描、重新内联所有资源。模板小的时候没什么感觉,但后来某项目模板有几百个占位符,图片资源也偏大,全量处理后每次构建要十几秒,这在 CI 里是不能接受的。
于是我给 rea 加了基于文件内容哈希的缓存。思路很简单:对每个要被内联的资源,计算它的hash,如果和上一次构建记录的hash相同,就直接复用上次生成的字符串,不用重新读取文件。只有内容变了才重新处理。
实现时我使用了一个简单的缓存目录:
.cache/rea/ 资源路径 -> hash -> 内联结果这样既不占用项目目录空间,也不会因为依赖外部数据库而增加维护成本。性能问题解决后,整个构建时间恢复到了秒级。
就我目前的使用体验来说,rea 的定位很明确:它不是一个大而全的构建平台,也不是要替代现有的插件体系,而是在“资源内联”这个具体问题上提供一种轻量、可控的解决思路。你可以直接复制这份代码,也可以把它改造成符合自己项目习惯的样子。只要第一步扫描、第二步内联、第三步组装这个骨架在,换成什么语言、加什么功能都不会跑偏。