阅读视图

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

Chrome 插件开发完全指南

Chrome 插件(扩展程序)可以为浏览器增加新功能,修改网页行为,与用户交互。本教程将带你从零开始,掌握 Chrome 插件开发的全部核心知识,最终能够开发、调试并发布自己的插件。


目录

  1. Chrome 插件概述

  2. 开发环境准备

  3. Manifest V3 配置文件

  4. 第一个插件:Hello World

  5. 插件核心组件详解

    • 5.1 弹出页面 (Popup)
    • 5.2 背景脚本 (Background Service Worker)
    • 5.3 内容脚本 (Content Scripts)
    • 5.4 选项页面 (Options Page)
    • 5.5 右键菜单 (Context Menus)
    • 5.6 桌面通知 (Notifications)
    • 5.7 页面操作 (Page Action) vs 浏览器操作 (Browser Action)
  6. 消息传递机制

    • 6.1 单向消息传递
    • 6.2 带回调的消息传递
    • 6.3 长时间连接
  7. 数据存储

    • 7.1 chrome.storage
    • 7.2 chrome.storage.local 与 chrome.storage.sync
    • 7.3 使用示例
  8. 权限与 API

    • 8.1 常用权限
    • 8.2 动态权限请求
  9. 调试与测试

  10. 打包与发布

  11. 高级主题

    • 11.1 DevTools 面板
    • 11.2 覆盖页面 (Override Pages)
    • 11.3 使用 WebAssembly
    • 11.4 国际化 (i18n)
  12. 最佳实践与注意事项

  13. 结语


1. Chrome 插件概述

Chrome 插件(扩展程序)是运行在浏览器中的小程序,可以增强浏览器功能或与当前浏览的页面交互。插件通常由 HTML、CSS、JavaScript 以及一个清单文件(manifest.json)组成。每个插件都有一个唯一的标识(扩展 ID),并可申请特定权限来访问 Chrome API 或用户数据。

插件的常见用途:

  • 修改网页样式或内容(如广告拦截器)
  • 添加浏览器侧边栏、按钮
  • 提供生产力工具(如密码管理器)
  • 与第三方服务集成

从 2022 年起,Chrome 要求所有插件使用 Manifest V3,新插件必须以 V3 开发。


2. 开发环境准备

开发 Chrome 插件无需复杂的环境,只需:

  • 最新版 Chrome 浏览器
  • 一个代码编辑器(VS Code、Sublime Text 等)
  • 基本的 HTML、CSS、JavaScript 知识

推荐步骤

  1. 在本地创建一个文件夹,作为插件项目根目录。

  2. 编写代码。

  3. 在 Chrome 中加载未打包的扩展程序进行测试:

    • 打开 chrome://extensions/
    • 开启“开发者模式”
    • 点击“加载已解压的扩展程序”,选择你的项目文件夹

每次修改代码后,在扩展程序页面点击刷新按钮(🔄)即可重新加载插件。


3. Manifest V3 配置文件

manifest.json 是插件的“身份证”,必须放在根目录。一个最基本的 V3 清单文件如下:

{
  "manifest_version": 3,
  "name": "我的第一个插件",
  "version": "1.0.0",
  "description": "一个简单的Chrome插件示例",
  "icons": {
    "16": "icons/icon16.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  },
  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "16": "icons/icon16.png",
      "48": "icons/icon48.png",
      "128": "icons/icon128.png"
    }
  },
  "background": {
    "service_worker": "background.js"
  },
  "permissions": [
    "storage",
    "activeTab"
  ],
  "host_permissions": [
    "<all_urls>"
  ],
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"],
      "css": ["content.css"]
    }
  ],
  "options_ui": {
    "page": "options.html",
    "open_in_tab": false
  }
}

关键字段说明

  • manifest_version:必须为 3。
  • nameversiondescription:插件基本信息。
  • icons:插件图标,推荐提供多种尺寸。
  • action:定义浏览器工具栏上的按钮(V3 中统一使用 action,不再区分 browser_action 和 page_action)。default_popup 指定点击按钮后弹出的 HTML 页面。
  • background:后台脚本,V3 中使用 Service Worker,不能是持久化页面。service_worker 指定脚本文件。
  • permissions:需要声明的权限,如 storage(存储)、activeTab(当前标签页)等。
  • host_permissions:需要访问的主机权限,如 "<all_urls>" 表示所有网址。
  • content_scripts:注入到网页中的脚本,会在页面加载时运行。matches 指定匹配的 URL 模式。
  • options_ui:选项页面,用户可在扩展管理页面点击“选项”打开。

4. 第一个插件:Hello World

我们创建一个最简单的插件:点击工具栏图标,弹出一个显示“Hello World”的弹出窗口。

步骤 1:创建项目文件夹

新建文件夹 hello-extension,在其中创建以下文件:

  • manifest.json
  • popup.html
  • popup.js(可选)
  • icon.png(任意图片,作为图标)

步骤 2:编写 manifest.json

{
  "manifest_version": 3,
  "name": "Hello World 插件",
  "version": "1.0",
  "description": "第一个插件",
  "icons": {
    "16": "icon.png",
    "48": "icon.png",
    "128": "icon.png"
  },
  "action": {
    "default_popup": "popup.html",
    "default_icon": "icon.png"
  }
}

步骤 3:编写 popup.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <style>
    body { width: 200px; height: 100px; display: flex; justify-content: center; align-items: center; font-family: sans-serif; }
    h1 { color: #4CAF50; }
  </style>
</head>
<body>
  <h1>Hello World!</h1>
  <script src="popup.js"></script>
</body>
</html>

步骤 4:编写 popup.js(可选)

console.log('Popup 已打开');

步骤 5:加载插件

  1. 打开 chrome://extensions/
  2. 开启“开发者模式”
  3. 点击“加载已解压的扩展程序”,选择 hello-extension 文件夹
  4. 插件加载后,工具栏会出现图标,点击即弹出 Hello World。

恭喜!你已经完成了第一个 Chrome 插件。


5. 插件核心组件详解

5.1 弹出页面 (Popup)

弹出页面是一个简单的 HTML 页面,在用户点击工具栏图标时显示。它的生命周期很短:每次点击都会重新加载,关闭即销毁。因此适合做简单的交互,不适合保存状态。如果需要在弹出页面中存储数据,可以结合 chrome.storage 或 localStorage(但注意 localStorage 在弹出页面中与普通网页一样是隔离的)。

弹出页面可以访问部分 Chrome API,但权限取决于插件的 permissions

示例:在弹出页面中显示当前标签页的 URL。

<!-- popup.html -->
<body>
  <div id="url">当前页面URL:</div>
  <script src="popup.js"></script>
</body>
// popup.js
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
  document.getElementById('url').innerText += tabs[0].url;
});

