HTML5 Web Workers技术

Published on 2016 - 09 - 25

想当初,JavaScript刚刚问世的时候,没有人担心它的性能。JavaScript只是一种简单的语言,可以在网页中运行小段脚本,而且只是非专业程序员的玩具。谁也没有把它当做一门能撑起门面的正规语言。

转眼20年过去了,JavaScript已经成为Web开发领域的王者。只要想给网页添加交互性,开发人员就会用到它,无论是游戏还是地图,或者购物车和漂亮的表单。然而,从许多方面来看,JavaScript语言与它现在的地位相比,仍然还有一些不相称的地方。

比如,JavaScript处理大计算量任务时就会导致问题。对于多种现代编程语言来说,这种大计算量的工作都是在后台完成的,而使用应用的人不会停下来,也不会受到干扰。但在JavaScript中,由于代码始终都在前台运行,因此耗费时间的代码会打断用户,阻塞页面,直到任务完成。对这个问题视而不见,就会导致访客厌烦,甚至永不再光顾你的网页。

注意 为解决JavaScript阻塞页面的问题,很多一线开发人员想出了各种招。比如,使用setInterval()或setTimeout()把大任务分成小任务,每次只运行一个小任务。这个办法非常适合某些任务(比如,对在画布上实现动画就很合适)。可是,对于不能拆分而又耗时很长的任务,这个办法会增加复杂性和困扰。

HTML5提出了更好的解决方案,一个叫Web Worker的对象,能够在后台完成工作。要是你有比较费时的工作,就可以创建一个新的Web Worker对象,把要运行的代码交给他,然后让它运行就好了。在它工作期间,还可以通过传递文本消息这种安全但受限的方式与它通信。

表2给出了当前浏览器对Web Worker的支持情况。

要求 IE Firefox Chrome Safari Opera Safari iOS Android
最低版本 10 3.5 3 4 10.6

Web Worker安全措施

在JavaScript中使用Web Worker可以在后台运行代码,同时在前台也做一些事。这就带来了编程领域中的一个尽人皆知的问题:如果应用同时可以做两件事,那么其中一件事有可能干扰另一件事。

这个问题会在两段代码争抢同一处数据时发生。比如,其中一段代码想读取某些数据,而另一段代码则想写入该数据,或者两段代码同时想设置一个变量,最终导致一个值覆盖另一个值,再或者两段代码以不同方式操作同一个对象,造成对象状态前后不一致。类似的问题很多,没办法一一列举和解决。通常,一个多线程应用(即在多个线程上执行不同代码的应用)在测试时运行得很好,但一投入正常使用,就会出现令人头疼的数据不一致问题。

现在好了,有了JavaScript的Web Worker,你就不必担心这个问题了。 因为它不允许你在网页之间或Web Worker之间共享数据。你可以把数据从网页发送到Web Worker(或者相反),但JavaScript会自动复制一份,并发送该副本。这意味着不同的线程不能同时占用相同的内存区域,也不会导致微妙的问题。当然,这种简化的模型也会限制Web Worker的能力,但能力上受到细微的限制却能换来安全,免得那些编程高手搬起石头砸自己的脚。
 

注意 如果在本地文件中运行Web Worker,需要在Chrome中设置-allow-file-access-from-files参数,否则会失败。要设置这个参数,最简单的办法是创建一个新的Chrome快捷方式,然后把这个参数附加到命令的末尾。详细说明请参见http://tinyurl.com/3j4dgcb。

费时的任务

除非用于那些真正费时的任务,否则很难发挥Web Worker的优势。换句话说,不应该用Web Worker来执行简单的任务。而对于那些让CPU不堪重负,又会拖延浏览器的计算任务,使用Web Worker的结果会大不相同。比如图4所示的搜索素数的任务,我们想找到某个区间内的素数。代码很简单,但这个任务需要的计算量很大,因为要进行较长时间的数值运算。

选择一个区间,然后单击按钮开始搜索。区间窄(如1~50 000),任务很快能完成,不会干扰任何人。但范围更大的搜索(如1~500 000),会导致页面数分钟没有反应。这时候不能单击、滚动,无法执行任何操作。浏览器甚至会给出一个“长时间运行脚本”的警告,并将整个页面灰掉

