ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

手写浏览器渲染引擎:从CSS解析到绘制管线的完整实现

手写浏览器渲染引擎:从CSS解析到绘制管线的完整实现 你可能已经看过无数篇“从前端视角理解浏览器渲染原理”的文章但真正自己动手写过一个浏览器渲染引擎的开发者少之又少。三年前我决定做这件事给自己定了一个目标不引用任何渲染库从一个空目录开始手写CSS解析、样式计算、布局计算和绘制管线最后在屏幕上渲染出一个真实的网页。整个过程比想象中难也比想象中有意思。这篇文章把我踩过的坑、理不清的概念、以及最终沉淀下来的实现路径完整记录下来希望能给同样对浏览器内核好奇的人一些参考。1. 为什么我会选择手搓一个渲染引擎1.1 单纯“背答案”的前端生涯让我受够了做了几年React开发之后我开始被几个“前端面试必背题”折磨margin collapse到底怎么发生的position: absolute的包含块是哪个祖先transform为什么看起来比修改left更流畅所有这些问题的正确答案都能查到但每个答案背后都关联着渲染引擎里某个具体模块的设计决策。真正让我下定决心的是Chrome DevTools里的Performance面板。录制一段滚动页面能看到Layout和Paint的时间线但我不知道那段Layout到底在算什么Paint又是在画什么。网上教程只会说“避免强制同步布局”从来不解释为什么读取offsetHeight会触发同步布局。这些问题的答案不在任何API文档里而在 HTML/CSS标准 和渲染引擎源码里。直接读 WebKit 几百万行代码不现实所以我选择自己写一个最小版本从零搭起完整的渲染管线。1.2 项目技术选型与范围裁剪我选了TypeScript作为实现语言。理由很实际开发调试方便浏览器里直接跑、生态里没有多余依赖、有完善的类型系统帮助梳理各模块的接口。这个项目不是要替代Chromium而是实现一个学术原型所以一上来就把范围砍到了最小可行版本HTML解析只支持标签节点、文本节点和有限属性CSS解析支持类、ID、标签选择器颜色、字体尺寸、盒模型相关属性布局块级布局、行内布局、absolute定位、简化版Flexbox绘制颜色背景、文本、边框渲染输出通过Canvas 2D API完成光栅化有人会问为什么不用真实浏览器的DOM和CSSOM因为一旦用了document.querySelector和getComputedStyle整个渲染引擎的骨架就被浏览器接管了你永远不知道内部发生了什么。从零手搓的意义就在于把每个环节都握在自己手里。2. CSS解析器实现tokenizer、规则匹配与样式级联2.1 从token流开始理解CSS语法很多人第一次接触CSS解析会被selector { property: value; }这个语法迷惑以为它很简单。CSS的标准语法其实分两层词法层tokenizer和语法层parser。词法层把CSS文本变成token流语法层再消费token流生成样式规则。我实现的第一版tokenizer把所有内容一股脑按照“空格分隔”切分结果遇到background: url(a.png?x1)和content: hello world就崩溃了。后来才意识到CSS tokenizer必须识别至少这些token类型ident标识符如bold、flexfunction如rgba(、calc(string带引号的文本urlurl(...)专用number与percentagedimension带单位的数值如16pxhash#开头可能是颜色也可能是ID选择器delim其他单个字符如:、;、{、}有意思的是CSS tokenizer在设计上就必须容忍错误遇到无法识别的字符时不能直接抛异常而是把它标记为delim或bad-string后继续前进。这个“容错优先”的设计决策贯穿了整个CSS解析规范也是为什么浏览器里写错CSS不会导致页面崩溃。我的tokenizer参考了 CSS Syntax Level 3 下面是核心简化逻辑enum TokenType { Ident, Function, AtKeyword, Hash, String, Url, Number, Percentage, Dimension, Delim, Whitespace, Colon, Semicolon, Comma, LBrace, RBrace } interface Token { type: TokenType; value: string; unit?: string; } function tokenize(input: string): Token[] { const tokens: Token[] []; let pos 0; while (pos input.length) { const ch input[pos]; if (isWhitespace(ch)) { let ws ch; while (isWhitespace(input[pos])) ws input[pos]; tokens.push({ type: TokenType.Whitespace, value: ws }); } else if (ch {) { tokens.push({ type: TokenType.LBrace, value: { }); pos; } else if (ch }) { tokens.push({ type: TokenType.RBrace, value: } }); pos; } else if (ch :) { tokens.push({ type: TokenType.Colon, value: : }); pos; } else if (ch ;) { tokens.push({ type: TokenType.Semicolon, value: ; }); pos; } else if (ch ,) { tokens.push({ type: TokenType.Comma, value: , }); pos; } else if (isDigit(ch) || ch || ch -) { // 解析数字和单位 const num readNumber(input, pos); pos num.nextPos; if (pos input.length isIdentStart(input[pos])) { const unit readIdentifier(input, pos); pos unit.nextPos; tokens.push({ type: TokenType.Dimension, value: num.value, unit: unit.value }); } else if (input[pos] %) { pos; tokens.push({ type: TokenType.Percentage, value: num.value }); } else { tokens.push({ type: TokenType.Number, value: num.value }); } } // ... 其他token分支 } return tokens; }这段代码最磨人的是ident和url的边界情况url(后面可能跟引号字符串也可能跟一段不带引号的路径路径里的;和)如果不小心处理就会把整个规则切断。我的建议是tokenizer阶段先把所有token无脑识别出来语法解析阶段再处理歧义两者职责分开实现起来会清晰很多。2.2 样式规则解析与选择器即时编译token流生成后第二步是解析样式规则。一个样式表就是一个rule列表每个rule由selector、declaration block组成。解析时遇到无法识别的declaration就跳过——这是CSS规范明确规定的容错策略。解析完成后需要为每条规则计算specificity特征值。CSS特异性计算规则不复杂统计选择器里id个数、class个数、type标签个数分别对应语义上的权重。我项目中用一个数组[inline, id, class, type]存储比较时从左到右逐个比大小天然可以处理0,1,0,0和0,0,2,0这类“class数量更多但id权重更高”的比较。选择器匹配采用“从右向左”方式先找到最右侧的关键选择器再从子树中向上检查父级。为什么从右向左因为匹配div p时右端的p元素数量远少于左端的div候选数量从消耗最少的一端出发能显著减少比较次数后续利用hash快速定位关联元素还能进一步加速。2.3 级联与继承人类最容易忽略的“脏活”拿到所有样式规则后就要计算每个DOM元素最终的ComputedStyle。这不是简单遍历CSS属性能搞定的事必须实现级联权重比较来源用户代理样式表 作者样式表 作者!important真实浏览器还有用户样式和UA优先级的细分特异性specificity数值规则顺序出现在CSS文件后面的规则特异性相同时胜出我的一个小发现现代浏览器里* { box-sizing: border-box; }比div { width: 100px }特异性低但很多新手会误以为所有规则都按“后出现的赢”。实际上级联是在“按来源分组”“按特异性排序”后才比较顺序光记结论不实现一遍很难真正理解这条规则链。继承则完全不同color、font-family等属性缺省时继承父元素值而width、margin不会。CSS规范里每个属性都标注了Inherited: no/yes我的实现里维护了一张属性是否可继承的表。为了正确计算出值还要实现em和rem转换em依赖父层字号rem依赖根节点字号必须在样式递归计算时传入上下文。2.4 相对单位转换是样式计算的隐藏难点真正让我卡了几天的是百分比与auto值的解决。类似width: 50%这样的声明在样式计算阶段无法直接转成px——需要知道包含块的宽度而包含块的宽度只有在布局阶段才能确定。为了解决这个循环依赖我的ComputedStyle结构里保留的是“计算前的值”“解析后的表达式”例如Percentage类型真正转成绝对px的时机推迟到布局时。设计上的教训样式计算和布局计算不应该用一刀切的方式隔开二者天然互相依赖。所以在结构设计时要把“相对值”作为能够传递给布局期的数据存在而不是在样式阶段就强行转换。3. 布局计算内包含块、盒模型与递归布局3.1 布局树的生成原理很多教程会把DOM树和布局树画成同一种结构但实际上两者差别巨大。display: none的元素在布局树里不存在::before、::after伪元素在DOM树里不存在但在布局树里存在更麻烦的是为了处理display: inline元素内部的块级子元素我还要插入“匿名块盒”来保持DOM的嵌套结构。布局树生成阶段我实现了这样一个映射函数function buildLayoutTree(domNode: DOMNode): LayoutNode { if (domNode.nodeType NodeType.Text) { return createTextLayoutNode(domNode); } const style resolveStyle(domNode); if (style.display none) { return null; // 直接剪枝 } const children domNode.children .map(child buildLayoutTree(child)) .filter(Boolean); // 处理匿名块盒如果列表里同时有inline和block子元素需要包裹inline为匿名块 return createElementLayoutNode(domNode, style, children); }这个阶段还有一个重要细节布局树节点要保存“盒类型”的判定结果。因为display: inline和display: block的布局算法完全不同宁可把这个判定提前到布局树构建阶段也不要在布局算法里反复翻样式属性。3.2 经典块级布局与其万年难题margin collapse正常流中的块级元素布局本质上是个从上到下的堆叠过程。每个块占据其包含块的宽度垂直方向从内容顶部开始y坐标不断增加。伪代码大体如下function layoutBlock(container: LayoutBox, startY: number) { container.y startY; container.x container.parent.x container.marginLeft; let cursorY startY container.paddingTop container.borderTop; for (const child of container.children) { if (child.isBlock()) { layoutBlock(child, cursorY); cursorY child.height child.marginBottom child.marginTop; } else { layoutInlineContent(child, cursorY); cursorY child.lineHeight; } } container.height cursorY - startY - container.paddingBottom - container.borderBottom; }但这上面有个严重错误就是margin折叠。相邻兄弟的margin-bottom和margin-top不会相加而是取较大值父子的margin-top也可能冲出父盒子上边界造成经典的外边距塌陷。我花了整整一天把CSS 2.1规范里的折叠规则简化成一张表场景折叠结果两个相邻兄弟块取两者较大margin值父元素与第一个子元素若之间无border/padding则取两者较大margin-top父元素与最后一个子元素若之间无border/padding则取两者较大margin-bottom空块自身上下margin也可能折叠如果要写一个完整的布局引擎margin折叠是你绕不过去的一段脏活而且一旦做错整个页面布局和真实浏览器相差非常大。我的建议是先只实现前两行规则跑通管线后再补齐全部边界情况。3.3 行内布局与文本基线处理行内布局Inline Layout是另一个让人抓狂的部分。文本不是简单往某一点“画上去”的而是需要排进line box行盒。行盒的高度取决于行内所有元素line-height的最大值文本的垂直位置取决于baseline基线而不是行盒顶部。对中文和英文混排而言基线差异会直接影响视觉位置。我的实现是给每个行内节点一个InlineRect计算时把文本转成多个run每个run记录其一行的起始坐标和占据宽度。然后根据text-align决定在行盒内的水平分布方式。默认的text-align: start不需要太多处理一旦支持center就需要先收集整行的宽度再统一偏移这给布局算法增加了不少复杂度。3.4 简化版Flexbox是如何拆解的Flexbox布局算法在CSS规范里有两层先要确定每个flex item的基础尺寸flex-basis再基于剩余空间执行伸缩计算。我做了一个单行简化版本只支持flex-direction: row计算所有item的flex-basis优先取用户指定的值否则取auto转成内容宽度将所有item的base尺寸相加对比容器宽度得到remaining可分配空间若剩余空间大于0按flex-grow比例放大每个item若小于0按flex-shrink缩小但缩小有下限默认min-width: auto即内容宽度justify-content在伸缩分配结束后处理align-items负责交叉轴对齐这里有个极其容易错的细节flex-grow是按“比例”分空间但flex-shrink的分配还要乘以flex-basis。flex: 1 1 0%与flex: 1 1 auto的结果迥异我把这个测试用例写进了项目的回归测试每次改动都跑一遍。3.5 绝对定位与包含块的关系position: absolute的定位基准是“最近的已定位祖先”如果没有则用初始包含块通常是视口。但很多实现版本会把“已定位祖先”误解为position: relative的祖先——其实是relative、absolute、fixed、sticky这四种都算。包含块一旦确定left、top就相对它的padding box计算这一点经常被开发者忽视。布局递归结束后还需要额外执行一次绝对定位元素的排版因为父元素的尺寸在此时才刚刚确定。4. 绘制管线从布局树到像素的旅程4.1 布局树是死的绘制命令才是活的完成布局之后每个元素都有了确定的坐标和尺寸但还不能直接往Canvas上画。因为真实浏览器的绘制不是“后写的覆盖先写的”而是严格按照层叠上下文顺序绘制。所以我的引擎引入了“绘制命令列表”Display List一个记录绘制操作的数组。interface DrawCommand { type: rect | text | border; box: BoxMetrics; style: ResolvedStyle; zIndex: number; }布局结束后我以zIndex从小到大排序所有参与绘制的Box相同zIndex的再按DOM树顺序确定绘制顺序。这一步特别重要直接关系着重叠元素的显示效果。4.2 绘制顺序的完整拆解CSS规范里的绘制顺序大致可以描述为背景与边框 → 负z-index子层 → 块级子元素 → 浮动 → 行内内容 → 正z-index子层。我的实现虽然没有接入z-index全部语义但保留了一个正确的层级栈先绘制当前节点的背景色、边框再递归绘制子节点最后才绘制文字内容。很多人会在这里问“为什么文字内容要最后画”因为文字和行内装饰必须覆盖在背景之上而且它们的布局位置依赖行盒line box的信息必须单独处理。如果先画文字再画背景文字会被父级背景盖住。4.3 图形上下文设计坐标变换与裁剪为了让绘制命令能够真正执行我抽象了一个GraphicsContext接口interface GraphicsContext { save(): void; restore(): void; translate(dx: number, dy: number): void; clipRect(rect: Rect): void; fillRect(rect: Rect, color: Color): void; strokeBorder(rect: Rect, style: BorderStyle): void; fillText(text: string, x: number, y: number, style: TextStyle): void; }实现这个接口时有个很重要但容易忽略的点边框是不允许被内容覆盖的也就是说fillRect绘制背景时必须减去border宽度否则一个border: 4px solid的元素里背景色会涂满整个盒模型包括边框区域看起来边框颜色就模糊了。最终我采用的方法是先绘制边框路径再填充内部区域需要裁剪时用clipRect配合save/restore保证状态不回污染上下文。4.4 实际执行光栅化时的踩坑记录我选择Canvas 2D作为光栅化后端因为它自带抗锯齿和文字绘制能力本质上和GPU光栅化的概念一致。踩坑主要集中在两点Canvas的fillText坐标系fillText(text, x, y)参数里的y是baseline而不是盒子的顶部。如果直接把box.y传进去会出现所有文字像悬浮在盒子上方。我的处理是先通过textBaseline top重置默认基线再传box.y paddingTop。四舍五入与次像素渲染当元素坐标是0.5像素时Canvas会有灰色毛边。简单处理方式是Math.round所有绘制坐标让文字边缘保持清晰。精准的做法是像真实浏览器一样在0.6px与0.4px之间做hinting但那个实现量对我这个项目而言偏大。绘制命令列表的好处到这里显现出来我可以先录制所有绘制命令再统一进行坐标变换和裁剪优化而不必在布局递归过程里穿插绘图调用。这种“记录与执行分离”的思路也是浏览器渲染引擎现代管线的核心特征。4.5 从绘制命令到图层合成绘制命令执行时不必每次都全量重画。我的项目中实现了一个简单图层管理当某个元素具有transform、opacity或overflow: hidden时把它提升为一个独立Layer图层可以预先离屏绘制offscreen canvas合成时再按顺序粘贴到主画布上。这与真实浏览器的Compositing阶段还不完全一样但已经能解释一个经典性能问题为什么transform动画比left动画平滑。因为left变化会使每个帧重新布局和重新绘制而transform只需图层在合成阶段轻微移动避开了昂贵的布局与绘制环节。5. 验证与调试像素比对和最小测试集的搭建5.1 单元测试解析器和布局快照手写了一个渲染引擎后最大的问题是如何确认它是正确的。我的做法是先做三个层级的测试CSS解析层测试输入一段CSS文本断言输出规则的数量、选择器的特异性、声明块的属性值布局树测试给定一段HTML断言每个布局节点x/y/width/height精确数值。这个测试的价值在于任何布局算法的修改都会直接影响数值结果回归问题可以快速定位绘制命令测试把绘制命令序列输出成JSON快照通过快照对比确认绘制顺序没有变化布局树的快照测试是我最推荐的调试手段。每次跑完布局把整棵树打印出来LayoutNode(100, 20, 640, 40) div#header #text(Hello)肉眼盯着这个坐标列表修改代码的效率极高。5.2 与真实Atom浏览器截图对比单纯自测可能自我欺骗。我写了一个脚本用Puppeteer加载同一个测试页面通过CDP协议获取元素布局位置和样式值然后把“真实浏览器数值”与“我的引擎输出”并行dump到一个表格里。针对差几个像素的案例逐一排查是margin计算错误还是%解析错误。5.3 调试过程中最重要的三招第一招是“孤立用例”。出现一个有问题的页面我会把DOM结构一层层删掉直到保留一个最小可复现案例这样能快速定位出是布局树生成的问题还是绘制顺序的问题。第二招是”栅格对照“。在实现绘制管线的早期我把Canvas的save/restore层级直接打出来用缩进表示嵌套深度一眼就能找出状态没有恢复的问题。第三招是渐进式单元测试。我强烈建议从htmlbodypHello, world/p/body/html开始每增加一个CSS属性就增加一组测试页面比如只有margin: 20px、border: 2px solid red、width: 50%。这样能保证每一步推进都在已验证的稳定基础上进行。6. 再多走一步合成线程与样式失效6.1 从绘制到合成的完整路径当绘制命令执行完毕渲染引擎还需要处理一件事如何把多个图层最终输出到屏幕。浏览器架构中渲染进程的Compositor线程负责接收图层将它们分割成Tile瓦片光栅化后再交给GPU显示。我的项目没有实现进程模型但实现了最核心的“图层树合成”把每个独立Layer的离屏Canvas依次贴到主Canvas上按z序覆盖。6.2 样式失效与重绘优化当DOM的样式变化时不能简单地全量重新布局和重新绘制。真实浏览器用“脏标记”来跟踪哪些元素需要重新布局、哪些元素只需要重绘。我也实现了一个最粗糙的DirtyFlag机制当属性变化时先把节点标记为dirty进入下一帧时才统一执行布局和绘制。这样多个JS操作合并到一帧内减少不必要的重复计算。getBoundingClientRect()等操作之所以会强制同步布局就是因为布局被强制提前执行而非统一到帧末。如果能在这个思路上再深入可以尝试实现“增量布局”只布局脏分支而清空其他分支的缓存难度会陡增但收益也是指数级的。6.3 接下来可以扩展的方向这个项目现在已经能解析基础选择器、完成块级和行内布局、执行简单Flexbox排版和绘制输出。后续如果要继续深入我觉得有三个方向价值最高选择器引擎升级加入后代、子代、相邻兄弟等组合器再实现:hover、:nth-child()伪类这会对样式匹配逻辑造成不小的考验CSS Grid布局它是二维布局模型和Flexbox的一维伸缩完全不同需要实现轨道计算fr单位和minmax()扩展性更强增量布局与重排优化写一个“真实页面滚动时哪些节点需要重排”的分析器把渲染性能优化的几个核心原则落地成代码个人体会是手写渲染引擎最容易出现的心理障碍是“无限细节吞噬动力”。如果你也想做类似的事建议先别求完整度而是敏捷地跑通HTML解析 - CSS解析 - 布局 - 绘制这条最短链路哪怕只画出一个红底黑字方块都算成功迈出第一步。之后每加一个新特性你的渲染引擎都会更有生命力。渲染引擎是一个值得一写再写的项目它让你对Web底层运行机制的理解完完全全变成肌肉记忆。
返回列表