关于DOM截图的一些事

背景

上上篇文章介绍了使用dom-to-image生成透明图片,然后在生产环境发现会有以下2个问题:
1. 一些移动设备在生成png时会报Tained canvaes may not be exported, 最开始以为是跨域问题,所以将加载的图片内容加了跨域属性,而且将所有http图片地址都转成了https地址(与主文档协议统一),然而并没有解决问题。

查询资料后发现当canvas中含有外部图片或foreignObject时会认为canvas已被污染,浏览器认为可能会泄露用户信息,故不允许导出。详见

  1. 上述问题,通过调用dom-to-image.toSvg可以绕过,然而在移动端,生成的图片却不被认为是一张图片,而会认为是一个文档文件, 在iOS设备上还好,用户依然可以以图片方式保存,只不过保存的图片没有进相册;在android端保存会报保存失败错误

基于以上问题,故考虑更换技术方案:

  1. 前端使用html2canvas库

    使用html2canvas时遇到了3个问题:

  • 如果用户再截图过程中滚动页面,会导致生成的图片内容也会截到滚动过程中的内容(待调研)

  • 如图: border没有显示出来,圆角背景没有透明,图片没有截完全(理论上这些问题都是支持的,但我没有实验成功)

  1. 后端生成

    后端已经有一套html2image截图工具, 但在调研时发现有以下几个问题:

  • 依然是圆角透明问题
  • 抓取时间时机问题,已知服务端抓取需要纯静态页面,所以特地做了一套静态页面(预告: 下次分享静态化相关),然而实际应用中依然有文字错位、图像模糊等问题,而且接口等待时间太长,所以服务端截图可靠性也不是很高。

Puppeteer

puppeteer 是Google在17年和Chrome Headless一起推出的一个工具,详情介绍

它可以自动化执行浏览器的所有操作,并可以进行dom复制、截图、观察控制台等一系列自动化操作

安装

yarn add puppeteer 未翻墙用户会超级慢,因为他会下载整个Chromium

未翻墙用户也可以安装 puppeteer-core , 然后自己搜索下载Chrome Driver, 并指定Chromium 位置即可

另外几个指令可能对未翻墙用户有效:

PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 # 跳过Chromium下载,跳过后可以手动下载,或者干脆fork到git仓库中

npm set puppeteer_download_host https://npm.taobao.org/mirrors #使用淘宝镜像
npm set chromedriver_cdnurl https://npm.taobao.org/mirrors/chromedriver


启动

而且控制起来超级简单,之前的博文中也有人介绍相关操作,废话不多说, 直接show code。

const browser = await puppeteer.launch({
    headless: true
  });
const page = await browser.newPage();
await page.setViewport({
    isMobile: true,
    width: 375 * 2, // 2倍屏
    height: 812 * 2
})
 await page.goto(url)

这样就开启了一个Chromium, 并打开了一个url。

截屏

await page.waitForSelector('#certificate-img', {
        visible: true
    })
let img = await page.$('#certificate-img')
await img.screenshot({
    path: `files/${uid}.png`,
    omitBackground: true // 允许截图带透明度
})

puppeteer等待有几种方式:

  • waitFor 等待一段时间或者某元素
  • waitForSelector 取值visible/hidden 等待某元素或某XPATH可见或隐藏
  • waitForFunction 等待方法回调,并且可以指定轮询方式
  • waitForNavigation 等待导航栏事件 “load” | “domcontentloaded” | “networkidle0” | “networkidle2
  • waitForXPath 同waitForSelector

截图结果十分完美,所谓所见即所得

多实例

以上截屏操作,在headless:true情况最少需要1s截取一个页面(办公环境下,页面需要ajax请求生产数据),需要截图的页面共有45000+个,这样算起来需要 45000/3600 = 12+小时以上,而且截的图片还要上传CDN, 这个速度实在受不了,而且一定会在高峰期影响到生产用户(离线导数据另说)

所以考虑同时执行多个任务

在node环境开启多进程还是比较简单的:

