从零手写迷你渲染引擎:解析CSS布局绘制全链路
2026/9/19 12:55:03 网站建设 项目流程

开篇先问一个问题:你写CSS写了几年,可曾想过浏览器拿到你这份CSS之后内部到底发生了什么?我指的不是DevTools里那个“Computed”面板,而是从字符串到像素的完整链路。如果你只是写业务样式,这个问题不回答也罢;但如果你想做性能优化、搞跨端渲染、或者像我一样单纯想弄明白“浏览器渲染引擎”到底是怎么工作的,那就绕不开这条链路:CSS解析、布局计算、绘制管线。

这个项目就是我自己从零手写的一个迷你渲染引擎,没有用Chromium内核,也没有套WebView,而是用Go从字符流开始,一点一点把CSS解析成AST,把DOM和CSSOM合并成带样式的盒子树,做完布局,最后生成绘制命令并输出成PNG。整套代码跑下来,你对“浏览器是怎么渲染页面”这件事的理解,会比看十篇原理文章都深刻。这篇文章就把整个项目的设计思路、核心实现、踩坑记录全部拆开讲,适合对浏览器底层原理感兴趣的前端工程师、想入门图形渲染的同学,以及所有不满足于“能调样式”的开发者。

1. 项目从哪来,做什么用

1.1 为什么非要自己写一个渲染引擎

市面上解释浏览器原理的资料不少,但多数停留在“DOM Tree、CSSOM、Render Tree、Layout、Paint”这种概念图上。概念图最大的问题在于它太干净了,干净到让你误以为每个阶段之间是简单的顺序流水线。实际上当你读到“layout”这一步具体怎么算坐标时,大多数资料就开始含糊其辞,因为布局算法牵扯到的边界情况多得像海边的沙子。

我搭这个项目时给自己定了几个硬性目标,这也是它能跑起来而不是烂尾的关键:

  • 不直接调用任何浏览器内核或渲染库,所有CSS解析逻辑、盒模型计算、绘制命令生成全部自己来。
  • 支持真实CSS的一个可用子集:选择器(类型、类、ID、后代)、常见属性(display、margin、padding、color、font-size、line-height等)、flex布局。
  • 最终能看到图片输出,而不是只打印一堆抽象语法树。没有视觉反馈的项目很难坚持做下去。

做完之后你会发现,它本质上是一个可以运行的“渲染引擎最小原型”。它缺失的部分(比如回流增量更新、层树、光栅化调度)恰恰是你理解真实浏览器有多复杂的最好参照物。

1.2 整体架构和模块划分

整个引擎我按“输入到输出”分成五个模块,每个模块只干一件事:

模块职责输入输出
Tokenizer词法分析CSS源代码字符串Token序列
Parser语法分析Token序列CSSOM(样式规则集合)
StyleTree样式计算DOM树 + CSSOM带计算后样式的节点树
LayoutEngine布局计算带样式的节点树盒子树(含坐标和尺寸)
PaintEngine绘制盒子树 + 视口尺寸绘制命令列表 / PNG图片

这个划分不是拍脑袋定的,它对应的是真实浏览器渲染管线的抽象层级。Tokenizer和Parser解决“CSS解析”,StyleTree和LayoutEngine解决“布局计算”,PaintEngine解决“绘制管线”。三个核心关键词正好对应项目的副标题。

我第一次跑通整个流程时最大的感触是:原来从一份HTML到一张图片,中间要过这么多道手。每一步看似简单,但每一步都有大量细节需要处理,尤其是布局和绘制之间的那张“盒子树”,它才是一切视觉呈现的根基。

2. CSS解析:从字符串到抽象语法树

2.1 词法分析:把字符流切成Token

拿到一坨CSS字符串之后,我做的第一件事不是急着“理解”它,而是先把字符串切成一个有意义的Token序列。这一步叫词法分析。为什么需要这一步?因为字符到Token的切分规则虽然简单,但它是后续语法分析的基础。

