第九式:浏览器也会摸鱼 🐟 ?
浏览器一帧都会干些什么?
我们都知道,页面的内容都是一帧一帧绘制出来的,浏览器刷新率代表浏览器一秒绘制多少帧。原则上说 1s 内绘制的帧数也多,画面表现就也细腻。
电影放映的标准是每秒放映 24 帧,也就是说电影每秒放映 24 幅画面,以达到动画的效果,超过 24 帧/s 连续的变化,视觉暂留就会将静态的画“动”起来。研究表明,人眼承受的极限为每秒 55 帧,还有研究表明,每秒 60 帧以上可以明显提升观众的观影感受。每秒 120 帧是每秒 24 帧的 5 倍,采用这样的拍摄技术可以让画面更加栩栩如生,让观众仿佛置身其中,给人一种似真似幻的感觉。
目前浏览器大多是 60Hz(60 帧/s),每一帧耗时也就是在 16.6ms 左右。那么在这一帧的(16.6ms) 过程中浏览器又干了些什么呢?
通过上面这张图可以清楚的知道,浏览器一帧会经过下面这几个过程:
- 接受输入事件,处理用户的交互,如点击、触碰、滚动等事件
- 执行事件回调
- 开始一帧
- 执行 RAF (
RequestAnimationFrame
)
- 页面布局,样式计算
- 绘制渲染
- 执行 RIC (
RequestIdelCallback
)
第七步的 RIC 事件不是每一帧结束都会执行,只有在一帧的 16.6ms 中做完了前面 6 件事儿且还有剩余时间,才会执行。如果一帧执行结束后还有时间执行 RIC 事件,那么下一帧需要在事件执行结束才能继续渲染,所以 RIC 执行不要超过 30ms,如果长时间不将控制权交还给浏览器,会影响下一帧的渲染,导致页面出现卡顿和事件响应不及时。
requestIdleCallback 的启示
我们以浏览器是否有剩余时间作为任务中断的标准,那么我们需要一种机制,当浏览器有剩余时间时通知我们。
requestIdleCallback((deadline) => { // deadline 有两个参数 // timeRemaining(): 当前帧还剩下多少时间,最大值50ms // didTimeout: 是否超时 // 另外 requestIdleCallback 后如果跟上第二个参数 {timeout: ...} 则会强制浏览器在当前帧执行完后执行。 if (deadline.timeRemaining() > 0) { // TODO } else { requestIdleCallback(otherTasks); } }); // 用法示例 let tasksNum = 10000; requestIdleCallback(unImportWork); function unImportWork(deadline) { while (deadline.timeRemaining() && tasksNum > 0) { console.log(`执行了 ${10000 - tasksNum + 1}个任务`); tasksNum--; } if (tasksNum > 0) { // 在未来的帧中继续执行 requestIdleCallback(unImportWork); } }
其实部分浏览器已经实现了这个 API,这就是 requestIdleCallback。但是由于以下因素,Facebook 在 React 的重构升级中, 抛弃了 requestIdleCallback 的原生 API,而实现了功能更完备的 requestIdleCallbackpolyfill
,这就是Scheduler。除了在空闲时触发回调的功能外,Scheduler 还提供了多种调度优先级供任务设置:
- 浏览器兼容性;
- 触发频率不稳定,受很多因素影响。比如当我们的浏览器切换 tab 后,之前 tab 注册的 requestIdleCallback 触发的频率会变得很低。
requestIdleCallback
的 callback 会在浏览器的空闲时间运行,而在w3c 文档[23]里,空闲时间分两种:
- 在执行一段连续的动画时,将给定帧提交到屏幕与开始处理下一帧之间的时间,这段时间内属于空闲时间。在连续动画和屏幕更新期间,此类空闲时间会频繁发生,但通常会非常短(即,如果我们的屏幕是 60hz(1s 内屏幕刷新 60 次)的设备,小于 16 毫秒),如下图所示。
- 另外一种空闲时间,当用户属于空闲状态(没有与网页进行任何交互),并且屏幕中也没有动画执行。此时空闲时间理论上是无限长的。但为了避免在不可预测的任务(例如处理用户输入)引起用户可察觉的延迟,这些空闲时间段的长度应限制为最大值 50ms,一旦空闲期结束,浏览器可以安排另一个空闲期。
也就是说,即使浏览器一直处于空闲状态的话,deadline.timeRemaining
可以得到的最长时间,也是 50ms,这是 w3c 标准[24] 规定的。一些低优先级的任务可使用 requestIdleCallback
等浏览器不忙的时候来执行,同时因为时间有限,它所执行的任务应该尽量是能够量化,细分的微小任务。
50 ms 的最大截止时间来自一个
RESPONSETIME
研究,该研究表明,对 100 毫秒内用户输入的响应通常被人类感知为瞬时的。将空闲期限限制为 50 ms 意味着即使用户输入在空闲任务开始后立即发生,用户代理仍有剩余的 50 ms 时间来响应用户输入,而不会产生用户可察觉的延迟。
当设备的性能越来越好,浏览器支持的效果越来越炫,浏览器的开发者开始越来越多的考虑使用原生 API 来处理一些之前特别占用性能的功能,自从最初的 requestAnimationFrame
,InsterSectionObserver
,到requestIdleCallback
,对于前端的将来,充满希望,没错,我们都会有“光明的未来”,哈哈 😄,关于浏览器的更多细节,可以参考我之前的两篇文章:
- 浏览器是如何工作的:Chrome V8 让你更懂 JavaScript[25]
- 47 张图带你走进浏览器的世界[26]
拓展阅读与参考
- requestIdleCallback-后台任务调度[27]
- 浏览器帧原理剖析[28]
- Accurately measuring layout on the web[29]
- Cooperative Scheduling of Background Tasks[30]
第十式:自制hash生成器
项目中,也许我们会遇到需要使用 JS 生成特定长度随机字符串的需求,比如用来做 Hash 值、uuid、随机码等,除了可以借助一些库和插件之外,其实部分场景下,我们完全可以自定义函数实现指定长度随机字符串的生成。
简洁版函数只需要两行代码:
/** * 生成长度为len的包含a-z、A-Z、0-9的随机字符串 */ function generateStr(len = 18) { // 一行代码生成0-9、A-Z、a-z、总长度为62的字符数组 var arr = [...new Array(62)].map((item, i) => String.fromCharCode(i + (i < 10 ? 0 : i < 36 ? 7 : 13) + 48) ); return [...new Array(len)] .map(() => arr[Math.floor(Math.random() * arr.length)]) .join(''); } generateStr(18);
如果担心重复,则可以添加一个Map
来缓存已经生成的字符串,每次返回时判断一下:
/** * 生成长度为len的包含a-z、A-Z、0-9的随机字符串 */ const cacheMap = new Map(); // 缓存已经生成过了的字符串 // 一行代码生成0-9、A-Z、a-z、总长度为62的字符数组 const arr = [...new Array(62)].map((item, i) => String.fromCharCode(i + (i < 10 ? 0 : i < 36 ? 7 : 13) + 48) ); function generateStr(len = 18) { const str = [...new Array(len)] .map(() => arr[Math.floor(Math.random() * arr.length)]) .join(''); if (cacheMap.has(str)) { // 这里会有死循环的问题,比如下面的for循环,i设置的大于62 console.log(cacheMap, str); // i 值越大,len越小,重复的概率越大 return generateStr(len); } else { cacheMap.set(str, true); return str; } } for (let i = 0; i < 20; i++) { // 长度选小一点,测试20次 // i设置的大于62会出现死循环,可先算出排列组合数进行预防 // i 值越大,len越小,重复的概率越大,执行时间越长 generateStr(1); } console.log(cacheMap);
1 行代码生成指定长度数字:这种方法有缺点,低概率会出现位数不足的问题(原因是 0.00566 * 100000 = 566,会丢失前面的 0),不推荐使用。
// len 最多16,可能出现 function generateNum(len = 16) { return Math.floor(Math.random() * Math.pow(10, len)); }
- 2 行代码生成包含大小写字母和数字的随机字符串[31]
第十一式:如何在离开页面时发送请求?
用户卸载网页的时候(关闭浏览器、刷新浏览器或者跳转其他页面时),有时需要向服务器发送一些统计数据;同时,前端在做异常监控、统计页面访问时长时,也会需要在页面崩溃、关闭的时候发送请求。很自然的做法是在 unload
事件或 beforeunload
事件的监听函数里面,使用 XMLHttpRequest
对象发送数据。但是,这样做不是很可靠,因为 XMLHttpRequest
对象是异步发送,很可能在它即将发送的时候,页面和相关资源已经卸载,会引起 function not found
的错误,从而导致发送取消或者发送失败。
解决方法就是 AJAX
通信改成同步发送,即只有发送完成,页面才能卸载。但是,很多浏览器已经不支持同步的 XMLHttpRequest
对象了(即 open()
方法的第三个参数为 false
):
window.addEventListener('unload', logData, false); function logData() { var client = new XMLHttpRequest(); // 第三个参数表示同步发送 client.open('POST', '/log', false); client.setRequestHeader('Content-Type', 'text/plain;charset=UTF-8'); client.send(analyticsData); }
同步通信有几种变通的方法:
- 一种做法是新建一个
<img>
元素,数据放在 src 属性,作为 URL 的查询字符串,这时浏览器会等待图片加载完成(服务器回应),再进行卸载。
- 另一种做法是创建一个循环,规定执行时间为几秒钟,在这几秒钟内把数据发出去,然后再卸载页面。
通过在 unload 事件处理器中,创建一个图片元素并设置它的 src 属性的方法来延迟卸载以保证数据的发送。因为绝大多数浏览器会延迟卸载以保证图片的载入,所以数据可以在卸载事件中发送。
const reportData = (url, data) => { let img = document.createElement('img'); const params = []; Object.keys(data).forEach((key) => { params.push(`${key}=${encodeURIComponent(data[key])}`); }); img.onload = () => (img = null); img.src = `${url}?${params.join('&')}`; };
这些做法的共同问题是,卸载的时间被硬生生拖长了,后面页面的加载被推迟了,用户体验不好。
而Navigator.sendBeacon
就是天生用来解决“网页离开时的请求发送”问题的,该方法可用于通过 HTTP 将少量数据异步传输到 Web 服务器。可以发现,同样是采用异步的方式,但是Navigator.sendBeacon
发出的异步请求是作为浏览器任务执行的,与当前页面是脱钩的。因此该方法不会阻塞页面卸载流程和延迟后面页面的加载。当用户代理成功把数据加入浏览器传输队列时,sendBeacon()
方法将会返回 true
,如果受到队列总数、数据大小的限制后,会返回 false
。返回true
后,只是表示进入了发送队列,浏览器会尽力保证发送成功,但是否成功了,无法判断。
目前 Google Analytics 使用 Navigator.sendBeacon
来上报数据。
Navigator.sendBeacon
方法接受两个参数,第一个参数是目标服务器的 URL,第二个参数是所要发送的数据(可选),可以是任意类型(字符串、表单对象、二进制对象等等)。这个方法的返回值是一个布尔值,成功发送数据为 true,否则为 false。该方法发送数据的 HTTP 方法是 POST,可以跨域,类似于表单提交数据。它不能指定回调函数。
window.addEventListener('unload', analytics, false); function analytics(state) { if (!navigator.sendBeacon) return; var URL = 'http://example.com/analytics'; var data = 'state=' + state + '&location=' + window.location; navigator.sendBeacon(URL, data); }
sendBeacon
方法具有如下特点:
- 发出的是异步请求,并且是 POST 请求,后端解析参数时,需要注意处理方式;
- 发出的请求,是放到的浏览器任务队列执行的,脱离了当前页面,所以不会阻塞当前页面的卸载和后面页面的加载过程,用户体验较好;
- 只能判断出是否放入浏览器任务队列,不能判断是否发送成功;
Beacon API
不提供相应的回调,因此后端返回最好省略response body
。
参考资料
- Google Analytics added sendBeacon functionality to Universal Analytics JavaScript API[32]
- Navigator.sendBeacon 无阻塞发送统计数据[33]
- Navigator.sendBeacon() —— MDN[34]
第十二式:让 VSCode、浏览器和你心有灵犀
在刚接手比较大型项目时,也许你会经常碰到这样的问题:需要修改一个页面,却苦于对项目结构不熟悉、文件夹结构不规范等,不知道文件在哪个目录下;要改一个 bug,却难以迅速定位到问题所在文件,这时候你是否幻想过,如果可以点击页面上的组件,在 VSCode 中自动跳转到对应文件,并定位到对应行号岂不美哉?
react-dev-inspector [35]就是满足你这些幻想的梦中女神,这个插件允许用户通过简单的点击直接从浏览器 React 组件跳转到本地 IDE 代码。TA 不仅能满足你的幻想,使用起来也是非常简单方便,看完这张动图,懂得都懂 😜:
如果看完图,你还不放心的话,不妨现在就先在在线预览体验[36]地址体验一下(在线体验地址里,激活点击跳转的快捷键是四个按键的组合,不过你完全不用担心,因为这个组合是可以自定义的,你完全可以改成两个按键的组合)。
前面说了,使用方式非常简单,只需三步:
- 首先,保证你的命令行本身可以通过
code
命令打开 VSCode 编辑器,比如code .
,用 VSCode 打开当前文件夹下的文件;如果没有配置这个,可以参考以下步骤:
- 首先打开 VSCode。
- 使用
command + shift + p
(注意 window 下使用ctrl + shift + p
) 然后搜索 code,选择install 'code' command in path
。
- 安装
react-dev-inspector
,修改babelrc.js
和webpack.config.ts
文件:
// babelrc.js export default { plugins: [ // plugin options docs see: // https://github.com/zthxxx/react-dev-inspector#inspector-babel-plugin-options 'react-dev-inspector/plugins/babel', ], }; // webpack.config.ts import type { Configuration } from 'webpack'; import { launchEditorMiddleware } from 'react-dev-inspector/plugins/webpack'; const config: Configuration = { /** * [server side] webpack dev server side middleware for launch IDE app */ devServer: { before: (app) => { app.use(launchEditorMiddleware); }, }, };
- 对项目入口文件进行以下修改:
import React from 'react'; import { Inspector, InspectParams } from 'react-dev-inspector'; const InspectorWrapper = process.env.NODE_ENV === 'development' ? Inspector : React.Fragment; export const Layout = () => { // ... return ( <InspectorWrapper // props docs see: // https://github.com/zthxxx/react-dev-inspector#inspector-component-props // 这里可以随便配置快捷键,你可以改成两个按键的组合 keys={['control', 'shift', 'command', 'c']} disableLaunchEditor={false} onHoverElement={(params: InspectParams) => {}} onClickElement={(params: InspectParams) => {}} > {/*这里是你原来的入口组件jsx*/} <YourComponent>...</YourComponent> </InspectorWrapper> ); };
当然,这个插件目前也支持在Vite2
、create-react-app
、Umi
中使用,接入也都很简单,可以参考react-dev-inspector GitHub 仓库及使用[37]文档。
这个插件的原理,简单说也分为三步:
- 构建时:添加一个
webpack loader
去遍历编译前的 AST 节点,在 DOM 节点上加上文件路径、名称等相关的信息。使用DefinePlugin
注入项目运行时的根路径,以便后续用来拼接文件路径,打开 VSCode 相应的文件。
- 运行时:需要在项目的最外层包裹
Inspector
组件,用于在浏览器端监听快捷键,弹出 debug 的遮罩层,在点击遮罩层的时候,利用fetch
向本机服务发送一个打开 VSCode 的请求。
- 本地服务:需要启动
react-dev-utils
里的一个中间件,监听一个特定的路径,在本机服务端执行打开 VSCode 的指令,如code src/pages/index.ts
。
如果你对其中的原理很感兴趣,可以参考字节跳动 Web Infra 团队的文章——开发提效——我点了页面上的元素,VSCode 乖乖打开了对应的组件?原理揭秘[38]。