if (cluster.isMaster) {
    for(let i = 0 ;i  {
        if (message && message.cmd && message.cmd === 'writefile') {
            writeFile(message.content)
        } else {
            console.log(`[Master]# Worker ${worker.id}: ${message}`)
            endTaskNum++
            if (endTaskNum === numCPUs) 
                cluster.disconnect()
            }
        }
        })
    cluster.on('exit', (worker, code, signal) => {
        let index = workers.findIndex(w => worker.id === w.id)
        console.log(`[Master]#${index} Worker ${worker.id} died.`)

        // 进程死掉后自动拉起
        let newWorker = cluster.fork()
        newWorker.send(index)
        workers.splice(index, 1, newWorker)
    })
} else {
    process.on('message', seq => {
        // console.log(`[Worker]# starts calculating...`)
        const start = Date.now()
        const result = doWork(seq)
        console.log(`[Worker]# The result of task ${process.pid} is ${result}, taking ${Date.now() - start} ms.`)
        process.send('My task has ended.')
    })
}

真正的工作进程就是doWork了。因实践过程中经常发现超时的现象,超时后我会主动终止进程,让进程自动重启,当前也会有断点续做的功能,很简单,这里不细说了

本以为可以挂机开工,却遇到了新问题,任务输出写到同一个文件,跑了一会发现日志增加非常缓慢,思考一下,应该是文件锁的原因,既然都在抢文件,那就交给master吧。

if (message && message.cmd && message.cmd === 'writefile') {
    writeFile(message.content)
}

最终整个任务耗时大约4h完成

后续还有洗数据过程,这里不就详述了,全部代码可私信我,由于时间仓促写的有点乱。

DOM to Image 使用场景及实现方法

恰好最近有需求要做个用户参赛证书,要生成动态数据,且要生成一张图片供用户保存。
大概长下面这样:

实现上述内容,第一印象想到可以将除动态内容之外内容做成图片模板,将通过定位填充动态内容,生成一个canvas,canvas.toDataUrl, 完活。
然而如图所示,“名字特别长”小朋友,明显不适合通过绝对定位的方式去填充,所以需要考虑相对定
搜索了一些方案, 其核心内容都是要对Dom进行截图。 常用框架有以下2种

  1. html2canvas
  2. dom to image
    以上2种方案都可以实现动态的DOM复刻,都可以满足需求,比较之下,发现2种实现方式却不相同。

工作原理

html2canvas

  1. 递归取出目标元素中的所有元素,放入一个list中
  2. 通过层级、浮动属性(z-index, float,position)等重新排序,生成一个renderQueue
  3. 遍历renderQueue,将css样式转为setFillStyle可识别的参数,依据nodeType调用相对应canvas方法,如文本则调用fillText,图片drawImage,设置背景色的div调用fillRect等
  4. 将画好的canvas填充进页面

以上的每个步骤,想想就很复杂,该库用了20多个js文件去处理这些问题,也导致了他整个包gzip后还有40KB左右

那么有没有更简便的方法呢?

dom to image

SVG中有一个foreignObject标签,专门用来接收XML(xhtml)等外部对象,这样,我们只要将想要呈现的元素一股脑塞进这个标签即可。
dom to image就使用了这个方案,整个代码下来只有769行, gzip后只有3.33KB。
大致的实现方法如下:
1. 递归复制原有DOM节点
2. 处理样式和字体
3. 序列化复制后的DOM为XML
4. 将XML填充到foreignObject
5. 通过SVG生成一个img, 画到canvas中,再转base64(如有需要)

具体实现


// 主要方法 draw(domNode, options) // toSvg(node, options) // (dom -- svg) cloneNode(node, filter, root) // (clone dom树和css样式) makeSvgDataUri(node, width, height) // dom -- svg -- data:uri)

常用入口API

已toPNG举例,调用顺序如下:

  1. 入口
function toPng(node, options) {
        return draw(node, options || {})
            .then(function (canvas) {
                return canvas.toDataURL();
            });
    }

  1. 转换成svg