以一条规则为例:

body { color: red; margin: 0 auto; }

词法分析器会把它切成这样的Token流:

类型: Ident("body") // 选择器 类型: LBrace // 左大括号 类型: Ident("color") // 属性名 类型: Colon // 冒号 类型: Ident("red") // 属性值 类型: SemiColon // 分号 类型: Ident("margin") 类型: Colon 类型: Ident("0") 类型: Whitespace 类型: Ident("auto") 类型: SemiColon // 分号 类型: RBrace // 右大括号

这里“0”和“auto”我都先用Ident接住,因为单独一段字符还无法确定它是数字、关键字还是其他东西,真正的语义判断要放到语法分析阶段去做。切Token阶段最重要的原则是“只切不判”,尽可能保持Token类型的封闭集合,这样Parser才好处理。

代码上我对每一种Token做了显式定义:

type TokenType int const ( TokenWhitespace TokenType = iota TokenIdent TokenLBrace TokenRBrace TokenColon TokenSemicolon TokenComma TokenEOF )

词法分析的坑在别处,不在Token类型本身,而在“游标管理”。这个老生常谈的问题在解析器里经常阴魂不散,后面我会专门用一节来展开。

2.2 语法分析:从Token流到CSSOM

Token流拿到手之后,Parser的工作就是把它变成有结构的数据,也就是CSSOM。这一步说白了是“根据CSS语法吃掉Token”,每次消费Token时都要判断眼前的Token是否符合预期,不符合就报错或者跳过。

我实现的是一个非常轻量的递归下降Parser,因为CSS的语法本身就比较规整,不需要上复杂的LR分析器。核心逻辑可以抽象成三件事:

  1. 读选择器,直到遇到左大括号。
  2. 读声明块,直到遇到右大括号。
  3. 重复直到EOF。

其中声明块的解析又分成:读属性名、读冒号、读值、读分号或右大括号结束。这里最容易忽略的是“属性值可能不止一个Token”,比如margin: 0 autofont: 14px/1.5 sans-serif,前者有两个Token,后者有四个Token。处理办法是把分号或右大括号当作值的结束标志,把所有中间Token攒成一个值列表。

最终CSSOM的数据结构是这样的:

type StyleSheet struct { Rules []Rule } type Rule struct { Selectors []Selector Declarations []Declaration } type Declaration struct { Property string Value []Token }

这一步做完你已经可以打印出可读的样式规则,但第二个阶段的大坑开始浮出水面:选择器匹配。CSS的选择器不是简单的一层关系,它涉及优先级、层叠、继承,这些细节直接决定了一个元素最终“计算之后”的样式长什么样。

2.3 优先级与层叠:选择器匹配为什么难

我的引擎只支持三种选择器:类型选择器、类选择器、ID选择器,外加它们的后代组合。即便如此,计算一个元素的最终样式也要走“匹配-合并-比较优先级”这条链子。

优先级计算我采用了朴素的元组比较:

type Specificity struct { IDs int Classes int Types int }

比较规则按经典的三元组逐级比较,ID数量优先,然后类,然后类型。这个判断看似简单,但实际操作中还有个很容易被忽略的点:同一条规则里可能有多个选择器,比如h1.title,这条规则的Specificity应该是{0, 1, 1},而不是{0, 1, 0}。你得把选择器列表里所有部分各自归类型再加总,不能把整条规则当成一个整体。

还有继承。colorfont-size这类属性天然具有继承性,但marginpaddingdisplay则不会继承。我的StyleTree构建时会把父节点计算后的样式作为默认样式传入子节点,然后在上面叠加子节点自己的样式规则。这个“叠加”顺序必须严格保持:设置默认值 -> 应用继承值 -> 应用匹配规则(按优先级排序) -> 覆盖为CSS默认初始值。顺序错了,样式表就会出现稀里糊涂的覆盖bug。

3. 布局计算:把样式变成坐标