注意:需要 activeTab 或 tabs 权限。

5.2 背景脚本 (Background Service Worker)

背景脚本是插件的“大脑”,用于监听浏览器事件、处理长时间运行的任务。V3 中,它是 Service Worker,在需要时启动,空闲时终止,因此不能使用全局变量持久保存状态。必须将数据存储到 chrome.storage 或 IndexedDB 中。

常用场景

  • 监听扩展安装、更新
  • 监听浏览器事件(如标签页更新、书签变化)
  • 与内容脚本通信,转发消息
  • 管理右键菜单、桌面通知

示例:监听插件安装事件。

// background.js
chrome.runtime.onInstalled.addListener((details) => {
  if (details.reason === 'install') {
    console.log('插件已安装');
    // 初始化存储等操作
  } else if (details.reason === 'update') {
    console.log('插件已更新');
  }
});

清单文件必须声明 background.service_worker

5.3 内容脚本 (Content Scripts)

内容脚本注入到网页中,可以访问并修改 DOM,但无法直接使用大部分 Chrome API(如 chrome.tabs)。它们运行在独立的作用域,与页面脚本隔离,但可以通过 DOM 与页面脚本通信(通过 window.postMessage)或通过消息传递与后台脚本通信。

示例:在页面中所有链接后添加“🔗”符号。

// content.js
const links = document.querySelectorAll('a');
links.forEach(link => {
  link.insertAdjacentText('afterend', ' 🔗');
});

清单文件中需配置 content_scripts,或在运行时通过 chrome.scripting.executeScript 动态注入。

5.4 选项页面 (Options Page)

为用户提供插件的设置界面,用户可在扩展管理页面点击“选项”按钮打开。选项页面可以是一个完整的 HTML 页面,通常用于保存用户偏好设置。

示例:一个简单的选项页面,允许用户设置背景颜色。

<!-- options.html -->
<body>
  <label>背景颜色:<input type="color" id="bgColor"></label>
  <button id="save">保存</button>
  <script src="options.js"></script>
</body>
// options.js
document.getElementById('save').addEventListener('click', () => {
  const color = document.getElementById('bgColor').value;
  chrome.storage.sync.set({ bgColor: color }, () => {
    console.log('已保存');
  });
});
// 加载已保存的颜色
chrome.storage.sync.get('bgColor', (data) => {
  if (data.bgColor) document.getElementById('bgColor').value = data.bgColor;
});

清单中需包含 options_ui

5.5 右键菜单 (Context Menus)

插件可以添加自定义右键菜单项。需要在后台脚本中调用 chrome.contextMenus.create 创建菜单,并监听 chrome.contextMenus.onClicked 事件。

权限:需要 contextMenus 权限。

示例:添加右键菜单“查看链接文本”,点击后弹出提示。

// background.js
chrome.runtime.onInstalled.addListener(() => {
  chrome.contextMenus.create({
    id: "show-link-text",
    title: "查看链接文本",
    contexts: ["link"]  // 仅在链接上显示
  });
});

chrome.contextMenus.onClicked.addListener((info, tab) => {
  if (info.menuItemId === "show-link-text") {
    const linkText = info.linkText;
    chrome.scripting.executeScript({
      target: { tabId: tab.id },
      func: (text) => alert(`链接文本:${text}`),
      args: [linkText]
    });
  }
});

注意:右键菜单在 V3 中需要 host_permissions 来执行脚本,或者使用 activeTab 权限(用户点击插件图标时授权)。

5.6 桌面通知 (Notifications)

插件可以发送系统通知,需要使用 notifications 权限。

示例

chrome.notifications.create({
  type: "basic",
  iconUrl: "icon.png",
  title: "提醒",
  message: "这是一条通知"
}, (notificationId) => {
  console.log("通知已创建,ID:" + notificationId);
});

5.7 页面操作 (Page Action) vs 浏览器操作 (Browser Action)

在 Manifest V3 中,两者统一为 action。但概念上:

  • 浏览器操作:插件对所有页面都有效,工具栏图标始终显示。
  • 页面操作:插件仅对特定页面有效,图标在不需要时变灰(或隐藏)。V3 中可通过 action 动态设置图标或禁用状态来实现类似效果。

6. 消息传递机制

由于各组件(背景、内容脚本、弹出页面、选项页面)运行在不同环境,需要通过消息传递进行通信。

6.1 单向消息传递

使用 chrome.runtime.sendMessage 发送消息,chrome.runtime.onMessage 接收。

示例:内容脚本向后台发送消息,后台处理后返回结果。

content.js

chrome.runtime.sendMessage({ greeting: "hello" }, (response) => {
  console.log("后台回复:" + response.reply);
});

background.js

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.greeting === "hello") {
    sendResponse({ reply: "world" });
  }
  return true; // 异步响应时需要返回 true
});

6.2 带回调的消息传递

同上,sendMessage 的第二个参数是回调函数,接收 sendResponse 的结果。注意若回调是异步的,需要在监听器中返回 true,否则 sendResponse 会失效。

6.3 长时间连接

使用 chrome.runtime.connect 建立长连接,通过 port.postMessage 和 port.onMessage 通信。

示例:后台与内容脚本建立长连接。

background.js

chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
  if (changeInfo.status === 'complete') {
    const port = chrome.tabs.connect(tabId, { name: "my-connection" });
    port.postMessage({ data: "页面加载完成" });
    port.onMessage.addListener((msg) => {
      console.log("收到来自页面的消息:", msg);
    });
  }
});

content.js

chrome.runtime.onConnect.addListener((port) => {
  if (port.name === "my-connection") {
    port.onMessage.addListener((msg) => {
      console.log("收到来自后台的消息:", msg);
      port.postMessage({ reply: "收到" });
    });
  }
});

