尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

从零手写迷你渲染引擎:解析CSS布局绘制全链路

发布时间:2026/9/19 12:55:10

资讯中心
01
ARTICLE

从零手写迷你渲染引擎:解析CSS布局绘制全链路

从零手写迷你渲染引擎:解析CSS布局绘制全链路
开篇先问一个问题你写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流到CSSOMToken流拿到手之后Parser的工作就是把它变成有结构的数据也就是CSSOM。这一步说白了是“根据CSS语法吃掉Token”每次消费Token时都要判断眼前的Token是否符合预期不符合就报错或者跳过。我实现的是一个非常轻量的递归下降Parser因为CSS的语法本身就比较规整不需要上复杂的LR分析器。核心逻辑可以抽象成三件事读选择器直到遇到左大括号。读声明块直到遇到右大括号。重复直到EOF。其中声明块的解析又分成读属性名、读冒号、读值、读分号或右大括号结束。这里最容易忽略的是“属性值可能不止一个Token”比如margin: 0 auto和font: 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}。你得把选择器列表里所有部分各自归类型再加总不能把整条规则当成一个整体。还有继承。color、font-size这类属性天然具有继承性但margin、padding、display则不会继承。我的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为mlborder-left为blpadding-left为plwidth为wpadding-right为prborder-right为brmargin-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-rowfunc 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-basis和width同时存在时的优先级。规范里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, borderWidth2, color#000000) // 边框 3. Text(x, y, hello, color#fff, fontSize14) // 文本这样做的好处有两个。第一绘制命令是对布局结果的显式描述你可以在真正光栅化之前先打印一遍命令列表人工检查逻辑对不对。第二很多优化思路比如“脏矩形重绘”“图层提升”本质上都是对绘制命令的管理而不是对像素的直接操作。你先有命令层后面想扩展这些东西才有基础。实现时我用了一个简单的枚举类型和参数结构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标准库image和image/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依赖lexerstyle依赖parser和domlayout依赖stylepaint依赖layout。千万不要图省事让layout直接去读parser的Token那样整个项目会乱成一锅粥。真实浏览器里模块边界更严格因为代码量大边界不清就会变成“改一个点崩一片”。测试用例的设计同样重要。我强烈建议你不要等到所有模块写完再测试而是每一步都准备一个“极小页面”来验证输出。比如解析阶段用一条”body { color: red; }”布局阶段用”body { margin: 10px; }”绘制阶段用一个带文字的div。每个阶段都能可视化验证你会发现debug的成本瞬间下降。我在项目里写了一个叫demo.html和demo.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}}}, }, }然后输入一份CSScssInput : 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-height和vertical-align有了更实在的理解。它们不是玄学只是在定义“盒子里那根虚拟基线到底放哪”。6.4 选型建议这些弯路你可以少走如果你也想复刻这个项目我给几个发自内心的建议选择一门带标准库图形输出的语言比如Go的image、Python的Pillow可以省去接入外部渲染库的麻烦把精力留在核心管线逻辑上。测试页面不要用复杂布局就用一个标题、一个段落、一个带背景的div加几行文本先跑通主流程再加flex、再加选择器优先级。绘制命令和光栅化要分开。哪怕你只是命令行打印绘制命令也比直接画在屏幕上更有利于调试。6.5 排查问题速查表现象可能原因排查思路解析卡死词法分析游标未前进在循环末尾断言消费字符数大于0文字重叠基线定位不准确先定位baseline再绘制字形盒子位置偏离父宽度计算在前子宽度改动在后台强制分两遍布局先横后纵flex子项不排开flex-grow分配到了负空间边界情况时对剩余空间做max(0, remaining)保护背景颜色覆盖文本绘制命令顺序错误严格按照背景、边框、文本的顺序生成命令写在最后这个项目从开头到跑通我前后用了大约两周的业余时间。期间无数次想放弃觉得“这玩意儿到底有没有用”但当我第一次看到自己写的解析器把一段CSS变成一张像模像样的PNG图片时那种满足感不是看文档能获得的。如果你问我做这个项目最大的收获是什么我会说你以后再也不会把浏览器渲染当成一个黑盒。你会知道它每一步大概在做什么哪一步最贵哪一步可以优化哪一步出了问题会表现为“页面白屏”还是“样式错乱”。这种底气是看多少博客都换不来的。如果你也在考虑自己动手写一个迷你渲染引擎别犹豫从一个最简单的选择器和一个最简单的块级布局开始吧。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。