关于浏览器:动手打造一款-canvas-排版引擎

51次阅读

共计 10958 个字符,预计需要花费 28 分钟才能阅读完成。

图片起源:https://unsplash.com

本文作者:飞腾

背景

在线示例

Demo

作为前端开发尤其是偏 c 端的前端开发者(如微信小程序),置信大家都碰到过分享流动图片、分享海报图相似的性能

个别这种需要的解决方案大体上能够分为以下几种:

  1. 依赖服务端,比方写一个 node 服务,用 puppeteer 拜访提前写好的网页来截图。
  2. 间接应用 CanvasRenderingContext2D 的 api 或者应用辅助绘图的工具如 react-canvas 等来绘制。
  3. 应用前端页面截图框架,比方 html2canvasdom2image,用 html 将页面构造写好,再在须要的时候调用框架 api 截图

计划剖析:

  1. 依赖服务端这种计划会耗费肯定的服务端资源,尤其截图这种服务,对 cpu 以及带宽的耗费都是很大的,因而在一些可能高并发或者图片比拟大的场景用这种计划体验会比拟差,等待时间很长,这种计划的长处是还原度十分高,因为服务端无头浏览器版本是确定的,所以能够确保所见即所得,并且从开发上来说,无其余学习老本,如果业务还不是很大访问量不高用这种计划是最牢靠的。
  2. 这种计划比拟硬核,比拟费时费力,大量的代码来计算布局的地位,文字是否换行等等,并且当开发实现后,如果 ui 后续有一些调整,又要在茫茫代码中寻找你要批改的那个它。这个计划的长处是细节很可控,实践上各种性能都能够实现,如果头发够用的话。
  3. 这应该也是目前 web 端应用最广的一种计划了,截止目前 html2canvas star 数量曾经 25k。html2canvas 的原理简略来说就是遍历 dom 构造中的属性而后转化到 canvas 上来渲染进去,所以它必然是依赖宿主环境的,那么在一些老旧的浏览器上可能会遇到兼容性问题,当然如果是开发中就遇到了还好,毕竟咱们是万能的前端开发(狗头),能够通过一些 hack 伎俩来躲避,然而 c 端产品会运行在各种各样的设施上,很难防止公布后在其余用户设施上兼容问题,并且出了问题除非用户上报,个别难以监控到,并且在国内小程序用户量基数很大,这个计划也不能在小程序中应用。所以这个计划看似一片祥和,然而会有一些兼容的问题。

在这几年不同的工作中,根本都遇到了须要分享图片的需要,尽管需要个别都不大频次不高,然而印象中每次做都不是很顺畅,下面几种计划也都试过了,多多少少都有一些问题。

萌发想法:

在一次需要评审中理解到在后续迭代有 ui 对立调整的布局,并且会波及到几个分享图片的性能,过后的业务是波及到小程序以及 h5 的。会后关上代码,看到了像山一样的分享图片代码,并且穿插着各种兼容胶水代码,如此宏大的代码只是为了生成一个小卡片的布局,如果是 html 布局,应该 100 行就能写完,过后就想着怎么来进行重构。

鉴于开发工夫还很富余,我在想有没有其余更便捷、牢靠、通用一点的解决方案,并且本人对这块也始终很感兴趣,秉持着学习的态度,于是萌发了本人写一个库的想法,通过思考后我抉择了 react-canvas 的实现思路,然而 react-canvas 依赖于 React 框架,为了放弃通用性,咱们本次开发的引擎不依赖特定 web 框架、不依赖 dom 的 api,能依据相似 css 的样式表来生成布局渲染,并且反对进阶性能能够进行交互。

在梳理了要做的性能后,一个繁难的 canvas 排版引擎浮现脑海。

什么是排版引擎

排版引擎(layout engine),也称为浏览器引擎(browser engine)、页面渲染引擎(rendering engine)或样版引擎,它是一种软件组件,负责获取标记式内容(如 HTML、XML 及图像文件等等)、整顿信息(如 CSS 及 XSL 等),并将排版后的内容输入至显示器或打印机。所有网页浏览器、电子邮件客户端、电子阅读器以及其它须要依据示意性的标记语言(Presentational markup)来显示内容的应用程序都须要排版引擎。