function draw(domNode, options) {
        return toSvg(domNode, options)
            .then(util.makeImage)
            .then(util.delay(100))
            .then(function (image) {
                var canvas = newCanvas(domNode);
                canvas.getContext('2d').drawImage(image, 0, 0);
                return canvas;
            });

        function newCanvas(domNode) {
            var canvas = document.createElement('canvas');
            canvas.width = options.width || util.width(domNode);
            canvas.height = options.height || util.height(domNode);

            if (options.bgcolor) {
                var ctx = canvas.getContext('2d');
                ctx.fillStyle = options.bgcolor;
                ctx.fillRect(0, 0, canvas.width, canvas.height);
            }

            return canvas;
        }
    }
  1. 递归复制节点
function toSvg(node, options) {
        options = options || {};
        copyOptions(options);
        return Promise.resolve(node)
            .then(function (node) {
                return cloneNode(node, options.filter, true);
            })
            .then(embedFonts)
            .then(inlineImages)
            .then(applyOptions)
            .then(function (clone) {
                return makeSvgDataUri(clone,
                    options.width || util.width(node),
                    options.height || util.height(node)
                );
            });

        function applyOptions(clone) {
            if (options.bgcolor) clone.style.backgroundColor = options.bgcolor;

            if (options.width) clone.style.width = options.width + 'px';
            if (options.height) clone.style.height = options.height + 'px';

            if (options.style)
                Object.keys(options.style).forEach(function (property) {
                    clone.style[property] = options.style[property];
                });

            return clone;
        }
    }
  1. ***填充svg数据 ***
   function makeSvgDataUri(node, width, height) {
        return Promise.resolve(node)
            .then(function (node) {
                node.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
                return new XMLSerializer().serializeToString(node);
            })
            .then(util.escapeXhtml)
            .then(function (xhtml) {
                return '<foreignObject x="0" y="0" width="100%" height="100%">' + xhtml + '</foreignObject>';
            })
            .then(function (foreignObject) {
                return '<svg xmlns="http://www.w3.org/2000/svg" width="' + width + '" height="' + height + '">' +
                    foreignObject + '</svg>';
            })
            .then(function (svg) {
                return 'data:image/svg+xml;charset=utf-8,' + svg;
            });
    }

使用

使用起来也非常简单, 几行代码即可

const domToImage = await import('dom-to-image')
this.$nextTick(() => {
    let ts = performance.now()
    let node = document.getElementById('certificate-img')
    domToImage.toPng(node)
        .then((dataUrl) => {
        let img = new Image()
        img.src = dataUrl
        console.log(performance.now() - ts) // 成品图约300ms左右
    })
        .catch(function (error) {
        console.error(error)
    })
})

Vue CLI现代模式

现代模式

使用Babel我们能够使用ES6及以上提供的最新的语言特性, 在带来方便的同时,我们也需要为旧版本浏览器提供polyfill或tranform。这样转换后的代码包往往比我们预想的要大,而且执行效率也会降低很多。

其实当前浏览器版本早已经(Chrome 61,Android 5+, iOS11+)支持了ES6 module加载, 这样我们就不需要为新型浏览器提供包更大,执行效率更慢的babel之后的代码了

Vue CLI 提供了一个modern mode来帮助解决上述问题

开启方式

vue-cli-service build --modern

举例对比

例如有如下代码:

export default {
    alert (text) {
        return new Promise((resolve) => {
            setTimeout(() => {
                alert(text);
                resolve(text)
            }, 1e3)
        })
    }
}

经过babel转义后的代码