很明显,可以使用Web Worker来改进这个页面。但在此之前,我们先看一看这个例子的标记和JavaScript代码。

标记不长,也很简单。页面使用了两个<input>控件,都是文本框。还有一个用于搜索的按钮和两个<div>元素,两个<div>分别用于保存结果和显示状态消息。以下就是<body>元素中的全部标记:

<p>Do a prime number search from <input id="from" value="1"> to
 <input id="to" value="20000">.</p>
<button id="searchButton" onclick="doSearch()">Start Searching</button>

<div id="primeContainer">
</div>

<div id="status"></div>

给保存素数的<div>元素添加样式有点意思。我们给它指定了固定高度和一个最大宽度,还设置了overflow和overflow-x属性以添加垂直滚动条(但没有水平滚动条):

#primeContainer {
  border: solid 1px black;
  margin-top: 20px;
  margin-bottom: 10px;
  padding: 3px;
  height: 300px;
  max-width: 500px;
  overflow: scroll;
  overflow-x: hidden;
  font-size: x-small;
}

这个例子的JavaScript代码有点长,可并不复杂。代码会取得文本框中的两个数,开始搜索,然后把找到的素数添加到页面中。查找素数的任务是由另一个函数完成的,该函数名叫findPrimes(),而且保存在另一个JavaScript文件中。

下面是doSearch()函数的完整代码:

function doSearch() {
  //取得指定搜索区间的两个数
  var fromNumber = document.getElementById("from").value;
  var toNumber = document.getElementById("to").value;

//执行搜索(这一步花时间)
  var primes = findPrimes(fromNumber, toNumber);

  //遍历素数数组,把它们转换成一个长字符串
  var primeList = "";
  for (var i=0; i<primes.length; i++) {
    primeList += primes[i];
    if (i != primes.length-1) primeList += ", ";
  }

  //把素数字符串插入页面中
  var displayList = document.getElementById("primeContainer");
  displayList.innerHTML = primeList;

  //更新状态消息,告诉用户当前情况
  var statusDisplay = document.getElementById("status");
  if (primeList.length == 0) {
    statusDisplay.innerHTML = "Search failed to find any results.";
  }
  else {
    statusDisplay.innerHTML = "The results are here!";
  }
}

看到了吧,标记和代码都不长,实际上可以说是简明扼要。但这只是表面上能看到的,如果搜索的区间很大,那搜索过程会得变慢吞吞地无法忍受,就好像开着高尔夫球车爬陡坡一样费劲。

把任务放在后台

Web Worker为解决这个问题定义了一个新对象,叫Worker。在需要在后台执行任务时,可以创建一个新的Worker,交给它一些代码,然后发送给它一些数据。

下面这行代码创建了一个新的Worker对象,让它执行PrimeWorker.js中的代码:

var worker = new Worker("PrimeWorker.js");

让Worker运行的代码都要放在一个单独的文件中。这样设计是为了避免新手让Worker引用全局变量,或者直接访问页面中的元素。这两个操作都是不允许的。

注意 浏览器会严格保持网页与Web Worker代码分离。因此,不可能让PrimeWorker.js中的代码把素数直接写到<div>元素里。Web Worker必须把相应数据发送给页面中的JavaScript代码,然后再通过它把结果显示出来。

网页与Web Worker之间通过消息来沟通。给Worker发送消息要使用该对象的postMessage()方法:

worker.postMessage(myData);

然后,Worker就会通过onMessage事件接收到该数据的一个副本。此时,它就开始工作。

类似地,如果Worker需要跟网页对话,它可以调用自己的postMessage()方法,并带上一些数据。而网页同样是在一个onMessage事件中接收这些数据。图5展示了上述通信过程。

这里演示了最简单的Web Worker工作流程,分三个步骤:页面向Worker发送一些数据,Worker开始运行,然后Worker再向页面发回一些数据

我们先说一个要注意的地方。调用postMessage()方法时,只能给它传入一个值。这对于传递搜索素数的区间来说是一个问题,因为区间由两个值确定。我们的方案是把这两个值放到一个对象字面量中。下面的代码是一个例子,这里的对象字面量包含两个属性(第一个是from,第二个是to),每个属性都有一个值:

worker.postMessage(
 { from: 1,
   to: 20000 }
);

