JavaScript学习--线程、资源下载与性能的关系,Web Worker如何解决JavaScript执行时间过长的问题

浏览器多线程&&JavaScript单线程

我们可能都知道JavaScript是单线程的,却不知道浏览器是多线程的。开发过程中遇到js线程和ui渲染线程互斥问题。导致ui无法正常更新等问题。这些问题的根源就是因为浏览器的多线程和js的单线程引起的。

由于浏览器的特性,浏览器在执行JavaScript代码的时候,并不能同时做其他事情。事实上,大多数浏览器都使用单一进程来处理用户界面(UI)更新和JavaScript脚本执行,所以同一时刻只能做其中的一件事情。JavaScript执行时间耗时越久,浏览器等待响应用户输入的时间越长。

JavaScript单线程

js运作在浏览器中,是单线程的,js代码始终在一个线程上执行,此线程被称为js引擎线程。
(ps:web worker也只是允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。)

但是如果单线程,任务都需要排队。排队是因为计算量大,CPU忙不过来,倒也算了,但是很多时候CPU是闲着的,因为IO设备(输入输出设备)很慢(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。

JavaScript语言的设计者意识到,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。

于是,所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。

同步任务:在主线程排队支持的任务,前一个任务执行完毕后,执行后一个任务,形成一个执行栈,线程执行时在内存形成的空间为栈,进程形成堆结构,这是内存的结构。执行栈可以实现函数的层层调用。注意不要理解成同步代码进入栈中,按栈的出栈顺序来执行。
异步任务会被主线程挂起,不会进入主线程,而是进入消息队列,而且必须指定回调函数,只有消息队列通知主线程,并且执行栈为空时,该消息对应的任务才会进入执行栈获得执行的机会。

浏览器多线程

1.js引擎线程(js引擎有多个线程,一个主线程,其它的后台配合主线程)
作用:执行js任务(执行js代码,用户输入,网络请求)
2.ui渲染线程
作用:渲染页面(js可以操作dom,影响渲染,所以js引擎线程和UI线程是互斥的。js执行时会阻塞页面的渲染。)
3.浏览器事件触发线程
作用:控制交互,响应用户
4.http请求线程
作用:ajax请求等
5.定时触发器线程
作用:setTimeout和setInteval
6.事件轮询处理线程
作用:轮询消息队列,event loop

共用于执行JavaScript和更新用户界面的进程通常被称为“浏览器UI线程”。UI线程的工作基于一个简单的队列系统,任务会被保存到队列中直到进程空闲。一旦空闲,队列中的下一个任务就被重新提取出来并运行。这些任务要么是运行JavaScript代码,要么是执行UI更新,包括回流和重绘。

<html>
    <head>
        <title>Browser UI Thread Example</title>
    </head>
    <body>
        <button onclick="handleClick()">click</button>
        <script type="text/javascript">
            function handleClick(){
                var div = document.createElement('div');
                div.innerHTML = "Clicked";
                document.body.appendChild(div);
            }
        </script>
    </body>
</html>

如上面这段代码,当按钮被点击时,它会触发两个UI线程来创建两个任务并添加到队列中。第一个任务是更新按钮的UI(浏览器自带),它需要改变外观以指示它被点击了,第二个任务是执行handleClick()的代码。在该函数执行的过程中,引发了另一次UI变化。这意味着,在JavaScript运行过程中,一个新的UI更新任务添加在队列中,当JavaScript运行完后,UI还会再执行一次。

当所有UI线程任务都执行完毕,进程进入空闲状态,并等待更多的任务进入队列。空闲状态时用户的所有交互都会立刻触发UI更新。如果用户试图在任务运行期与页面交互,不仅没有即使的UI更新,甚至可能新的UI任务都不会被创建并加入队列。事实上,大多数浏览器在JavaScript运行时会停止把新任务加入UI线程的队列中。比如当你运行某些JavaScript代码的时候点击按钮,浏览器可能不会把重绘按钮按下状态的任务或点击按钮启动的新的JavaScript任务加入队列。最终结果是一个失去响应的UI,表现为“挂起”或“假死”。

为了避免不连续的用户体验,最好的办法是限制所有JavaScript任务在100毫秒或更短的时间内完成,以避免类似情况出现。可以考虑用下面的方法。

使用定时器让出时间片段

如果JavaScript代码不能在100毫秒内执行完毕,这个时候最理想的是让出UI线程的控制权,使得UI就可以更新,更新完毕再继续执行未执行完毕的JavaScript代码

定时器基础

在JavaScript中可以使用setTimeout()和setInterval()创建定时器。setTimeout()创建一个只执行一次的定时器,而setInterval()创建一个周期性重复运行的定时器。定时器与UI线程的交互方式有助于把长时间运行脚本拆分为比较短的片段。

function todo() {
	// 。。
     setTimeout(function(){
         // ..
     },250);
     anotherMethod();
}

todo()函数调用setTimeout()函数后250ms,在UI队列中插入定时器中这段JavaScript代码,而且只有等待UI队列中定时器前面的任务执行完毕才能执行定时器中的内容。也就是说只有当todo()函数执行结束后才可能执行定时器任务。
如果anotherMethod()执行时间超过250ms,那么定时器代码将在anotherMethod()函数执行完毕后立即执行。注意,这样如果两份代码间有逻辑关系的话就可能会导致执行结果有误。

注意,等待时间为0也会延迟加入UI队列,这是由于定时器使UI线程从一个任务切换到下一个任务,此时,定时器代码会重置所有相关的浏览器限制,包括长时间运行脚本定时器。此外,调用栈也在定时器的代码中重置为0。这一特性使得定时器称为长时间运行JavaScript代码理想的浏览器解决方案。

console.log('hello1');
setTimeout(function(){
    console.log('hello2');
},0);
console.log('hello3');

输出结果依次为:hello1 hello3 hello2
上面的代码中,setTimeout()函数进入下一个事件循环,也就是说UI队列进行了一次更新。

注意!如果UI队列中已经存在由同一个setTimeout()创建的任务,那么后续任务不会被添加到UI队列中。

JavaScript定时器延迟通常不太精准,相差大约几毫秒。指定定时器延时250毫秒,并不意味着任务一定会在调用setTimeout() 之后过250毫秒时准确加入队列。所有浏览器都试图尽可能精确,但通常会发生几毫秒偏移,或快或慢。正因为如此,定时器不可用于测量实际时间。定时器延时的最小值有助于避免在其他浏览器和其他操作系统的定时器出现分辨率问题。

使用定时器处理数组

如果循环结构中满足以下两个条件:

  • 处理过程是否必须同步
  • 数据是否必须按顺序处理?

如果回答都是“否”,那么代码将适用于定时器分解任务。一种异步代码如下:

        var todo = items.concat();    // 克隆原数组
        setTimeout(function(){
            // 取得数组的下个元素并进行处理
            process(todo.shift());
            // 如果还有需要处理的元素,创建另一个定时器
            if (todo.length > 0) {
                setTimeout(arguments.callee,25);
            } else {
                callback();
            }
        },25);

arguments.callee指向当前正在运行的匿名函数。可以将该段代码进行封装。

注意,每个定时器的真是延时时间在很大程度上取决于具体情况。普遍来讲,最好使用至少25毫秒,因为再小的延时,对大多数UI更新来说不够用。

分割任务

如果一个函数运行时间较长,那么检查一下是否可以把它拆分为一系列在较短时间内完成的子函数。如下代码将每个任务/方法都放进tasks数组,然后在定时器中只调用一个方法,这是一种封装。

function multistep(steps,args,callback){
    var tasks = steps.concat(); //克隆数组
    setTimeout(function(){
        // 执行下一个任务
        var task = tasks.shift();
        task.apply(null,args || []);

        // 检查是否还有其他任务
        if (tasks.length > 0) {
            setTimeout(arguments.callee,25);
        } else {
            callback();
        }
    },25);
}
记录代码运行时间
var start = +new Date(),
stop;
someLongProcess();
stop = +new Date();
if (stop - start < 50) {
    alert("right");
} else {
    alert("tolong");
}

+可以将Date对象转化为数字,那么在后续的数字运算中就无须转换了。
通过添加一个时间检测机制来改进前面的任务数组处理方法,使得每个定时器能处理多个数组条目:

function timedProcessArray(items,process,callback) {
    var todo = items.comcat(); // 克隆原始数组
    setTimeout(function(){
        var start = +new Date();
        do {
            process(todo.shift());
        }while(todo.length > 0 && (+new Date() - start < 50));

        if (todo.length > 0) {
            setTimeout(arguments.callee,25);
        } else {
            callback(items);
        }
    },25);
}

上面这种写法就可以避免把任务分解成过于零碎的片段,也就是说能比较充分地利用延迟,减少延迟时间的浪费。

定时器与性能

同一时间只有一个定时器存在,只有当这个定时器结束后才会新创建一个。建议限制高频率重复定时器的数量,建议创建独立的重复定时器,每次执行多个操作。

Web Worker

Web Worker API引入一个接口,能使代码运行且不占用浏览器UI线程的时间。

每个新的Worker都在自己的线程中运行代码。

由于Web Worker没有绑定UI线程,这意味着它们不能访问浏览器的许多资源。JavaScript和UI共享同一进程的部分原因是它们之间互相访问频繁。Web Worker从外部线程中修改DOM会导致用户界面出现错误。每个Web Worker都有自己的全局运行环境
Web Worker的全局运行环境由如下部分组成:

  • 一个navigator对象,只包括四个属性:appName、appVersion、userAgent和platform。
  • 一个location对象(与window.location相同,不过所有属性都是只读的)。
  • 一个self对象,指向全局worker对象。
  • 一个importScripts()对象,用来加载Worker所用到的外部JavaScript文件。
  • 所有的ECMAScript对象,注入Object、Array、Date等。
  • XMLHTTPRuest构造器。
  • setTimeout()和setInterval()
  • 一个close()方法,它能立刻停止Worker运行。

Web Worker需要保存在完全独立的JavaScript文件,var worker = new Worker(“code.js”);此代码一旦执行,将为这个文件创建一个新的线程和一个新的Worker运行环境。该文件会被异步下载,直到文件下载并执行完成后才会启动此worker。

与Web Worker通信
var worker = new Worker("code.js");
worker.onmessage = function(event) {
    alert(event.data);
};
worker.postMessage("hello");

onmessage时间处理器用来接收Worker传回来的数据。postMessage()方法将数据传给Worker。这种消息机制是网页和Web Worker通信的唯一途径。Worker内部调用self.onmessage和self.postMessage()接收和发送数据给网页线程。

Web Worker适用于那些处理纯数据,或者与浏览器UI无关的长时间运行脚本

任何超过100毫秒的处理过程,都应该考虑Worker方案是不是比基于定时器的方案更为合适。当然,前提是浏览器支持Web Worker。Web Worker可以用于:

  • 编码/解码大字符串
  • 复杂的数学运算(包括图像或视频处理)
  • 大数组排序
资源下载与性能

瀑布图可以帮助我们更清晰地理解资源下载时性能问题发生的原因,瀑布图描述了每个资源文件的下载过程。

从下面这张图我们可以了解到为什么JavaScript文件的位置会影响其他文件的下载。
在这里插入图片描述
这张图对应的代码为:

    <head>
        <script src="file1.js"></script>
        <script src="file2.js"></script>
        <script src="file3.js"></script>
        <link type="text/css" href="style.css">
    </head>

我们可以看到,每个文件必须等到前一个文件下载并执行完才会开始下载。

注意,浏览器只有在解析到<body>标签之前,不会渲染页面的任何部分。把脚本放到页面顶部将会导致明显的延迟,通常表现为显示空白页面,用户无法浏览内容,也无法与页面进行交互。也就是说执行上面这段代码带来的体验是比较差的。
而现在大多数浏览器已经支持并行下载JavaScript文件了,只不过执行顺序还是按照代码中的先后顺序,这样就保证了<script>标签在下载外部资源的时候不会阻塞其他<script>资源。但仍然会影响其他资源的下载。

由于脚本会阻塞页面其他资源的下载,所以推荐将所有的<script>标签尽可能放在<body>标签的底部,以尽量减少对整个页面下载的影响。

需要注意的是,外链脚本需要降低页面渲染的性能影响,内嵌脚本也需要被限制,这以为着如果要改善页面渲染性能,需要尽可能降低JavaScipt的执行时间。

在<link>标签会导致页面阻塞去等待样式表的下载,如果把脚本代码或脚本链接代码放在该标签之后,也会影响页面渲染,这是由于为了确保JS脚本在执行时能获得最精准的样式信息,此时页面会被阻塞而去等待样式表的下载。

可以读如何读网络请求的瀑布图来了解资源下载过程是如何影响其他资源的下载以及页面渲染与资源下载的关系。

为什么要合并文件?—减少资源请求数目。但是JavaScript文件的大小也会影响性能。
如果为了减少HTTP请求数目而将文件合并为一个大的文件,此时浏览器下载和执行会可能占据较长时间而导致较大的性能问题。为避免这种情况,我们需要向页面中逐步加载JavaScript文件,这样做在某种程度上来说不会阻塞浏览器。

无阻塞脚本–在页面加载完成后才加载JavaScript,也就是说在window对象的load事件触发后再下载脚本。实现这一效果有下面几种方式。

使用defer延迟脚本执行

defer属性指明本文素所含的脚本不会修改DOM,因此代码能够安全地执行。这里安全执行和不修改DOM的意思就是说:带有defer属性的<script>标签可以放在文档的任何位置。对应的JavaScript文件将在页面解析到<script>标签的时候开始下载,但并不会执行,直到DOM加载完成(onload事件被触发前)。当一个带有defer属性的JavaScript文件下载时,它不会阻塞浏览器的其他进程,因此此类文件可以与页面中的其他资源并行下载

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>css-learning</title>
    </head>
    <body>
        <script defer>console.log("defer")</script>
        <script>console.log("script")</script>
        <script>
            window.onload = function() {
                console.log("load");
            }
        </script>
    </body>
</html>

结果为:
defer
script
load
一般来说,支持defer属性的一般是输出为script defer load (

async与defer的区别
defer是脚本下载后延迟执行,HTML5规范要求脚本按照它们出现的先后顺序执行。但现实中却不一定。
async告诉浏览器立即下载文件,与defer不同的是,属性为async的脚本并不保证按照它们的先后顺序执行,但是都有相同的一点—延迟执行。

动态脚本元素
var script = document.createElement("script");
 script.type = "text/javascript";
script.src = "file1.js";
document.getElementsByTagName("head")[0].appendChild(script);

上面的代码表示该文件即file1.js将在<script>元素被添加到页面时开始下载。无论在何时启动下载,文件的下载和执行过程不会阻塞页面其他过程。通常来讲,把新创建的<script>标签添加到<head>标签比添加到<body>里更保险,尤其是在页面加载过程中执行代码时更是如此。

虽然动态加载<script>不会阻塞页面其他进程,但是如果该<script>对应的文件需要给接下来的代码提供接口,那就必须保证在执行下面的代码之前<script>的文件加载并执行完毕。

var script = document.createElement("script");
script.type = "text/javascript";
script.onload = function() {
   alert("Script loaded");
};
script.src = "file1.js";
document.getElementsByTagName("head")[0].appendChild(script);

而IE支持另一种实现方式,它会触发一个readystatechange事件。<script>元素提供一个readyState属性,它的值在外链文件的下载过程的不同阶段会发生变化,该属性有五种取值:
uninitialized 初始状态
loading 开始下载
loaded 下载完成
interactive 数据完成下载但尚不可用
complete 所有数据已准备就绪

最有用的状态是loaded和complete

动态加载JavaScript文件的封装:

function loadScript(url,callback) {
    var script = document.createElement("script");
    script.type = "text/javascript";
    if (script.readyState) { // IE
        script.onreadystatechange = function () {
            if (script.readyState == "loaded" || script.readyState == "complete") {
                script.onreadystatechange = null;
                callback();
            }
        };
    } else {  // 其他浏览器
        script.onload = function() {
        callback();
    }
    script.src = url;
    document.getElementsByTagName("head")[0].appendChild(script);
}

如果需要动态加载多个文件,可以在callback中继续调用函数,但是这样不易于管理。更好的做法是把需要下载的文件以正确的顺序合并成一个文件。

上述方法是最通用的无阻塞加载解决方案。

另一种无阻塞加载脚本的方法是使用XMLHttpRequest(XHR)对象获取脚本注入页面。

var xhr = new XMLHttpRequest();
xhr.open("get","file1.js",true);
xhr.onreadystatechange = function() {
    if (xhr.readyState == 4) {
        if (xhr.status >= 200 && xhr.status < 300 || shr.status == 304) {
            var script = document.createElement("script");
            script.type = "text/javascript";
            script.text = xhr.responseText;
            document.body.appendChild(script);
        }
    }
}
xhr.send(null);

状态码为2xx表示有效响应,304代表从缓存中读取。最后创建一个带有内联脚本的<script>标签。一旦新创建的<script>元素被添加到页面,代码就会立刻执行然后准备就绪。

这种方法的主要优点是可以下载JavaScript代码而不立刻执行。由于代码是在<script>标签之外返回的,因此它下载后不会自动执行,这使得你可以把脚本的执行推迟到你准备好的时候。另一个优点是兼容性好。局限性在于JavaScript文件必须与所请求的页面处于相同的域,这意味着Javascript文件不能从CDN下载。因此,大型web应用通常不会使用XHR脚本注入技术

向页面中添加大量JavaScript的推荐做法:先添加动态加载其他文件的代码,然后加载初始化页面所需的剩下代码。即:

<script type="text/javascript" src="loader.js"></script>
<script type="text/javascript">
    loadScript("text.javascript",function() {
        Application.init();
    })
</script>

也可以将loader.js中的内容(假设代码比较少)直接放在html文件中,这样可以减少一次HTTP请求。

事件委托

每绑定一个事件都是有代价的,它要么是加重了页面负担(更多的标签或JavaScript代码),要么是增加了运行期的执行事件。

事件绑定占用了处理时间,而且浏览器需要跟踪每个事件处理器,也占用了更多的内存。

一个简单而优雅的处理DOM事件的技术是事件委托。它是基于这样一个事实:事件逐层冒泡并能被父级元素捕获。使用事件代理,只给外层元素绑定一个处理器,就可以处理在其子元素上触发的所有事件。

根据DOM标准,每个事件都要经历三个阶段:

  • 捕获
  • 到达目标
  • 冒泡(IE不支持捕获)
    document.getElementById('menu').onclick = function(e) {
        // 浏览器target
        e = e || window.event;
        var target = e.target || e.srcElement;

        var pageid,hrefparts;
        // 只关心hrefs,非链接点击则退出
        if (target.nodeName !== 'a') {
            return;
        }
        // 从链接中找出页面ID
        hrefparts = target.href.split('/');
        pageid = hrefparts[hrefparts.length - 1];
        pageid = pageid.replace('.html','');

        // 更新页面
        ajaxRequest('xhr.php?page=' + id,updatePageContents);

        // 浏览器阻止默认行为并取消冒泡
        if (typeof e.preventDefault === 'function') {
            e.preventDefault();
            e.stopPropagation();
        } else {
            e.returnValue = false;
            e.cancelBubble = true;
        }
    }

其中,跨浏览器的部分包括:

  • 访问事件对象,判断事件源
  • 取消文档树中的冒泡(可选)
  • 阻止默认动作(可选,但本例需要,因为需要捕获并阻止打开链接)
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值