简介

原因

  1. 作为前端开发,长期与浏览器打交道,需要或者说想要了解部分浏览器的内部流程,但是功能齐全的浏览器又十分复杂,所以想通过实现个玩具来了解流程。
  2. 我想练下TS。。。

浏览器流程

想要实现一个渲染引擎,需要先确认渲染引擎是浏览器的哪一部分,具体是做什么的,有什么功能。
先来看一下Chrome浏览器:

  • Browser Process:
    • 负责包括地址栏,书签栏,前进后退按钮等部分的工作
    • 负责处理浏览器的一些不可见的底层操作,比如网络请求和文件访问
  • Renderer Process:
    • 负责一个 tab 内关于网页呈现的所有事情
  • Plugin Process:
    • 负责控制一个网页用到的所有插件,如 flash
  • GPU Process
    • 负责处理 GPU 相关的任务

浏览器进程.png

很明显渲染引擎部分肯定是存在于Renderer Process下了,那我们来看一下渲染进程的构成:
2084336019-5a65972413011_fix732.png
而我们要实现的“渲染”引擎,仅仅是渲染进程中的GUI渲染线程这一小部分。

实现内容

这里是一张网上流传很久的图,就按照它来做。

渲染主流程图.png
来源(可能需要合理上网):https://www.html5rocks.com/zh/tutorials/internals/howbrowserswork/

实现

下面我们就按照上图一步一步的来做,因为掺杂代码且篇幅较长,阅读时间可能会比较长,大家可以选择感兴趣的章节查看。

标签树(HTML Parser)

样式(CSS Parser)

渲染树(attchment)

布局计算(Layout)

渲染(Painting)

标签树(HTML Parser)

我们首先来定一下DOM树的数据格式,然后实现一个解析器(不确定我实现的算不算解析器)

简易DOM

当在浏览器里console.dir(document)的时候,可以看到详细的DOM对象的属性,很多,肯定写不完…… 还是写个简单的吧。
首先,树就是节点带个子节点数组:

  1. export interface Node {
  2. children:Array<Node>,
  3. node_type:NodeType
  4. }
  5. // 构建node
  6. export function buildNode(name: string, attrs?: AttrMap,children?:Array<Node>):Node {
  7. // 因篇幅省略
  8. }

详细解释一下上面的类型

这个NodeType暂时只实现了文本节点和普通节点,而且为了方便文本节点没有设置任何属性(后续样式直接继承了父节点)。

而通用节点也是使用了最简单的描述方式: 标签名、标签属性。

  1. // 定义一个全局的stringHash类型
  2. export type strHash = {
  3. [details: string]:string
  4. }
  5. // 这里NodeType有两种类型,一直不知道TS这样使用对不对,有更好的使用方式还请大佬评论提点
  6. export type NodeType = ElementData | string
  7. // node节点的属性字典
  8. export type AttrMap = strHash
  9. export class ElementData {
  10. tag_name: string;
  11. attributes: AttrMap;
  12. constructor(tag_name: string, attributes: AttrMap){
  13. this.tag_name = tag_name
  14. this.attributes = attributes
  15. }
  16. idGet():string{
  17. return this.attributes['id']
  18. }
  19. classGet(){
  20. return this.attributes?.["class"]?.split(' ')||[]
  21. }
  22. }

解析HTML

HTML的解析器其实十分复杂,因为他有大量的错误语法兼容。市面上也有很多成熟的实现:gumbo-parserBeautiful Soup 等等,而我只准备实现其中最基础的标签、属性、文字。
入口方法:

  1. parseHtml(html: string):dom.Node{
  2. let nodes = new Parser(0,html).parse_nodes()
  3. if(nodes.length==1){
  4. return nodes[0]
  5. }else{
  6. return dom.buildNode('html',{},nodes)
  7. }
  8. }

我使用指针扫描字符串,下面是指针和最基础的几个扫描函数。

  1. class Parser{
  2. pos: number;
  3. input: string;
  4. constructor(pos: number, input: string){
  5. this.pos = pos
  6. this.input = input
  7. }
  8. // 下一字符
  9. next_char():string{
  10. return this.input[this.pos]
  11. }
  12. // 返回当前字符,并将this.pos+1。
  13. next_char_skip():string {
  14. let iter = this.input[this.pos]
  15. this.pos++
  16. return iter;
  17. }
  18. // 是否遍历到字符的最后一个
  19. is_over():boolean{
  20. return this.pos>=this.input.length
  21. }
  22. // 当前位置的开头
  23. starts_with_point(str:string):boolean {
  24. return this.input.startsWith(str,this.pos)
  25. }
  26. /**
  27. * 此函数是解析类的核心方法,根据传入的匹配函数来连续匹配符合某种规则的字符
  28. * 既能获取符合规则的字符串,又能跳过指定字符串,后续解析大多基于此方法。
  29. * @param test 匹配字符函数
  30. * @returns 符合规则的连续字符
  31. */
  32. check_str(test:(str:string)=>boolean):string {
  33. let result:string = ''
  34. while (!this.is_over() && test(this.next_char())) {
  35. result=`${result}${this.next_char_skip()}`
  36. }
  37. return result
  38. }
  39. /// ……其余详细函数
  40. }

有了基础方法,后面就是利用基础方法匹配语法。注:本小节下方所有函数均为Parser类中的函数

先来个简单的,跳过空格/回车等无用字符:

  1. check_str_empty(){
  2. const reg = /\s/
  3. this.check_str((str)=>reg.test(str))
  4. }

