项目详情
github 地址: https://github.com/muwoo/vnode2canvas
demo 实例: demo https://muwoo.github.io/vnode2canvas/
背景
起初, 在公司做一些活动页的时候, 经常需要用到截图分享的千人千面的功能, 而且这种需求并不止一两次, 而是经常会出现在各种各样的截图场景. 第一次碰到这种需求的时候, 基本上都会去手撸 canvasAPI 去做渲染功能, 这种情况的步骤大致如下:
写一大串 dom template 标签
渲染 template 成 dom 标签
开始捕捉 dom 元素, 绘制 canvas
canvas 渲染图片
面临的主要问题是复用性太差, 其次是性能上也有问题, 用户看到的界面不一定和正式渲染出的界面一致, 可能存在渲染差异. 因为我工作中主要使用的是 vue, 对 vue 核心思想也有一定研究, vue 通过 vnode 实现了对不同端的渲染工作, 那有没有可能通过 vnode 实现对 canvas 的渲染呢? 也就是说, 没有
vnode -> html -> canvas
而是直接 vnode -> canvas. 同时利用 vue 的数据驱动, 来达到绘制的数据驱动. 想法有了, 下面开始实施.
调研
这篇文章对此有详细的介绍: 60 FPS on the mobile web https://engineering.flipboard.com/2015/02/mobile-web 这里简单的概括一下:
canvas 是一种立即模式的渲染方式, 不会存储额外的渲染信息. Canvas 受益于立即模式, 允许直接发送绘图命令到 GPU. 但若用它来构建用户界面, 需要进行一个更高层次的抽象. 例如一些简单的处理, 比如当绘制一个异步加载的资源到一个元素上时会出现问题, 如在图片上绘制文本. 在 HTML 中, 由于元素存在顺序, 以及 CSS 中存在 z-index, 因此是很容易实现的. dom 渲染是一种保留模式, 保留模式是一种声明性 API, 用于维护绘制到其中的对象的层次结构. 保留模式 API 的优点是, 对于你的应用程序, 他们通常更容易构建复杂的场景, 例如 DOM. 通常这都会带来性能成本, 需要额外的内存来保存场景和更新场景, 这可能会很慢.
开始!
canvas 的渲染其实也是一种尝试, 既然前人以及做了充分的实践, 那么我们便站在巨人的肩膀上去基于 vue 来实现一个数据驱动的 canvas 渲染. 说做就做!
处理 vnode
熟悉 Vue 源码的应该都知道, Vue 通过 render 函数, 传入 createElement 方法来构造出一个 vnode, 通过发布 -- 订阅模式来实现对数据的监听, 重新生成 vnode. 我们要做的就是在 vnode 这一层开始. 所以, 我们基于 Vue 源码的方式, 实现一个监听函数, 并混入 Vue 实例中:
- Vue.mixin({
- // ...
- created() {
- if (this.$options.renderCanvas) {
- // ...
- // 监听 vnode 中引用的变化, 重新渲染
- this.$watch(this.updateCanvas, this.noop)
- // ...
- }
- },
- methods: {
- updateCanvas() {
- // 模拟 Vue render 函数
- // 寻找实例中定义的 renderCanvas 方法, 并传入 createElement 方法
- let vnode = this.$options.renderCanvas.call(this._renderProxy, this.$createElement)
- }
- })
复制代码
这样我们就可以愉快的在组件内部使用:
- renderCanvas (h) {
- return h(...)
- }
复制代码
canvas 元素处理
render 的 vnode 我们需要做额外的一些约束, 比如 dom 的 div 应该对应 canvas 里面的什么, dom 里面的文本, 对应 canvas 里面的什么... 也就是说我们可以这样做一些约束:
自定义标签 | 绘制形式 | 类比 dom |
---|---|---|
view/scrollView/scrollItem | rect | div |
text | text | span |
image | img | img |
其中这些元素类分别都继承于一个 Super 类, 并且由于它们各有不同的展示方式, 因此它们分别实现自己的 draw 方法, 做定制化的展示.
绘制对象的布局机制实现
绘制 canvas 布局最基础的写法是为 canvas 元素传入一系列坐标点和相关的基础宽高, 这样写到实际项目中可能是这样的:
- renderCanvas(h) {
- return h('view', {
- style: {
- left: 10,
- top: 10,
- width: 100,
- height: 100
- }
- })
- }
复制代码
这样写确实有点不方便维护, 目前有好几种解决方案, 一种是使用 css-layout 去做管理. css-layout 支持的转换属性如下:
这样也只是做了一层转换, 帮我们更好的用 css 思维去写 canvas, 但是如果我们很不爽 css in js 的写法, 其实我们还可以写一个 webpack loader 来加载外部 css:
- const css = require('css')
- module.exports = function (source, other) {
- let cssAST = css.parse(source)
- let parseCss = new ParseCss(cssAST)
- parseCss.parse()
- this.cacheable();
- this.callback(null, parseCss.declareStyle(), other);
- };
- class ParseCss {
- constructor(cssAST) {
- this.rules = cssAST.stylesheet.rules
- this.targetStyle = {}
- }
- parse () {
- this.rules.forEach((rule) => {
- let selector = rule.selectors[0]
- this.targetStyle[selector] = {}
- rule.declarations.forEach((dec) => {
- this.targetStyle[selector][dec.property] = this.formatValue(dec.value)
- })
- })
- }
- formatValue (string) {
- string = string.replace(/"/g,'').replace(/'/g,'')
- return string.indexOf('px') !== -1 ? parseInt(string) : string
- }
- declareStyle (property) {
- return `window.${property || 'vStyle'} = ${JSON.stringify(this.targetStyle)}`
- }
- }
复制代码
主要也就是将 css 文件转成 AST 语法树, 之后再对语法树做转换, 转成 canvas 需要的定义形式. 并以变量的形式注入到组件中.
实现列表滚动
如果我们的元素很多, 需要滚动时, 我们必须解决 canvas 内部元素滚动的问题. 这里我选择了使用 Zynga Scroller http://github.com/zynga/scroller 来模拟用户滚动方法, 通过他返回的滚动坐标点, 来对 canvas 进行重绘.
详细的参考这里 https://github.com/muwoo/vnode2canvas/blob/master/src/core/shape/scrollView.js
事件模拟
对于 click,touch 等 dom 事件的模拟, 我们采用的方案是根据点击区域进行检测, 并找出最底层的元素, 递归寻找父元素并触发对应事件处理程序, 从而模拟事件冒泡.
详细的实现可以参考这里 https://github.com/muwoo/vnode2canvas/blob/master/src/core/event.js
最后
canvas 绘制页面也是一种创新的尝试, 希望这里的研究对你有启发, 也欢迎您的 PR. 这里也做了很多性能优化, 限于篇幅不在赘述了, 有兴趣也可以一起探讨.
最后: 它并不意味着完全取代基于 DOM 的渲染, 这仍然需要文本输入, 复制 / 粘贴, 可访问性和 SEO. 出于这些原因, 我们可以使用 canvas 和基于 DOM 的渲染的组合.
来源: https://juejin.im/post/5b4c9b69f265da0f6d72ca59