7. 数据存储

7.1 chrome.storage

推荐使用 chrome.storage API 存储数据,它是异步的,且与浏览器同步(如果使用 sync 存储,会在登录 Chrome 的用户间同步)。数据大小限制:sync 约 100KB,local 约 5MB(可申请更多)。

7.2 chrome.storage.local 与 chrome.storage.sync

  • local:仅存储在本地,数据量大,不跨设备同步。
  • sync:跨设备同步,但容量小,适合存储用户偏好。

7.3 使用示例

// 存储数据
chrome.storage.sync.set({ key: "value" }, () => {
  console.log("已保存");
});

// 读取数据
chrome.storage.sync.get(["key"], (result) => {
  console.log("读取到的值:" + result.key);
});

// 移除数据
chrome.storage.sync.remove("key", () => {
  console.log("已移除");
});

// 监听存储变化
chrome.storage.onChanged.addListener((changes, areaName) => {
  console.log("存储变化:", changes, areaName);
});

8. 权限与 API

8.1 常用权限

  • activeTab:临时获取当前活动标签页的访问权限,用户点击插件按钮时自动授予,适合无需复杂权限的场景。
  • tabs:访问标签页的完整信息(URL、标题等),并可操作标签页(创建、更新、关闭)。
  • storage:使用 chrome.storage
  • cookies:操作 cookies。
  • webRequest:拦截和修改网络请求(V3 中需使用 declarativeNetRequest)。
  • notifications:显示桌面通知。
  • contextMenus:添加右键菜单。
  • bookmarkshistory 等。

8.2 动态权限请求

V3 中,部分权限可在运行时通过 chrome.permissions.request 请求。例如:

chrome.permissions.request({
  permissions: ["cookies"],
  origins: ["*://*.example.com/*"]
}, (granted) => {
  if (granted) console.log("权限已授予");
});

同时可以检查权限、移除权限等。


9. 调试与测试

  • 插件整体调试:在 chrome://extensions/ 中点击插件卡片下的“背景页”链接,可以打开 DevTools 调试 Service Worker。
  • 内容脚本调试:打开目标网页,按 F12 打开 DevTools,在“Sources”面板的“内容脚本”分类下找到你的插件内容脚本,可设置断点。
  • 弹出页面调试:右键点击插件图标,选择“检查弹出内容”,即可调试弹出页面的 DevTools。
  • 选项页面调试:在选项页面上右键选择“检查”。
  • 查看错误:在 chrome://extensions/ 页面,如果插件有错误,卡片上会有“错误”按钮,点击查看详情。

10. 打包与发布

  1. 准备图标:必须提供至少 128x128 的图标,建议提供 16、48、128 三种尺寸。

  2. 添加商店描述:准备详细说明、截图(至少 1 张)、宣传图(可选)。

  3. 创建开发者账户:登录 Chrome Web Store 开发者控制台,支付一次性注册费(约 5 美元)。

  4. 打包插件

    • 在 chrome://extensions/ 中,点击“打包扩展程序”,选择项目根目录,会生成 .crx 文件和私钥(.pem)。私钥务必保存好,用于后续更新。
  5. 上传:在开发者控制台点击“新增项目”,上传 .crx 或直接上传项目 zip。填写表单,提交审核。审核通常需要几小时到几天。

  6. 更新:修改 manifest.json 中的 version 号,重新打包(使用原来的私钥),在控制台上传更新。


11. 高级主题

11.1 DevTools 面板

插件可以添加自己的面板到 Chrome DevTools 中。需要声明 devtools_page 字段,并编写对应的 HTML 和 JS。

"devtools_page": "devtools.html"

devtools.html 中引入 JS,调用 chrome.devtools.panels.create 创建面板。

11.2 覆盖页面 (Override Pages)

可以替换 Chrome 的某些内置页面,如新标签页、书签页、历史页。需要在 chrome_url_overrides 字段中声明。

"chrome_url_overrides": {
  "newtab": "my-newtab.html"
}

11.3 使用 WebAssembly

Manifest V3 允许在扩展中使用 WebAssembly,可以提高某些计算密集型任务的性能。只需将 .wasm 文件包含在插件包中,并在 JavaScript 中加载即可。

11.4 国际化 (i18n)

支持多语言:创建 _locales 文件夹,下设语言子文件夹(如 enzh_CN),内含 messages.json。在 manifest.json 中可使用 __MSG_xxx__ 引用本地化字符串。

messages.json 示例

{
  "extensionName": {
    "message": "我的插件",
    "description": "插件名称"
  },
  "extensionDescription": {
    "message": "一个示例插件",
    "description": "插件描述"
  }
}

在 manifest 中:"name": "__MSG_extensionName__"


12. 最佳实践与注意事项

  • 安全第一:不要在内容脚本中直接使用 eval 或 innerHTML 插入未经验证的外部数据,防止 XSS 攻击。尽量使用 textContent 或 createElement
  • 性能优化:Service Worker 和内容脚本应尽量轻量,避免阻塞页面渲染。使用异步 API,避免长时间运行的任务。
  • 权限最小化:只申请必要的权限,提高用户信任度。
  • 跨设备同步:对于用户设置,尽量使用 chrome.storage.sync 让设置随账号同步。
  • 错误处理:所有异步 API 调用都应添加错误回调(onError 或检查 chrome.runtime.lastError)。
  • 代码分离:保持后台脚本、内容脚本、弹出页面的代码独立,通过消息通信,降低耦合。
  • 测试兼容性:在不同版本的 Chrome 上测试,确保 API 可用。使用 chrome.runtime.getManifest() 检查清单版本,有条件地使用 API。
  • 遵循 Chrome 商店政策:内容不可含有恶意代码、收集隐私信息需明确告知、不干扰其他扩展等。

13. 结语

Chrome 插件开发是一个门槛低但可以非常深入的技术。通过本教程,你已经掌握了从清单配置到各个组件、从消息传递到存储、从调试到发布的完整流程。现在,你可以开始构思自己的插件创意,并将它实现出来。

继续探索 Chrome 官方文档(developer.chrome.com/docs/extensions/),那里有更详细的 API 参考和大量示例。祝你开发顺利,创造出有用的插件!