标签、属性名都是连续的字母/数字字符串,所以:

  1. // 解析 标签 或者 属性名 (就是匹配一串字母、数字的字符串)
  2. parse_tag_name():string {
  3. return this.check_str((str)=>{
  4. const regWord = /[A-Za-z0-9]/;
  5. if(str.match(regWord)){
  6. return true
  7. }
  8. return false
  9. })
  10. }

然后,终于到正式解析节点的部分了:

  1. // 解析一个节点
  2. // 如果是"<"就解析Dom,否则就当文本节点解析
  3. parse_node():dom.Node{
  4. if (this.next_char()=="<") {
  5. return this.parse_ele_node()
  6. }else{
  7. return this.parse_text_node()
  8. }
  9. }
  10. // 解析一个文本节点
  11. parse_text_node():dom.Node{
  12. const textNode = this.check_str(str=>str!='<')
  13. return dom.buildNode(textNode)
  14. }
  15. // 解析一个dom节点
  16. parse_ele_node():dom.Node{
  17. if (this.next_char_skip() == '<') {
  18. // 初始标签,< 之后就是标签名,直接调用解析标签名方法
  19. let tag_name = this.parse_tag_name();
  20. // HTML是在标签名后面直接写属性,所以解析完标签名之后,解析属性
  21. let attrs = this.parse_attributes();
  22. // 解析完属性如果是闭合标识那就继续
  23. if (this.next_char_skip() == '>') {
  24. // 标签的开始部分就完成了,这时候进入标签内部了,内部就是子节点,见下方
  25. let children = this.parse_nodes();
  26. // 下面这部分是判断结束标签的语法和是否与开始标签相同
  27. if (this.next_char_skip() == '<'&&
  28. this.next_char_skip() == '/'&&
  29. this.parse_tag_name() == tag_name&&
  30. this.next_char_skip() == '>'
  31. ){
  32. return dom.buildNode(tag_name,attrs,children)
  33. }else{
  34. throw new Error('HTML模板错误,结束标签错误')
  35. }
  36. }else{
  37. throw new Error('HTML模板错误,不以’>‘结束')
  38. }
  39. }else{
  40. throw new Error('HTML模板错误,不以’<‘开始')
  41. }
  42. }
  43. // 解析一组节点 就是一直匹配到结束
  44. parse_nodes():Array<dom.Node>{
  45. // 函数在parse_ele_node中调用,而函数内又调用了parse_node,形成递归
  46. let nodesArr = []
  47. while(1){
  48. this.check_str_empty();
  49. if (this.is_over() || this.starts_with_point("</")) {
  50. break;
  51. }
  52. nodesArr.push(this.parse_node());
  53. }
  54. return nodesArr
  55. }

看到这里,解析一个dom节点的流程就基本清晰了,耐心点,我们再看看流程中的参数解析的过程:

  1. // 解析参数主要就是匹配到“=”,等号左侧为属性名,右侧为属性值,直到发现“>”为止
  2. // 流程中掺杂部分错误处理,仅此而已
  3. parse_attributes():dom.strHash{
  4. let obj = {}
  5. while(this.next_char()!='>'){
  6. this.check_str_empty()
  7. let [name, value] = this.parse_attrs()
  8. obj[name] = value
  9. }
  10. return obj
  11. }
  12. // 解析参数-内部参数
  13. parse_attrs():Array<string>{
  14. let name = this.parse_tag_name();
  15. if (this.next_char_skip()!="=") {
  16. throw new Error("标签内属性设置无‘=’")
  17. }
  18. let value = this.parse_attr_value();
  19. return [name, value]
  20. }
  21. // 解析参数-内部参数值
  22. parse_attr_value():string{
  23. let open_quote = this.next_char_skip();
  24. if (open_quote != '"'&&open_quote != '\'') {
  25. throw new Error('标签属性格式错误')
  26. }
  27. let value = this.check_str(c=> c != open_quote);
  28. if (open_quote != this.next_char_skip()) {
  29. throw new Error('标签属性格式错误')
  30. }
  31. return value
  32. }

至此,我们的HTML解析就结束了

  1. import {parseHtml} from './html'
  2. const parsed_dom = parseHtml(htmlStr)

此时这个parsed_dom就是一个我们最初定义的DOM树,至此我们的HTML解析工作就完成了。

样式(CSS Parser)