注意 请注意,你可以给Worker传入任何对象字面量。到了后台,浏览器会使用JSON将传入的对象转换为无害字符串,复制它,然后再重新将其转换成对象。

了解了这些细节后,就可以对前面看到的doSearch()函数进行一番改进了。这里,不再让它自己搜索素数,而让它创建一个Worker来承担相应的任务:

var worker;

function doSearch() {
  //禁用按钮,防止用户同时输入多个搜索区间

  searchButton.disabled = true;
  //创建新的Worker
  worker = new Worker("PrimeWorker.js");

  //指定onMessage事件
  //以便从Worker那里收到消息
  worker.onmessage = receivedWorkerMessage;

  //取得数值范围,发送给Web Worker
  var fromNumber = document.getElementById("from").value;
  var toNumber = document.getElementById("to").value;

  worker.postMessage(
   { from: fromNumber,
     to: toNumber }
  );

  //告诉用户正在搜索
  statusDisplay.innerHTML = "A web worker is on the job ("+
   fromNumber + " to " + toNumber + ") ...";
}

现在,PrimeWorker.js开始工作了。它接收到onMessage事件,执行搜索,然后给网页发送回一条新消息,包含找到的素数:

onmessage = function(event) {
  //网页发过来的对象保存在event .data属性中
  var fromNumber = event.data.from;
  var toNumber = event.data.to;

  //在该数值范围内搜索素数
  var primes = findPrimes(fromNumber, toNumber);

  //搜索完成,把结果发回网页
  postMessage(primes);
};

function findPrimes(fromNumber, toNumber) {
  //(费事的素数判断过程都在这个函数里)
}

在Worker调用postMessage()的时候,就会触发onMessage事件,进而会调用网页中的receivedWorkerMessage()函数:

function receivedWorkerMessage(event) {
  //取得素数列表
  var primes = event.data;

  //把素数显示出来
  ...

  //启动搜索功能
  searchButton.disabled = false;
}

这里省略的代码与在上一节看到的一样,就是把素数的数组转换为文本,然后把文本插入到网页中。

总的来说,代码的结构是变了,但逻辑相差无几。可是,结果呢?完全不一样。现在,即便搜索大量的素数,页面也能保持响应。可以滚动页面、在文本框里输入、选择之前搜索的结果。要不是页面底部的消息,恐怕没人会知道是后台使用了Web Worker。

提示 你的Web Worker需要使用另一个JavaScript文件中的代码吗?可以使用importScripts()函数。假如你需要在PrimeWorker.js中调用FindPrimes.js文件中的函数,只要添加下面这行代码即可: importScripts("FindPrimes.js");

处理Worker错误

我们知道,postMessage()方法是跟Web Worker通信的关键。不过,还有一种方式可以给网页发送通知——用onerror事件告诉网页有错误发生:

worker.onerror = workerError;

这样,如果后台脚本遇到了问题或因为数据无效出现错误,Worker就能把打包的错误数据发送给网页。以下就是一个在网页中显示错误消息的示例代码:

function workerError(error) {
  statusDisplay.innerHTML = error.message;
}

除了message属性外,错误对象还有lineno和filename属性,分别保存着错误所在的行号及文件的名字。

取消后台任务

学习了一个简单的Web Worker的用例之后,下面该考虑如何改进了。首先,要支持取消后台任务,也就是让网页能在任务执行期间中断它。

停止Worker工作的方式有两种,一种是Worker对象调用自己的close()方法,但更常用的是创建Worker对象的页面调用该对象的terminate()方法。比如,下面的代码就可以用来响应取消任务的按钮单击操作:

function cancelSearch() {
  worker.terminate();
  statusDisplay.innerHTML = "";
  searchButton.disabled = false;
}

传递复杂消息

关于Web Worker,最后我们还想介绍一项技术,那就是进度信息。图6展示了一个改进后的示例页面,其中显示了进度信息。

在搜索素数的过程中,会不断更新状态消息,告知用户完成了百分之多少。当然,这里要是用一个实色进度条就更符合预期了

要显示进度,Web Worker必须在工作的同时把进度百分比发给页面。我们知道,Web Worker只有一个与创建它的页面对话的方式,即使用postMessage()方法。因此,要发送进度百分比,就要发送两种消息:进度通知(在工作过程中)和素数列表(工作结束后)。这项技术的关键在于明确区分两类消息,因此页面中的onMessage事件处理程序就知道哪个是进度信息,哪个是结果了。