摘自 Wikipedia 对浏览器排版引擎的形容,对于前端同学来说这些概念应该是比拟相熟的,常见的排版引擎比方 webkit、Gecko 等。

设计

指标

本次需要承载了以下几个指标:

  1. 框架反对“文档流布局”,这也是咱们的外围需要,不须要开发者指定元素的地位,以及主动宽高。
  2. 过程式调用转为申明式调用,即不须要调用繁琐的 api 来绘制图形,只须要编写 template 就能够生成图形。
  3. 跨平台,这里次要是指能够在 web 以及各种小程序上运行,不依赖特定框架。
  4. 反对交互,即能够减少事件,并且能够对 UI 进行批改。

总结下来就是在能够在 canvas 里写“网页”。

api 设计

在最后的构想里,打算应用 相似 vue template 语法 来作为构造款式数据,然而这么做会减少编译老本,对于我想要实现的外围性能来说它的终点有点太远了。在衡量过后,最终打算应用 相似 React createElement 的语法 + Javascript style object的模式的 api,优先实现外围性能。

另外须要留神的是,咱们的指标不是在 canvas 里实现浏览器规范,而是尽可能贴近 css 的 api,以提供一套计划能实现文档流布局。

指标 api 长这样

// 创立图层
const layer = lib.createLayer(options);

// 创立节点树
// c(tag,options,children)
const node = lib.createElement((c) => {
  return c(
    "view", // 节点名
    {
      styles: {
        backgroundColor: "#000",
        fontSize: 14,
        padding: [10, 20],
      }, // 款式
      attrs: {}, // 属性 比方 src
      on: {click(e) {console.log(e.target);
        },
      }, // 事件 如 click load
    },
    [c("text", {}, "Hello World")] // 子节点
  );
});

// 挂载节点
node.mount(layer);

如上所示,api 的外围在于创立节点的三个参数:

  1. tagName 节点名,这里咱们反对根本的元素,像 view,image,text,scroll-view 等,另外还反对自定义标签,通过全局componentapi 来注册一个新的组件,利于扩大。