CSS的解析我同样不会做太多的特殊处理,只确保实现基础能力,能够正常解析以下代码即可:

  1. * { display: block;padding: 12px; }
  2. div .a { background: #ff0000; }

由此推断,我们需要实现:选择器解析、属性解析
选择器解析:简单选择器、选择器组合
属性解析:普通字符串、像素字符串、16进制颜色字符串
实现内容定位完成,下面开始实现,基本方式和HTML解析基本一致,先定义样式规则对象,之后将符合CSS语法的字符串解析为规则对象。

样式规则Rules

上面我们已经简单的解释过了,一个规则应该是选择器组和属性组的结合:

  1. // 最终输出,一组样式规则
  2. export interface StyleSheet {
  3. rules:Array<Rules>
  4. }
  5. export interface Rules{
  6. selectors:Array<Selector>, // 选择器
  7. declarations:Array<Declaration<string|ColorValue>> // 属性
  8. }

参照上方的计划,目前准备实现的是简单选择器,未进行其他类型选择器的实现。所以选择器对象:

  1. export interface Selector {
  2. Simple:SimpleSelector;
  3. }

而简单选择器我们都知道,根据类、ID、标签等选择器类型来界定了一个“权重”。从链接里可以看到,权重是根据“有无”来界定的,也就是存在ID选择器的情况下,不管有多少类选择器,ID选择器都是最高权重,但是这里为了方便,就直接按照最简单的理解方式实现了权重计算。
简单选择器类:

  1. export class SimpleSelector{
  2. tag_name: Array<string>
  3. id:Array<string>
  4. class:Array<string>
  5. constructor(tag_name: Array<string>,id:Array<string>,className:Array<string>){
  6. this.tag_name = tag_name
  7. this.id = id
  8. this.class = className
  9. }
  10. specificity=():number=>this.id.length*100+this.class.length*10+this.tag_name.length
  11. }

另一个就是属性,单个属性例如: margin: auto; 其实就是键值对。
目前属性值的类型只支持字符串和颜色,我希望后面能增加一些,但是在TS的使用上,这种 或 逻辑的类型一直处理不太好,后续弄明白再回来优化,但是类型大概就是这样:

  1. // 属性值
  2. export interface Declaration<T>{
  3. name:string,
  4. value:T
  5. }
  6. export interface ColorValue {
  7. r: number,
  8. g: number,
  9. b: number,
  10. a: number
  11. }

解析CSS

定义好类型之后就可以进行CSS的解析了,入口函数:

  1. export function parseCss(source:string):StyleSheet{
  2. const parser = new Parser(0,source);
  3. return {rules:parser.parse_rules()}
  4. }

其实解析类的基础方法和HTML解析器基本一致,这里就不赘述了,主要看一下选择器解析和属性解析。
选择器解析:

  1. // 解析选择器
  2. parse_selectors():Array<Selector>{
  3. let selectors:Array<Selector> = []
  4. this.check_str_empty()
  5. selectors.push({Simple:this.parse_simple_selector()})
  6. this.check_str_empty()
  7. const nextStr = this.next_char()
  8. if(nextStr=='{'){
  9. this.check_str_empty()
  10. return selectors
  11. }else{
  12. throw new Error('类型选择器编排格式错误')
  13. }
  14. }
  15. // 解析单个选择器
  16. parse_simple_selector():SimpleSelector{
  17. let tag_name=[]
  18. let ids=[]
  19. let className =[]
  20. while(!this.is_over()){
  21. this.check_str_empty()
  22. const nextStr = this.next_char()
  23. if (nextStr === '#') {
  24. this.next_char_skip()
  25. ids.push(this.parse_identifier())
  26. }else if(nextStr === '.'){
  27. this.next_char_skip()
  28. className.push(this.parse_identifier());
  29. }else if(nextStr === '*'){
  30. this.next_char_skip()
  31. }else if (valid_identifier_char(nextStr)){
  32. tag_name.push(this.parse_identifier())
  33. }else{
  34. break
  35. }
  36. }
  37. return new SimpleSelector(tag_name,ids,className)
  38. }

属性解析相对也比较简单:

  1. // 解析单个属性(css的键值对)
  2. parse_declaration():Declaration<string|ColorValue>{
  3. this.check_str_empty()
  4. // 解析属性值
  5. let prototype_name = this.parse_identifier()
  6. this.check_str_empty()
  7. const nextStr = this.next_char_skip()
  8. if (nextStr==':') {
  9. this.check_str_empty()
  10. // 解析属性值
  11. let value = this.parse_value()
  12. this.check_str_empty()
  13. if (this.next_char_skip()==';') {
  14. return {
  15. name:prototype_name,
  16. value:value
  17. }
  18. }else{
  19. throw new Error('css属性没有;关闭')
  20. }
  21. }else{
  22. throw new Error('css属性语法错误')
  23. }
  24. }

属性解析部分只展示了整体的逻辑,解析逻辑都大同小异,如果认真做了HTML解析,这部分应该比较好理解,详细代码可以去CSS解析查看。

  1. import {parseCss} from './css'
  2. const parsed_style_sheet = parseCss(cssStr)

此时这个parsed_style_sheet就是一个个我们定义的StyleSheet规则组。

渲染树(attchment)

在这一步准备实现的是属性分配,该模块是将前面两个解析器的解析结果作为输入,并按照一定的规则给DOM节点添加CSS属性。最终的输出结果是一棵带有CSS样式的树。
样式树单个节点对象:

  1. // 单个样式树节点
  2. export class StyleNode{
  3. node:dom.Node
  4. children:Array<StyleNode>
  5. specified_values:myHash<string|css.ColorValue>
  6. constructor(node,children,specified_valu){
  7. this.node = node
  8. this.children = children
  9. this.specified_values = specified_valu
  10. }
  11. /// 本章节可暂时忽略以下代码
  12. // 如果存在,就返回属性值
  13. value(name:string){
  14. return this.specified_values[name]
  15. }
  16. }

这部分是我个人认为在整个流程中最简单的部分,主要是因为我并未实现:继承、初始值、行内属性、!important 声明等兼容。当摒弃了这些复杂的内容后,这部分的核心逻辑就仅仅是遍历DOM树,并根据id、选择器、标签名匹配CSS并关联的操作。
我们先实现单个节点的匹配操作:

  1. /**
  2. * 获取有对应class/tagname/id的规则组,并给权重
  3. * @param elem
  4. * @param stylesheet
  5. * @returns
  6. */
  7. function match_rules(elem:dom.ElementData,stylesheet:css.StyleSheet):Array<ruleHight>{
  8. return stylesheet.rules.map(rule =>{
  9. return {
  10. declarations:rule.declarations,
  11. selector_specificity_all:match_selector(rule.selectors,elem)
  12. }
  13. }).filter(ruleHight=>ruleHight.selector_specificity_all>0)
  14. }
  15. /**
  16. * 获取选择器组和节点匹配的权重
  17. * @param selector css选择器
  18. * @param element dom节点
  19. */
  20. function match_selector(selectors:Array<css.Selector>,element:dom.ElementData):number{
  21. return selectors.reduce((prev,selector)=>{
  22. if(matches_simple_selector(selector.Simple,element)){
  23. // 这里这个+1操作,是为了适配 * 选择器,在之前的css解析中,* 选择器的权重为0。
  24. // 当总权重为0时会在下一步被过滤掉,所以多写一个+1,正式排序权重为上一步计算权重+1
  25. return selector.Simple.specificity()+prev+1
  26. }
  27. },0)
  28. }
  29. // 要测试简单选择器是否与元素匹配,只需查看每个选择器组件
  30. function matches_simple_selector(simple:css.SimpleSelector,element:dom.ElementData):boolean{
  31. const tag_name_has:boolean = simple.tag_name.length===0||simple.tag_name.includes(element.tag_name)
  32. const id_arr:boolean = simple.id.length===0||simple.id.includes(element.idGet())
  33. const class_arr:boolean = simple.class.length===0||simple.class.some(cl=>{
  34. return element.classGet().includes(cl)
  35. })
  36. return tag_name_has&&id_arr&&class_arr
  37. }

通过以上方法,可以给单个节点匹配对应的样式,且有对应样式的权重。我则是简单的按照权重从小到大排序,之后依次赋属性值,这样权重大的属性就会覆盖之前赋值的小权重属性。

  1. /**
  2. * 获取对应dom的style值
  3. * @param elem dom的参数
  4. * @param stylesheet 样式树
  5. * @returns
  6. */
  7. function specified_values(elem:dom.ElementData,stylesheet:css.StyleSheet):myHash<string|css.ColorValue>{
  8. let res = {}
  9. const rules = match_rules(elem,stylesheet)
  10. rules.sort((a,b)=>{
  11. return a.selector_specificity_all-b.selector_specificity_all
  12. })
  13. rules.forEach(ruleHight=>{
  14. ruleHight.declarations.forEach(declaration=>{
  15. res[declaration.name] = declaration.value
  16. })
  17. })
  18. return res
  19. }

现在就获取了一个节点的完整样式属性,然后最后一步,直接递归遍历树,每一个节点执行对应的获取样式属性的方法即可:

  1. // 样式表
  2. export function get_style_tree(root:dom.Node, stylesheet:css.StyleSheet,parent:myHash<string|css.ColorValue>={}):StyleNode {
  3. // 如果是文本节点就直接取父节点的样式(还是做了一点点继承,主要是没有单独的文本节点样式。。。。)
  4. let style_values:myHash<string|css.ColorValue> =
  5. typeof root.node_type !== 'string'?
  6. specified_values(root.node_type,stylesheet):
  7. parent
  8. let style_tree:StyleNode = new StyleNode(
  9. root,root.children.map(node => get_style_tree(node,stylesheet,style_values)),style_values)
  10. return style_tree
  11. }

详细代码可查看 style文件

  1. import {get_style_tree} from './style'
  2. const pStyle = get_style_tree(parsed_dom,parsed_style_sheet)

最终根据DOM树和StyleSheet规则组,生成了带有样式属性的style_tree。

布局计算(Layout)

我觉得直接跳到这里的肯定多。毋庸置疑,这部分一定是最难的,我在项目刚刚开始的时候就期待着这部分的实现。当然,和之前一样,依旧不能实现一个完整的布局内容,本模块我只实现了最简单的display:block块的布局。
而计算布局的最终结果,则是一个“盒模型”树,我们将通过上面得到的样式树计算他的位置和大小。说来惭愧,之前一直不理解Layout过程的核心应该是盒模型,直到读到这篇文章
既然我们最后要得到一个盒模型,那我们先来定义一个盒模型:

  1. interface EdgeSizes{
  2. left: number,
  3. right: number,
  4. top: number,
  5. bottom: number,
  6. }
  7. // 大小和位置的信息集
  8. export class Rect{
  9. x: number
  10. y: number
  11. width: number
  12. height: number
  13. constructor(x: number, y: number, width: number, height: number){
  14. this.x = x
  15. this.y = y
  16. this.width = width
  17. this.height = height
  18. }
  19. // 套壳子,比如,当前盒子是content盒子,传入padding,返回一个padding_box
  20. // 根据Dimensions中的使用就很好理解了
  21. expanded_by(edge: EdgeSizes):Rect {
  22. return new Rect(
  23. this.x - edge.left,
  24. this.y - edge.top,
  25. this.width + edge.left + edge.right,
  26. this.height + edge.top + edge.bottom,
  27. )
  28. }
  29. }
  30. // 盒模型,margin_box、border_box、padding_box、content等均为Rect类,可以直接得到大小、位置信息。
  31. export class Dimensions{
  32. // 相对于原点的位置,内容大小
  33. content:Rect
  34. // 四个方向尺寸
  35. padding: EdgeSizes
  36. border: EdgeSizes
  37. margin: EdgeSizes
  38. constructor(content:Rect,padding:EdgeSizes,border: EdgeSizes,margin: EdgeSizes){
  39. this.content = content
  40. this.padding = padding
  41. this.border = border
  42. this.margin = margin
  43. }
  44. padding_box():Rect{
  45. return this.content.expanded_by(this.padding)
  46. }
  47. border_box():Rect{
  48. return this.padding_box().expanded_by(this.border)
  49. }
  50. margin_box():Rect{
  51. return this.border_box().expanded_by(this.margin)
  52. }
  53. }

布局树则是盒模型的树,这棵树的构建要分为两步进行,首先我们要先构建一个内部存储盒模型信息的完整的树结构,之后再遍历树进行定位计算。
为什么一定要先构建树后计算呢?因为我们子节点的宽度不仅仅是由css规范的,而且默认情况下还等于父节点宽度。同时,父节点高度默认情况下也要根据子节点高度和来计算。而如果没有提前构建完整的树,那我们一定是无法计算父节点高度的!

构建布局树

  1. export function defaultDimensions():Dimensions{
  2. return new Dimensions(defaultRect(),defaultEdgeSizes(),defaultEdgeSizes(),defaultEdgeSizes())
  3. }
  4. // 布局块类型
  5. export enum BoxType {
  6. BlockNode,
  7. NoneBlock,
  8. InlineBlockNode,
  9. InlineNode,
  10. TextNode
  11. }
  12. // 布局树的节点
  13. export class LayoutBox{
  14. dimensions:Dimensions // 盒模型
  15. box_type:BoxType // 盒子类型
  16. children:Array<LayoutBox>
  17. style_node:StyleNode|null
  18. constructor(box_type:BoxType,children: Array<LayoutBox>,style_node:StyleNode,dimensions:Dimensions=defaultDimensions()){
  19. this.dimensions = dimensions
  20. this.box_type = box_type
  21. this.children = children
  22. switch(box_type){
  23. case BoxType.BlockNode:
  24. case BoxType.InlineBlockNode:
  25. case BoxType.InlineNode:
  26. case BoxType.TextNode:
  27. this.style_node = style_node
  28. break
  29. case BoxType.NoneBlock:
  30. default:
  31. this.style_node = null
  32. }
  33. }
  34. // 布局
  35. layout(containing_block: Dimensions):void{}
  36. ///// 下方详解
  37. }

BoxType是一个布局块类型的枚举,是根据样式树中display属性来判断的,首先来说当前确实未实现除block块之外的布局,但是依旧做了简单分类,方便以后拓展,当前对block/inline类型做了不同的处理,如果是block类型则作为单独子节点存储,如果是inline类型,则统一放到一个子节点下,如果是display:none则直接跳过,不参与布局计算,代码如下:

  1. function build_layout_tree(style_node:StyleNode):LayoutBox{
  2. let root = new LayoutBox(style_node.display(),[],
  3. // 这一步主要是将子节点置空(我个人认为后面占用内存小)
  4. new StyleNode(style_node.node,[],style_node.specified_values))
  5. style_node.children.forEach(child_node =>{
  6. root.pushChild(child_node)
  7. })
  8. return root
  9. }
  10. export class LayoutBox{
  11. ///// 同上方LayoutBox类,属性见上方代码
  12. // 不同子节点处理方案
  13. pushChild(child_node:StyleNode){
  14. switch(child_node.display()){
  15. case BoxType.BlockNode:
  16. case BoxType.TextNode:
  17. this.children.push(build_layout_tree(child_node))
  18. break
  19. case BoxType.InlineNode:
  20. case BoxType.InlineBlockNode:
  21. this.inlineChild(child_node).children.push(build_layout_tree(child_node))
  22. break
  23. case BoxType.NoneBlock:
  24. break
  25. }
  26. }
  27. /**
  28. * 获取行内块
  29. * 如果当前父节点本身就是InlineNode,那就直接返回父节点
  30. * 如果当前父节点不是InlineNode,那看当前最后的子节点是不是InlineNode,如果是就直接用它,如果不是就新建一个来用
  31. * @returns 一个InlineNode类型的节点
  32. */
  33. inlineChild(node:StyleNode):LayoutBox{
  34. if(this.box_type === BoxType.InlineNode||this.box_type === BoxType.NoneBlock){
  35. return this
  36. }else{
  37. if(!this.children.length||this.children[this.children.length-1].box_type!==BoxType.InlineNode){
  38. this.children.push(new LayoutBox(BoxType.InlineNode,[],node))
  39. }
  40. return this.children[this.children.length-1]
  41. }
  42. }
  43. }

此时已经完成树的构建了,下面就是最核心的布局计算了。

布局计算

当然布局计算也是“偷工减料”版,并未实现:定位、浮动等一系列复杂规则。
在上文中已经提到过,节点的宽度取决于它的父节点,而节点的高度则取决于它的子节点,这就意味着我们在计算宽度时,要自上而下的遍历树,以便在子节点宽度计算时父节点已经完成宽度计算,而在计算高度时,则要自下而上的遍历树,以便父节点的高度计算时,子节点已经完成高度的计算。
我们通过这样一段代码来实现:

  1. export class LayoutBox{
  2. ///// 同上方LayoutBox类,属性见上方代码 ......
  3. // 块模式布局
  4. layout_block(containing_block: Dimensions):void {
  5. // console.log(containing_block);
  6. // 计算块宽度(宽度取决于父节点,所以先计算宽度在计算子节点)
  7. this.calculate_block_width(containing_block)
  8. // 计算块的位置
  9. this.calculate_block_position(containing_block);
  10. // 计算子节点(计算宽度后计算子节点)
  11. this.calculate_block_children()
  12. // 计算块高度(高度取决于子节点,所以先计算子节点之后才能处理高度)
  13. /// 第二轮,增加个文本节点的高度
  14. if (this.box_type === BoxType.TextNode) {
  15. // 文本节点的高度计算
  16. this.calculate_Text_hight()
  17. }else{
  18. this.calculate_block_hight()
  19. }
  20. }
  21. }

我们在子节点计算之前先进行宽度计算,在高度计算之前先进行子节点的计算,本质是利用递归过程中,函数调用栈中的缓存,直接使用调用栈中的父节点宽度和子节点高度。
下面来看下我认为最复杂的宽度计算(开始前没思考过宽度计算如此复杂),我会分步来介绍,首先先获取属性:

  1. // 拓展了前面style中的类,来获取值
  2. /**
  3. * 获取属性值,如果name找不到就找fallback_name,还没有就直接返回默认值value
  4. * @param name
  5. * @param fallback_name
  6. * @param value
  7. */
  8. lookup(name:string, fallback_name:string,value:string|css.ColorValue){
  9. return this.value(name)||this.value(fallback_name)||value;
  10. }

例如: let margin_left = style.lookup("margin-left", "margin", '0'); 用来取margin-left 的值
然后先计算当前的默认宽度(auto暂时按照0计算),因为后续所有宽度适配逻辑,都是根据当前宽度和父节点宽度对比计算的。

  1. /**
  2. * 计算宽/高 的和,auto 暂时当做0
  3. * @param restNums 所有参数
  4. * @returns 所有参数的和
  5. */
  6. function add_px(...restNums:string[]){
  7. return restNums.reduce((prev,next)=>{
  8. let nextNums:number
  9. if(next=='auto'){
  10. nextNums = 0
  11. }else{
  12. nextNums = Number(next)
  13. if(Number.isNaN(nextNums)){
  14. throw new Error('布局过程中发现错误px类型')
  15. }
  16. }
  17. return nextNums+prev
  18. },0)
  19. }

然后用子节点宽度和父节点宽度对比,如果子节点宽度大于父节点宽度,且子节点margin设置的auto,则自动赋值为0,此时子节点自动被父节点截取。
如果子节点宽度小于父节点宽度,则按照各种规则给他填充,最终还是要占满,大家可以看下浏览器的控制台,会有自动补齐但是无法选中的margin。具体规则见代码:

  1. export class LayoutBox{
  2. ///// 同上方LayoutBox类,属性见上方代码 ......
  3. // 计算宽度
  4. calculate_block_width(containing_block: Dimensions){
  5. if(this.style_node){
  6. let style = this.style_node
  7. let margin_left = style.lookup("margin-left", "margin", '0');
  8. let margin_right = style.lookup("margin-right", "margin", '0');
  9. let border_left = style.lookup("border-left-width", "border-width", '0');
  10. let border_right = style.lookup("border-right-width", "border-width", '0');
  11. let padding_left = style.lookup("padding-left", "padding", '0');
  12. let padding_right = style.lookup("padding-right", "padding", '0');
  13. let width = style.value("width") || 'auto'
  14. let total = add_px(
  15. margin_left as string,
  16. margin_right as string,
  17. border_left as string,
  18. border_right as string,
  19. padding_left as string,
  20. padding_right as string,
  21. width as string);
  22. // 如果宽度超了,而且margin设置的auto,那就给他默认值0
  23. if (width != 'auto' && total > containing_block.content.width) {
  24. if (margin_left == 'auto') {margin_left = '0'}
  25. if (margin_right == 'auto') {margin_right = '0'}
  26. }
  27. // 如果宽度小了,按照各种规则给他弄满,最终还是要占满(用margin补)
  28. let underflow = containing_block.content.width - total;
  29. const [width_auto,margin_r_auto,margin_l_auto] = [width=='auto',margin_right=='auto',margin_left=='auto']
  30. if(width_auto){
  31. // 如果宽度是自适应
  32. if (margin_l_auto) margin_left = '0'
  33. if (margin_r_auto) margin_right = '0'
  34. if (underflow >= 0) {
  35. // 那宽度直接等于需要补充的值
  36. width = `${underflow}`
  37. } else {
  38. //或者已经超出了,则margin_right变短(也就是右侧截掉)
  39. width = '0'
  40. margin_right = `${Number(margin_right)+underflow}`
  41. }
  42. }else{
  43. // 如果宽度固定
  44. if(margin_l_auto&&margin_r_auto){
  45. // 左右都自适应,各取一半
  46. margin_left = `${underflow/2}`
  47. margin_right = `${underflow/2}`
  48. }else{
  49. if(!margin_l_auto&&!margin_r_auto){
  50. // 左右都不自适应,margin_right自己去适应,还要计算上自己本身的值
  51. margin_right = `${Number(margin_right)+underflow}`
  52. }else if(margin_r_auto){
  53. // 右自适应
  54. margin_right =`${underflow}`
  55. }else{
  56. // 左自适应
  57. margin_left =`${underflow}`
  58. }
  59. }
  60. }
  61. // 盒模型开始赋值
  62. let di = this.dimensions;
  63. di.content.width = Number(width)
  64. di.padding.left = Number(padding_left)
  65. di.padding.right = Number(padding_right)
  66. di.border.left = Number(border_left)
  67. di.border.right = Number(border_right)
  68. di.margin.left = Number(margin_left)
  69. di.margin.right = Number(margin_right)
  70. }
  71. }
  72. }

下面就是相对简单的位置布局了,因为我们取消了特殊布局和浮动,所以我们只需要根据父节点的padding/margin/border等属性进行子节点的XY定位就好了,这里唯一一个思考点在于Y轴,Y轴按照正常页面布局流,当前节点应该在同级前一个节点的底部,而我们的算法在执行上一个子节点时会将父节点height增加,所以当前Y值直接取值父节点的最底部即可:

  1. // 计算位置
  2. calculate_block_position(containing_block: Dimensions){
  3. let style = this.style_node
  4. if (style) {
  5. let d = this.dimensions;
  6. // 这里很有意思,上一个执行的子节点会将父节点height增加,所以当前的Y值直接
  7. // 可以取父节点的高度,父节点就是参数 containing_block
  8. // 如果是auto变为0
  9. const getNumber = (str:string):number=>{
  10. if (str == 'auto') {return 0}
  11. return Number(str)
  12. }
  13. d.margin.top = getNumber(style.lookup("margin-top", "margin", '0') as string)
  14. d.margin.bottom = getNumber(style.lookup("margin-bottom", "margin", '0') as string)
  15. d.border.top = getNumber(style.lookup("border-top-width", "border-width", '0') as string)
  16. d.border.bottom = getNumber(style.lookup("border-bottom-width", "border-width", '0') as string)
  17. d.padding.top = getNumber(style.lookup("padding-top", "padding", '0') as string)
  18. d.padding.bottom = getNumber(style.lookup("padding-bottom", "padding", '0') as string)
  19. d.content.x = containing_block.content.x +
  20. d.margin.left + d.border.left + d.padding.left;
  21. d.content.y = containing_block.content.height + containing_block.content.y +
  22. d.margin.top + d.border.top + d.padding.top;
  23. }
  24. }

子节点,递归调用方法,除此之外额外操作就是给父节点的高度赋值。

  1. // 计算子节点
  2. calculate_block_children(){
  3. // 直接把孩子递归,但是记得在递归过程中取高度出来
  4. const children = this.children,di=this.dimensions
  5. for (const child of children) {
  6. child.layout(di);
  7. di.content.height+=child.dimensions.margin_box().height;
  8. }
  9. }

最后,高度计算,其实就是查看有没有明文的高度属性,如果没有则使用子节点计算时加好的:

  1. // 计算高度
  2. calculate_block_hight(){
  3. // 如果有明确的高度则使用明确高度,如果没有就直接使用已存在的(子节点布局时加的)
  4. if (this.style_node) {
  5. const cssHight = this.style_node.value('height')
  6. if (cssHight&&cssHight!=='auto') {
  7. this.dimensions.content.height = Number(cssHight);
  8. }
  9. }
  10. }

至此,我们的布局计算就完成了,这时候回想一下,我们将HTML字符串和CSS字符串变为了一堆有位置、有尺寸、有颜色的盒子对象,还挺神奇的。

渲染(Painting)

最后则是将盒子渲染成图像的过程,浏览器将这一过程称之为“光栅化”,浏览器使用了Skia、Direct2D等图形库来实现,而我则借助了node-canvas(node层的图形库似乎真的很少)实现。
按照惯例,只做最简单实现,当前只实现了绘制矩形和绘制文字:

  1. function parseFloat(color:ColorValue):string{
  2. return "#" +
  3. ("0" + color.r.toString(16)).slice(-2) +
  4. ("0" + color.g.toString(16)).slice(-2) +
  5. ("0" + color.b.toString(16)).slice(-2)
  6. }
  7. namespace PaintingDraw{
  8. export class drawRectangle{
  9. color?:ColorValue
  10. rect:Rect
  11. constructor(rect:Rect,color?:ColorValue){
  12. this.color = color
  13. this.rect = rect
  14. }
  15. drawItem(context:CanvasRenderingContext2D){
  16. context.fillStyle = this.color?parseFloat(this.color):'transparent'
  17. const rect = this.rect
  18. context.fillRect(rect.x, rect.y, rect.width, rect.height)
  19. }
  20. }
  21. export class drawText{
  22. color:ColorValue
  23. rect:Rect
  24. text:string
  25. constructor(text:string,rect:Rect,color:ColorValue={
  26. r:0,g:0,b:0,a:255
  27. }){
  28. this.color = color
  29. this.text = text
  30. this.rect = rect
  31. }
  32. drawItem(context:CanvasRenderingContext2D){
  33. context.fillStyle = this.color?parseFloat(this.color):'transparent'
  34. const rect = this.rect
  35. const fillStyle = this.color?parseFloat(this.color):'transparent'
  36. context.textBaseline = 'top'
  37. context.fillStyle = fillStyle
  38. context.font = `${rect.height}px Impact`
  39. context.fillText(this.text, rect.x, rect.y)
  40. }
  41. }
  42. }

渲染类构建完毕,下面我会将整棵树变为一个渲染操作列表,之后遍历这个列表,按顺序进行图形绘制:

  1. // 构建渲染列表
  2. function build_display_list(layout_root: LayoutBox):DisplayList {
  3. let list:DisplayList = []
  4. build_layout(list, layout_root);
  5. return list;
  6. }
  7. function build_layout(list:DisplayList, layout_root:LayoutBox){
  8. // console.log(layout_root.box_type);
  9. if (layout_root.box_type===BoxType.TextNode) {
  10. // 绘制文字
  11. build_layout_Text(list, layout_root)
  12. }else if(layout_root.box_type===BoxType.BlockNode){
  13. // 绘制矩形
  14. build_layout_box(list, layout_root)
  15. }
  16. for (const boxChild of layout_root.children) {
  17. build_layout(list,boxChild)
  18. }
  19. }

下面我们再来看一下矩形的绘制,矩形的绘制主要分两步,背景和边框,而背景是一个矩形,如果颜色透明就跳过(这里也说明了颜色透明和display:none的区别,display:none在布局阶段就已经被跳过),而边框则是四个矩形:

  1. function build_layout_box(list:DisplayList,layout_box:LayoutBox){
  2. render_background(list, layout_box);
  3. render_borders(list, layout_box);
  4. }
  5. // 把矩形渲染放进去
  6. function render_background(list:DisplayList,layout_box:LayoutBox){
  7. const colorValue = get_color(layout_box,'background','background-color')
  8. list.push(new PaintingDraw.drawRectangle(layout_box.dimensions.border_box(),colorValue||undefined))
  9. }
  10. // 渲染边框,其实是渲染四个矩形
  11. function render_borders(list:DisplayList,layout_box:LayoutBox){
  12. const borderColor = get_color(layout_box,'border-color')
  13. let d = layout_box.dimensions
  14. let border_box = d.border_box();
  15. // 上边框
  16. list.push(new PaintingDraw.drawRectangle(
  17. new Rect (border_box.x,border_box.y,border_box.width,d.border.top),
  18. borderColor||undefined))
  19. // 右边框
  20. list.push(new PaintingDraw.drawRectangle(
  21. new Rect (border_box.x+border_box.width,border_box.y,d.border.right,border_box.height),
  22. borderColor||undefined))
  23. // 下边框
  24. list.push(new PaintingDraw.drawRectangle(
  25. new Rect (border_box.x,border_box.y+border_box.height,border_box.width+d.border.right,d.border.bottom),
  26. borderColor||undefined))
  27. // 左边框
  28. list.push(new PaintingDraw.drawRectangle(
  29. new Rect (border_box.x,border_box.y,d.border.left,border_box.height),
  30. borderColor||undefined))
  31. }
  32. function get_color(layout_box:LayoutBox,name:string,otherName?:string){
  33. if (layout_box.style_node) {
  34. if (otherName){
  35. return layout_box.style_node.lookup(name,otherName,null) as ColorValue
  36. }
  37. return layout_box.style_node.value(name) as ColorValue
  38. }
  39. return null
  40. }

文字渲染:

  1. // 渲染文字
  2. function build_layout_Text(list:DisplayList,layout_box:LayoutBox){
  3. const fontColor = get_color(layout_box,'color')
  4. list.push(new PaintingDraw.drawText(layout_box.style_node.node.node_type as string,
  5. layout_box.dimensions.border_box(),fontColor||undefined))
  6. }

最后,我们将列表进行图像绘制,并输出

  1. // 主函数,将绘制树变为图片
  2. export function paint(layout_root:LayoutBox, bounds:Dimensions):Buffer{
  3. let display_list = build_display_list(layout_root);
  4. const canvas:Canvas = createCanvas(bounds.content.width, bounds.content.height)
  5. const context:CanvasRenderingContext2D = canvas.getContext('2d')
  6. for (const drawClass of display_list) {
  7. drawClass.drawItem(context)
  8. }
  9. return canvas.toBuffer('image/png')
  10. }

最终结果:

  1. <html>
  2. <div class="outer">
  3. <p class="inner">
  4. Hello,world!
  5. </p>
  6. <p class="textTest">
  7. Text Test
  8. </p>
  9. <p class="inner" id="bye">
  10. Goodbye!
  11. </p>
  12. </div>
  13. </html>
  1. * {
  2. display: block;
  3. }
  4. span {
  5. display: inline;
  6. }
  7. html {
  8. margin: auto;
  9. background: #ffffff;
  10. }
  11. head {
  12. display: none;
  13. }
  14. .outer {
  15. width:600px;
  16. background: #00ccff;
  17. border-color: #666666;
  18. border-width: 2px;
  19. margin: 50px;
  20. }
  21. .textTest{
  22. background: #008000;
  23. font-size:20px;
  24. color:#f0f00f;
  25. }
  26. .inner {
  27. border-color: #cc0000;
  28. border-width: 4px;
  29. height: 100px;
  30. margin: auto;
  31. margin-bottom: 20px;
  32. width: 500px;
  33. background: #0000ff;
  34. font-size:24px;
  35. color:#ffffff;
  36. }

test.png
最终,这个玩具实现了渲染引擎的最基本功能,输入HTML、CSS字符串,输出正确的渲染图像。
大家如果感兴趣或者有哪部分代码不完善的可以直接去github上看一下

参考资料:
https://zhuanlan.zhihu.com/p/47407398
http://dev.chromium.org/developers/design-documents
https://segmentfault.com/a/1190000012925872
https://limpet.net/mbrubeck/2014/08/08/toy-layout-engine-1.html