事件模型
CustomEvent 接口 自定义事件
var event = new CustomEvent('build', { 'detail': 'hello' });
function eventHandler(e) {
console.log(e.detail);
}
document.body.addEventListener('build', function (e) {
console.log(e.detail);
});
document.body.dispatchEvent(event);
javascript.ruanyifeng.com/dom/event.h…
浏览器缓存机制 强制缓存和协商缓存
浏览器的缓存机制也就是我们说的HTTP缓存机制,其机制是根据HTTP报文的缓存标识进行的,所以在分析浏览器缓存机制之前,我们先使用图文简单介绍一下HTTP报文,HTTP报文分为两种:
HTTP请求(Request)报文,报文格式为:请求行 – HTTP头(通用信息头,请求头,实体头) – 请求报文主体(只有POST才有报文主体),如下图
HTTP响应(Response)报文,报文格式为:状态行 – HTTP头(通用信息头,响应头,实体头) – 响应报文主体,如下图
注:通用信息头指的是请求和响应报文都支持的头域,分别为Cache-Control、Connection、Date、Pragma、Transfer-Encoding、Upgrade、Via;实体头则是实体信息的实体头域,分别为Allow、Content-Base、Content-Encoding、Content-Language、Content-Length、Content-Location、Content-MD5、Content-Range、Content-Type、Etag、Expires、Last-Modified、extension-header。这里只是为了方便理解,将通用信息头,响应头/请求头,实体头都归为了HTTP头。
控制强制缓存的字段分别是Expires和Cache-Control,其中Cache-Control优先级比Expires高。
强制缓存
Expires
Expires是HTTP/1.0控制网页缓存的字段,其值为服务器返回该请求结果缓存的到期时间,即再次发起该请求时,如果客户端的时间小于Expires的值时,直接使用缓存结果。
Expires是HTTP/1.0的字段,但是现在浏览器默认使用的是HTTP/1.1,那么在HTTP/1.1中网页缓存还是否由Expires控制?
到了HTTP/1.1,Expire已经被Cache-Control替代,原因在于Expires控制缓存的原理是使用客户端的时间与服务端返回的时间做对比,那么如果客户端与服务端的时间因为某些原因(例如时区不同;客户端和服务端有一方的时间不准确)发生误差,那么强制缓存则会直接失效,这样的话强制缓存的存在则毫无意义
Cache-Control
在HTTP/1.1中,Cache-Control是最重要的规则,主要用于控制网页缓存,主要取值为:
- public:所有内容都将被缓存(客户端和代理服务器都可缓存)
- private:所有内容只有客户端可以缓存,Cache-Control的默认取值
- no-cache:客户端缓存内容,但是是否使用缓存则需要经过协商缓存来验证决定
- no-store:所有内容都不会被缓存,即不使用强制缓存,也不使用协商缓存
- max-age=xxx (xxx is numeric):缓存内容将在xxx秒后失效
协商缓存
协商缓存生效,返回304,如下
304
协商缓存失效,返回200和请求结果结果,如下
200
同样,协商缓存的标识也是在响应报文的HTTP头中和请求结果一起返回给浏览器的,控制协商缓存的字段分别有:Last-Modified / If-Modified-Since和Etag / If-None-Match,其中Etag / If-None-Match的优先级比Last-Modified / If-Modified-Since高。
Last-Modified / If-Modified-Since
Last-Modified是服务器响应请求时,返回该资源文件在服务器最后被修改的时间,如下。
last-modify
If-Modified-Since则是客户端再次发起该请求时,携带上次请求返回的Last-Modified值,通过此字段值告诉服务器该资源上次请求返回的最后被修改时间。服务器收到该请求,发现请求头含有If-Modified-Since字段,则会根据If-Modified-Since的字段值与该资源在服务器的最后被修改时间做对比,若服务器的资源最后被修改时间大于If-Modified-Since的字段值,则重新返回资源,状态码为200;否则则返回304,代表资源无更新,可继续使用缓存文件,如下。
If-Modified-Since
Etag / If-None-Match
Etag是服务器响应请求时,返回当前资源文件的一个唯一标识(由服务器生成),如下。
Etag
If-None-Match是客户端再次发起该请求时,携带上次请求返回的唯一标识Etag值,通过此字段值告诉服务器该资源上次请求返回的唯一标识值。服务器收到该请求后,发现该请求头中含有If-None-Match,则会根据If-None-Match的字段值与该资源在服务器的Etag值做对比,一致则返回304,代表资源无更新,继续使用缓存文件;不一致则重新返回资源文件,状态码为200,如下。
Etag-match
注:Etag / If-None-Match优先级高于Last-Modified / If-Modified-Since,同时存在则只有Etag / If-None-Match生效。
总结
强制缓存优先于协商缓存进行,若强制缓存(Expires和Cache-Control)生效则直接使用缓存,若不生效则进行协商缓存(Last-Modified / If-Modified-Since和Etag / If-None-Match),协商缓存由服务器决定是否使用缓存,若协商缓存失效,那么代表该请求的缓存失效,重新获取请求结果,再存入浏览器缓存中;生效则返回304,继续使用缓
Chrome 浏览器架构
渲染进程负责页面的内容
渲染进程负责所有发生在浏览器页签中的事情。在一个渲染进程中,主线程负责解析,编译或运行代码等工作,当我们使用 Worker 时,Worker 线程会负责运行一部分代码。合成线程和光栅线程是也是运行在渲染进程中的,负责更高效和顺畅的渲染页面。
合成线程对事件的处理
在前面的章节中,我们知道了合成线程可以通过合成技术合成不同的光栅层优化性能,如果页面并不监听任何事件,合成线程可以完全独立于主线程生成新的合成帧。但如果页面监听了事件呢?
标记“慢滚动”区域
由于运行 Javascript 是主线程的工作,当页面被合成线程合成过,合成线程会标记那些有事件监听的区域。有了这些信息,当事件发生在响应的区域时,合成线程就会将事件发送给主线程处理。如果在非事件监听区域,则渲染进程直接创建新的帧而不关心主线程。
在事件监听时标记
在 web 开发中常见的方式就是事件代理。利用事件冒泡,我们可以在目标元素的上层元素中监听事件。参照下面的代码。
document.body.addEventListener('touchstart', event => { if (event.target === area) { event.preventDefault(); }});
通过这种写法,可以更高效的监听事件。但如果从浏览器的角度看,此时整个页面会被标记成“慢滚动”区域。这意味着虽然页面中的某些部分并不需要事件监听,但合成线程依然要在每次交互发生后等待主线程处理事件,合成线程的优化效果不复存在。
为了解决这个问题,我们可在事件代理时传入passive: true (IE 不支持) 参数。这样告诉渲染线程,依然需要将事件发送给主线程处理,但不需要等待。
document.body.addEventListener('touchstart', event => { if (event.target === area) { event.preventDefault() } }, {passive: true});
关于使用 passive 改善滚屏性能,可以参考MDN 使用passive改善滚屏性能。
查找事件目标
当渲染线程将事件发送给主线程后,第一件事就是找到事件触发的目标。通过在渲染过程中生成的绘制信息,可以根据坐标找到目标元素。
减少发送给主线程的事件数量
为了保证动画的顺畅,需要显示器在每秒刷新 60 次。对于典型的触摸事件由合成线程提交给主线程的事件频率可以达到每秒 60-120 次,对于典型的鼠标事件每秒会发送 100 次。事件发送的频率通常比屏幕刷新频率要高。
如果类似touchmove这样的事件每秒向主线程发送 120 次可能会造成主线程执行时间过长而影响性能。
rawevents.png
为了减少发送给主线程的事件数量,Chrome 合并了连续的事件。类似wheel,mousewheel,mousemove,pointermove,touchmove这样的事件会被延迟到下一次requestAnimationFrame前触发.
coalescedevents.png
而任何的离散事件,类似keydown, keyup, mouseup, mousedown, touchstart和 touchend都会立即被发送给主线程处理。
总结
到此,我们已经可以通过从用户在浏览器地址栏中的一次输入到页面图像的显示了解浏览器是如何工作的。这里我们总结一下。
- 浏览器进程做为最重要的进程负责大多数页签外部的工作,包括地址栏显示、网络请求、页签状态管理等。
- 不同的渲染进程负责不同的站点渲染工作,渲染进程间彼此独立。
- 渲染进程在渲染页面的过程中会通过浏览器进程获取站点资源,只有安全的资源才会被渲染进程接收到。
- 渲染进程中主线程负责除了图像生成外绝大多数工作,如何减少主线程上代码的运行是交互性能优化的关键。
- 渲染进程中的合成线程和栅格线程负责图像生成,利用分层技术可以优化图像生成的效率。
- 当用户与页面发生交互时,事件的传播途径从浏览器进程到渲染进程的合成线程再根据事件监听的区域决定是否要传递给渲染进程的主线程处理。 ]
浏览器的工作原理:新式网络浏览器幕后揭秘
www.html5rocks.com/zh/tutorial…
Preload,Prefetch 和 Preconnect
资源提示与指令。你也许听说过 preload,prefetch 和 preconnect,可是我们想研究的更深一点,搞清他们之间的区别并且充分的利用它们。它们带来的好处包括允许前端开发人员来优化资源的加载,减少往返路径并且在浏览页面时可以更快的加载到资源。
Preload
Preload 是一个新的控制特定资源如何被加载的新的 Web 标准,这是已经在 2016 年 1 月废弃的 subresource prefetch 的升级版。这个指令可以在 <link> 中使用,比如 <link rel="preload">。一般来说,最好使用 preload 来加载你最重要的资源,比如图像,CSS,JavaScript 和字体文件。这不要与浏览器预加载混淆,浏览器预加载只预先加载在HTML中声明的资源。preload 指令事实上克服了这个限制并且允许预加载在 CSS 和JavaScript 中定义的资源,并允许决定何时应用每个资源。
Preload 与 prefetch 不同的地方就是它专注于当前的页面,并以高优先级加载资源,Prefetch 专注于下一个页面将要加载的资源并以低优先级加载。同时也要注意 preload 并不会阻塞 window 的 onload 事件。
使用 Preload 的好处
使用 preload 指令的好处包括:
- 允许浏览器来设定资源加载的优先级因此可以允许前端开发者来优化指定资源的加载。
- 赋予浏览器决定资源类型的能力,因此它能分辨这个资源在以后是否可以重复利用。
- 浏览器可以通过指定
as属性来决定这个请求是否符合 content security policy。 - 浏览器可以基于资源的类型(比如 image/webp)来发送适当的
accept头。
举例
这里有一个非常基本的预加载图像的例子:
<link rel="preload" href="image.png">
复制代码复制代码
这里有一个预加载字体的例子,记住:如果你的预加载需要 CORS 的跨域请求,那么也要加上 crossorigin 的属性。
<link rel="preload" href="https://example.com/fonts/font.woff" as="font" crossorigin>
复制代码复制代码
这里有一个通过 HTML 和 JavaScript 预加载样式表的例子:
<!-- Via markup -->
<link rel="preload" href="/css/mystyles.css" as="style">
复制代码复制代码
<!-- Via JavaScript -->
<script>
var res = document.createElement("link");
res.rel = "preload";
res.as = "style";
res.href = "css/mystyles.css";
document.head.appendChild(res);
</script>
Prefetch
Prefetch 是一个低优先级的资源提示,允许浏览器在后台(空闲时)获取将来可能用得到的资源,并且将他们存储在浏览器的缓存中。一旦一个页面加载完毕就会开始下载其他的资源,然后当用户点击了一个带有 prefetched 的连接,它将可以立刻从缓存中加载内容。有三种不同的 prefetch 的类型,link,DNS 和 prerendering,下面来详细分析。
Link Prefetching
像上面提到的,link prefetching 假设用户将请求它们,所以允许浏览器获取资源并将他们存储在缓存中。浏览器会寻找 HTML <link> 元素中的 prefetch 或者 HTTP 头中如下的 Link:
- HTML:
<link rel="prefetch" href="/uploads/images/pic.png"> - HTTP Header:
Link: </uploads/images/pic.png>; rel=prefetch
DNS Prefetching
DNS prefetching 允许浏览器在用户浏览页面时在后台运行 DNS 的解析。如此一来,DNS 的解析在用户点击一个链接时已经完成,所以可以减少延迟。可以在一个 link 标签的属性中添加 rel="dns-prefetch' 来对指定的 URL 进行 DNS prefetching,我们建议对 Google fonts,Google Analytics 和 CDN 进行处理。
"DNS 请求在带宽方面流量非常小,可是延迟会很高,尤其是在移动设备上。通过 prefetching 指定的 DNS 可以在特定的场景显著的减小延迟,比如用户点击链接的时候。有些时候,甚至可以减小一秒钟的延迟 —— Mozilla Developer Network"
这也对需要重定向的资源很有用,如下:
<!-- Prefetch DNS for external assets -->
<link rel="dns-prefetch" href="//fonts.googleapis.com">
<link rel="dns-prefetch" href="//www.google-analytics.com">
<link rel="dns-prefetch" href="//opensource.keycdn.com">
<link rel="dns-prefetch" href="//cdn.domain.com"
Prerendering
Prerendering 和 prefetching 非常相似,它们都优化了可能导航到的下一页上的资源的加载,区别是 prerendering 在后台渲染了整个页面,整个页面所有的资源。如下:
<link rel="prerender" href="https://www.keycdn.com">
复制代码复制代码
"prerender 提示可以用来指示将要导航到的下一个 HTML:用户代理将作为一个 HTML 的响应来获取和处理资源,要使用适当的 content-types 获取其他内容类型,或者不需要 HTML 预处理,可以使用 prefetch。—— W3C"
Preconnect
本文介绍的最后一个资源提示是 preconnect,preconnect 允许浏览器在一个 HTTP 请求正式发给服务器前预先执行一些操作,这包括 DNS 解析,TLS 协商,TCP 握手,这消除了往返延迟并为用户节省了时间。
"Preconnect 是优化的重要手段,它可以减少很多请求中的往返路径 —— 在某些情况下可以减少数百或者数千毫秒的延迟。—— lya Grigorik"
preconnect 可以直接添加到 HTML 中 link 标签的属性中,也可以写在 HTTP 头中或者通过 JavaScript 生成,如下是一个为 CDN 使用 preconnect 的例子:
<link href="https://cdn.domain.com" rel="preconnect" crossorigin>
复制代码复制代码
如下是为 Google Fonts 使用 preconnect 的例子,通过给 fonts.gstatic.com 加入 preconnect 提示,浏览器将立刻发起请求,和 CSS 请求并行执行。在这个场景下,preconnect 从关键路径中消除了三个 RTTs(Round-Trip Time) 并减少了超过半秒的延迟,lya Grigorik 的 eliminating RTTS with preconnect 一文中有更详细的分析。
使用 preconnect 是个有效而且克制的资源优化方法,它不仅可以优化页面并且可以防止资源利用的浪费。除了 Internet Explorer,Safari,IOS Safari 和 Opera Mini 的现代浏览器已经支持了 preconnect。
Service worker
可以把 Service Worker 理解为一个介于客户端和服务器之间的一个代理服务器。
service worker 可以:
- 后台消息传递
- 网络代理,转发请求,伪造响应
- 离线缓存
- 消息推送
- ... ...
Service worker 与 web worker的异同点
相同点:
- Service Worker 工作在 worker context 中,是没有访问 DOM 的权限的,所以我们无法在 Service Worker 中获取 DOM 节点,也无法在其中操作 DOM 元素;
- 我们可以通过 postMessage 接口把数据传递给其他 JS 文件;
- Service Worker 中运行的代码不会被阻塞,也不会阻塞其他页面的 JS 文件中的代码;
不同点:
Service Worker 是一个浏览器中的进程而不是浏览器内核下的线程,因此它在被注册安装之后,能够被在多个页面中使用,也不会因为页面的关闭而被销毁。因此,Service Worker 很适合被用与多个页面需要使用的复杂数据的计算。
- 安装
- 激活,激活成功之后,打开 chrome://inspect/#service-workers 可以查看到当前运行的 service worker
- 监听 fetch 和 message 事件,下面两种事件会进行简要描述
- 销毁,是否销毁由浏览器决定,如果一个 service worker 长期不使用或者机器内存有限,则可能会销毁这个 worker
利用 service worker 缓存文件
只有https和 localhost可以使用service worker
\
if (navigator.serviceWorker) { navigator.serviceWorker.register('service-worker.js') .then(function(registration) { console.log('service worker 注册成功'); }) .catch(function (err) { console.log('servcie worker 注册失败') }); } // main.js
\
可以通过监听 install 事件进行一些初始化工作,或者什么也不做。 因为我们是要缓存离线文件,所以可以在 install 事件中开始缓存,但是只是将文件加到 caches 缓存中,真正想让浏览器使用缓存文件需要在 fetch 事件中拦截
var cacheFiles = [ 'about.js', 'blog.js' ]; self.addEventListener('install', function (evt) { evt.waitUntil(// 接收一个 promise 对象,直到promise 对象 resolve 之后,才会继续运行 service-worker.js caches.open('my-test-cahce-v1').then(function (cache) { // 使用 open() 方法打开一个缓存,缓存通过名称进行区分 return cache.addAll(cacheFiles); // 缓存文件 }) ); });
\
想让浏览器使用缓存,还需要拦截 fetch 事件
// 缓存图片 self.addEventListener('fetch', function (evt) { evt.respondWith( caches.match(evt.request).then(function(response) { if (response) { // 检查缓存中是否已经缓存了这个请求 return response; } var request = evt.request.clone(); return fetch(request).then(function (response) { if (!response && response.status !== 200 && !response.headers.get('Content-type').match(/image/)) { // 查看是否是图片文件,如果不是,就直接返回请求,不会缓存 return response; } var responseClone = response.clone(); // 复制一份 response 图片的 request 或者 response 对象属于 stream 只能使用一次,之后一份存入缓存,另一份发送给页面 caches.open('my-test-cache-v1').then(function (cache) { cache.put(evt.request, responseClone); }); return response; }); }) ) });
service worker 的更新很简单,只要 service-worker.js 的文件内容有更新,就会使用新的脚本。但是有一点要注意:旧缓存文件的清除、新文件的缓存要在 activate 事件中进行,因为可能旧的页面还在使用之前的缓存文件,清除之后会失去作用。
\
问题
运行时间
service worker 并不是一直在后台运行的。在页面关闭后,浏览器可以继续保持 service worker 运行,也可以关闭 service worker,这取决与浏览器自己的行为。所以不要定义一些全局变量
var hitCounter = 0; this.addEventListener('fetch', function(event) { hitCounter++; event.respondWith( new Response('Hit number ' + hitCounter) ); });
返回的结果可能是没有规律的:1,2,1,2,1,1,2....,原因是 hitCounter 并没有一直存在,如果浏览器关闭了它,下次启动的时候 hitCounter 就赋值为 0 了
权限太大
当 service worker 监听 fetch 事件以后,对应的请求都会经过 service worker。通过 chrome 的 network 工具,可以看到此类请求会标注:from service worker。如果 service worker 中出现了问题,会导致所有请求失败,包括普通的 html 文件。所以 service worker 的代码质量、容错性一定要很好才能保证 web app 正常运行
制定缓存策略
- 对 CSS 、JS 等易更改文件优先使用网络请求的数据, 而对于图片资源则优先使用缓存
- 在网络条件好的情况下优先使用网络请求数据, 而网络条件较差时则尽可能的直接使用缓存
Web worker
DedicatedWorker 和 SharedWorker
DedicatedWorker(简称 Worker):线程只能与一个页面渲染进程 (Render Process) 进行绑定和通信, 不能多 Tab 共享
SharedWorker:可以在多个浏览器 Tab 中访问到同一个 Worker 实例, 实现多 Tab 共享数据, 共享 webSocket 连接等.
多线程
Web Worker 会创建操作系统级别的线程
\
JS 多线程, 是有独立于主线程的 JS 运行环境. 如下图所示: Worker 线程有独立的内存空间, Message Queue, Event Loop, Call Stack 等, 线程间通过 postMessage 通信.
\
JS 单线程中的" 并发", 准确来说是 Concurrent. 如下图所示, 运行时只有一个函数调用栈, 通过 Event Loop 实现不同 Task 的上下文切换 (Context Switch). 这些 Task 通过 BOM API 调起其他线程为主线程工作, 但回调函数代码逻辑依然由 JS 串行运行
应用场景
\
- 可以减少主线程卡顿.
- 可能会带来性能提升.
减少卡顿
根据 Chrome 团队提出的用户感知性能模型 RAIL, 同步 JS 执行时间不能过长. 量化来说, 播放动画时建议小于 16ms, 用户操作响应建议小于 100ms, 页面打开到开始呈现内容建议小于 1000ms.
逻辑异步化
减少主线程卡顿的主要方法为异步化执行, 比如播放动画时, 将同步任务拆分为多个小于 16ms 的子任务, 然后在页面每一帧前通过 requestAnimationFrame 按计划执行一个子任务, 直到全部子任务执行完毕.
\
\
存在问题:
- 不是所有 JS 逻辑都可拆分. 比如数组排序, 树的递归查找, 图像处理算法等, 执行中需要维护当前状态, 且调用上非线性, 无法轻易地拆分为子任务.
- 可以拆分的逻辑难以把控粒度. 如下图所示, 拆分的子任务在高性能机器 (iphoneX) 上可以控制在 16ms 内, 但在性能落后机器 (iphone6) 上就超过了 deadline. 16ms 的用户感知时间, 并不会因为用户手上机器的差别而变化, Google 给出的建议是再拆小到 3-4ms.
- 拆分的子任务并不稳定.对同步 JS 逻辑的拆分, 需要根据业务场景寻找原子逻辑, 而原子逻辑会跟随业务变化, 每次改动业务都需要去 review 原子逻辑
Worker 一步到位
Worker 的多线程能力, 使得同步 JS 任务的拆分一步到位: 从宏观上将整个同步 JS 任务异步化. 不需要再去苦苦寻找原子逻辑, 逻辑异步化的设计上也更加简单和可维护.
\
在浏览器主线程渲染周期内, 将可能阻塞页面渲染的 JS 运行任务 (Jank Job) 迁移到 Worker 线程中, 进而减少主线程的负担, 缩短渲染间隔, 减少页面卡顿.
\
性能提升
Worker 多线程并不会直接带来计算性能的提升, 能否提升与设备 CPU 核数和线程策略有关.
多线程与 CPU 核数
进程是操作系统资源分配的基本单位,线程是操作系统调度 CPU 的基本单位. 操作系统对线程能占用的 CPU 计算资源有复杂的分配策略. 如下图所示:
- 单核多线程通过时间切片交替执行.
- 多核多线程可在不同核中真正并行.
Worker 线程策略
一台设备上相同任务在各线程中运行耗时是一样的. 如下图所示: 我们将主线程 JS 任务交给新建的 Worker 线程, 任务在 Worker 线程上运行并不会比原本主线程更快, 而线程新建消耗和通信开销使得渲染间隔可能变得更久.
\
在单核机器上, 计算资源是内卷的, 新建的 Worker 线程并不能为页面争取到更多的计算资源. 在多核机器上, 新建的 Worker 线程和主线程都能做运算, 页面总计算资源增多, 但对单次任务来说, 在哪个线程上运行耗时是一样的.
\
真正带来性能提升的是多核多线程并发.
\
把主线程还给 UI
Worker 的应用场景, 本质上是从主线程中剥离逻辑, 让主线程专注于 UI 渲染.
Worker API
Worker 环境和主线程环境的异同
Worker 是无 UI 的线程, 无法调用 UI 相关的 DOM/BOM API. Worker 具体支持的 API 可参考 MDN 的 functions and classes available to workers.
Worker 运行环境与主线程的共同点主要包括:
- 包含完整的 JS 运行时, 支持 ECMAScript 规范定义的语言语法和内置对象.
- 支持 XmlHttpRequest, 能独立发送网络请求与后台交互.
- 包含只读的 Location, 指向 Worker 线程执行的 script url, 可通过 url 传递参数给 Worker 环境.
- 包含只读的 Navigator, 用于获取浏览器信息, 如通过 Navigator.userAgent 识别浏览器.
- 支持 setTimeout / setInterval 计时器, 可用于实现异步逻辑.
- 支持 WebSocket 进行网络 I/O; 支持 IndexedDB 进行文件 I/O.
Worker 线程运行环境和主线程的差异点有:
- Worker 线程没有 DOM API, 无法新建和操作 DOM; 也无法访问到主线程的 DOM Element.
- Worker 线程和主线程间内存独立, Worker 线程无法访问页面上的全局变量 (window, document 等) 和 JS 函数.
- Worker 线程不能调用 alert() 或 confirm() 等 UI 相关的 BOM API.
- Worker 线程被主线程控制, 主线程可以新建和销毁 Worker.
- Worker 线程可以通过 self.close 自行销毁.
通信速度
Worker 多线程虽然实现了 JS 任务的并行运行, 也带来额外的通信开销
\
从线程 A 调用 postMessage 发送数据到线程 B onmessage 接收到数据有时间差, 这段时间差称为
通信消耗
\
提升的性能 = 并行提升的性能 – 通信消耗的性能. 在线程计算能力固定的情况下, 要通过多线程提升更多性能, 需要尽量减少通信消耗.
而且主线程 postMessage 会占用主线程同步执行, 占用时间与数据传输方式和数据规模相关. 要避免多线程通信导致的主线程卡顿, 需选择合适的传输方式, 并控制每个渲染周期内的数据传输规模.
数据传输方式
\
通信方式有 3 种: Structured Clone, Transfer Memory 和 Shared Array Buffer.
\
Structured Clone
-
- postMessage 默认的通信方式. 如下图所示, 复制一份线程 A 的 JS Object 内存给到线程 B, 线程 B 能获取和操作新复制的内存.
Structured Clone 通过复制内存的方式简单有效地隔离不同线程内存, 避免冲突; 且传输的 Object 数据结构很灵活. 但复制过程中, 线程 A 要同步执行 Object Serialization(序列化), 线程 B 要同步执行 Object Deserialization; 如果 Object 规模过大, 会占用大量的线程时间.
Transfer Memory
-
- 意为转移内存, 它不需要 Serialization/Deserialization, 能大大减少传输过程占用的线程时间.
\
Transfer Memory 以失去控制权来换取高效传输, 通过内存独占给多线程并发加锁. 但只能转让 ArrayBuffer 等大小规整的二进制 (Raw Binary) 数据; 对矩阵数据 (如 RGB 图片) 比较适用. 实践上也要考虑从 JS Object 生成二进制数据的运算成本.
Shared Array Buffers
-
- 共享内存, 线程 A 和线程 B 可以同时访问和操作同一块内存空间. 数据都共享了, 也就没有传输什么事了.
但多个并行的线程共享内存, 会产生竞争问题 (Race Conditions). 不像前 2 种传输方式默认加锁, Shared Array Buffers 把难题抛给开发者, 开发者可以用 Atomics 来维护这块共享的内存. 作为较新的传输方式, 浏览器兼容性可想而知, 目前只有 Chrome 68+ 支持.
\
传输方式小结
- 全浏览器兼容的 Structured Clone 是较好的选择, 但要考虑数据传输规模, 下文我们会详细展开.
- Transfer Memory 的兼容性也不错 (IE11+), 但数据独占和数据类型的限制, 使得它是特定场景的最优解, 不是通用解;
- Shared Array Buffers 当下糟糕的兼容性和线程锁的开发成本, 建议先暗中观察.
JSON.stringify 更快?
2020 年的当下, 不需要再使用 JSON.stringify. 其一是 Structured Clone 内置的 serialize/deserialize 比 JSON.stringify 性能更高; 其二是 JSON.stringify 只适合序列化基本数据类型, 而 Structured Clone 还支持复制其他内置数据类型 (如 Map, Blob, RegExp 等, 虽然大部分应用场景只用到基本数据类型).
实践建议
使用 Worker 是有成本的: Worker 线程会占用系统资源; 同构代码和异步通信会增加维护成本; 多线程编程会挑战前端仔的思维.
Worker 应该是常驻线程
虽然 Worker 规范提供了 terminate API 来结束 Worker 线程, 但线程的频繁新建会消耗资源. 大多数场景下, Worker 线程应该用作常驻的线程. 开发中优先复用常驻线程.
控制 Worker 线程数目
这也很好理解, Worker 线程在争取 CPU 计算资源时, 受限于 CPU 的核心数, 过多的线程并不能线性地提升性能, 而每个 Worker 线程会有约 1M 的固有内存消耗.
理解多线程开发方式
多线程开发的思维和方式, 是个比较大的话题. 开发者需要控制线程间的通信规模, 减少线程间数据和状态的依赖, 尝试去了解和控制 Worker 线程.
浏览器存储(cookie、localStorage、sessionStorage
- cookie在浏览器请求中每次都会附加请求头中发送给服务器。用户代理(一般值浏览器)所实现的大小最少要到达4096字节
- localStorage保存数据会一直保存没有过期时间,不会随浏览器发送给服务器。大小5M或更大
- sessionStorage仅当前页面有效一旦关闭就会被释放。也不会随浏览器发送给服务器。大小5M或更大
cookie
浏览器在本地按照一定规则存储一些文本字符串,每当浏览器像服务器发送请求时带这些字符串。服务器根据字符串判定浏览器的状态比如:登录、订单、皮肤。服务器就可以根据不同的cookie识别出不同的用户信息。浏览器和服务器cookie交互图如下。
cookie如何产生
1、在浏览器访问服务器时由服务器返回一个Set-Cookie响应头,当浏览器解析这个响应头时设置cookie
2、通过浏览器js脚本设置 document.cookie = 'name=monsterooo';
浏览器访问服务器携带cookie过程
js设置cookie详解
服务器设置cookie这里不过多介绍了同客户端js设置类似,重点来看一下js如何设置cookie和一些细节。
在js中设置cookie完整格式是:document.cookie="key=value[; expires=date][; domain=domain][; path=path][; secure]"
-
key=value
key设置的是cookie的键,value设置的是cookie的值。示例如下:
document.cookie = "name=monsterooo";
-
expires
设置cookie的生存时间,默认为当然浏览器会话(Session)。当设置一个时间时,每次访问浏览器会用当前时间和cookie的expries做比对,如果过期cookie则会被删除。设置格式为GMT时间格式。示例如下:var t = new Date( +(new Date()) + 1000 * 120 ); document.cookie = `name=monsterooo;expires=${t.toGMTString()};`; -
domain
在浏览器读取cookie的时候只有当cookie的domain和浏览器当然的域名匹配才能读取到。默认情况下cookie的domain和当然访问一样。但是很多网址不止有一个域名比如:a.example.com和b.example.com如果他们想要共享cookie那么cookie的domain需要设置为domain=.example.com,path路径需要设置为path=/。这样之后两个域名都能同时访问到cookie了。var t = new Date( +(new Date()) + 1000 * 120 ); document.cookie = `name=monsterooo;expires=${t.toLocaleTimeString()}; domain=.example.com; path=/`; -
path
path路径和domain功能类似,只是path的范围更小。path控制cookie在当前域名的路径,只有路径相匹配cookie才能被读取到。在www.example.com/order/index.html中cookie设置如下document.cookie = `order=10; expires=${t.toGMTString()}; path=/order`;,那么只有在/order路径下的页面cookie中才会带有order值。
localStorage和sessionStorage
localStorage和sessionStorage都继承于Storage,提供了统一的api来访问和设置数据。api列表为:
- clear 清空存储中的所有本地存储数据
- getItem 接受一个参数key,获取对应key的本地存储
- key 接受一个整数索引,返回对应本地存储中索引的键
- removeItem 接受一个参数key,删除对应本地存储的key
- setItem 接受两个参数,key和value,如果不存在则添加,存在则更新。
localStorage.setItem('order', 'a109');
console.log(localStorage.key(0)); // order
console.log(localStorage.getItem('order')) // a109
localStorage.removeItem('order');
localStorage.clear();
// 对象访问方式同样有效
localStorage.order = 'b110';
localStorage.order; // b110
扩展
Cookie的一个极端使用例子是僵尸Cookie(或称之为“删不掉的Cookie”),这类Cookie较难以删除,甚至删除之后会自动重建。它们一般是使使用Web storage API、Flash本地共享对象或者其他技术手段来达到目的的。相关内容可以看:
Script Error 的产生原因和解决方法
Script error." 也叫做跨域错误,当网站执行一个被存放在其他域名下的脚本时,就有可能遇到该错误。
举个栗子:
html 代码运行在 8081 端口下 然后加载了 8080 端口下的 js
a.js 代码如下:
其中并没有 fn1 这个方法,所以将触发 onerror 事件,将 message 打印出来,我们将看到如下信息
解决办法:
- 开启 CORS (跨域资源共享)
-
- HTML5 新的规定,是可以允许本地获取到跨域脚本的错误信息
-
- 一是跨域脚本的服务器必须通过 Access-Controll-Allow-Origin 头信息允许当前域名可以获取错误信息
- 二是当前域名的 script 标签也必须指明 src 属性指定的地址是支持跨域的地址,也就是 crossorigin 属性
给 script 增加 crossorigin = "anonymous" 属性
-
-
- anonymous(会在请求中的header中的带上Origin属性,但请求不会带上cookie和其他的一些认证信息)
- use-credentials(同时会在跨域请求中带上cookie和其他的一些认证信息)
-
- try catch(不建议使用,麻烦不好 )
文件上传 (大文件上传)
主体思路
客户端
利用 Blob.prototype.slice 方法,根据预先设置好的切片最大数量将文件切分为一个个切片,然后借助 http 的可并发性,同时上传多个切片,这样从原本传一个大文件,变成了同时传多个小的文件切片,可以大大减少上传时间
服务端
接收到所有切片后合并切片:
- 何时合并切片,即切片什么时候传输完成?
前端在每个切片中都携带切片最大数量的信息,当服务端接受到这个数量的切片时自动合并,也可以额外发一个请求主动通知服务端进行切片的合并
- 如何合并切片?
使用 nodejs 的 读写流(createWriteStream),将所有切片的流传输到最终文件的流里
分片上传客户端实现
文件分片
const SIZE = 10 * 1024 * 1024; createFileChunk(file, size = SIZE) { const fileChunkList = []; let cur = 0; while (cur < file.size) { fileChunkList.push({ file: file.slice(cur, cur + size) }); cur += size; } return fileChunkList; },
整理切片
给每个切片一个标识作为 hash,这里暂时使用文件名 + 下标,这样后端可以知道当前切片是第几个切片,用于之后的合并切片
\
const fileChunkList = this.createFileChunk(this.container.file); fileChunkList.map(({ file },index) => ({ chunk: file, hash: this.container.file.name + "-" + index // 文件名 + 数组下标 }));
上传切片
async uploadChunks() { const requestList = this.data .map(({ chunk,hash }) => { const formData = new FormData(); formData.append("chunk", chunk); formData.append("hash", hash); formData.append("filename", this.container.file.name); return { formData }; }) .map(async ({ formData }) => this.request({ url: "http://localhost:3000", data: formData }) ); await Promise.all(requestList); // 并发切片 },
发送合并切片请求
分片上传客户端实现
文件分片
const SIZE = 10 * 1024 * 1024; createFileChunk(file, size = SIZE) { const fileChunkList = []; let cur = 0; while (cur < file.size) { fileChunkList.push({ file: file.slice(cur, cur + size) }); cur += size; } return fileChunkList; },
整理切片
给每个切片一个标识作为 hash,这里暂时使用文件名 + 下标,这样后端可以知道当前切片是第几个切片,用于之后的合并切片
const fileChunkList = this.createFileChunk(this.container.file); fileChunkList.map(({ file },index) => ({ chunk: file, hash: this.container.file.name + "-" + index // 文件名 + 数组下标 }));
上传切片
async uploadChunks() { const requestList = this.data.map(({ chunk,hash }) => { const formData = new FormData(); formData.append("chunk", chunk); formData.append("hash", hash); formData.append("filename", this.container.file.name); return { formData }; }) .map(async ({ formData }) => this.request({ url: "http://localhost:3000", data: formData }) ); await Promise.all(requestList); // 并发切片 },
发送合并切片请求
分片上传服务端实现
接收切片
使用 multiparty 包处理前端传来的 FormData
在接受文件切片时,需要先创建存储切片的文件夹,由于前端在发送每个切片时额外携带了唯一值 hash,所以以 hash 作为文件名,将切片从临时路径移动切片文件夹中
const multipart = new multiparty.Form(); multipart.parse(req, async (err, fields, files) => { if (err) { return; } const [chunk] = files.chunk; const [hash] = fields.hash; const [filename] = fields.filename; const chunkDir = path.resolve(UPLOAD_DIR, filename); // 切片目录不存在,创建切片目录 if (!fse.existsSync(chunkDir)) { await fse.mkdirs(chunkDir); } // fs-extra 专用方法,类似 fs.rename 并且跨平台 // fs-extra 的 rename 方法 windows 平台会有权限问题 // github.com/meteor/mete… await fse.move(chunk.path, ${chunkDir}/${hash}); res.end("received file chunk"); }); });
合并切片
由于前端在发送合并请求时会携带文件名,服务端根据文件名可以找到上一步创建的切片文件夹
接着使用 fs.createWriteStream 创建一个可写流,可写流文件名就是切片文件夹名 + 后缀名组合而成
\
export const mergeChunks = async (filename: string, size: number = SIZE) => { const filePath = path.resolve(PUBLIC_DIR, filename); const chunksDir = path.resolve(TEMP_DIR, filename); const chunkFiles = await fs.readdir(chunksDir); // 根据切片下标进行排序 // 否则直接读取目录的获得的顺序可能会错乱 chunkFiles.sort((a, b) => Number(a.split('-')[1]) - Number(b.split('-')[1])); await Promise.all( chunkFiles.map((chunkFile, index) => pipeStream( path.resolve(chunksDir, chunkFile), // 根据size计算每次可读流都会传输到可写流的指定位置 fs.createWriteStream(filePath, { start: index * size }) )) ); await fs.rmdir(chunksDir); // 合并后删除保存切片的目录 }
请求的时候多提供一个 size 参数,目的是能够并发合并多个可读流到可写流中,这样即使流的顺序不同也能传输到正确的位置
分片上传服务端实现
接收切片
使用 multiparty 包处理前端传来的 FormData
在接受文件切片时,需要先创建存储切片的文件夹,由于前端在发送每个切片时额外携带了唯一值 hash,所以以 hash 作为文件名,将切片从临时路径移动切片文件夹中
const multipart = new multiparty.Form(); multipart.parse(req, async (err, fields, files) => { if (err) { return; } const [chunk] = files.chunk; const [hash] = fields.hash; const [filename] = fields.filename; const chunkDir = path.resolve(UPLOAD_DIR, filename); // 切片目录不存在,创建切片目录 if (!fse.existsSync(chunkDir)) { await fse.mkdirs(chunkDir); } // fs-extra 专用方法,类似 fs.rename 并且跨平台 // fs-extra 的 rename 方法 windows 平台会有权限问题 // github.com/meteor/mete… await fse.move(chunk.path, ${chunkDir}/${hash}); res.end("received file chunk"); }); });
合并切片
由于前端在发送合并请求时会携带文件名,服务端根据文件名可以找到上一步创建的切片文件夹
接着使用 fs.createWriteStream 创建一个可写流,可写流文件名就是切片文件夹名 + 后缀名组合而成
\
export const mergeChunks = async (filename: string, size: number = SIZE) => { const filePath = path.resolve(PUBLIC_DIR, filename); const chunksDir = path.resolve(TEMP_DIR, filename); const chunkFiles = await fs.readdir(chunksDir); // 根据切片下标进行排序 // 否则直接读取目录的获得的顺序可能会错乱 chunkFiles.sort((a, b) => Number(a.split('-')[1]) - Number(b.split('-')[1])); await Promise.all( chunkFiles.map((chunkFile, index) => pipeStream( path.resolve(chunksDir, chunkFile), // 根据size计算每次可读流都会传输到可写流的指定位置 fs.createWriteStream(filePath, { start: index * size }) )) ); await fs.rmdir(chunksDir); // 合并后删除保存切片的目录 }
请求的时候多提供一个 size 参数,目的是能够并发合并多个可读流到可写流中,这样即使流的顺序不同也能传输到正确的位置
优化
生成 hash
根据文件内容生成 hash
考虑到如果上传一个超大文件,读取文件内容计算 hash 是非常耗费时间的,并且会引起 UI 的阻塞,导致页面假死状态,所以我们使用 web-worker 在 worker 线程计算 hash,这样用户仍可以在主界面正常的交互
文件秒传
所谓的文件秒传,即在服务端已经存在了上传的资源,所以当用户再次上传时会直接提示上传成功
\
文件秒传需要依赖上一步生成的 hash,即在上传前,先计算出文件 hash,并把 hash 发送给服务端进行验证,由于 hash 的唯一性,所以一旦服务端能找到 hash 相同的文件,则直接返回上传成功的信息即可
\
暂停上传
使用 XMLHttpRequest 的 abort 方法,可以取消一个 xhr 请求的发送,为此我们需要将上传每个切片的 xhr 对象保存起来
request({ url, method = "post", data, headers = {}, onProgress = e => e, + requestList }) { return new Promise(resolve => { const xhr = new XMLHttpRequest(); xhr.upload.onprogress = onProgress; xhr.open(method, url); Object.keys(headers).forEach(key => xhr.setRequestHeader(key, headers[key]) ); xhr.send(data); xhr.onload = e => { + // 将请求成功的 xhr 从列表中删除 + if (requestList) { + const xhrIndex = requestList.findIndex(item => item === xhr); + requestList.splice(xhrIndex, 1); + } resolve({ data: e.target.response }); }; + // 暴露当前 xhr 给外部 + requestList?.push(xhr); }); },
\
每当一个切片上传成功时,将对应的 xhr 从 requestList 中删除,所以 requestList 中只保存正在上传切片的 xhr
恢复上传
当文件切片上传后,服务端会建立一个文件夹存储所有上传的切片,所以每次前端上传前可以调用一个接口,服务端将已上传的切片的切片名返回,前端再跳过这些已经上传切片,这样就实现了“续传”的效果
\
而这个接口可以和之前秒传的验证接口合并,前端每次上传前发送一个验证的请求,返回两种结果
- 服务端已存在该文件,不需要再次上传
- 服务端不存在该文件或者已上传部分文件切片,通知前端进行上传,并把已上传的文件切片返回给前端
调用时机:
- 点击上传时,检查是否需要上传和已上传的切片
- 点击暂停后的恢复上传,返回已上传的切片
恢复上传后:
根据服务端返回的切片名列表,通过 filter 过滤掉已上传的切片