Offscreen Canvas & DOM API

浏览器主线程需要响应用户交互,渲染 UI,如果此时进行一些需要大量计算的操作,就会造成无法及时响应的 “Jank” 现象,十分影响用户体验。为此,使用 Worker 可以启动独立于主线程的另一个线程,在其中执行 JS 代码。但是在 Worker 中执行的代码在能力上会有一些限制,比如考虑到线程安全,在 Worker 中不能直接操作 DOM,也使用不了 Canvas API。 PWA 中使用的 Service Worker 也是一种特殊的 Worker。除此之外,还有 Shared Worker 和 Dedicated Worker,本文涉及的是后者。

最近看到 Offscreen Canvas 和 AMP WorkerDOM 这两种 Worker 的实践方案,其实是从两个角度解决问题:

  1. Offscreen Canvas:主线程依旧执行复杂操作,但是将动画,Canvas 绘制交给 Worker 执行,然后同步到主线程。
  2. AMP WorkerDOM:将复杂的性能开销大的操作(DOM Diff)交给 Worker 执行,解放主线程。

下面我们分别简单了解下这两种方案。

Offscreen Canvas

这里直接引用的 Google 介绍 Offscreen Canvas 的最新文章 中的例子 在主线程中使用 Three.js 绘制一个简单的立方体,在 rAF 中更新旋转角度并重绘。如果此时主线程进行复杂操作(点击 Make me busy 按钮),可以看到 Canvas 中帧率下降为 0,甚至整个页面也变成不可滚动状态。

为了解决卡顿问题,可以在 Worker 中调用 Canvas API 进行绘制,这涉及到 Offscreen Canvas 的概念,更加详细的尤其是涉及到浏览器渲染管线的知识可以阅读知乎上 易旭昕老师 的这篇文章,文中介绍了 Offscreen Canvas 的两种使用方式(“Commit” 和 “Transfer”),下面使用的是 “Commit” 方式。

主线程中 Canvas 只是一个 placeholder,它的控制权会交给 Worker:

  1. 通过 transferControlToOffscreen 将主线程中 Canvas 的控制权交给 Offscreen Canvas。
  2. 将 Offscreen Canvas 传递给 Worker,这里使用了 postMessage,要注意 Canvas 也是可以被结构化克隆算法序列化的,因此可以直接放入第一个 message 参数对象中。除此之外,我们还传递了一个数组,从 MDN 的介绍看,这是一串和message 同时传递的 Transferable 对象,这些对象的所有权将被转移给消息的接收方也就是 Worker,而发送一方即主线程将不再保有所有权。
const canvasOffscreen = $canvas.transferControlToOffscreen();
worker.postMessage({
    msg: 'start',
    origin: urlParts.join('/'),
    canvas: canvasOffscreen
}, [canvasOffscreen]);

在早期的 Offscreen Canvas 实现中,在 Worker 中需要通过 commit() 将渲染结果发回给主线程进行同步。目前 MDN 的例子也并没有更新:

var gl = offscreen.getContext('webgl');
// Push frames back to the original HTMLCanvasElement
gl.commit();