为此,需要在发送消息对象字面量时定义不同的属性。比如,在Web Worker发送进度信息时,可以将该信息命名为“Progress”,而在发送素数列表时,将其命名为“PrimeList”。

在消息对象字面量里定义新的属性,是我们前面向Web Worker发送区间数值时用过的技巧。这里的新属性messageType和data,分别用于描述消息类型和数据本身。

好了,我们可以重写Web Worker的代码,为素数列表添加一个messageType属性:

onmessage = function(event) {
  //搜索素数
  var primes = findPrimes(event.data.from, event.data.to);

  //发回结果
  postMessage(
   {messageType: "PrimeList", data: primes}
  );
};

为了发回进度信息,findPrimes()函数里的代码也要调用postMessage()向网页发回信息。这里传递的对象字面量同样包含两个属性:messageType和data,但此时前者声明的是进度通知,后者指定的是进度百分比:

function findPrimes(fromNumber, toNumber) {
  ...

  //计算进度百分比
  var progress = Math.round(i/list.length*100);

  //只在进度变化超过1%时才发送进度百分比信息
  if (progress != previousProgress) {
    postMessage(
     {messageType: "Progress", data: progress}
    );
    previousProgress = progress;
  }
  ...
}

页面在接收到这个消息后,首先要检查messageType属性,以便知道收到的是什么数据。如果是素数列表,则将结果显示在网页中。如果是进度通知,则更新进度文本:

function receivedWorkerMessage(event) {
  var message = event.data;

  if (message.messageType == "PrimeList") {
    var primes = message.data;

    //显示素数列表,这里的代码与前面例子中的一样
    ...
  }
  else if (message.messageType == "Progress") {
    //报告当前进度
    statusDisplay.innerHTML = message.data + "% done ...";
  }
}

注意 可以采取另一种方式来设计这个页面。可以让Web Worker每找到一个素数时就调用一次postMessage()方法。这样网页就可以实时地把找到的素数显示出来。显然,此时的优点是可以同步显示结果,但缺点则是不停地阻断页面(因为Web Worker查找素数的速度很快)。到底怎么设计才好呢?这取决于你的任务,比如完成任务的时间长短、只显示部分结果有没有价值、得到每部分结果的效率如何,等等。
 
利用Web Worker的其他方式

搜索素数的例子是使用Web Worker的最直观方式,即执行具有明确描述的任务。每次搜索开始,页面都会创建一个新的Worker对象,每个Web Worker对象独立负责一项任务。而且,每个对象只接收一个消息,然后发回一个消息。

恐怕实际开发中的页面不会这么简单。下面我们列出一些可能的情况,让你能够进一步扩展这里的例子,进而满足自己的实际需要。

  • 在多个任务中重用Web Woker。Worker对象完成既定任务,触发onMessage事件处理程序后并不会被销毁。它只会闲置在那儿,等待新的任务。如果你再给它发送新的消息,它会马上进入状态,投入新的工作。

    • 创建多个Web Worker。一个页面并不限于只能创建一个Worker对象。比如,若要支持 访客同时搜索多个区间内的素数,就需要为每个搜索单独创建一个Worker,然后通过数组来跟踪它们。这样,每当有Worker返回结果,就可以把结果添加到页面中,同时注意不覆盖其他Worker的结果。(为了稳妥起见,还是建议大家少创建Web Worker,它们都不是“省油的灯”,一次运行太多会拖慢计算机。)
    • 在一个Web Worker中创建另一个Web Worker。每个Web Worker都可以创建自己的Web Worker,向它们发送消息,从它们那里接收消息。对于复杂的计算任务,比如计算斐波那契数这种需要递归的计算,在Worker内创建Worker可以派上用场。
    • 通过Web Worker下载数据。Web Worker可以使用XMLHttpRequest对象取得新页面,或者向Web服务发送请求。取得了所需的信息后,它们可以调用postMessage()方法,把数据发回页面。
    • 利用Web Worker执行周期性任务。与普通网页中的脚本一样,Web Worker可以调用setTimeout()或setInterval()函数。因此,可以通过Web Worker来定期检测某个网站是否有新数据。

参考文档