3.1 从DOM到盒子树

样式计算完之后,你会得到一棵“带样式的DOM树”,但这时候还没有任何盒子。布局阶段做的第一件事,是把带样式的DOM节点映射成盒子节点。一个节点可以是一个盒子的起点,也可以是多个盒子的起点;反过来,一个盒子也可能对应多个节点。DOM和盒子并非一一对应,这是理解布局最核心的地方。

在我这个mini引擎里,布局树其实就是盒子树,每个节点只代表一个矩形区域:

type BoxType int const ( BoxBlock BoxType = iota BoxInline BoxAnonymousBlock ) type LayoutBox struct { Node *DomNode // 对应的DOM节点 BoxType BoxType Style *ComputedStyles Children []*LayoutBox X, Y float64 // 盒子左上角坐标 Width, Height float64 // 盒子尺寸 Margin, Border, Padding // 内边距/边框/外边距 }

DOM到盒子的映射规则我在项目里做了简化:display: block的节点生成Block盒子,display: inline的节点生成Inline盒子。但是有个现实情况必须处理:如果一个块级容器里既有块级子元素又有文本节点,那文本节点得包一个匿名块级盒子。真实浏览器里这部分实现非常复杂,我这里用一个简单的分组逻辑模拟:先把子节点按“是否块级”分组,连续的行内节点合并成一个匿名块盒子。

3.2 块级布局:从上往下排

块级布局是整个布局引擎的地基。核心算法只有一句话:在父盒子的内容区域内,从上往下依次摆放子盒子,每个子盒子的纵向偏移量等于上一个子盒子的底部。代码也很直白:

func (l *LayoutBox) layoutBlock(c *LayoutContext) { l.calculateWidth(c) l.calculatePosition(c) l.layoutChildren(c) l.calculateHeight(c) }

calculateWidth这一步做的是“横向约束求解”。它要处理一个CSS经典的宽度模型:假设父盒子的内容宽度为W,子盒子的margin-left为ml,border-left为bl,padding-left为pl,width为w,padding-right为pr,border-right为br,margin-right为mr,那么必须满足:

ml + bl + pl + w + pr + br + mr = W

这也是我之前一直觉得“看懂了盒模型”但亲手算不出坐标的原因所在。真实浏览器中如果这些值之和小于父容器宽度,多出来的空间要按margin的auto规则分配;如果大于父容器宽度,则会走“过度约束”规则,忽略margin-right。我实现了一个简化版,把margin: 0 auto这种场景的“水平居中”做对了。

calculatePosition负责把子盒子的Y坐标设置为“当前已用高度”加自身margin-top,同时在布局上下文中记录新的高度累加值。calculateHeight负责处理两种情况:显式指定了height,就按height计算;没指定,就累加子盒子的高度总和。

3.3 flex布局:理解父容器如何安排子项

块级布局跑通之后,我开始加flex布局。当时想的很简单:不就是一行排开吗?实际做完才理解为什么flex的规范那么厚。

先看最核心的“主轴分配”逻辑。flex布局第一步仍然是计算主轴总长度,然后计算子项的基准尺寸,再把剩余空间按flex-grow比例分配给各个子项。我实现的是一维的flex-row:

func layoutFlexRow(container *LayoutBox, context *LayoutContext) { // 第一步:为每个子项计算基础尺寸 for _, child := range container.Children { child.layout(context) } // 第二步:汇总已占用的主轴长度 totalSize := 0.0 for _, child := range container.Children { totalSize += getMainSize(child) } // 第三步:计算剩余空间并按flex-grow比例分配 remaining := container.ContentWidth() - totalSize totalGrow := sumFlexGrow(container.Children) if remaining > 0 && totalGrow > 0 { for _, child := range container.Children { extra := remaining * (child.FlexGrow / totalGrow) setMainSize(child, child.MainSize()+extra) } } // 第四步:沿主轴方向依次摆放 currentOffset := container.PaddingLeft for _, child := range container.Children { child.X = currentOffset currentOffset += child.MainSize() + child.MarginRight + child.MarginLeft } }

这里面最容易出错的不是分配逻辑,而是flex-basiswidth同时存在时的优先级。规范里flex-basis优先,我在实现初期颠倒了这个顺序,结果导致好几张测试图的宽度全部错乱。这部分建议你在实现时单独写一个测试用例,输入固定的flex-basis值和width值,断言最终的主轴长度,否则这类bug你根本发现不了。

flex布局我最想吐槽的一点是交叉轴对齐。align-items: center听起来很美好,但如果你没有给交叉轴确定一个“容器高度”,那么center的基准就完全谈不上。所以flex布局的第一步永远是确定容器尺寸,而不是先排子项,这个顺序反了会让坐标全部错位。

4. 绘制管线:从布局树到像素

4.1 绘制命令:不直接画像素,先描述“怎么画”

布局完成之后,我们已经拿到了每个盒子的坐标和尺寸。接下来的问题是:怎么把这些矩形画出来,并且画得像一个网页,而不是一堆色块拼接图。

答案是绘制命令。绘制管线里的第一步,不是拿画布去填充颜色,而是把要画的东西抽象成一条有序的绘制命令列表。比如一个带红色背景、黑色边框、白色文字的块级元素,对应的绘制命令大致是这样的:

1. FillRect(x, y, w, h, color=#ffffff) // 背景 2. DrawRect(x, y, w, h, borderWidth=2, color=#000000) // 边框 3. Text(x, y, "hello", color=#fff, fontSize=14) // 文本

这样做的好处有两个。第一,绘制命令是对布局结果的显式描述,你可以在真正光栅化之前先打印一遍命令列表,人工检查逻辑对不对。第二,很多优化思路比如“脏矩形重绘”“图层提升”,本质上都是对绘制命令的管理,而不是对像素的直接操作。你先有命令层,后面想扩展这些东西才有基础。

实现时我用了一个简单的枚举类型和参数结构:

type PaintOpType int const ( OpFillRect PaintOpType = iota OpDrawRect OpDrawText ) type PaintOp struct { Type PaintOpType Rect Rectangle Color Color FontSize float64 Text string }

这一层的抽象看起来多此一举,但等你往下做光栅化的时候会发现,绘制命令的排序直接决定了谁盖住谁,而排序的依据不是“命令列表的顺序”,而是“布局树的层级遍历顺序”。这里其实暗合了浏览器层叠上下文的概念上的思想:绘制顺序不等于创建顺序。

4.2 把绘制命令变成PNG

有了绘制命令列表之后,最后一步就是真正的光栅化兑现。我用的Go标准库imageimage/png,实现了一个最简单的CPU光栅器,性能不重要,重要的是让每条命令都能变成像素:

func Rasterize(ops []PaintOp) *image.RGBA { img := image.NewRGBA(image.Rect(0, 0, 800, 600)) bg := Color{R: 255, G: 255, B: 255, A: 255} draw.Draw(img, img.Bounds(), &image.Uniform{C: bg}, image.Point{}, draw.Src) for _, op := range ops { switch op.Type { case OpFillRect: fillRect(img, op.Rect, op.Color) case OpDrawRect: drawBorder(img, op.Rect, op.Width, op.Color) case OpDrawText: drawSimpleText(img, op.Text, op.Rect, op.FontSize, op.Color) } } return img }

fillRect的实现很直接,双层循环把矩形区域内的像素设置成目标颜色。drawBorder也一样,按边框宽度画四条边。最麻烦的是drawSimpleText,因为我并没有接入FreeType或者字体库,而是自己维护了一张极简点阵字体表。这里我踩了一个大坑:字体度量。不同字符的字宽和高并不是等宽的,我的点阵表需要每个字符提供advance(字符宽度)和ascent(上沿高度),否则文字会挤或者错位。

实现文本绘制时,我按照“基线对齐”来定位每一行的文字,而不是简单地用矩形左上角对齐。前者更贴近真实排版,虽然点阵字体没有真实字体的那些细节,但至少能让你理解glyph、字体度量、换行这些概念背后到底在算什么。这个细节如果你直接跳过,绘制出来的文本会歪得没法看。

5. 实操过程与核心代码实现

5.1 项目结构和测试用例的设计

代码工程结构,我推荐至少保持这样的边界:

src/ lexer/ parser/ dom/ style/ layout/ paint/ main.go

每个模块之间严格单向依赖:parser依赖lexer,style依赖parser和dom,layout依赖style,paint依赖layout。千万不要图省事让layout直接去读parser的Token,那样整个项目会乱成一锅粥。真实浏览器里模块边界更严格,因为代码量大,边界不清就会变成“改一个点崩一片”。

测试用例的设计同样重要。我强烈建议你不要等到所有模块写完再测试,而是每一步都准备一个“极小页面”来验证输出。比如解析阶段用一条”body { color: red; }”,布局阶段用”body { margin: 10px; }”,绘制阶段用一个带文字的div。每个阶段都能可视化验证,你会发现debug的成本瞬间下降。

我在项目里写了一个叫demo.htmldemo.css的固定测试文件,内容就是一个标题加两行正文,处理完输出output.png。只要每次改动之后重新跑一遍,看输出的PNG是否和预期一致,就可以快速判断有没有改坏东西。

5.2 从一个demo看完整流程

为了让你更有体感,我贴出这个项目的“一天入门”demo。它把整个引擎串联起来,从CSS字符串到最终图片输出,大概100行核心代码。

先定义要渲染的DOM结构。为了省事,我用了一个可读的JSON形式来描述DOM,而不是另外写HTML解析器。原因是这个项目的重点是CSS解析和渲染,HTML解析是另一个巨大课题,暂时不掺和:

root := &DomNode{ Tag: "body", Children: []*DomNode{ {Tag: "h1", Children: []*DomNode{{Text: "Hello MiNi Renderer"}}}, {Tag: "div", Children: []*DomNode{{Text: "This is a demo"}}}, }, }

然后输入一份CSS:

cssInput := ` body { margin: 40px auto; max-width: 600px; } h1 { color: #333; font-size: 24px; } div { background: #f5f5f5; padding: 16px; } `

接下来就是流水线调用:

tokens := lexer.Tokenize(cssInput) ss := parser.Parse(tokens) styledTree := style.BuildStyleTree(root, ss) layoutTree := layout.BuildLayoutTree(styledTree, 800, 600) paintOps := paint.GeneratePaintOps(layoutTree) img := paint.Rasterize(paintOps)

这段代码最后生成的PNG,我已经跑过很多遍,稳定输出一张带着标题、正文、灰色背景块的示意图。这个demo虽然简陋,但它完成了“从字符串到像素”的全链路,这就是整个项目最大的价值。

5.3 实操心得:每个阶段最容易卡住的地方

做完这整个项目,我最大的体会是:卡住的时候,不要急着改代码,先停下来画图。纯粹在脑子里推演布局坐标非常容易出错,尤其是flex布局调试时,我把每个盒子的坐标、尺寸、margin、padding全部打印出来,用文本表格的方式在终端里看:

name x y w h margin-left margin-right body 40 40 520 188 40 40 h1 40 40 520 33 0 0 child div 40 89 520 66 0 0

这种表格一出来,哪一步算错了立刻就能看到。真实浏览器的DevTools里那个“Layout”面板,本质上就是在投递这种信息,只是人家做得更可视化。

另外一个心得是:优先保证正确性,再考虑抽象和性能。我一开始就试图把Layout抽象成可配置的策略模式,写了一大堆接口,结果代码跳来跳去,BUG查得欲仙欲死。后来把接口全拆了,直接用最朴素的if/else写布局逻辑,代码变长了,但每个分支都能看清,调试效率翻倍。

6. 常见问题与排查技巧实录

6.1 词法分析器的“无限循环”

我先遇到的是经典的“游标不前进”问题。写Tokenizer时,如果遇到一个无法识别的字符,又没及时让游标向前移动,程序会陷在一个死循环里。我一开始遇到的是@media这种关键词,Token类型表里没有@,当时图省事直接返回未知类型,结果就是游标卡在@上一直处理不出来。

解决办法是在词法分析器里加一个硬性“前进保证”:每次tokenize循环末尾必须断言本次消费的字符数大于0,否则抛出panic。这一步在集成测试里帮我抓到了不少隐藏bug,推荐你也加上。

6.2 布局中“父子上下文”混在一起

布局阶段我踩过最大的坑是:子盒子的布局结果影响了父盒子的高度,但父盒子的宽度还没有算完。典型场景是子盒子设置了一个很宽的宽度,导致父盒子宽度被撑大,但父盒子的尺寸已经按之前的宽度定好了。这个问题在真实浏览器中对应“reflow”的复杂性,只是真实浏览器有更精细的脏标记和增量更新机制。我的解决办法比较简单粗暴:布局分两遍,第一遍只计算每个盒子的宽度和水平方向约束,第二遍从上到下计算纵向坐标和高度。这就是上面layoutBlock里calculateWidth和layoutChildren分开的根本原因。你先横向后纵向,这个顺序不能乱。

6.3 文本的基线问题

绘制文本时我最初用盒子的左上角作为文字原点,结果中文和英文混排时,文字要么偏上要么偏下,看起来像贴在天花板上。后来我把文字绘制改成“先知道baseline,再画字形”的思路:盒子的Y坐标加上padding-top,再往上取一个baseline偏移,然后在这个baseline上方绘制字形。这之后文字的位置才基本正常。

这个经历让我对CSS里的line-heightvertical-align有了更实在的理解。它们不是玄学,只是在定义“盒子里那根虚拟基线到底放哪”。

6.4 选型建议:这些弯路你可以少走

如果你也想复刻这个项目,我给几个发自内心的建议:

  • 选择一门带标准库图形输出的语言,比如Go的image、Python的Pillow,可以省去接入外部渲染库的麻烦,把精力留在核心管线逻辑上。
  • 测试页面不要用复杂布局,就用一个标题、一个段落、一个带背景的div加几行文本,先跑通主流程,再加flex、再加选择器优先级。
  • 绘制命令和光栅化要分开。哪怕你只是命令行打印绘制命令,也比直接画在屏幕上更有利于调试。

6.5 排查问题速查表

现象可能原因排查思路
解析卡死词法分析游标未前进在循环末尾断言消费字符数大于0
文字重叠基线定位不准确先定位baseline再绘制字形
盒子位置偏离父宽度计算在前,子宽度改动在后台强制分两遍布局,先横后纵
flex子项不排开flex-grow分配到了负空间边界情况时对剩余空间做max(0, remaining)保护
背景颜色覆盖文本绘制命令顺序错误严格按照背景、边框、文本的顺序生成命令

写在最后

这个项目从开头到跑通,我前后用了大约两周的业余时间。期间无数次想放弃,觉得“这玩意儿到底有没有用”,但当我第一次看到自己写的解析器把一段CSS变成一张像模像样的PNG图片时,那种满足感不是看文档能获得的。

如果你问我做这个项目最大的收获是什么,我会说:你以后再也不会把浏览器渲染当成一个黑盒。你会知道它每一步大概在做什么,哪一步最贵,哪一步可以优化,哪一步出了问题会表现为“页面白屏”还是“样式错乱”。这种底气,是看多少博客都换不来的。如果你也在考虑自己动手写一个迷你渲染引擎,别犹豫,从一个最简单的选择器和一个最简单的块级布局开始吧。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询