function button(c, text) {
  return c(
    "view",
    {
      styles: {// ...},
    },
    text
  );
}

// 注册一个自定义标签
lib.component("button", (opt, children, c) => button(c, children));

// 应用
const node = lib.createElement((c) => {return c("view", {}, [c("button", {}, "这是全局组件")]);
});
  1. options,即标签的参数,反对 styles,attrs,on,别离为 款式、_属性_、_事件_
  2. children,即子节点,同时也能够是文字。

咱们冀望执行以上 api 后能够在 canvas 中渲染出文字,并且点击后能够响应相应事件。

流程架构

框架的首次渲染将按以下流程执行,前面也会依照这个程序进行解说:

上面会将流程图中的要害细节进行讲述,代码中波及到一些算法以及数据结构须要留神。

模块细节

预处理

在拿到视图模型(即开发者通过createElementapi 编写的模型)后,须要首先对其进行预处理,这一步是为了过滤用户输出,用户输出的模型只是通知框架用意的指标,并不能间接拿来应用:

  1. 节点预处理

    • 反对简写字符串,这一步须要将字符串转为 Text 对象
    • 因为咱们前面须要频繁拜访兄弟节点以及父节点,所以这一步将兄弟节点以及父节点都保留在以后节点,并且标记出所在父容器中的地位,这一点很重要,这个概念相似于 React 中的 Fiber 构造,在后续计算中频繁应用到,并且为咱们实现 可中断渲染 打下了根底。
  2. 款式预处理

    • 一些款式是反对多种简写形式的,须要将其转换为目标值。如padding:[10,20],在预处理器中须要转换成paddingLeftpaddingRightpaddingToppaddingBottom4 个值。
    • 设置节点默认值,如 view 节点默认 display 属性为block
    • 继承值解决,如 fontSize 属性默认继承父级
  3. 异样值解决,用户填写了不合乎预期的值在这一步进行揭示。
  4. 初始化事件挂载、资源申请等。
  5. 其余为后续计算以及渲染的筹备工作(前面会讲到)。
initStyles() {this._extendStyles()

    this._completeStyles()

    this._initRenderStyles()}

布局解决

在上一步预处理过后,咱们就失去了一个带有残缺款式的节点树,接下来须要计算布局,计算布局分为尺寸和地位的计算,这里须要留神的是,流程里为什么要先计算尺寸呢?认真思考一下,如果咱们先计算地位,像文字,图片这种之后的节点,是须要在上一个尺寸地位计算结束再去参考计算。所以这一步是所有节点原地计算尺寸结束后,再计算所有节点的地位。

整个过程如下动画。

计算尺寸

更业余一点的说法应该是计算盒模型,说到盒模型大家应该是耳熟能详了,根底面试简直必问的。

图片起源:https://mdn.mozillademos.org/…

在 css 中,能够通过 box-sizing 属性来应用不同的盒模型,然而咱们本次不反对调整,默认为border-box

对于一个节点,他的尺寸能够简化为几种状况:

  1. 参考父节点,如width:50%
  2. 设置了具体值,如width:100px
  3. 参考子节点,如 width:fit-content,另外像image text 节点也是由内容决定尺寸。

梳理好这几种模式之后就能够开始遍历计算了,对于一个树咱们有多种遍历模式。

_广度优先遍历_:

_深度优先遍历_:

这里咱们对下面几种状况别离做思考:

  1. 因为是参考父节点所以须要从父到子遍历。
  2. 没有遍历程序要求。
  3. 父节点须要等所有子节点计算实现后再进行计算,因而须要广度优先遍历,并且是从子到父。

这里呈现了一个问题,第 1 种和第 3 种所需遍历形式呈现了抵触,然而回过头来看预处理局部正是从父到子的遍历,因而 1、2 局部计算尺寸的工作能够提前在预处理局部计算好,这样达到这一步的时候只须要计算第 3 局部,即依据子节点计算。

class Element extends TreeNode {
  // ...

  // 父节点计算高度
  _initWidthHeight() {const { width, height, display} = this.styles;
    if (isAuto(width) || isAuto(height)) {
      // 这一步须要遍历,判断一下
      this.layout = this._measureLayout();}

    if (this._InFlexBox()) {this.line.refreshWidthHeight(this);
    } else if (display === STYLES.DISPLAY.INLINE_BLOCK) {
      // 如果是 inline-block  这里仅计算高度
      this._bindLine();}
  }

  // 计算本身的高度
  _measureLayout() {
    let width = 0; // 须要思考本来的宽度
    let height = 0;
    this._getChildrenInFlow().forEach((child) => {// calc width and height});

    return {width, height};
  }

  // ...
}

代码局部就是遍历在文档流中的间接子节点来累加高度以及宽度,另外解决上比拟麻烦的是对于一行会有多个节点的状况,比方 inline-blockflex,这里减少了 Line 对象来辅助治理,在 Line 实例中会对以后行内的对象进行治理,子节点会绑定到一个行实例,直到这个 Line 实例达到最大限度无奈退出,父节点计算尺寸时如果读取到 Line 则间接读取所在行的实例。

这里 Text Image 等本身有内容的节点就须要继承后重写 _measureLayout 办法,Text在外部计算换行后的宽度与高度,Image则计算缩放后的尺寸。

class Text extends Element {
  // 依据设置的文字大小等来计算换行后的尺寸
  _measureLayout() {this._calcLine();
    return this._layout;
  }
}

计算地位

计算完尺寸后就能够计算地位了,这里遍历形式须要从父到子进行广度优先遍历,对于一个元素来说,只有确定了父元素以及上一个元素的地位,就能够确定本身的地位。

这一步只须要思考依据父节点曾经上一个节点的地位来确认本身的地位,如果不在文档流中则依据最近的参考节点进行定位。

绝对简单的是如果是绑定了 Line 实例的节点,则在 Line 实例外部进行计算,在 Line 外部的计算则是相似的,不过须要另外解决对齐形式以及主动换行等逻辑。

// 代码仅保留外围逻辑
_initPosition() {
    // 初始化 ctx 地位
    if (!this._isInFlow()) {// 不在文档流中解决} else if (this._isFlex() || this._isInlineBlock()) {this.line.refreshElementPosition(this)
    } else {this.x = this._getContainerLayout().contentX
      this.y = this._getPreLayout().y + this._getPreLayout().height
    }
  }
class Line {
  // 计算对齐
  refreshXAlign() {if (!this.end.parent) return;
    let offsetX = this.outerWidth - this.width;
    if (this.parent.renderStyles.textAlign === "center") {offsetX = offsetX / 2;} else if (this.parent.renderStyles.textAlign === "left") {offsetX = 0;}
    this.offsetX = offsetX;
  }
}

好了这一步实现后布局处理器的工作就实现了,接下来框架会将节点输出渲染器进行渲染。

渲染器

对于绘制单个节点来说分为以下几个步骤:

  • 绘制暗影, 因为暗影是在里面的,所以须要在裁剪之前绘制
  • 绘制裁剪以及边框
  • 绘制背景
  • 绘制子节点以及内容,如 TextImage

对于渲染单个节点来说,性能比拟惯例,渲染器基本功能是依据输出来绘制不同的图形、文字、图片,因而咱们只须要实现这些 api 就能够了,而后将节点的款式通过这些 api 按程序来渲染进去,这里又说到程序了,那么渲染这一步咱们应该依照什么程序呢。这里给出答案 深度优先遍历

canvas 默认合成模式下,在同一地位绘制,后渲染的会笼罩在下面,也就是说后渲染的节点的 z-index 更大。(因为复杂度起因,目前没有实现像浏览器 合成层 的解决,临时是不反对手动设置 z-index 的。)

另外咱们还须要思考一种状况,如何去实现 overflow:hidden 成果呢,比方圆角,在 canvas 中超出的内容咱们须要进行裁剪显示,然而仅仅对父节点裁剪是不合乎需要的,在浏览器中父节点的裁剪成果是能够对子节点失效的。

在 canvas 中一个残缺的裁剪过程调用是这样的.

// save ctx status
ctx.save();

// do clip
ctx.clip();

// do something like paint...

// restore ctx status
ctx.restore();
//

须要理解的是,CanvasRenderingContext2D中的状态以栈的数据结构保留,当咱们屡次执行 save 后,每执行一次 restore 就会复原到最近的一次状态

也就是说只有在 cliprestore这个过程内绘制的内容才会被裁减,因而如果要实现父节点裁剪对子节点也失效,咱们不能在渲染一个节点后马上restore,须要等到外部子节点都渲染完后再调用。

上面通过图片解说

如图,数字是渲染程序

  • 绘制节点 1,因为还有子节点,所以不能马上 restore
  • 绘制节点 2,还有子节点,绘制节点 3,节点 3 没有子节点,因而执行 restore
  • 绘制节点 4,没有子节点,执行 restore,留神啦,此时节点 2 内的节点都曾经绘制结束,因而须要再次执行 restore,复原到节点 1 的绘制上下文
  • 绘制节点 5,没有子节点,执行 restore,此时节点 1 内都绘制结束,再次执行 restore

因为咱们在预处理中曾经实现了 Fiber 构造,并且晓得节点所在父节点的地位,只须要在每个节点渲染实现后进行判断,须要调用多少次restore

至此,通过漫长的 debug 以及重构,曾经能失常将输出的节点渲染进去了,另外须要做的是减少对其余 css 属性的反对,此时心田曾经是冲动万分,然而看着控制台里输入的渲染节点,总感觉还能做点什么。

对了!每个图形的模型都保留了,那是不是能够对这些模型进行批改以及交互呢,首先定一个小指标,实现事件零碎。

事件处理器

canvas 中的图形并不能像 dom 元素那样响应事件,因而须要对 dom 事件进行代理,判断在 canvas 上产生事件的地位,再散发到对应的 canvas 图形节点。

如果依照惯例的事件总线设计思路,咱们只须要将不同的事件保留在不同的 List 构造中,在触发的时候遍历判断点是否在节点区域,然而这种计划必定不行,究其原因还是性能问题。

在浏览器中,事件的触发分为 捕捉 冒泡 ,也就是说要依照节点的层级从顶至下先执行 捕捉 ,涉及到最深的节点后,再以相同的程序执行 冒泡 过程,List构造无奈满足,遍历这个数据结构的工夫复杂度会很高,体现到用户体验上就是操作有提早。

通过一阵的头脑风暴后想到事件其实也能够保留在树结构中,将有事件监听的节点抽离进去组成一个新的树,能够称之为“事件树”,而不是保留在原节点树上。

如图,在 1、2、3 节点挂载 click 事件,会在事件处理器内生成另一个回调树结构,在回调时只须要对这个树进行遍历,并且能够进行剪枝优化,如果父节点没有触发,则这个父节点下的子元素都不须要遍历,进步性能体现。

另外一个重点就是断定事件点是否在元素内,对于这个问题,曾经有了许多成熟的算法,如 射线法

工夫复杂度:O(n) 适用范围:任意多边形

算法思维:
以被测点 Q 为端点,向任意方向作射线(个别程度向右作射线),统计该射线与多边形的交点数。如果为奇数,Q 在多边形内;如果为偶数,Q 在多边形外。

然而对于咱们这个场景,除了圆角外都是矩形,而圆角解决起来会比拟麻烦,因而初版都是应用矩形来进行判断,后续再作为优化点改良。

依照这个思路就能够实现咱们繁难的事件处理器。

class EventManager {
  // ...

  // 增加事件监听
  addEventListener(type, callback, element, isCapture) {
    // ...
    // 结构回调树
    this.addCallback(callback, element, tree, list, isCapture);
  }

  // 事件触发
  _emit(e) {const tree = this[`${e.type}Tree`];
    if (!tree) return;

    /**
     * 遍历树,查看是否回调
     * 如果父级没有被触发,则子级也不须要查看,跳到下个同级节点
     * 执行 capture 回调,将 on 回调增加到 stack
     */
    const callbackList = [];
    let curArr = tree._getChildren();
    while (curArr.length) {walkArray(curArr, (node, callBreak, isEnd) => {
        if (node.element.isVisible() &&
          this.isPointInElement(e.relativeX, e.relativeY, node.element)
        ) {node.runCapture(e);
          callbackList.unshift(node);
          // 同级前面节点不须要执行了
          callBreak();
          curArr = node._getChildren();} else if (isEnd) {
          // 到最初一个还是没监测到,完结
          curArr = [];}
      });
    }

    /**
     * 执行 on 回调,从子到父
     */
    for (let i = 0; i < callbackList.length; i++) {if (!e.currentTarget) e.currentTarget = callbackList[i].element;
      callbackList[i].runCallback(e);
      // 解决阻止冒泡逻辑
      if (e.cancelBubble) break;
    }
  }

  // ...
}

事件处理器实现后,能够来实现一个 scroll-view 了,外部实现原理是用两个 view,内部固定宽高,外部能够撑开,内部通过事件处理器注册事件来管制渲染的 transform 值,须要留神的是,transform渲染后,子元素的地位就不在原来的地位了,所以如果在子元素挂载了事件会偏移,这里在 scroll-view 外部注册了相应的捕捉事件,当事件传入 scroll-view 外部后,批改事件实例的绝对地位,来纠正偏移。

class ScrollView extends View {
  // ...

  constructor(options, children) {
    // ...
    // 外部再初始化一个 scroll-view,高度自适应,外层宽高固定
    this._scrollView = new View(options, [this]);
    // ...
  }

  // 为本人注册事件
  addEventListener() {
    // 注册捕捉事件,批改事件的绝对地位
    this.eventManager.EVENTS.forEach((eventName) => {
      this.eventManager.addEventListener(
        eventName,
        (e) => {if (direction.match("y")) {e.relativeY -= this.currentScrollY;}
          if (direction.match("x")) {e.relativeX -= this.currentScrollX;}
        },
        this._scrollView,
        true
      );
    });

    // 解决滚动
    this.eventManager.addEventListener("mousewheel", (e) => {// do scroll...});

    // ...
  }
}

重排重绘

除了生成动态布局性能外,框架也有重绘重排的过程,当批改了节点的属性后会触发,外部提供了 setStyle,appendChild 等 api 来批改款式或者构造,会依据属性值来确认是否须要重排,如批改 width 会触发重排后重绘,批改 backgroundColor 则只会触发重绘, 比方 scroll-view 滚动时,只是扭转了 transform 值,只会进行重绘。

兼容性

尽管框架自身不依赖 dom,间接基于 CanvasRenderingContext2D 进行绘制,然而一些场景下仍须要作兼容性解决,上面举几个例子。

  • 微信小程序平台绘制图片 api 与规范不同,因而在 image 组件判断了平台,如果是微信则调用微信特定 api 进行获取
  • 微信小程序平台设置字体粗细在 iOS 真机上不失效,外部判断平台后,会将文字绘制两次,第二次在第一次根底上进行偏移,造成加粗成果。

自定义渲染

尽管框架自身曾经反对大部分场景的布局,然而业务需要场景复杂多变,所以提供了自定义绘制的能力,即只进行布局,绘制办法交给开发者自行调用,提供更高的灵活性。

engine.createElement((c) => {
  return c("view", {render(ctx, canvas, target) {// 这里能够获取到 ctx 以及布局信息,开发者绘制自定义内容},
  });
});

web 框架中应用

尽管 api 自身绝对简略,然而依然须要写一些反复的代码,结构复杂的时候不便于浏览。

当在古代 web 框架中应用时,能够采纳相应的框架版本,比方 vue 版本,外部会将 vue 节点转换为 api 调用,应用起来会更易于浏览,然而须要留神,因为外部会有节点转换过程,相比间接应用会有性能损耗,在结构复杂时差别会较显著。

<i-canvas :width="300" :height="600">
  <i-scroll-view :styles="{height:600}">
    <i-view>
      <i-image
        :src="imageSrc"
        :styles="styles.image"
        mode="aspectFill"
      ></i-image>
      <i-view :styles="styles.title">
        <i-text>Hello World</i-text>
      </i-view>
    </i-view>
  </i-scroll-view>
</i-canvas>

调试

鉴于业务场景比较简单,框架目前提供的调试工具还比拟根底,通过设置 debug 参数能够开启节点布局的调试,框架会将所有节点的布局绘制进去,如果须要查看单个节点的布局,须要通过挂载事件后打印到控制台进行调试。后续外围功能完善后会提供更全面的可视化调试工具。

成绩

通过亲自体验,在个别页面的开发效率上,曾经与写 html 并驾齐驱,这里为了展现成绩,我写了一个简略的组件库 demo 页。

源码

组件库 Demo

性能

框架在通过几次重构后曾经获得了不错的体现,性能体现如下

曾经做了的优化:

  • 遍历算法优化
  • 数据结构优化
  • scroll-view 重绘优化

    • scroll-view 重绘只渲染范畴内的元素
    • scroll-view 可视范畴外的元素不会渲染
  • 图片实例缓存,尽管有 http 缓存,然而对于同样的图片会产生多个实例,外部做了实例缓存

待优化:

  • 可中断渲染,因为咱们曾经实现了相似 Fiber 构造,所以后续有须要加上这个个性也比拟不便
  • 预处理器还须要加强,加强对于用户输出的款式与构造的兼容,加强健壮性

总结

从最后想实现一个简略的图片渲染性能,最初实现了一个繁难的 canvas 排版引擎,尽管实现的 feature 无限并且还有不少细节与 bug 须要修复,然而曾经具备根本的布局以及交互能力,其中还是踩了不少坑,重构了很屡次,同时也不禁感叹浏览器排版引擎的弱小。并且从中也领会到了算法与数据结构的魅力,良好的设计是性能高、维护性佳的基石,也取得不少乐趣。

另外这种模式通过欠缺后集体感觉还是有不少想象力,除了简略的图片生成,还能够用于 h5 游戏的列表布局、海量数据的表格渲染等场景,另外前期还有一个想法,目前社区渲染这块曾经有很多做的不错的库,所以想将布局以及计算换行、图片缩放等性能独立进去一个独自的工具库,通过集成其余库来进行渲染。

自己表达能力无限,可能还是有很多细节没有失去廓清,也欢送大家评论交换。

感激浏览

本文公布自 网易云音乐大前端团队,文章未经受权禁止任何模式的转载。咱们长年招收前端、iOS、Android,如果你筹备换工作,又恰好喜爱云音乐,那就退出咱们 grp.music-fe(at)corp.netease.com!

正文完
 0