(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["chunk-ade9fe2e"],{

/***/ "0bfb":
/***/ (function(module, exports, __webpack_require__) {

"use strict";

// 21.2.5.3 get RegExp.prototype.flags
var anObject = __webpack_require__("cb7c");
module.exports = function () {
  var that = anObject(this);
  var result = '';
  if (that.global) result += 'g';
  if (that.ignoreCase) result += 'i';
  if (that.multiline) result += 'm';
  if (that.unicode) result += 'u';
  if (that.sticky) result += 'y';
  return result;
};


/***/ }),

/***/ "3846":
/***/ (function(module, exports, __webpack_require__) {

// 21.2.5.3 get RegExp.prototype.flags()
if (__webpack_require__("9e1e") && /./g.flags != 'g') __webpack_require__("86cc").f(RegExp.prototype, 'flags', {
  configurable: true,
  get: __webpack_require__("0bfb")
});


/***/ }),

/***/ "6b54":
/***/ (function(module, exports, __webpack_require__) {

"use strict";

__webpack_require__("3846");
var anObject = __webpack_require__("cb7c");
var $flags = __webpack_require__("0bfb");
var DESCRIPTORS = __webpack_require__("9e1e");
var TO_STRING = 'toString';
var $toString = /./[TO_STRING];

var define = function (fn) {
  __webpack_require__("2aba")(RegExp.prototype, TO_STRING, fn, true);
};

// 21.2.5.14 RegExp.prototype.toString()
if (__webpack_require__("79e5")(function () { return $toString.call({ source: 'a', flags: 'b' }) != '/a/b'; })) {
  define(function toString() {
    var R = anObject(this);
    return '/'.concat(R.source, '/',
      'flags' in R ? R.flags : !DESCRIPTORS && R instanceof RegExp ? $flags.call(R) : undefined);
  });
// FF44- RegExp#toString has a wrong name
} else if ($toString.name != TO_STRING) {
  define(function toString() {
    return $toString.call(this);
  });
}


/***/ }),

/***/ "84b8":
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var core_js_modules_es6_regexp_to_string__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("6b54");
/* harmony import */ var core_js_modules_es6_regexp_to_string__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(core_js_modules_es6_regexp_to_string__WEBPACK_IMPORTED_MODULE_0__);

/* harmony default export */ __webpack_exports__["default"] = ({
  alert: function (_alert) {
    function alert(_x) {
      return _alert.apply(this, arguments);
    }

    alert.toString = function () {
      return _alert.toString();
    };

    return alert;
  }(function (text) {
    return new Promise(function (resolve) {
      setTimeout(function () {
        alert(text);
        resolve(text);
      }, 1e3);
    });
  })
});

/***/ })

}]);
//# sourceMappingURL=chunk-ade9fe2e-legacy.ec6fe87b.js.map

Modern 模式生成的代码

(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["chunk-2d0de508"],{

/***/ "84b8":
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony default export */ __webpack_exports__["default"] = ({
  alert(text) {
    return new Promise(resolve => {
      setTimeout(() => {
        alert(text);
        resolve(text);
      }, 1e3);
    });
  }

});

/***/ })

}]);
//# sourceMappingURL=chunk-2d0de508.818bdd59.js.map

可以看到代码节省了超过80%代码量 ((519B-3000B)/3000B)

加载方式

经过—modern构建生成的页面,会自动引入es modules的包和legacy的包,由浏览器根据自身能力去区分。

  • 支持ES6的浏览器会去加载 script type="module"的脚本,而忽略 script nomodule的脚本
  • 不支持的浏览器识别不了script type="module"nomodule,所以只能去加载script type="text/javascript"的脚本
  • Safari 10 有个bug会导致nomodule表现异常,所幸--modern模式已经嵌入一段代码解决, 见下述代码

页面结构如下:

<script type="module" src="//s04.cdn.ipalfish.com/picturebook/js/vendors.7323d42d.js"></script>
.
.
.
<!--解决Safari10的bug-->
<script>!function(){var e=document,t=e.createElement("script");if(!("noModule"in t)&&"onbeforeload"in t){var n=!1;e.addEventListener("beforeload",function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()},!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove()}}();</script>
.
.
.
<script type="text/javascript" src="//s04.cdn.ipalfish.com/picturebook/js/vendors-legacy.253dbb81.js" nomodule></script>

弊端

  • 主文档变大:多了一些浏览器加载用不到的script,相比较js包大小而言主文档多的一些字符可以忽略
  • 构建时间变长: 因为要构建2次, 但时间花费在编译期,提升了运行期的效率还是值得的。

参考

Vue cli 源码修改记录相关 主要通过webpack ModernModePlugin 实现

浏览器资源加载优先级

前端资源加载优先级

我们经常会思考一个经典问题,”一个页面从输入URL到页面加载显示完成,这个过程都发生什么?”, 此问题涉及的范围可谓非常之广,今天我们只关注浏览器加载资源的过程。

资源加载优先级

首先,浏览器会根据资源重要性,给资源设定优先级(实际上会更复杂,浏览器会根据资源类型进行猜测,例如先加载css然后再加载js和img),我们可以通过Chrome Devtools查看资源加载优先级,如果你的Network面板没有Priority选项,可以在表头上点击右键显示,下图是我们生产环境一个页面的JS 和 CSS加载优先级