但是在 Chrome 68 的实现(需要在 chrome://flags 中开启 Experimental Web Platform features)中,Worker 中已经可以使用 rAF 代替上面的 commit 进行同步了。

值得一提的是在这个例子中我们还使用了 URL.createObjectURL(),由于 Worker 构造函数接收 URL 作为参数,将源代码转成 base64 编码就可以直接创建 Worker,当然最后别忘了销毁掉。在 AMP WorkerDOM 中也使用了这种方案扩展了源码的执行上下文,后面我们会详细介绍。

const workerCode = document.querySelector('#workerCode').textContent;
const blob = new Blob([workerCode], { type: 'text/javascript' });
const url = URL.createObjectURL(blob);
const worker = new Worker(url);
URL.revokeObjectURL(url); // cleanup

最后补充 2018_siggraph_asia 上对于 Offscreen Canvas 现状的总结:

历史做法

这里补充一点在 Offscreen Canvas 实现之前,在 Worker 中执行 WebGL 的方法。 在 MDN 上 2014 年 「WebGL in Web Workers, Today – and Faster than Expected!」 一文中,介绍了一种通过在 Worker 中实现 WebGL 上下文的 proxy,模拟执行环境,以便直接运行例如 PlayCanvas 这样的 3D 引擎:

在主线程创建 Worker,由于文章发表当时并不能直接序列化 canvas 并传递到 Worker 内, 只能将 canvas context 上的一些静态方法在主线程执行后,把结果传过去,例如 getSupportedExtensions()

// 主线程 proxyClient.js

var worker = new Worker('worker.js');

// Create a fake temporary GL context
var canvas = document.createElement('canvas');
var ctx = canvas.getContext('webgl-experimental') || canvas.getContext('webgl');
worker.postMessage({
    target: 'gl',
    op: 'setPrefetched',
    parameters: parameters,
    extensions: ctx.getSupportedExtensions(),
    precisions: precisions,
    preMain: true
});

在 Worker 内接到信息后,会将这些静态方法的结果挂到 WebGLWorker 原型链上:

this.onmessage = function(msg) {
    switch(msg.op) {
        case 'setPrefetched': {
            WebGLWorker.prototype.prefetchedParameters = msg.parameters;
            WebGLWorker.prototype.prefetchedExtensions = msg.extensions;
            WebGLWorker.prototype.prefetchedPrecisions = msg.precisions;
            removeRunDependency('gl-prefetch');
            break;
        }
        default: throw 'weird gl onmessage ' + JSON.stringify(msg);
    }
};
this.getSupportedExtensions = function() {
    return this.prefetchedExtensions;
};

现在来关注在 Worker 内执行的 WebGLWorker,首先在 Worker 中并没有 rAF()(Chrome 68 之后已经可以),只能使用 setTimeout() 实现:

// proxyWorker.js

window.requestAnimationFrame = (function() {
  var nextRAF = 0;
  return function(func) {
    var now = Date.now();
    if (nextRAF === 0) {
      nextRAF = now + 1000/60;
    } else {
      while (now + 2 >= nextRAF) {
        nextRAF += 1000/60;
      }
    }
    var delay = Math.max(nextRAF - now, 0);
    setTimeout(func, delay);
  };
})();

然后我们需要在每一帧末尾,将实际的渲染命令发送回主线程执行:

// webGLWorker.js

var trueRAF = window.requestAnimationFrame;
window.requestAnimationFrame = function(func) {
    trueRAF(function() {
        if (preRAF() === false) {
            window.requestAnimationFrame(func); // skip this frame, do it later
            return;
        }
        func();
        // 
        postRAF();
    });
}
function postRAF() {
    if (commandBuffer.length > 0) {
        postMessage({ target: 'gl', op: 'render', commandBuffer: commandBuffer });
        commandBuffer = [];
    }
}

代码可以参考webgl-worker,作为早期的一种尝试,在浏览器实现了 Offscreen Canvas 之后其实已经没有参考意义了。

AMP WorkerDOM

我们知道在 Worker 中执行的代码在能力上会有一些限制,比如考虑到线程安全,在 Worker 中不能直接操作 DOM。 但是 AMP 提出的 WorkerDOM 方案拓展了 Worker 的使用场景。以下是来自官网的介绍:

Move complexity of intermediate work related to DOM mutations to a background thread, sending only the necessary manipulations to a foreground thread.

主要思路是在 Worker 中进行相对开销较大的 DOM Diff,将 patch 结果发回主线程,由主线程在真实 DOM 上应用修改。因此需要在 Worker 中实现 virtual DOM,相信这也是 WorkerDOM 名称的由来吧。

要注意 WorkerDOM 并不能突破在 Worker 中直接执行 DOM API 的限制,只是在 Worker 执行环境中 Mock 了 DOM API,这样用户代码就可以正常执行:

// worker.ts
// 扩展上下文
const code = `
    'use strict';
    ${workerScript}
    (function() {
        var self = this;
        var window = this;
        var document = this.document;
        var localStorage = this.localStorage;
        var location = this.location;
        // 用户编写的包含调用 DOM API 的原始代码
        ${authorScript}
    }).call(WorkerThread.workerDOM); // WorkerDOM API
`;
return new Worker(URL.createObjectURL(new Blob([code])));

那么传入的 WorkerThread.workerDOM 到底长啥样呢?

WorkerDOMGlobalScope 接口

所有 Worker 都会实现 WorkerGlobalScope 接口。而 WorkerThread.workerDOM 实现了自定义的 WorkerDOMGlobalScope 接口,Mock 了大量 DOM API:

export const workerDOM: WorkerDOMGlobalScope = {
    document: doc,
    addEventListener: doc.addEventListener.bind(doc),
    removeEventListener: doc.removeEventListener.bind(doc),
    localStorage: {},
    location: {},
    url: '/',
    appendKeys,
};

下面我们来看看主线程和 Worker 的具体实现。

在 Worker 中 Mock DOM API

document 为例,我们来看看它的实现。尤其需要注意 observeMutations

export function createDocument(postMessageMethod?: Function): Document {
    const doc = new Document();
    doc.isConnected = true;
    doc.appendChild((doc.body = doc.createElement('body')));

    // 监听变化
    observeMutations(doc);
    propagateEvents();
    propagateSyncValues();

    return doc;
}

在监听变化方面,Mock 了 MutationObserver。在标准的 MutationObserver 中,DOM 的改变会触发构造函数传入的回调,其中包含了当前 DOM 上的一组变更记录,每一条记录是以 MutationRecord 形式存在的。

export function observe(doc: Document, postMessage: Function): void {
    document = doc;
    new doc.defaultView.MutationObserver(
        // 一组 MutationRecord
        mutations => handleMutations(mutations, postMessage)
    ).observe(doc.body); // 监听 body 上的改变
}

而 WorkerDOM 在标准 MutationRecord 的基础上,增加了一些属性:

export interface MutationRecord {
    readonly target: Node;
    readonly addedNodes?: Array<Node>;
    // ...省略标准属性

    // MutationRecord Extensions
    readonly type: MutationRecordType;
    // Modifications of properties pass the property name modified.
    readonly propertyName?: string | null;
    // Mutation of attributes or properties must pass a value representing the new value.
    readonly value?: string | null;
    // Event subscription mutations
    readonly addedEvents?: Array<TransferrableEventSubscriptionChange>;
    readonly removedEvents?: Array<TransferrableEventSubscriptionChange>;
}

在发生 DOM 变化时,Worker 会通过 postMessage 向主线程传递两类消息:

  1. HYDRATE 消息 这个消息只有一次
  2. MUTATE 消息

那么在 WorkerDOM 里 Mock 的 DOM 节点比如 docuemnt.body 是如何触发 MutationObserver 的监听呢? 在 WorkerDOM 的实现中,Mock 的 DOM API 操作,以 appendChild 为例,除了进行节点的移动,最后会调用 mutate() 通知 MutationObserver 触发修改:

public appendChild(child: Node): void {
    child.remove();
    child.parentNode = this;
    propagate(child, 'isConnected', this.isConnected);
    this.childNodes.push(child);

    // 通知 MutationObserver 更新
    mutate({
        addedNodes: [child],
        previousSibling: this.childNodes[this.childNodes.length - 2],
        type: MutationRecordType.CHILD_LIST,
        target: this,
    });
}

接到通知的 MutationObserver,会通知每个关心该 DOM 节点的观察者,筛选方法也很简单,match() 实际比较两个节点创建时分配的内置属性 __index__,相等就认为是相同节点。 另外,由于在之前创建 Document 时监听了 body 节点,所以所有节点的修改最终都会冒泡上来。

export function mutate(record: MutationRecord): void {
    observers.forEach(observer => {
        let target: Node | null = record.target;
        let matched = match(observer.target, target);
        if (!matched) {
            do {
                if ((matched = match(observer.target, target))) {
                    pushMutation(observer, record);
                    break;
                }
            } while ((target = target.parentNode));
        }
    });
}

现在 WorkerDOM 的更新信息已经由 Worker 传递给主线程了,接下来需要主线程进行真实的 DOM 操作。

主线程更新 DOM

主线程会接收来自 Worker 的 DOM 更新细节,执行最终的 DOM 更新操作。 类似 Vue 的 __patch__ 方法,但是 WorkerDOM 进行了功能上的拆分,将更新分成了以下两种情况:

  1. hydrate 首次 DOM 结构的创建
  2. mutate 后续在原有 DOM 节点上的属性更新

与之对应的,主线程会接收来自 Worker 的这两类消息,进行最终的 DOM 更新操作:

worker.onmessage = ({ data }: MessageFromWorker) => {
    switch (data[TransferrableKeys.type]) {
    case MessageType.HYDRATE:
        hydrate(
            (data as HydrationFromWorker)[TransferrableKeys.nodes],
            (data as HydrationFromWorker)[TransferrableKeys.strings],
            (data as HydrationFromWorker)[TransferrableKeys.addedEvents],
            baseElement,
            worker,
        );
        break;
    case MessageType.MUTATE:
        mutate(
            (data as MutationFromWorker)[TransferrableKeys.nodes],
            (data as MutationFromWorker)[TransferrableKeys.strings],
            (data as MutationFromWorker)[TransferrableKeys.mutations],
            sanitizer,
        );
        break;
    }
};

Vue 的 hydrate 在 SSR 场景下在已有 DOM 结构上仅进行事件绑定。 在拿到更新信息后,hydrate 方法主要做了两件事,通过 hydrateNode 进行 DOM 添加删除操作,然后添加事件监听。

export function hydrate(
    skeleton: HydrateableNode,
    stringValues: Array<string>,
    addEvents: Array<TransferrableEventSubscriptionChange>,
    baseElement: HTMLElement,
    worker: Worker,
) {
    // Process String Additions
    stringValues.forEach(value => storeString(value));
    // Process Node Addition / Removal
    hydrateNode(skeleton, baseElement, worker);
    // Process Event Addition
    addEvents.forEach(event => {
        const node = getNode(event[TransferrableKeys._index_]);
        node && processListenerChange(worker, node, true, getString(event[TransferrableKeys.type]), event[TransferrableKeys.index]);
    });
}

局限性

由于 Mock 了大部分 DOM API,因此将 React 这样的视图解决方案放在 Worker 中执行是没有问题的,官方 Github 上就有例子。但是某些属性和 API 例如涉及到视口以及滚动的目前还无能为力,比如在源码 Element.ts 中就清楚的列出了目前还没有实现的属性和方法:

// Unimplemented properties
// Element.clientHeight
// Element.clientLeft
// ...

// Unimplemented Methods
// Element.scrollIntoView()
// Element.querySelector
// ...

参考资料