Pretext 如何颠覆前端文本布局

Pretext 完全指南:高性能文本测量与布局的终极方案

作者:Cheng Lou(前 React Core 成员)
项目地址:github.com/chenglou/pr…
关键词:文本测量、避免重排、Knuth-Plass、Shrinkwrap、多语言

前言:为什么我们需要重新思考 Web 上的文本布局?

“The future of text layout is not CSS.”
—— 这是 Pretext 项目给人的第一印象,也是它试图回答的问题。

当你在一个聊天应用中快速滚动消息列表,或者拖拽一个仪表板的边栏来调整布局时,你是否曾感觉到页面突然卡顿、掉帧?背后的元凶,很可能就是文本测量

浏览器渲染文本的管线,是为三十年前的静态文档设计的:加载、布局、绘制。但在今天的 Web 应用中,文本不再是静态的——它需要实时响应拖拽、动态适应容器、精确参与复杂布局。然而,每一次我们想要知道一段文本的高度,都会触发一次同步的布局重排(reflow)。就像图片中描述的那样:

“Measuring the height of a single text block forces the browser to recalculate the position of every element on the page. When you measure five hundred text blocks in sequence, you trigger five hundred full layout passes.”

这种模式被称为 布局抖动(layout thrashing) ,它是现代 Web 应用中卡顿的最大来源之一。Chrome DevTools 会为此亮起红色警示条,Lighthouse 也会因此扣除性能得分。但开发者别无选择——CSS 并没有提供在不渲染的情况下计算文本高度的 API。文本的尺寸信息被锁在 DOM 背后,而每一次索取,都必须支付高昂的性能代价。

Pretext 的出现,正是为了彻底终结这种困境。

“The performance improvement is not incremental — it is categorical. 0.05ms versus 30ms. Zero reflows versus five hundred.”

1. Pretext 是什么?

Pretext 是一个专注于解决前端文本测量与布局难题的库。它的核心目标是:在不触达 DOM 的情况下,快速、准确地测量一段文本在特定字体和宽度下的高度、行数等布局信息。

它的最大创新点在于:

  • 零 DOM 测量:完全避免了使用 getBoundingClientRect 或 offsetHeight 等会触发浏览器重排(reflow)  的昂贵操作。
  • 自研测量逻辑:它利用 Canvas API 的 measureText 作为测量基准,并通过巧妙的迭代方法逼近浏览器的原生渲染效果。
  • 极致性能:将“文本分析”与“布局计算”分离,实现计算结果的复用,在特定基准下 layout() 阶段仅需约 0.09ms

因此,Pretext 非常适合用于实现虚拟滚动、复杂自定义布局、开发时校验以及防止布局偏移等高级场景。

2. 核心原理:准备与布局的分离

Pretext 的设计基于一个核心洞察:文本布局计算可以拆分为两个阶段,其中只有第一阶段需要“昂贵”的测量。

2.1 阶段一:prepare() —— 文本分析与字符测量