可以看到浏览器将资源优先级分为以下5类

Highest 最高
Hight 高
Medium 中等
Low 低
Lowest 最低

这五类规则见下图

img

手动控制加载优先级

虽然浏览器会自动判断资源重要性,但通常情况下,浏览器会因为获取的信息不足,导致做出错误的判断

那么如何手动控制浏览器加载优先级呢?

主要有3种指令:

  • preload 预加载
  • preconnect 预连接
  • prefetch 预获取

preload

link rel="preload" 告知浏览器当前导航需要某个资源,应尽快开始提取

可以注意到link上使用了属性“as”, 该属性允许你告知浏览器您将加载的资源类型,以便浏览器可以正确处理该资源

link rel="preload" 是强制浏览器执行的指令;与我们将探讨的其他资源提示不同,它是浏览器必须执行的指令,而不只是可选提示.

preconnect

link rel="preconnect" 告知浏览器您的页面打算与另一个起点建立连接,以及您希望尽快启动该过程。

预连接的作用是提前进行DNS查找,TCP连接,TLS协商(https)

但请注意,preconnet虽然成本很低,但依然会占用CPU时间,而且如果10s内没有使用此连接,情况尤为糟糕,因为当浏览器关闭连接时,所有已完成的连接都将遭到浪费。

除此之外,还有另外一个标签可用于回退方案,而且被浏览器厂商广泛支持

link rel="dns-prefetch" href="//s04.cdn.ipalfish.com"

prefetch

link rel="prefetch" 与以上2个指令不同,他不会发生资源抢占,而是在带宽空闲(idle)时发生

此指令通常用于此资源用于未来,例如其他页面跳转,或者用户交互后才会出现的行为,通常此资源的优先级会标记为 Lowest

prefetch 不会降低现有资源的优先级,即如果此资源很快会被加载,则尽量不要使用prefetch,否则会导致资源会被加载多次。

项目实践

绘本项目使用vue-cli 3搭建,已在生产环境实践使用prefetch,preload加载资源

Vue-cli3 默认会进行以上优化,方案是:

  1. 根据入口文件依赖添加PreloadWebpackPlugin ,以便初始化渲染

  2. 根据async chunk生成的文件 添加PrefetchWebpackPlugin 插件

开学季活动页面举例

路由规则如下:

routes: [
    {
      path: '/',
      name: 'Index',
      component: Index
    },
    {
      path: '/record',
      name: 'Record',
      component: Record
    },
    {
      path: '/rule',
      name: 'Rule',
      component: Rule
    },
    {
      path: '*',
      redirect: '/'
    }
  ]

根据以上对prefetch的描述,我们知道首屏加载资源使用prefetch很可能会导致资源会被多次加载,对此我们可以使用2种方案:

  1. 首页(首屏)加载资源不进行async处理
  2. 有选择的进行prefetch

现在项目中使用的是第二种方案,先在webpack中去掉prefetch插件

Object.keys(pages).forEach((page) =&gt; {
    // 去掉prefetch 业务自己决定 https://cli.vuejs.org/zh/guide/html-and-static-assets.html#prefetch
    config.plugins.delete(`prefetch-${pages[page].path}`)
  })

然后业务方根据自己需要添加prefetch指令

const Index = () =&gt; import(
  /* webpackChunkName: "school-season-index" */
  './index')

const Record = () =&gt; import(
  /* webpackChunkName: "school-season-record" */
  /* webpackPrefetch: true */
  './record')

const Rule = () =&gt; import(
  /* webpackChunkName: "school-season-rule" */
  /* webpackPrefetch: true */
  './rule')

以上可通过开学季活动页面查看

除此之外还有prerender 可进行页面预渲染,但支持的浏览器厂商较少,暂不做考虑

参考

https://github.com/joshbuchea/HEAD

https://www.w3.org/TR/resource-hints

https://developers.google.com/web/fundamentals/performance/resource-prioritization

https://medium.com/reloading/preload-prefetch-and-priorities-in-chrome-776165961bbf