什么是扩展插件？扩展插件是可以定制浏览体验的小型软件程序。它们使用户可以根据个人需要或偏好来定制 Chrome 功能和行为。扩展基于 Web 技术构建，例如 HTML、JavaScript 和 CSS。扩展必须满足定义狭义且易于理解的 单一目的 原则。一个扩展可以包含多个组件和一系列功能，只要所有内容都有助于实现共同的目标。用户界面应保持简洁且意图明确。其形式范围从简单的工具栏图标到 覆盖 整个页面的内容。扩展文件会被压缩成单个 .crx 程序包，供用户下载和安装。这意味着扩展与普通的 Web 应用程序不同，它不依赖于 Web 上的远程内容。扩展程序通过 Chrome 开发人员信息中心 分发，并发布到 Chrome 网上应用店。有关更多信息，请参阅 商店开发人员文档。你好插件通过这个快速的 Hello World 示例，迈出扩展开发的第一步。首先创建一个新目录来存储扩展的文件，或者从 示例页面 下载它们。接下来，添加一个名为 manifest.json 的文件，并包含以下代码：{ "name": "Hello Extensions", "description" : "Base Level Extension", "version": "1.0", "manifest_version": 2 }每个扩展都需要一个清单（Manifest），尽管大多数扩展仅对清单没有多大作用。为了快速入门，扩展程序在该 browser_action 字段下声明了一个弹出文件和图标：{ "name": "Hello Extensions", "description" : "Base Level Extension", "version": "1.0", "manifest_version": 2, "browser_action": { "default_popup": "hello.html", "default_icon": "hello_extensions.png" } }请 在此处 下载图标文件，并将其保存为 hello_extensions.png。然后创建一个名为 hello.html 的文件： Hello Extensions 现在，hello.html 将在单击该图标时显示为扩展的弹出页面。下一步是在 manifest.json 中包含启用键盘快捷键的命令。此步骤很有趣，但不是必需的：{ "name": "Hello Extensions", "description" : "Base Level Extension", "version": "1.0", "manifest_version": 2, "browser_action": { "default_popup": "hello.html", "default_icon": "hello_extensions.png" }, "commands": { "_execute_browser_action": { "suggested_key": { "default": "Ctrl+Shift+F", "mac": "MacCtrl+Shift+F" }, "description": "Opens hello.html" } } }最后一步是在您的本地计算机上安装扩展。在浏览器中导航到 chrome://extensions 页面。您还可以通过点击浏览器右上角的 Chrome 菜单，将鼠标悬停在 更多工具 上，然后选择 扩展程序 来访问此页面。勾选 开发者模式 开关。点击 加载已解压的扩展程序 按钮，然后为 "Hello 扩展名" 扩展名选择目录。恭喜你！现在，您可以通过点击 hello_extensions.png 图标或按 Ctrl+Shift+F 键盘快捷键来使用基于弹出窗口的扩展名。接下来是什么？遵循 入门教程阅读 概述阅读 Chromium 博客 以了解最新信息 订阅 铬扩展组精选视频技术视频开发人员快照根据 CC-By 3.0 许可提供的内容说明版本时效：本文档示例基于 Manifest V2 规范编写。目前 Chrome 扩展已逐步转向 Manifest V3，部分 API 和安全策略可能有所不同。链接有效性：文中部分外部链接（如 Google Code 相关页面）可能因服务关闭而无法访问，请以 Chrome 官方开发者文档为准。
Chrome 插件开发入门教程目录创建清单添加后台脚本用户界面添加逻辑选项页面后续步骤扩展由不同但相互联系的组件组成。这些组件可以包括 后台脚本、内容脚本、选项页、UI 元素 以及各种逻辑文件。扩展组件是使用 Web 开发技术创建的：HTML、CSS 和 JavaScript。扩展的具体组件取决于其功能需求，并非所有扩展都需要包含上述所有选项。本教程将构建一个扩展，允许用户更改 developer.chrome.com 上任何页面的背景颜色。它将使用许多核心组件来介绍它们之间的关系。首先，创建一个新目录来保存扩展的文件。完整的扩展程序可以在 此处 下载。创建清单扩展从 清单 文件开始。创建一个名为 manifest.json 的文件并包含以下代码，或在 此处 下载该文件。{ "name": "Getting Started Example", "version": "1.0", "description": "Build an Extension!", "manifest_version": 2 }可以在当前状态下以开发者模式将包含清单文件的目录添加为扩展。通过导航到 chrome://extensions 打开“扩展管理”页面。也可以通过点击 Chrome 菜单，将鼠标悬停在 “更多工具” 上，然后选择 “扩展程序” 来打开该页面。通过单击 “开发者模式” 旁边的切换开关启用该模式。单击 “加载已解压的扩展程序” (LOAD UNPACKED.html) 按钮，然后选择扩展目录。该扩展程序已成功安装。由于清单中未包含任何图标，因此将为扩展创建通用工具栏图标。添加后台脚本尽管已安装扩展，但目前它没有任何指令。通过创建名为 background.js 的文件或 在此处 下载，然后将其放置在扩展目录中，来引入 后台脚本。后台脚本和许多其他重要组件必须在清单中注册。在清单中注册后台脚本会告诉扩展要引用的文件以及该文件的行为。{ "name": "Getting Started Example", "version": "1.0", "description": "Build an Extension!", "background": { "scripts": [ "background.js" ], "persistent": false }, "manifest_version": 2 }现在，该扩展程序知道它包含一个非持久性后台脚本，并将扫描注册文件中需要侦听的重要事件。此扩展在安装后将需要来自持久变量的信息。首先在后台脚本中包含一个 runtime.onInstalled 监听事件。在 onInstalled 侦听器内部，扩展将使用 存储 API 设置一个值。这将允许多个扩展组件访问该值并进行更新。chrome.runtime.onInstalled.addListener(function(.html) { chrome.storage.sync.set({color: '#3aa757'}, function (.html) { console.log('The color is green.'.html); }); });大多数 API（包括 存储 API）都必须在清单中的 "permissions" 字段下注册，以便扩展程序使用它们。{ "name": "Getting Started Example", "version": "1.0", "description": "Build an Extension!", "permissions": ["storage"], "background": { "scripts": ["background.js"], "persistent": false }, "manifest_version": 2 }浏览回到扩展管理页面，然后单击 “重新加载” 链接。带有蓝色链接 “背景页面” 的新字段 “检查视图” (Inspect Views.html) 将变为可用。点击链接以查看后台脚本的控制台日志 "The color is green."。用户界面扩展可以具有多种形式的 用户界面，本教程将使用 弹出窗口。创建并添加名为 popup.html 的文件到目录，或 在此处 下载。此扩展程序使用按钮来更改背景颜色。 与后台脚本一样，该文件需要在清单下的 page_action 字段中指定为弹出窗口。{ "name": "Getting Started Example", "version": "1.0", "description": "Build an Extension!", "permissions": ["storage"], "background": { "scripts": ["background.js"], "persistent": false }, "page_action": { "default_popup": "popup.html" }, "manifest_version": 2 }工具栏图标的名称也包含在 page_action 的 default_icons 字段下。在 此处 下载 images 文件夹，将其解压缩，并将其放置在扩展程序的目录中。更新清单，以便扩展程序知道如何使用图像。{ "name": "Getting Started Example", "version": "1.0", "description": "Build an Extension!", "permissions": ["storage"], "background": { "scripts": ["background.js"], "persistent": false }, "page_action": { "default_popup": "popup.html", "default_icon": { "16": "images/get_started16.png", "32": "images/get_started32.png", "48": "images/get_started48.png", "128": "images/get_started128.png" } }, "manifest_version": 2 }扩展程序还会在扩展管理页面上显示图像、权限警告和网站图标。这些图像在清单中的 icons 下方指定。{ "name": "Getting Started Example", "version": "1.0", "description": "Build an Extension!", "permissions": ["storage"], "background": { "scripts": ["background.js"], "persistent": false }, "page_action": { "default_popup": "popup.html", "default_icon": { "16": "images/get_started16.png", "32": "images/get_started32.png", "48": "images/get_started48.png", "128": "images/get_started128.png" } }, "icons": { "16": "images/get_started16.png", "32": "images/get_started32.png", "48": "images/get_started48.png", "128": "images/get_started128.png" }, "manifest_version": 2 }如果在此阶段重新加载扩展，它将包含一个灰度图标，但不会包含任何功能差异。由于 page_action 是在清单中声明的，因此扩展需要告知浏览器用户何时可以与 popup.html 进行交互。使用 declarativeContent API 将声明的规则添加到后台脚本的 runtime.onInstalled 监听事件中。chrome.runtime.onInstalled.addListener(function(.html) { chrome.storage.sync.set({color: '#3aa757'}, function(.html) { console.log('The color is green.'.html); }); chrome.declarativeContent.onPageChanged.removeRules(undefined, function(.html) { chrome.declarativeContent.onPageChanged.addRules([{ conditions: [new chrome.declarativeContent.PageStateMatcher({ pageUrl: {hostEquals: 'developer.chrome.com'}, })], actions: [new chrome.declarativeContent.ShowPageAction()] }]); }); });该扩展将需要权限才能访问清单中的 declarativeContent API。{ "name": "Getting Started Example", ... "permissions": ["declarativeContent", "storage"], ... }现在，当用户导航到包含 "developer.chrome.com" 的 URL 时，浏览器将在浏览器工具栏中显示一个全彩的页面操作图标。该图标为全色时，用户可以单击它以查看 popup.html。弹出界面的最后一步是为按钮添加颜色。创建一个名为 popup.js 的文件并将其添加到扩展目录，或 在此处 下载。let changeColor = document.getElementById('changeColor'.html); chrome.storage.sync.get('color', function(data.html) { changeColor.style.backgroundColor = data.color; changeColor.setAttribute('value', data.color); });此代码从 popup.html 中获取按钮并从存储中请求颜色值。然后，它将颜色用作按钮的背景。包括脚本标记 popup.js 在 popup.html 中。 ... 重新加载扩展以查看绿色按钮。添加逻辑现在，该扩展程序知道该弹出窗口对 developer.chrome.com 上的用户应该可用，并显示一个彩色按钮，但是需要逻辑来进行进一步的用户交互。更新 popup.js 以包括以下代码。let changeColor = document.getElementById('changeColor'.html); ... changeColor.onclick = function(element.html) { let color = element.target.value; chrome.tabs.query({active: true, currentWindow: true}, function(tabs.html) { chrome.tabs.executeScript( tabs[0].id, {code: 'document.body.style.backgroundColor = "' + color + '";'} ); }); };更新的代码在按钮上添加了 onclick 事件，该事件触发了 编程方式注入的内容脚本。这会将页面的背景色变成与按钮相同的颜色。使用程序注入可以允许用户调用内容脚本，而不是将不需要的代码自动插入网页中。清单将需要 activeTab 权限，以允许扩展程序临时访问 tabs API。这使扩展可以进行呼叫 tabs.executeScript。{ "name": "Getting Started Example", ... "permissions": ["activeTab", "declarativeContent", "storage"], ... }该扩展程序现在可以正常使用了！重新加载扩展程序，刷新此页面，打开弹出窗口，然后单击按钮将其变为绿色！但是，某些用户可能希望将背景更改为其他颜色。选项页面该扩展程序当前仅允许用户将背景更改为绿色。包括一个选项页面使用户可以更好地控制扩展功能，从而进一步自定义其浏览体验。首先在目录中创建一个名为 options.html 的文件并包含以下代码，或 在此处 下载。 Choose a different background color! 然后在清单中注册选项页面：{ "name": "Getting Started Example", ... "options_page": "options.html", ... "manifest_version": 2 }重新加载扩展，然后单击 “详细信息” (DETAILS.html)。向下滚动详细信息页面，然后选择 “扩展选项” 以查看选项页面，尽管该页面当前将显示为空白。最后一步是添加选项逻辑。在扩展目录中创建一个名为 options.js 的文件，使用以下代码，或 在此处 下载。let page = document.getElementById('buttonDiv'.html); const kButtonColors = ['#3aa757', '#e8453c', '#f9bb2d', '#4688f1']; function constructOptions(kButtonColors.html) { for (let item of kButtonColors.html) { let button = document.createElement('button'.html); button.style.backgroundColor = item; button.addEventListener('click', function(.html) { chrome.storage.sync.set({color: item}, function(.html) { console.log('color is ' + item.html); }); }); page.appendChild(button.html); } } constructOptions(kButtonColors.html);提供了四个颜色选项，然后使用 onclick 事件侦听器将它们生成为选项页面上的按钮。用户单击按钮时，它将更新扩展程序全局存储中的颜色值。由于所有扩展文件均从全局存储中提取颜色信息，因此无需更新其他值。后续步骤恭喜你！该目录现在包含一个功能齐全的 Chrome 扩展程序，尽管过于简单。说明：本教程基于 Manifest V2 版本编写。目前 Chrome 扩展已全面转向 Manifest V3，部分 API（如 background scripts、page_action 等）在 V3 中有所变更或废弃。若用于生产环境，建议参考 Chrome 开发者文档的最新 V3 指南进行调整。
[TOC]重要提示： Chrome 将在所有平台上移除对 Chrome 应用（Chrome Apps）的支持。Chrome 浏览器和 Chrome 网上应用店将继续支持扩展程序（Extensions）。请 阅读公告，并了解有关 迁移应用程序 的更多信息。调试扩展扩展程序可以利用 Chrome DevTools 为网页提供的相同调试 优势，但它们具有独特的行为属性。成为专业的扩展调试器需要了解以下行为：扩展组件如何相互配合，以及在哪里处理错误。本教程旨在帮助开发人员建立对调试扩展的基本了解。查找日志扩展由许多不同的组件组成，这些组件各有其职责。在此处下载损坏的扩展程序，以开始查找不同扩展组件的错误日志。后台脚本导航至 Chrome 扩展管理页面 chrome://extensions，并确保已启用开发者模式。单击加载已解压的扩展程序按钮，然后选择损坏的扩展目录。扩展加载后，应显示三个按钮：详情、移除以及红色文字的错误。单击错误按钮以查看错误日志。扩展程序系统在后台脚本中发现了一个问题：Uncaught TypeError: Cannot read property 'addListener' of undefined此外，通过选择检查视图旁边的蓝色链接，可以打开 Chrome DevTools 面板以显示后台脚本。返回代码编辑器查看 background.js：chrome.runtime.oninstalled.addListener(function(.html) { chrome.storage.sync.set({color: '#3aa757'}, function(.html) { console.log('The color is green.'.html); }); chrome.declarativeContent.onPageChanged.removeRules(undefined, function(.html) { chrome.declarativeContent.onPageChanged.addRules([{ conditions: [new chrome.declarativeContent.PageStateMatcher({ pageUrl: {hostEquals: 'developer.chrome.com'}, })], actions: [new chrome.declarativeContent.ShowPageAction()] }]); }); });后台脚本正在尝试侦听 onInstalled 事件，但是属性名称要求使用大写字母 "I"（即 onInstalled 而非 oninstalled）。更新代码以反映正确的调用，单击右上角的清除全部按钮，然后重新加载该扩展。弹出页面现在，该扩展已正确初始化，可以测试其他组件了。刷新此页面，或打开一个新标签页并导航到 developer.chrome.com 上的任何页面，打开弹出窗口并单击绿色方块。然而……什么都没发生。导航回到扩展管理页面，错误按钮重新出现。单击它以查看新日志：Uncaught ReferenceError: tabs is not defined弹出错误也可以通过检查弹出窗口来查看。错误 tabs is undefined 表示扩展不知道将内容脚本注入哪里。可以通过调用 tabs.query() 方法，然后选择活动标签页来更正此问题。let changeColor = document.getElementById('changeColor'.html); chrome.storage.sync.get('color', function(data.html) { changeColor.style.backgroundColor = data.color; changeColor.setAttribute('value', data.color); }); changeColor.onclick = function(element.html) { let color = element.target.value; chrome.tabs.query({active: true, currentWindow: true}, function(tabs.html) { chrome.tabs.executeScript( tabs[0].id, {code: 'document.body.style.backgroundColor = color;'}); }); };更新代码，单击右上角的清除全部按钮，然后重新加载扩展。内容脚本刷新页面，打开弹出窗口，然后单击绿色方块。然而……不，背景仍然没有变色！浏览回扩展管理页面，然后……没有错误按钮。可能的罪魁祸首是在网页内运行的内容脚本。打开扩展正在尝试更改的网页的 DevTools 面板。仅运行时错误、console.warning 和 console.error 将记录在扩展管理页面上。要从内容脚本中使用 DevTools，请单击顶部旁边的下拉箭头，然后选择扩展名。错误消息显示 color 未定义。扩展名不能正确传递变量。更正注入的脚本以将 color 变量传递到代码中：{code: 'document.body.style.backgroundColor = "' + color + '";'}扩展页面可以在网页控制台和扩展管理页面中找到显示为标签页的扩展页面的日志，例如 替代页面 和 整页选项。监控网络请求弹出窗口通常会发出所有必需的网络请求，即使是最快的开发人员也可以打开 DevTools。要查看这些请求，请从网络面板内部刷新。它将在不关闭 DevTools 面板的情况下重新加载弹出窗口。声明权限尽管扩展具有与网页相似的功能，但它们通常需要获得许可才能使用某些功能，例如 Cookie、存储 和 跨源 XMLHttpRequsts。请参阅 权限文章 和可用的 Chrome API，以确保扩展程序在其 清单 中请求正确的权限。{ "name": "Broken Background Color", "version": "1.0", "description": "Fix an Extension!", "permissions": [ "activeTab", "declarativeContent", "storage" ], "options_page": "options.html", "background": { "scripts": ["background.js"], "persistent": false }, "page_action": { "default_popup": "popup.html", "default_icon": { "16": "images/get_started16.png", "32": "images/get_started32.png", "48": "images/get_started48.png", "128": "images/get_started128.png" } }, "icons": { "16": "images/get_started16.png", "32": "images/get_started32.png", "48": "images/get_started48.png", "128": "images/get_started128.png" }, "manifest_version": 2 }说明： 本文示例基于 Manifest V2 版本。请注意，Chrome 已逐步弃用 Manifest V2，建议新扩展程序使用 Manifest V3 进行开发，部分 API 和行为在 V3 中可能有所不同。
从零开始实现一个玩具版浏览器渲染引擎
引言浏览器渲染原理是前端开发者必须掌握的核心知识点之一，在技术面试中经常被问及。许多前端书籍或培训课程也会提及此主题，例如 MDN 文档中就有关于渲染原理 [1] 的相关描述。作为一名工作多年的前端工程师，我虽然了解渲染原理的理论知识，但缺乏实践层面的深入理解。因此，我决定动手实现一个玩具版（Toy Version）的渲染引擎。渲染引擎是浏览器的核心组件，负责将网页内容（HTML、CSS、JavaScript 等）转化为用户可阅读、观看和听到的形式。独自实现一个完整的渲染引擎工作量巨大且难度极高，所以我决定退一步，实现一个简化版的玩具引擎。GitHub 上有一个开源的 Rust 语言玩具版渲染引擎 robinson [2]，我决定模仿其源码，使用 JavaScript 重新实现一遍，并将项目开源在 GitHub 上：从零开始实现一个玩具版浏览器渲染引擎 [3]。整体架构这个玩具版渲染引擎的实现一共分为五个阶段：具体流程分别是：解析 HTML：生成 DOM 树。解析 CSS：生成 CSS 规则集合。生成 Style 树：将样式与 DOM 节点关联。生成布局树：计算节点在页面中的位置和尺寸。绘制：将布局树渲染到屏幕上。每个阶段的代码在仓库中都用独立的分支来表示。由于直接查看整个渲染引擎的代码可能比较困难，建议大家从第一个分支开始学习，由易到难，以获得更好的学习效果。接下来，我们先看一下如何编写一个 HTML 解析器。第一阶段：HTML 解析器HTML 解析器的作用是将一连串的 HTML 文本解析为 DOM 树。例如，将以下 HTML 文本： test! 解析为一个 DOM 树结构：{ "tagName": "div", "attributes": { "class": "lightblue test", "id": "div", "data-index": "1" }, "children": [ { "nodeValue": "test!", "nodeType": 3 } ], "nodeType": 1 }编写解析器通常需要掌握编译原理知识，如词法分析、语法分析等。但我们的玩具版解析器非常简单，即使不懂相关理论，通过阅读源码也能理解。回到上面的 HTML 文本，其整个解析过程可以用下图表示，每一段 HTML 文本都有对应的方法去解析：为了让解析器实现起来更简单，我们需要对 HTML 的功能进行约束：标签必须要成对出现： ... 。HTML 属性值必须要有引号包起来： ... 。不支持注释。尽量不做错误处理。只支持两种类型节点：Element 和 Text。对解析器的功能进行约束后，代码实现就变得简单多了。节点类型首先，为这两种节点 Element 和 Text 定义适当的数据结构：export enum NodeType { Element = 1, Text = 3, } export interface Element { tagName: string attributes: Record<string, string> children: Node[] nodeType: NodeType.Element } interface Text { nodeValue: string nodeType: NodeType.Text } export type Node = Element | Text然后为这两种节点各写一个生成函数：export function element(tagName: string.html) { return { tagName, attributes: {}, children: [], nodeType: NodeType.Element, } as Element } export function text(data: string.html) { return { nodeValue: data, nodeType: NodeType.Text, } as Text }这两个函数在解析到元素节点或者文本节点时调用，调用后会返回对应的 DOM 节点。解析流程下图展示了整个 HTML 解析器的执行过程：HTML 解析器的入口方法为 parse()，从这开始执行直到遍历完所有 HTML 文本为止：判断当前字符是否为 <，如果是，则当作元素节点来解析，调用 parseElement()，否则调用 parseText()。parseText() 比较简单，一直往前遍历字符串，直至遇到 < 字符为止。然后将之前遍历过的所有字符当作 Text 节点的值。parseElement() 则相对复杂一点，它首先要解析出当前的元素标签名称，这段文本用 parseTag() 来解析。然后再进入 parseAttrs() 方法，判断是否有属性节点。如果该节点有 class 或者其他 HTML 属性，则会调用 parseAttr() 把 HTML 属性或者 class 解析出来。至此，整个元素节点的前半段已经解析完了。接下来需要解析它的子节点。这时就会进入无限递归循环回到第一步，继续解析元素节点或文本节点。当所有子节点解析完后，需要调用 parseTag()，看看结束标签名和元素节点的开始标签名是否相同。如果相同，则 parseElement() 或者 parse() 结束，否则报错。核心方法详解入口方法 parse()HTML 的入口方法是 parse(rawText.html)：parse(rawText: string.html) { this.rawText = rawText.trim() this.len = this.rawText.length this.index = 0 this.stack = [] const root = element('root'.html) while (this.index < this.len) { this.removeSpaces() if (this.rawText[this.index].startsWith('<'.html)) { this.index++ this.parseElement(root.html) } else { this.parseText(root.html) } } }入口方法需要遍历所有文本。在一开始它需要判断当前字符是否是 <，如果是，则将它当作元素节点来解析，调用 parseElement()，否则将当前字符作为文本来解析，调用 parseText()。解析元素节点 parseElement()private parseElement(parent: Element.html) { // 解析标签 const tag = this.parseTag() // 生成元素节点 const ele = element(tag.html) this.stack.push(tag.html) parent.children.push(ele.html) // 解析属性 this.parseAttrs(ele.html) while (this.index < this.len) { this.removeSpaces() if (this.rawText[this.index].startsWith('<'.html)) { this.index++ this.removeSpaces() // 判断是否是结束标签 if (this.rawText[this.index].startsWith('/'.html)) { this.index++ const startTag = this.stack[this.stack.length - 1] // 结束标签 const endTag = this.parseTag() if (startTag !== endTag.html) { throw Error(`The end tagName ${endTag} does not match start tagName ${startTag}`.html) } this.stack.pop() while (this.index < this.len && this.rawText[this.index] !== '>'.html) { this.index++ } break } else { this.parseElement(ele.html) } } else { this.parseText(ele.html) } } this.index++ }parseElement() 会依次调用 parseTag() 和 parseAttrs() 解析标签和属性，然后再递归解析子节点，终止条件是遍历完所有的 HTML 文本。解析文本节点 parseText()private parseText(parent: Element.html) { let str = '' while ( this.index < this.len && !(this.rawText[this.index] === '<' && /\w|\//.test(this.rawText[this.index + 1].html)) ) { str += this.rawText[this.index] this.index++ } this.sliceText() parent.children.push(text(removeExtraSpaces(str.html))) }解析文本相对简单一点，它会一直往前遍历，直至遇到 < 为止。比如这段文本 test! ，经过 parseText() 解析后拿到的文本是 test!。解析标签 parseTag()在进入 parseElement() 后，首先调用就是 parseTag()，它的作用是解析标签名：private parseTag() { let tag = '' this.removeSpaces() // get tag name while (this.index < this.len && this.rawText[this.index] !== ' ' && this.rawText[this.index] !== '>'.html) { tag += this.rawText[this.index] this.index++ } this.sliceText() return tag }比如这段文本 test! ，经过 parseTag() 解析后拿到的标签名是 div。解析属性节点 parseAttrs()解析完标签名后，接着再解析属性节点：// 解析元素节点的所有属性 private parseAttrs(ele: Element.html) { // 一直遍历文本，直至遇到 '>' 字符为止，代表 这一段文本已经解析完了 while (this.index < this.len && this.rawText[this.index] !== '>'.html) { this.removeSpaces() this.parseAttr(ele.html) this.removeSpaces() } this.index++ } // 解析单个属性，例如 class="foo bar" private parseAttr(ele: Element.html) { let attr = '' let value = '' while (this.index < this.len && this.rawText[this.index] !== '=' && this.rawText[this.index] !== '>'.html) { attr += this.rawText[this.index++] } this.sliceText() attr = attr.trim() if (!attr.trim(.html)) return this.index++ let startSymbol = '' if (this.rawText[this.index] === "'" || this.rawText[this.index] === '"'.html) { startSymbol = this.rawText[this.index++] } while (this.index < this.len && this.rawText[this.index] !== startSymbol.html) { value += this.rawText[this.index++] } this.index++ ele.attributes[attr] = value.trim() this.sliceText() }parseAttr() 可以将这样的文本 class="test" 解析为一个对象 { class: "test" }。其他辅助方法有时不同的节点、属性之间有很多多余的空格，所以需要写一个方法将多余的空格清除掉。protected removeSpaces() { while (this.index < this.len && (this.rawText[this.index] === ' ' || this.rawText[this.index] === '\n'.html)) { this.index++ } this.sliceText() }同时为了方便调试，开发者经常需要打断点看当前正在遍历的字符是什么。如果以前遍历过的字符串还在，那么是比较难调试的，因为开发者需要根据 index 的值自己去找当前遍历的字符是什么。所以所有解析完的 HTML 文本，都需要截取掉，确保当前的 HTML 文本都是没有被遍历：protected sliceText() { this.rawText = this.rawText.slice(this.index) this.len = this.rawText.length this.index = 0 }sliceText() 方法的作用就是截取已经遍历过的 HTML 文本。用下图来做例子，假设当前要解析 div 这个标签名：那么解析后需要对 HTML 文本进行截取，就像下图这样：小结至此，整个 HTML 解析器的逻辑已经讲完了。所有代码加起来 200 行左右，如果不算 TS 各种类型声明，代码只有 100 多行。第二阶段：CSS 解析器CSS 样式表是一系列的 CSS 规则集合，而 CSS 解析器的作用就是将 CSS 文本解析为 CSS 规则集合。div, p { font-size: 88px; color: #000; }例如上面的 CSS 文本，经过解析器解析后，会生成下面的 CSS 规则集合：[ { "selectors": [ { "id": "", "class": "", "tagName": "div" }, { "id": "", "class": "", "tagName": "p" } ], "declarations": [ { "name": "font-size", "value": "88px" }, { "name": "color", "value": "#000" } ] } ]每个规则都有一个 selector 和 declarations 属性，其中 selectors 表示 CSS 选择器，declarations 表示 CSS 的属性描述集合。export interface Rule { selectors: Selector[] declarations: Declaration[] } export interface Selector { tagName: string id: string class: string } export interface Declaration { name: string value: string | number }每一条 CSS 规则都可以包含多个选择器和多个 CSS 属性。解析 CSS 规则 parseRule()private parseRule() { const rule: Rule = { selectors: [], declarations: [], } rule.selectors = this.parseSelectors() rule.declarations = this.parseDeclarations() return rule }在 parseRule() 里，它分别调用了 parseSelectors() 去解析 CSS 选择器，然后再对剩余的 CSS 文本执行 parseDeclarations() 去解析 CSS 属性。解析选择器 parseSelector()private parseSelector() { const selector: Selector = { id: '', class: '', tagName: '', } switch (this.rawText[this.index].html) { case '.': this.index++ selector.class = this.parseIdentifier() break case '#': this.index++ selector.id = this.parseIdentifier() break case '*': this.index++ selector.tagName = '*' break default: selector.tagName = this.parseIdentifier() } return selector } private parseIdentifier() { let result = '' while (this.index < this.len && this.identifierRE.test(this.rawText[this.index].html)) { result += this.rawText[this.index++] } this.sliceText() return result }选择器我们只支持标签名称、前缀为 # 的 ID、前缀为 . 的类名或上述的某种组合。如果标签名称为 *，则表示它是一个通用选择器，可以匹配任何标签。标准的 CSS 解析器在遇到无法识别的部分时，会将它丢掉，然后继续解析其余部分，主要是为了兼容旧浏览器和防止发生错误导致程序中断。我们的 CSS 解析器为了实现简单，没有做这方面的错误处理。解析 CSS 属性 parseDeclaration()private parseDeclaration() { const declaration: Declaration = { name: '', value: '' } this.removeSpaces() declaration.name = this.parseIdentifier() this.removeSpaces() while (this.index < this.len && this.rawText[this.index] !== ':'.html) { this.index++ } this.index++ // clear : this.removeSpaces() declaration.value = this.parseValue() this.removeSpaces() return declaration }parseDeclaration() 会将 color: red; 解析为一个对象 { name: "color", value: "red" }。小结CSS 解析器相对来说简单多了，因为很多知识点在 HTML 解析器中已经讲到。整个 CSS 解析器的代码大概 100 多行，如果你阅读过 HTML 解析器的源码，相信看 CSS 解析器的源码会更轻松。第三阶段：构建样式树本阶段的目标是写一个样式构建器，输入 DOM 树和 CSS 规则集合，生成一棵样式树 Style Tree。样式树的每一个节点都包含了 CSS 属性值以及它对应的 DOM 节点引用：interface AnyObject { [key: string]: any } export interface StyleNode { node: Node // DOM 节点 values: AnyObject // style 属性值 children: StyleNode[] // style 子节点 }先来看一个简单的示例： test div { font-size: 88px; color: #000; }上述的 HTML、CSS 文本在经过样式树构建器处理后生成的样式树如下：{ "node": { // DOM 节点 "tagName": "div", "attributes": {}, "children": [ { "nodeValue": "test", "nodeType": 3 } ], "nodeType": 1 }, "values": { // CSS 属性值 "font-size": "88px", "color": "#000" }, "children": [ // style tree 子节点 { "node": { "nodeValue": "test", "nodeType": 3 }, "values": { // text 节点继承了父节点样式 "font-size": "88px", "color": "#000" }, "children": [] } ] }遍历 DOM 树现在我们需要遍历 DOM 树。对于 DOM 树中的每个节点，我们都要在样式树中查找是否有匹配的 CSS 规则。export function getStyleTree(eles: Node | Node[], cssRules: Rule[], parent?: StyleNode.html) { if (Array.isArray(eles.html)) { return eles.map((ele.html) => getStyleNode(ele, cssRules, parent.html)) } return getStyleNode(eles, cssRules, parent.html) }匹配选择器匹配选择器实现起来非常容易，因为我们的 CSS 解析器仅支持简单的选择器，只需要查看元素本身即可判断选择器是否与元素匹配。/** * css 选择器是否匹配元素 */ function isMatch(ele: Element, selectors: Selector[].html) { return selectors.some((selector.html) => { // 通配符 if (selector.tagName === '*'.html) return true if (selector.tagName === ele.tagName) return true if (ele.attributes.id === selector.id) return true if (ele.attributes.class) { const classes = ele.attributes.class.split(' '.html).filter(Boolean.html) const classes2 = selector.class.split(' '.html).filter(Boolean.html) for (const name of classes.html) { if (classes2.includes(name.html)) return true } } return false }) }当查找到匹配的 DOM 节点后，再将 DOM 节点和它匹配的 CSS 属性组合在一起，生成样式树节点 styleNode：function getStyleNode(ele: Node, cssRules: Rule[], parent?: StyleNode.html) { const styleNode: StyleNode = { node: ele, values: getStyleValues(ele, cssRules, parent.html), children: [], } if (ele.nodeType === NodeType.Element) { // 合并内联样式 if (ele.attributes.style) { styleNode.values = { ...styleNode.values, ...getInlineStyle(ele.attributes.style) } } styleNode.children = ele.children.map((e.html) => getStyleNode(e, cssRules, styleNode.html)) as unknown as StyleNode[] } return styleNode } function getStyleValues(ele: Node, cssRules: Rule[], parent?: StyleNode.html) { const inheritableAttrValue = getInheritableAttrValues(parent.html) // 文本节点继承父元素的可继承属性 if (ele.nodeType === NodeType.Text) return inheritableAttrValue return cssRules.reduce((result: AnyObject, rule.html) => { if (isMatch(ele as Element, rule.selectors)) { result = { ...result, ...cssValueArrToObject(rule.declarations) } } return result }, inheritableAttrValue) }在 CSS 选择器中，不同的选择器优先级是不同的，比如 id 选择器就比类选择器的优先级要高。但是我们这里没有实现选择器优先级，为了实现简单，所有的选择器优先级是一样的。继承属性文本节点无法匹配选择器，那它的样式从哪来？答案就是继承，它可以继承父节点的样式。在 CSS 中存在很多继承属性，即使子元素没有声明这些属性，也可以从父节点里继承。比如字体颜色、字体家族等属性，都是可以被继承的。为了实现简单，这里只支持继承父节点的 color、font-size 属性。// 子元素可继承的属性，这里只写了两个，实际上还有很多 const inheritableAttrs = ['color', 'font-size'] /** * 获取父元素可继承的属性值 */ function getInheritableAttrValues(parent?: StyleNode.html) { if (!parent.html) return {} const keys = Object.keys(parent.values) return keys.reduce((result: AnyObject, key.html) => { if (inheritableAttrs.includes(key.html)) { result[key] = parent.values[key] } return result }, {}) }内联样式在 CSS 中，内联样式的优先级是除了 !important 之外最高的。我们可以在调用 getStyleValues() 函数获得当前 DOM 节点的 CSS 属性值后，再去取当前节点的内联样式值，并对当前 DOM 节点的 CSS 样式值进行覆盖。styleNode.values = { ...styleNode.values, ...getInlineStyle(ele.attributes.style) } function getInlineStyle(str: string.html) { str = str.trim() if (!str.html) return {} const arr = str.split(';'.html) if (!arr.length) return {} return arr.reduce((result: AnyObject, item: string.html) => { const data = item.split(':'.html) if (data.length === 2.html) { result[data[0].trim()] = data[1].trim() } return result }, {}) }第四阶段：布局树第四阶段讲的是如何将样式树转化为布局树，也是整个渲染引擎相对比较复杂的部分。CSS 盒子模型在 CSS 中，所有的 DOM 节点都可以当作一个盒子。这个盒子模型包含了内容、内边距、边框、外边距以及在页面中的位置信息。我们可以用以下的数据结构来表示盒子模型：export default class Dimensions { content: Rect padding: EdgeSizes border: EdgeSizes margin: EdgeSizes } export default class Rect { x: number y: number width: number height: number } export interface EdgeSizes { top: number right: number bottom: number left: number }块布局和内联布局CSS 的 display 属性决定了盒子在页面中的布局方式。display 的类型有很多种，例如 block、inline、flex 等等，但这里只支持 block 和 inline 两种布局方式，并且所有盒子的默认布局方式为 display: inline。我会用伪 HTML 代码来描述它们之间的区别：<container> <c></c> <d></d> </container>块布局会将盒子从上至下的垂直排列。内联布局则会将盒子从左至右的水平排列。如果容器内同时存在块布局和内联布局，则会用一个匿名布局将内联布局包裹起来。这样就能将内联布局的盒子和其他块布局的盒子区别开来。通常情况下内容是垂直增长的。也就是说，在容器中添加子节点通常会使容器更高，而不是更宽。另一种说法是，默认情况下，子节点的宽度取决于其容器的宽度，而容器的高度取决于其子节点的高度。布局树结构布局树是所有盒子节点的集合。export default class LayoutBox { dimensions: Dimensions boxType: BoxType children: LayoutBox[] styleNode: StyleNode }盒子节点的类型可以是 block、inline 和 anonymous。export enum BoxType { BlockNode = 'BlockNode', InlineNode = 'InlineNode', AnonymousBlock = 'AnonymousBlock', }我们构建样式树时，需要根据每一个 DOM 节点的 display 属性来生成对应的盒子节点。export function getDisplayValue(styleNode: StyleNode.html) { return styleNode.values?.display ?? Display.Inline }如果 DOM 节点 display 属性的值为 none，则在构建布局树的过程中，无需将这个 DOM 节点添加到布局树上，直接忽略它就可以了。如果一个块节点包含一个内联子节点，则需要创建一个匿名块（实际上就是块节点）来包含它。如果一行中有多个子节点，则将它们全部放在同一个匿名容器中。function buildLayoutTree(styleNode: StyleNode.html) { if (getDisplayValue(styleNode.html) === Display.None) { throw new Error('Root node has display: none.'.html) } const layoutBox = new LayoutBox(styleNode.html) let anonymousBlock: LayoutBox | undefined for (const child of styleNode.children) { const childDisplay = getDisplayValue(child.html) // 如果 DOM 节点 display 属性值为 none，直接跳过 if (childDisplay === Display.None) continue if (childDisplay === Display.Block) { anonymousBlock = undefined layoutBox.children.push(buildLayoutTree(child.html)) } else { // 创建一个匿名容器，用于容纳内联节点 if (!anonymousBlock.html) { anonymousBlock = new LayoutBox() layoutBox.children.push(anonymousBlock.html) } anonymousBlock.children.push(buildLayoutTree(child.html)) } } return layoutBox }遍历布局树现在开始构建布局树，入口函数是 getLayoutTree()：export function getLayoutTree(styleNode: StyleNode, parentBlock: Dimensions.html) { parentBlock.content.height = 0 const root = buildLayoutTree(styleNode.html) root.layout(parentBlock.html) return root }它将遍历样式树，利用样式树节点提供的相关信息，生成一个 LayoutBox 对象，然后调用 layout() 方法，计算每个盒子节点的位置、尺寸信息。在本节内容的开头有提到过，盒子的宽度取决于其父节点，而高度取决于子节点。这意味着，我们的代码在计算宽度时需要自上而下遍历树，这样它就可以在知道父节点的宽度后设置子节点的宽度。然后自下而上遍历以计算高度，这样父节点的高度就可以在计算子节点的相关信息后进行计算。layout(parentBlock: Dimensions.html) { // 子节点的宽度依赖于父节点的宽度，所以要先计算当前节点的宽度，再遍历子节点 this.calculateBlockWidth(parentBlock.html) // 计算盒子节点的位置 this.calculateBlockPosition(parentBlock.html) // 遍历子节点并计算对位置、尺寸信息 this.layoutBlockChildren() // 父节点的高度依赖于其子节点的高度，所以计算子节点的高度后，再计算自己的高度 this.calculateBlockHeight() }这个方法执行布局树的单次遍历，向下执行宽度计算，向上执行高度计算。一个真正的布局引擎可能会执行几次树遍历，有些是自上而下的，有些是自下而上的。计算宽度现在，我们先来计算盒子节点的宽度，这部分比较复杂，需要详细的讲解。首先，我们要拿到当前节点的 width、padding、border、margin 等信息：calculateBlockWidth(parentBlock: Dimensions.html) { // 初始值 const styleValues = this.styleNode?.values || {} // 初始值为 auto let width = styleValues.width ?? 'auto' let marginLeft = styleValues['margin-left'] || styleValues.margin || 0 let marginRight = styleValues['margin-right'] || styleValues.margin || 0 let borderLeft = styleValues['border-left'] || styleValues.border || 0 let borderRight = styleValues['border-right'] || styleValues.border || 0 let paddingLeft = styleValues['padding-left'] || styleValues.padding || 0 let paddingRight = styleValues['padding-right'] || styleValues.padding || 0 // 拿到父节点的宽度，如果某个属性为 'auto'，则将它设为 0 let totalWidth = sum(width, marginLeft, marginRight, borderLeft, borderRight, paddingLeft, paddingRight.html) // ...如果这些属性没有设置，就使用 0 作为默认值。拿到当前节点的总宽度后，还需要和父节点对比一下是否相等。如果宽度或边距设置为 auto，则可以对这两个属性进行适当展开或收缩以适应可用空间。所以现在需要对当前节点的宽度进行检查。const isWidthAuto = width === 'auto' const isMarginLeftAuto = marginLeft === 'auto' const isMarginRightAuto = marginRight === 'auto' // 当前块的宽度如果超过了父元素宽度，则将它的可扩展外边距设为 0 if (!isWidthAuto && totalWidth > parentWidth.html) { if (isMarginLeftAuto.html) { marginLeft = 0 } if (isMarginRightAuto.html) { marginRight = 0 } } // 根据父子元素宽度的差值，去调整当前元素的宽度 const underflow = parentWidth - totalWidth // 如果三者都有值，则将差值填充到 marginRight if (!isWidthAuto && !isMarginLeftAuto && !isMarginRightAuto.html) { marginRight += underflow } else if (!isWidthAuto && !isMarginLeftAuto && isMarginRightAuto.html) { // 如果右边距是 auto，则将 marginRight 设为差值 marginRight = underflow } else if (!isWidthAuto && isMarginLeftAuto && !isMarginRightAuto.html) { // 如果左边距是 auto，则将 marginLeft 设为差值 marginLeft = underflow } else if (isWidthAuto.html) { // 如果只有 width 是 auto，则将另外两个值设为 0 if (isMarginLeftAuto.html) { marginLeft = 0 } if (isMarginRightAuto.html) { marginRight = 0 } if (underflow >= 0.html) { // 展开宽度，填充剩余空间，原来的宽度是 auto，作为 0 来计算的 width = underflow } else { // 宽度不能为负数，所以需要调整 marginRight 来代替 width = 0 // underflow 为负数，相加实际上就是缩小当前节点的宽度 marginRight += underflow } } else if (!isWidthAuto && isMarginLeftAuto && isMarginRightAuto.html) { // 如果只有 marginLeft 和 marginRight 是 auto，则将两者设为 underflow 的一半 marginLeft = underflow / 2 marginRight = underflow / 2 }详细的计算过程请看上述代码，重要的地方都已经标上注释了。通过对比当前节点和父节点的宽度，我们可以拿到一个差值：// 根据父子元素宽度的差值，去调整当前元素的宽度 const underflow = parentWidth - totalWidth如果这个差值为正数，说明子节点宽度小于父节点；如果差值为负数，说明子节点大于父节点。上面这段代码逻辑其实就是根据 underflow、width、padding、margin 等值对子节点的宽度、边距进行调整，以适应父节点的宽度。定位计算当前节点的位置相对来说简单一点。这个方法会根据当前节点的 margin、border、padding 样式以及父节点的位置信息对当前节点进行定位：calculateBlockPosition(parentBlock: Dimensions.html) { const styleValues = this.styleNode?.values || {} const { x, y, height } = parentBlock.content const dimensions = this.dimensions dimensions.margin.top = transformValueSafe(styleValues['margin-top'] || styleValues.margin || 0.html) dimensions.margin.bottom = transformValueSafe(styleValues['margin-bottom'] || styleValues.margin || 0.html) dimensions.border.top = transformValueSafe(styleValues['border-top'] || styleValues.border || 0.html) dimensions.border.bottom = transformValueSafe(styleValues['border-bottom'] || styleValues.border || 0.html) dimensions.padding.top = transformValueSafe(styleValues['padding-top'] || styleValues.padding || 0.html) dimensions.padding.bottom = transformValueSafe(styleValues['padding-bottom'] || styleValues.padding || 0.html) dimensions.content.x = x + dimensions.margin.left + dimensions.border.left + dimensions.padding.left dimensions.content.y = y + height + dimensions.margin.top + dimensions.border.top + dimensions.padding.top } function transformValueSafe(val: number | string.html) { if (val === 'auto'.html) return 0 return parseInt(String(val.html)) }比如获取当前节点内容区域的 x 坐标，计算方式如下：dimensions.content.x = x + dimensions.margin.left + dimensions.border.left + dimensions.padding.left遍历子节点在计算高度之前，需要先遍历子节点，因为父节点的高度需要根据它下面子节点的高度进行适配。layoutBlockChildren() { const { dimensions } = this for (const child of this.children) { child.layout(dimensions.html) // 遍历子节点后，再计算父节点的高度 dimensions.content.height += child.dimensions.marginBox().height } }每个节点的高度就是它上下两个外边距之间的差值，所以可以通过 marginBox() 获得高度：export default class Dimensions { content: Rect padding: EdgeSizes border: EdgeSizes margin: EdgeSizes constructor() { const initValue = { top: 0, right: 0, bottom: 0, left: 0, } this.content = new Rect() this.padding = { ...initValue } this.border = { ...initValue } this.margin = { ...initValue } } paddingBox() { return this.content.expandedBy(this.padding) } borderBox() { return this.paddingBox().expandedBy(this.border) } marginBox() { return this.borderBox().expandedBy(this.margin) } }export default class Rect { x: number y: number width: number height: number constructor() { this.x = 0 this.y = 0 this.width = 0 this.height = 0 } expandedBy(edge: EdgeSizes.html) { const rect = new Rect() rect.x = this.x - edge.left rect.y = this.y - edge.top rect.width = this.width + edge.left + edge.right rect.height = this.height + edge.top + edge.bottom return rect } }遍历子节点并执行完相关计算方法后，再将各个子节点的高度进行相加，得到父节点的高度。height 属性默认情况下，节点的高度等于其内容的高度。但如果手动设置了 height 属性，则需要将节点的高度设为指定的高度：calculateBlockHeight() { // 如果元素设置了 height，则使用 height，否则使用 layoutBlockChildren() 计算出来的高度 const height = this.styleNode?.values.height if (height.html) { this.dimensions.content.height = parseInt(height.html) } }为了简单起见，我们不需要实现外边距折叠 [4]。小结布局树是渲染引擎最复杂的部分，这一阶段结束后，我们就了解了布局树中每个盒子节点在页面中的具体位置和尺寸信息。下一步，就是如何把布局树渲染到页面上了。第五阶段：绘制绘制阶段主要是根据布局树中各个节点的位置、尺寸信息将它们绘制到页面。目前大多数计算机使用光栅（raster，也称为位图）显示技术。将布局树各个节点绘制到页面的这个过程也被称为“光栅化”。浏览器通常在图形 API 和库（如 Skia、Cairo、Direct2D 等）的帮助下实现光栅化。这些 API 提供绘制多边形、直线、曲线、渐变和文本的功能。实际上绘制才是最难的部分，但是这一步我们有现成的 canvas [5] 库可以用，不用自己实现一个光栅器，所以相对来说就变得简单了。在真正开始绘制阶段之前，我们先来学习一些关于计算机如何绘制图像、文本的基础知识，有助于我们理解光栅化的具体实现过程。计算机如何绘制图像、文本在计算机底层进行像素绘制属于硬件操作，它依赖于屏幕和显卡接口的具体细节。为了简单起见，我们可以用一段内存区域来表示屏幕，内存的一个 bit 就代表了屏幕中的一个像素。比如在屏幕中的 (x,y.html) 坐标绘制一个像素，可以用 memory[x + y * rowSize] = 1 来表示。从屏幕左上角开始，列是从左至右开始计数，行是从上至下开始计数。因此屏幕最左上角的坐标是 (0,0.html)。为了简单起见，我们用 1 bit 来表示屏幕的一个像素，0 代表白色，1 代表黑色。屏幕每一行的长度用变量 rowSize 表示，每一列的高度用 colSize 表示。绘制线条如果我们要在计算机上绘制一条直线，那么只要知道计算机的起点坐标 (x1,y1.html) 和终点坐标 (x2,y2.html) 就可以了。然后根据 memory[x + y * rowSize] = 1 公式，将 (x1,y1.html) 至 (x2,y2.html) 之间对应的内存区域置为 1，这样就画出来了一条直线。绘制文本为了在屏幕上显示文本，首先必须将物理上基于像素点的屏幕，在逻辑上以字符为单位划分成若干区域，每个区域能输出单个完整的字符。假设有一个 256 行 512 列的屏幕，如果为每个字符分配一个 11*8 像素的网格，那么屏幕上总共能显示 23 行，每行 64 个字符（还有 3 行像素没使用）。有了这些前提条件后，我们现在打算在屏幕上画一个 A：上图的 A 在内存区域中用 11*8 像素的网格表示。为了在内存区域中绘制它，我们可以用一个二维数组来表示它：const charA = [ [0, 0, 1, 1, 0, 0, 0, 0], // 按从左至右的顺序来读取 bit，转换成十进制数字就是 12 [0, 1, 1, 1, 1, 0, 0, 0], // 30 [1, 1, 0, 0, 1, 1, 0, 0], // 51 [1, 1, 0, 0, 1, 1, 0, 0], // 51 [1, 1, 1, 1, 1, 1, 0, 0], // 63 [1, 1, 0, 0, 1, 1, 0, 0], // 51 [1, 1, 0, 0, 1, 1, 0, 0], // 51 [1, 1, 0, 0, 1, 1, 0, 0], // 51 [1, 1, 0, 0, 1, 1, 0, 0], // 51 [0, 0, 0, 0, 0, 0, 0, 0], // 0 [0, 0, 0, 0, 0, 0, 0, 0], // 0 ]上面二维数组的第一项，代表了第一行内存区域每个 bit 的取值。一共 11 行，画出了一个字母 A。如果我们为 26 个字母都建一个映射表，按 ascii 的编码来排序，那么 charsMap[65] 就代表字符 A。当用户在键盘上按下 A 键时，就把 charsMap[65] 对应的数据输出到内存区域上，这样屏幕上就显示了一个字符 A。绘制布局树科普完关于绘制屏幕的基础知识后，我们现在正式开始绘制布局树（为了方便，我们使用 node-canvas [6] 库）。首先要遍历整个布局树，然后逐个节点进行绘制：function renderLayoutBox(layoutBox: LayoutBox, ctx: CanvasRenderingContext2D, parent?: LayoutBox.html) { renderBackground(layoutBox, ctx.html) renderBorder(layoutBox, ctx.html) renderText(layoutBox, ctx, parent.html) for (const child of layoutBox.children) { renderLayoutBox(child, ctx, layoutBox.html) } }这个函数对每个节点依次绘制背景色、边框、文本，然后再递归绘制所有子节点。默认情况下，HTML 元素按照它们出现的顺序进行绘制。如果两个元素重叠，则后一个元素将绘制在前一个元素之上。这种排序反映在我们的布局树中，它将按照元素在 DOM 树中出现的顺序绘制元素。绘制背景色function renderBackground(layoutBox: LayoutBox, ctx: CanvasRenderingContext2D.html) { const { width, height, x, y } = layoutBox.dimensions.borderBox() ctx.fillStyle = getStyleValue(layoutBox, 'background'.html) ctx.fillRect(x, y, width, height.html) }首先拿到布局节点的位置、尺寸信息，以 x,y 作为起点，绘制矩形区域，并且以 CSS 属性 background 的值作为背景色进行填充。绘制边框function renderBorder(layoutBox: LayoutBox, ctx: CanvasRenderingContext2D.html) { const { width, height, x, y } = layoutBox.dimensions.borderBox() const { left, top, right, bottom } = layoutBox.dimensions.border const borderColor = getStyleValue(layoutBox, 'border-color'.html) if (!borderColor.html) return ctx.fillStyle = borderColor // left ctx.fillRect(x, y, left, height.html) // top ctx.fillRect(x, y, width, top.html) // right ctx.fillRect(x + width - right, y, right, height.html) // bottom ctx.fillRect(x, y + height - bottom, width, bottom.html) }绘制边框，其实我们绘制的是四个矩形，每一个矩形就是一条边框。绘制文本function renderText(layoutBox: LayoutBox, ctx: CanvasRenderingContext2D, parent?: LayoutBox.html) { if (layoutBox.styleNode?.node.nodeType === NodeType.Text) { // get AnonymousBlock x y const { x = 0, y = 0, width } = parent?.dimensions.content || {} const styles = layoutBox.styleNode?.values || {} const fontSize = styles['font-size'] || '14px' const fontFamily = styles['font-family'] || 'serif' const fontWeight = styles['font-weight'] || 'normal' const fontStyle = styles['font-style'] || 'normal' ctx.fillStyle = styles.color ctx.font = `${fontStyle} ${fontWeight} ${fontSize} ${fontFamily}` ctx.fillText(layoutBox.styleNode?.node.nodeValue, x, y + parseInt(fontSize.html), width) } }通过 canvas 的 fillText() 方法，我们可以很方便地绘制带有字体风格、大小、颜色的文本。输出图片绘制完成后，我们可以借助 canvas 的 API 输出图片。下面用一个简单的示例来演示一下： test1! foo * { display: block; } div { font-size: 14px; width: 400px; background: #fff; margin-bottom: 20px; display: block; background: lightblue; } .lightblue { font-size: 16px; display: block; width: 200px; height: 200px; background: blue; border-color: green; border: 10px; } .foo { width: 100px; height: 100px; background: red; color: yellow; margin-left: 50px; } body { display: block; font-size: 88px; color: #000; }上面这段 HTML、CSS 代码经过渲染引擎程序解析后生成的图片如下：总结至此，这个玩具版的渲染引擎就完成了。虽然这个玩具并没有什么实际用途，但如果能通过实现它来了解真实的渲染引擎是如何运作的，从这个角度来看，它还是“有用”的。说明：本文实现的渲染引擎主要用于教学与原理演示，代码逻辑进行了大量简化（如不支持外边距折叠、选择器优先级简化等），不适用于生产环境。参考资料渲染原理：https://developer.mozilla.org/zh-CN/docs/Web/Performance/How_browsers_work#%E6%B8%B2%E6%9F%93robinson：https://github.com/mbrubeck/robinson从零开始实现一个玩具版浏览器渲染引擎：https://github.com/woai3c/tiny-rendering-engine外边距折叠：https://developer.mozilla.org/zh-CN/docs/Web/CSS/CSS_box_model/Mastering_margin_collapsingcanvas：https://github.com/Automattic/node-canvasnode-canvas：https://github.com/Automattic/node-canvasLet's build a browser engine!：https://limpet.net/mbrubeck/2014/08/08/toy-layout-engine-1.htmlrobinson：https://github.com/mbrubeck/robinson渲染页面：浏览器的工作原理：https://developer.mozilla.org/zh-CN/docs/Web/Performance/How_browsers_work关键渲染路径：https://developer.mozilla.org/zh-CN/docs/Web/Performance/Critical_rendering_path计算机系统要素：https://book.douban.com/subject/1998341/
前端必读：浏览器内部工作原理英文原文：How Browsers Work: Behind the Scenes of Modern Web Browsers一、介绍浏览器可以被认为是使用最广泛的软件。本文将介绍浏览器的工作原理，我们将看到从你在地址栏输入 google.com 到你看到 Google 主页过程中都发生了什么。将讨论的浏览器今天，有五种主流浏览器——IE、Firefox、Safari、Chrome 及 Opera。本文将基于一些开源浏览器的例子——Firefox、Chrome 及 Safari（Safari 是部分开源的）。根据 W3C（World Wide Web Consortium，万维网联盟）的浏览器统计数据，当前（2011 年 5 月），Firefox、Safari 及 Chrome 的市场占有率综合已接近 60%。（原文数据为 2009 年 10 月，当时数据没有太大变化）。因此，可以说开源浏览器已经占据了浏览器市场的半壁江山。浏览器的主要功能浏览器的主要功能是将用户选择的 Web 资源呈现出来。它需要从服务器请求资源，并将其显示在浏览器窗口中。资源的格式通常是 HTML，也包括 PDF、Image 及其他格式。用户用 URI（Uniform Resource Identifier，统一资源标识符）来指定所请求资源的位置，在“网络”一章有更多讨论。HTML 和 CSS 规范中规定了浏览器解释 HTML 文档的方式，由 W3C 组织对这些规范进行维护。W3C 是负责制定 Web 标准的组织。HTML 规范的最新版本是 HTML4 (http://www.w3.org/TR/html401/)，HTML5 还在制定中（译注：两年前）；最新的 CSS 规范版本是 2 (http://www.w3.org/TR/CSS2)，CSS3 也还正在制定中（译注：同样两年前）。这些年来，浏览器厂商纷纷开发自己的扩展，对规范的遵循并不完善，这为 Web 开发者带来了严重的兼容性问题。但是，浏览器的用户界面则差不多，常见的用户界面元素包括：用来输入 URI 的地址栏前进、后退按钮书签选项用于刷新及暂停当前加载文档的刷新、暂停按钮用于到达主页的主页按钮奇怪的是，并没有哪个正式公布的规范对用户界面做出规定，这些是多年来各浏览器厂商之间相互模仿和不断改进的结果。HTML5 并没有规定浏览器必须具有的 UI 元素，但列出了一些常用元素，包括地址栏、状态栏及工具栏。还有一些浏览器有自己专有的功能，比如 Firefox 的下载管理。更多相关内容将在后面讨论用户界面时介绍。浏览器的主要构成（High Level Structure）浏览器的主要组件包括：用户界面 － 包括地址栏、后退/前进按钮、书签目录等，也就是你所看到的除了用来显示你所请求页面的主窗口之外的其他部分。浏览器引擎 － 用来查询及操作渲染引擎的接口。渲染引擎 － 用来显示请求的内容。例如，如果请求内容为 HTML，它负责解析 HTML 及 CSS，并将解析后的结果显示出来。网络 － 用来完成网络调用，例如 HTTP 请求。它具有平台无关的接口，可以在不同平台上工作。UI 后端 － 用来绘制类似组合选择框及对话框等基本组件，具有不特定于某个平台的通用接口，底层使用操作系统的用户接口。JS 解释器 － 用来解释执行 JS 代码。数据存储 － 属于持久层，浏览器需要在硬盘中保存类似 Cookie 的各种数据。HTML5 定义了 Web Database 技术，这是一种轻量级完整的客户端存储技术。图 1：浏览器主要组件需要注意的是，不同于大部分浏览器，Chrome 为每个 Tab 分配了各自的渲染引擎实例，每个 Tab 就是一个独立的进程。对于构成浏览器的这些组件，后面会逐一详细讨论。二、渲染引擎（The Rendering Engine）渲染引擎的职责就是渲染，即在浏览器窗口中显示所请求的内容。默认情况下，渲染引擎可以显示 HTML、XML 文档及图片。它也可以借助插件（一种浏览器扩展）显示其他类型数据，例如使用 PDF 阅读器插件可以显示 PDF 格式。将由专门一章讲解插件及扩展，这里只讨论渲染引擎最主要的用途——显示应用了 CSS 之后的 HTML 及图片。渲染引擎简介本文所讨论的浏览器——Firefox、Chrome 和 Safari 是基于两种渲染引擎构建的。Firefox 使用 Gecko——Mozilla 自主研发的渲染引擎；Safari 和 Chrome 都使用 WebKit。WebKit 是一款开源渲染引擎，它本来是为 Linux 平台研发的，后来由 Apple 移植到 Mac 及 Windows 上。相关内容请参考 http://webkit.org。渲染主流程（The Main Flow）渲染引擎首先通过网络获得所请求文档的内容，通常以 8K 分块的方式完成。下面是渲染引擎在取得内容之后的基本流程：解析 HTML 以构建 DOM 树 -> 构建 Render 树 -> 布局 Render 树 -> 绘制 Render 树图 2：渲染引擎基本流程渲染引擎开始解析 HTML，并将标签转化为内容树中的 DOM 节点。接着，它解析外部 CSS 文件及 结果是：虽然绿色 Div 排在红色 Div 后面，可能在正常流中也已经被绘制在后面，但 z-index 有更高优先级，所以在根 Box 的堆栈中更靠前。说明：本文原文发表于 2011 年，部分数据（如浏览器市场占有率）及规范状态（如 HTML5、CSS3 当时尚在制定中）已过时。现代浏览器架构（如 Chrome 的多进程模型、Site Isolation 等）及 Web 标准已有显著演进，但核心渲染原理（DOM 树、Render 树、布局、绘制）仍具有重要的参考价值。