这个阶段做的事情,可以理解为一个“文本编译”过程:

  1. 文本规范化:统一处理空白字符、制表符、换行符。
  2. 分段(Segmentation) :将文本按照“可断行”的边界拆分成若干段。这里涉及到 Unicode 标准中的断字规则(UAX #14),以及如何处理混合方向的文本(如阿拉伯语和英语混合)。
  3. 字符测量:使用 Canvas measureText 测量每个“段”的宽度。这是整个过程中唯一一次调用浏览器原生测量能力的地方。
  4. 缓存结果:所有测量结果被打包成一个不透明的句柄(opaque handle),供后续使用。

为什么是 Canvas measureText
Canvas 的 measureText 同样会触发布局计算,但它的开销远小于完整的 DOM 布局。更重要的是,它返回的是精确的、与浏览器字体渲染引擎一致的宽度值,这就保证了 Pretext 的计算结果与真实渲染高度一致。

2.2 阶段二:layout() —— 纯算术布局

这个阶段接收 prepare() 返回的句柄,以及当前容器宽度 maxWidth 和行高 lineHeight,然后进行纯算术运算:

  1. 断行算法:基于预计算的段宽度,模拟浏览器的断行逻辑(默认 word-break: normal + overflow-wrap: break-word),将段组装成行。
  2. 累加高度:每行高度固定为 lineHeight,累加得到总高度。
  3. 返回结果{ height, lineCount }

关键点layout() 中没有任何 DOM 操作,也没有新的文本测量,只是数学计算。因此,它可以在窗口大小变化时被高频调用,而几乎不产生性能开销。

2.3 性能基准

仓库中给出了一个基准测试快照(500 段文本的批量处理):

阶段 耗时
prepare() ~19ms
layout() ~0.09ms

这意味着,即使你的应用有 500 个独立的文本块需要测量,一次性准备的成本只有 19 毫秒,而后续每次窗口 resize 重新计算所有文本块的高度,仅需 0.09 毫秒。这在传统 DOM 测量方式下是不可想象的。

3. API 详解与实战场景

Pretext 提供了两套 API,分别对应两种主要使用场景。下面逐一拆解。

3.1 场景一:仅需测量高度/行数

这是最常用的场景,适用于虚拟滚动、动态容器高度、布局偏移防护等。

核心 API
// 准备阶段:分析文本并测量字符宽度
function prepare(
  text: string,
  font: string,
  options?: { whiteSpace?: 'normal' | 'pre-wrap' }
): PreparedText;

// 布局阶段:计算在给定宽度和行高下的布局信息
function layout(
  prepared: PreparedText,
  maxWidth: number,
  lineHeight: number
): { height: number; lineCount: number };
实战:虚拟滚动中的文本高度

假设你实现了一个聊天消息列表,每条消息文本长度不同,需要精确计算每条消息的高度以实现虚拟滚动。

import { prepare, layout } from '@chenglou/pretext';

// 消息数据结构
const messages = [
  { id: 1, text: 'AGI 春天到了。', font: '14px -apple-system' },
  { id: 2, text: 'This is a much longer message that might wrap to multiple lines depending on the container width.', font: '14px -apple-system' },
  // ...
];

// 缓存 PreparedText,避免重复准备
const preparedCache = new Map();

function getMessageHeight(text, font, containerWidth, lineHeight) {
  let prepared = preparedCache.get(text + font);
  if (!prepared) {
    prepared = prepare(text, font);
    preparedCache.set(text + font, prepared);
  }
  const { height } = layout(prepared, containerWidth, lineHeight);
  return height;
}

// 在虚拟滚动组件中使用
function estimateTotalHeight() {
  return messages.reduce((sum, msg) => sum + getMessageHeight(msg.text, msg.font, 320, 20), 0);
}

3.2 场景二:手动控制每一行

当需要将文本渲染到 Canvas、SVG 或 WebGL 时,逐行控制是必须的。

核心 API
// 准备阶段:返回更丰富的分段数据
function prepareWithSegments(
  text: string,
  font: string,
  options?: { whiteSpace?: 'normal' | 'pre-wrap' }
): PreparedTextWithSegments;

// 获取所有行(固定最大宽度)
function layoutWithLines(
  prepared: PreparedTextWithSegments,
  maxWidth: number,
  lineHeight: number
): { height: number; lineCount: number; lines: LayoutLine[] };

// 逐行迭代(支持每行不同宽度)
function layoutNextLine(
  prepared: PreparedTextWithSegments,
  start: LayoutCursor,
  maxWidth: number
): LayoutLine | null;

其中 LayoutLine 结构:

type LayoutLine = {
  text: string;      // 该行的完整文本
  width: number;     // 该行的实际宽度
  start: LayoutCursor;  // 起始光标位置
  end: LayoutCursor;    // 结束光标位置
};
实战:Canvas 渲染 + 文本环绕图片

这是 layoutNextLine 的典型应用场景。

import { prepareWithSegments, layoutNextLine } from '@chenglou/pretext';

function renderParagraphWithFloatedImage(ctx, text, font, x, y, columnWidth, image) {
  const prepared = prepareWithSegments(text, font);
  let cursor = { segmentIndex: 0, graphemeIndex: 0 };
  let currentY = y;
  const lineHeight = 24;

  while (true) {
    // 判断当前 Y 坐标是否在图片范围内
    const isBesideImage = currentY >= image.y && currentY < image.y + image.height;
    // 如果在图片旁边,可用宽度 = 列宽 - 图片宽度 - 间距
    const currentWidth = isBesideImage ? columnWidth - image.width - 16 : columnWidth;

    const line = layoutNextLine(prepared, cursor, currentWidth);
    if (line === null) break;

    ctx.fillText(line.text, x + (isBesideImage ? image.width + 16 : 0), currentY);
    cursor = line.end;
    currentY += lineHeight;
  }
}

3.3 辅助功能

// 清除内部缓存,适用于动态切换大量不同字体时释放内存
function clearCache(): void;

// 设置文本分段所使用的区域设置,影响断字规则
function setLocale(locale?: string): void;

4. 实战案例一:The Editorial Engine —— 60fps 的多栏实时重排

“The Editorial Engine”  是 Pretext 能力的一个典型演示。它模拟了一个多栏编辑布局,其中包含可拖拽的圆形(orbs)和实时响应的文本环绕。

20260331-203419.gif

在这个案例中:

  • 页面分为多栏(multi-column),每栏内有大量文本。
  • 用户可以拖拽圆形的“障碍物”,改变它们在页面中的位置。
  • 当障碍物移动时,周围文本需要实时调整换行:原本环绕在左侧的文字可能立即变为环绕在右侧,或者从两栏变为单栏。
  • 所有这一切都必须在 60fps 下流畅运行,即每帧计算时间不能超过 16.6 毫秒。

如果使用传统的 DOM 测量方式,每次拖拽都可能触发数十次甚至上百次重排,导致严重的卡顿。而 Pretext 的做法是:

  1. 预先准备:对每栏的文本调用 prepareWithSegments,提前完成分段和字符测量。
  2. 响应式布局:在拖拽过程中,每一帧都会根据当前障碍物的位置,计算出每一栏内每一段文本的“可用宽度”,然后调用 layoutNextLine 逐行生成文本内容。
  3. 渲染到 Canvas:由于 Pretext 可以逐行给出文本内容,这些内容可以直接绘制到 Canvas 上,完全绕过 DOM 的布局引擎。

最终效果是:拖拽时文本的换行位置实时改变,动画丝滑流畅,没有一丝卡顿。

5. 实战案例二:Justification Algorithms Compared —— 超越浏览器的排版质量

CSS 提供的 text-align: justify 采用的是贪婪算法(greedy algorithm) :从左到右尽可能多地往一行里塞单词,然后均匀拉伸单词间距。这种算法快,但代价是糟糕的排版质量——尤其是在窄栏中,单词间距会变得极不均匀,形成垂直贯穿段落的“河流(rivers)”,严重影响阅读体验。

Pretext 的演示  “Justification Algorithms Compared”  展示了三种对齐方式的精确对比,数据清晰地揭示了差异: 20260331-202222.gif

算法 行数 平均偏差 最大偏差 河流空间数
CSS / 贪婪算法 26 86.9% 304.6% 16
Pretext(带连字符) 25 32.7% 93.8% 4
Pretext(Knuth-Plass) 25 13.1% 32.9% 0

解读这些数据:

  • 行数:CSS 贪婪算法产生了 26 行,而两种 Pretext 算法都只有 25 行。这意味着贪婪算法在断行时效率更低,多出了一整行。
  • 平均偏差:指每行单词间距与理想间距的平均偏离程度。CSS 的平均偏差高达 86.9% ,意味着单词间距极不均匀;而带连字符的 Pretext 将偏差降到 32.7%,Knuth-Plass 进一步压到 13.1% ,趋近专业排版。
  • 最大偏差:最差一行的间距偏离程度。CSS 在某一行上出现了 304.6%  的惊人偏差——这意味着单词间距是理想间距的三倍多,产生巨大的视觉裂痕。Knuth-Plass 的最大偏差仅为 32.9%,几乎察觉不到。
  • 河流空间数:垂直贯穿段落的空白间隙数量。CSS 产生了 16 处河流,严重干扰阅读视线;带连字符的 Pretext 减少到 4 处;而 Knuth-Plass 彻底消除了河流

Knuth-Plass 算法由 Donald Knuth 和 Michael Plass 为 TeX 排版系统开发,至今仍是段落优化的黄金标准。它构建一个所有可行断点的图,然后寻找最短路径——即能让整个段落的间距最均匀的断行组合。在 Web 上实现这个算法一直很困难,因为需要精确的字符宽度测量和高效的图搜索。Pretext 通过其底层的精确测量能力,让这一切成为可能。

6. 实战案例三:Shrinkwrap Showdown —— 精确的最小宽度

CSS 提供了 fit-content 属性,可以让容器宽度“适应内容”。但它的行为是:容器宽度 = 最长行的宽度。如果一个段落有 3 行,最后一行很短,容器仍然会被撑开到第一行的宽度,留下大量空白。

Pretext 的  “Shrinkwrap Showdown”  演示展示了一种完全不同的能力:精确的最小宽度

20260331-202123.gif

使用 walkLineRanges,Pretext 可以:

  1. 对给定文本进行二分搜索,寻找最窄的宽度,使得换行后行数不变(即不会因为宽度减小而增加新的行)。
  2. 最终得到的是“刚好能容纳所有行”的最小宽度,没有冗余像素。

为什么 CSS 做不到?

CSS only knows "fit-content" — the width of the widest line after wrapping. If a paragraph wraps to 3 lines and the last line is short, CSS still sizes the container to the longest line. There's no CSS property for "find the narrowest width that still produces exactly 3 lines." That requires measuring text at multiple widths and comparing line counts — exactly what Pretext's walkLineRanges() does, without touching the DOM. Pure arithmetic, no reflows, instant results.

这段说明精准地揭示了问题的本质:CSS 的布局引擎是单次确定性的——给定宽度,输出换行结果;但反过来,“给定换行结果(行数),反向寻找最小宽度”这种操作,CSS 并没有提供 API。要完成这个任务,就必须在不同的宽度假设下反复测量文本,并比较行数的变化。

这正是 Pretext 的独特价值所在:

  • 无需 DOM:测量是纯算术的,不触发布局重排。
  • 二分搜索:由于 layout() 极快(0.09ms/500段),可以轻松在几十次迭代内找到精确的最小宽度。
  • 即时结果:整个过程不产生任何视觉抖动,计算完成即可直接使用。

这个能力的应用场景非常广泛:

  • 聊天气泡:让气泡宽度恰好贴合文本,不会因为最后一行短而留下大量空白。
  • 标签系统:每个标签的宽度精确适应其文本,布局更紧凑。
  • 自适应 UI:在响应式布局中,动态调整容器宽度以匹配内容。
  • 工具提示(Tooltip) :让提示框的宽度刚好包裹多行文本,而不是被最长行撑开。

CSS 目前无法实现这种效果,因为它的布局引擎没有提供“在给定行数约束下寻找最小宽度”的 API。而 Pretext 通过将文本测量与布局计算解耦,让这类过去不可能实现的操作变得轻而易举。

7. 实战案例四:Masonry —— 零重排的瀑布流布局

瀑布流布局(Masonry Layout)是 Pinterest、Unsplash 等网站的经典设计:卡片以列布局排列,每张卡片的高度不同,下一张卡片会放置在当前高度最小的列下方,以实现紧凑的视觉排列。

20260331-204956.gif 实现瀑布流布局的传统方式通常是:

  1. 将所有卡片渲染到 DOM 中(可能先设为不可见)。
  2. 使用 getBoundingClientRect 或 offsetHeight 获取每张卡片的实际高度。
  3. 用 JavaScript 计算每张卡片应该放置的列位置,并设置 top 和 left 值。

这个过程中,步骤 2 会触发强制重排(reflow) 。如果卡片数量很多(例如 100 张),浏览器需要反复计算布局,导致页面卡顿、滚动不流畅,甚至在 Chrome DevTools 中出现醒目的红色“强制重排”标记。

Pretext 的 Masonry 演示 提供了一种全新的思路:使用 Pretext 预先计算每张卡片中文本的高度,从而完全避免 DOM 测量

核心实现逻辑

  1. 预测量:在卡片渲染之前,对每张卡片内的文本(标题、正文等)调用 prepare() + layout(),得到精确的文本高度。由于卡片其他元素(图片、边距)的高度是已知的,可以累加得到整张卡片的预测高度。
  2. 纯算术布局:使用预测高度计算每张卡片应放置的列位置。所有计算都是算术运算,不涉及任何 DOM 查询或重排。
  3. 一次性渲染:计算出所有卡片的位置后,一次性将卡片渲染到 DOM 中(或直接使用绝对定位)。此时布局已经确定,浏览器只需执行一次布局和绘制。

性能优势

与传统方式相比,Pretext 方案带来的提升是数量级的:

方式 DOM 测量次数 重排次数 滚动卡顿风险
传统 DOM 测量 卡片数量(如 100 次) 至少卡片数量次,可能更多
Pretext 预测高度 0 1(最终渲染时) 极低

更重要的是,由于文本高度的计算不依赖 DOM,开发者可以在服务端或构建时完成高度预测,甚至在用户交互(如窗口大小改变)时,只需重新执行纯算术的 layout() 即可更新布局,无需再次触碰 DOM。

真实体验

实际体验该演示时,可以明显感受到:无论滚动多快、加载多少卡片,界面始终丝滑流畅,毫无卡顿感。这正是 Pretext 将文本测量移出渲染关键路径后带来的直接效果。

适用场景

  • 内容流应用:如 Pinterest、设计作品集、新闻聚合。
  • 动态高度卡片列表:卡片内容由用户生成,高度不确定。
  • 高性能无限滚动:在滚动加载更多卡片时,预先计算新卡片的高度,避免加载过程中的布局抖动。

这个案例再次印证了 Pretext 的核心价值:将文本测量从渲染关键路径中剥离,让复杂布局拥有可预测的性能表现

8. 设计哲学与背后的思考

8.1 为什么不用 Intl.Segmenter?

现代浏览器提供了 Intl.Segmenter API 用于文本分段,但 Pretext 的作者选择了自研分段逻辑。原因有二:

  1. 性能Intl.Segmenter 在处理大量文本时仍有性能开销,且无法与 Canvas measureText 的结果直接集成。
  2. 控制力:自研逻辑可以精确控制断行规则,并与测量结果深度绑定。

8.2 为什么不用 WebAssembly 字体引擎?

理论上,用 HarfBuzz 等专业排版引擎编译成 WASM 可以得到更精确的结果。但 Pretext 的目标是轻量、易用、与浏览器渲染一致。Canvas measureText 直接使用浏览器的字体引擎,其结果天然与最终渲染一致,这是任何第三方引擎无法保证的。

8.3 关于“准确性”的承诺

Pretext 的目标不是 100% 像素级精确(因为字体渲染本身在不同操作系统上有差异),而是与浏览器默认排版行为足够接近,满足绝大多数前端布局需求。目前它支持的 CSS 属性集是:

  • white-space: normal 或 pre-wrap
  • word-break: normal
  • overflow-wrap: break-word
  • line-break: auto

对于超出这个范围的场景(如 word-break: keep-all 或自定义连字符),Pretext 可能无法完美处理。

9. 与现有方案的对比

方案 原理 性能 排版质量 适用场景
Pretext Canvas measureText + 纯算术布局 + 可选 Knuth-Plass 极高(layout ~0.09ms/500段) 可达到专业排版级别 虚拟滚动、Canvas 渲染、高质量排版
DOM 测量 创建隐藏元素,触发重排 低(每次测量都可能重排) 取决于浏览器 一次性测量、非高频场景
CSS 文本布局 浏览器原生引擎 高(但无法获取测量结果) 贪婪算法,质量一般 常规文档流
canvas 测量 + 手动计算 自己实现断行逻辑 中等(需反复测量) 取决于实现质量 简单场景

10. 最佳实践与注意事项

10.1 缓存 PreparedText

prepare() 的成本相对较高,因此务必复用其结果。

javascript

// ❌ 不好
function getHeight(text, width, lineHeight) {
  const prepared = prepare(text, '16px Inter');
  return layout(prepared, width, lineHeight).height;
}

// ✅ 好
const cache = new Map();
function getHeight(text, width, lineHeight) {
  const key = text;
  if (!cache.has(key)) {
    cache.set(key, prepare(text, '16px Inter'));
  }
  return layout(cache.get(key), width, lineHeight).height;
}

10.2 字体声明必须与 CSS 一致

font 参数是 CSS font 属性的简写形式,例如 '16px Inter''bold 14px "Helvetica Neue"'。如果与 CSS 中实际渲染的字体不一致,测量结果会偏离。

10.3 lineHeight 需要与实际 CSS 行高一致

layout() 中的 lineHeight 用于计算总高度,但它不会影响断行逻辑(断行只依赖宽度)。如果传入的 lineHeight 与实际 CSS 行高不一致,最终高度会偏离。

10.4 系统字体的坑

仓库特别提示:system-ui 在 macOS 上对于 layout() 的准确性不安全。建议使用具体的字体名称,如 'Inter''-apple-system' 等。

10.5 清除缓存

如果你的应用会动态切换大量不同的字体或文本,建议适时调用 clearCache() 释放内存。

11. 未来展望

从仓库的活跃度来看(最近一次提交在 2026 年 3 月),Pretext 仍在积极演进。根据 TODO 和开发文档,未来计划包括:

  • 服务端支持:让 Node.js 环境也能使用 Pretext 进行文本测量。
  • 更完整的 CSS 属性支持:如 word-break: keep-allhyphens 等。
  • Knuth-Plass 算法的持续优化:让全局最优断行在更多场景下达到实时性能。
  • 性能进一步优化:通过 Web Worker 并行化准备阶段。

12. 总结

Pretext 是一个理念超前、实现精巧的前端基础设施库。它通过将文本测量与布局计算解耦,在保持与浏览器渲染一致的前提下,实现了数量级的性能提升。

但 Pretext 的价值远不止于性能。正如四个演示所证明的:

  • The Editorial Engine 展示了 Pretext 如何支撑 60fps 的实时交互布局,让拖拽、环绕等复杂排版不再卡顿。
  • Justification Algorithms Compared 展示了 Pretext 可以带来超越 CSS 的排版质量——全局最优断行(Knuth-Plass)配合音节级连字符,让 Web 上的对齐文字第一次接近专业排版软件的质感。
  • Shrinkwrap Showdown 展示了 Pretext 可以带来超越 CSS 的布局控制力——精确的最小宽度计算,让容器可以“刚好包裹”多行文本,而不是被最长行撑开。
  • Masonry 则展示了 Pretext 在零重排瀑布流中的实际落地效果,彻底消除了因高度测量导致的布局抖动。

如果你正面临以下问题,Pretext 值得一试:

  • 需要实现一个包含复杂文本的虚拟滚动列表,但苦于高度测量带来的性能问题。
  • 希望消除动态内容引起的布局偏移(CLS),想在渲染前就知道文本高度。
  • 需要将文本渲染到 Canvas、SVG 或 WebGL,并实现自定义布局(如文本环绕、多栏实时重排)。
  • 追求高质量的排版效果,想让 Web 上的对齐文字更均匀、更专业。
  • 希望实现 CSS 无法完成的精确布局,如“刚好包裹多行文本”的最小宽度容器或高性能瀑布流。

项目核心数据:

  • 作者:Cheng Lou(前 React Core 成员)
  • 语言:TypeScript (89.5%)
  • 性能layout() 阶段平均 < 0.1ms(500段文本)
  • 热度:GitHub 24.2k Stars
  • 许可证:MIT

“The future of text layout is not CSS.”
这句话不是说要抛弃 CSS,而是说,当我们需要超越 CSS 能力范围的布局控制力和排版质量时,Pretext 这样的工具将填补空白。它让我们重新获得了对文本布局的控制权,同时将性能损耗降到最低。

希望这篇深度指南能帮助你全面理解 Pretext,并在实际项目中发挥它的价值。


注:文中使用的演示 GIF 来自 Pretext 官方示例,完整交互式演示请访问 chenglou.me/pretext

产品:这个文字颜色能不能根据背景图自动换?

产品:这个文字颜色能不能根据背景图自动换?我:安排

当产品经理拿着两张背景图——一张深邃的午夜蓝、一张清新的樱花粉——问出这句话时,我知道,又要动脑子了。

事情是这样的

那天产品小哥跑过来,手里拿着两张设计稿:一张是深邃的午夜蓝纯色背景,另一张是清新的樱花粉渐变背景。

“你看啊,”他指着图上的文字区域,“我们的商品详情页,深色背景上用黑色字根本看不清,浅色背景上白字又太刺眼。能不能——让文字颜色自己适应背景?”

我看着他期待的小眼神,深吸一口气:“安排。”

需求拆解

其实这个需求很清晰:文字颜色需要根据背景图的颜色自动调整

更具体地说:

  • 深色背景 → 文字变浅色(白或浅灰)
  • 浅色背景 → 文字变深色(黑或深灰)

但如果只是简单判断黑白,遇到五颜六色的背景图(比如渐变、花纹)就不够用了。我们需要真正读懂背景图的主色调。

技术选型

要在前端实现这个功能,核心是读取图片的颜色信息。方案如下:

  1. 用 Canvas 绘制背景图
  2. 获取图片的像素数据
  3. 计算平均色或亮度
  4. 根据亮度决定文字颜色

没错,就这四步。下面开干。

编程的本质就是以数据为中心。  图片,说到底就是一个数组。数组的长宽对应图片的尺寸,而每个元素里存储着该像素的 RGBA 值——红、绿、蓝和透明度。我们要做的,就是读取这个数组,分析它的颜色分布,然后做出决策。这听起来很酷,对吧?

第一步:获取图片像素数据

function getImagePixels(image) {
  const canvas = document.createElement('canvas');
  const { naturalWidth: width, naturalHeight: height } = image;
  canvas.width = width;
  canvas.height = height;
  const ctx = canvas.getContext('2d');
  ctx.drawImage(image, 0, 0, width, height);
  const imageData = ctx.getImageData(0, 0, width, height).data;
  
  // 为了方便计算,返回二维数组 [x][y] = [r, g, b, a]
  const pixels = [];
  for (let x = 0; x < width; x++) {
    pixels[x] = [];
    for (let y = 0; y < height; y++) {
      const idx = (y * width + x) * 4;
      pixels[x][y] = [
        imageData[idx],     // R
        imageData[idx + 1], // G
        imageData[idx + 2], // B
        imageData[idx + 3]  // A
      ];
    }
  }
  return pixels;
}

这里有个坑需要注意:像素索引是 (y * width + x) * 4,别写错了,不然颜色就全乱了。

第二步:计算区域平均亮度

我们不需要全图平均,只计算文字所在区域的背景色即可,这样更精准。

function getAverageBrightness(pixels, xRange, yRange) {
  const [xMin, xMax] = xRange;
  const [yMin, yMax] = yRange;
  let rSum = 0, gSum = 0, bSum = 0;
  let count = 0;
  
  for (let x = xMin; x < xMax; x++) {
    if (!pixels[x]) continue;
    for (let y = yMin; y < yMax; y++) {
      if (!pixels[x][y]) continue;
      const [r, g, b] = pixels[x][y];
      rSum += r;
      gSum += g;
      bSum += b;
      count++;
    }
  }
  
  if (count === 0) return 128; // 默认中灰
  
  const avgR = rSum / count;
  const avgG = gSum / count;
  const avgB = bSum / count;
  
  // 人眼对绿色最敏感,亮度公式
  return 0.299 * avgR + 0.587 * avgG + 0.114 * avgB;
}

第三步:决定文字颜色

亮度范围 0~255,以 128 为分界:

function getTextColor(brightness) {
  return brightness > 128 ? '#000000' : '#FFFFFF';
}

第四步:整合到页面

const img = document.getElementById('bgImage');
const textElement = document.querySelector('.dynamic-text');

img.onload = () => {
  // 获取像素数据
  const pixels = getImagePixels(img);
  const width = pixels.length;
  const height = pixels[0]?.length || 0;
  
  // 文字通常在图片底部中央,取这个区域
  const textAreaX = [width * 0.3, width * 0.7];
  const textAreaY = [height * 0.7, height * 0.9];
  
  const brightness = getAverageBrightness(pixels, textAreaX, textAreaY);
  const textColor = getTextColor(brightness);
  
  textElement.style.color = textColor;
  
  // 可选:加个半透明底,更稳妥
  textElement.style.textShadow = brightness > 128 
    ? '0 0 2px rgba(0,0,0,0.3)' 
    : '0 0 2px rgba(255,255,255,0.3)';
};

// 跨域处理
img.crossOrigin = 'Anonymous';
if (img.complete) img.onload();

优化与坑点

1. 性能问题

图片很大时遍历所有像素会卡。采样降频:每隔 10 个像素取一次,速度提升 100 倍。

// 采样版
for (let x = 0; x < width; x += 10) {
  for (let y = 0; y < height; y += 10) {
    // 采样处理
  }
}

2. 跨域问题

如果图片是 CDN 上的,记得设置 crossOrigin,并且服务端要支持 CORS。

3. 图片加载

一定要在 onload 里处理,否则 Canvas 是空的。

4. 复杂背景怎么办

如果背景是渐变或复杂图案,纯黑白文字可能还不够。可以加一层半透明蒙层:

textElement.style.backgroundColor = brightness > 128 
  ? 'rgba(0,0,0,0.5)' 
  : 'rgba(255,255,255,0.5)';

最终效果

搞定之后,我拿给产品小哥演示:

  • 深色背景图 → 白色文字,带淡淡阴影
  • 浅色背景图 → 黑色文字,清晰可见
  • 花纹复杂的 → 自动取平均亮度,稳稳适配

产品小哥满意地点点头:“不错,安排上了。”

我也满意地点点头:又一个小需求,用技术优雅地解决了。

写在最后

这个方案的核心就三件事:画 Canvas、取像素、算亮度。代码量不大,但非常实用。

如果你也遇到类似的需求——无论是商品详情页、活动 banner,还是用户自定义背景——都可以用这套思路搞定。

最后送大家一句话:与其让产品经理追着你改颜色,不如让代码自己学会挑颜色。 你还遇到过什么奇葩需求 欢迎在评论区大声吐槽。

❌