当前所有版本的 iOS Safari(包括所有基于 iOS WebView 的浏览器)都存在一个 bug,下载中的脚本会阻塞页面的显示,无论脚本是否在页面底部或是否有
defer
或
async
属性。
如果动态创建 script 标签并在异步回调中进行脚本加载,可以避免阻塞,大幅度改善 Mobile Safari 的首屏渲染时间,经统计可能会快 240 毫秒以上。
当前的的最佳实践是,在 rAF 后再推迟以进行脚本加载:
<html>
<body>
<!-- 页面底部内联 -->
<script>
{
const loadScript = () => {
var script = document.createElement('script')
script.crossOrigin = 'anonymous'
script.src = '/bundle.js'
document.body.appendChild(script)
}
// 使用一个非 microtask 回调,使回调执行在下一个 event loop 中
const scheduleNextTick = callback => {
const el = new Image()
el.src = 'data:,'
el.onload = el.onerror = callback
}
const isPageVisible =
(document.visibilityState || document.webkitVisibilityState) ===
'visible'
if (isPageVisible) {
// raf 后推迟,不在 rendering 阶段执行可能阻塞的操作
requestAnimationFrame(() => scheduleNextTick(loadScript))
} else {
loadScript()
}
}
</script>
</body>
</html>
问题分析
通过如下文档可以复现这个问题:
<body>
<pre id="firstAF"></pre>
<script>
requestAnimationFrame(() => {
firstAF.textContent = Date.now() - performance.timing.navigationStart
})
</script>
<script src="/empty.js?networkDelay=2000"></script>
</body>
上述代码用 raf(
requestAnimationFrame
)函数收集页面的首屏渲染时间,若启动服务器返回空文件(empty.js)并模拟网络延迟 2 秒,则在 iOS 上会有与延迟时间对应的白屏。
一个有趣的例外是,如果往页面中填充一些内容,会使首屏渲染的时机提前 —— 经验证,这个内容必须是可见的文本(display 不为 none,visibility 不为 hidden),长度应在 201 个非空字符以上(非 tab、space 和 line-break)。
借此可以使用一个 hack,往没有高度的元素内填充大量零宽空格,就能使页面提前显示(因为可能不确定不同页面的初始内容是否足够):
<div style={{height: 0}}>{'u200b'.repeat(201)}</div>
但是用填充内容来触发渲染并不是完全有效,它只能减弱影响(这可能是 bug 通常没有被注意到的原因,许多文档默认就有足够多的内容或者网速足够快),而不能免除影响。
如果对不同脚本属性和是否应用 hack(填充内容)进行统计,可以得出如下的结果:
## iOS 11.4 Mobile Safari
| method | firstAF |
| ------------------ | ------- |
| script[defer]+hack | 499 |
| script[async] | 499 |
| script[async]+hack | 501 |
| script+hack | 504 |
| script[defer] | 2039 |
| script | 2045 |
## iOS 12.2 Mobile Safari
| method | firstAF |
| ------------------ | ------- |
| script[async]+hack | 232 |
| script[defer]+hack | 313 |
| script+hack | 320 |
| script[defer] | 2042 |
| script[async] | 2045 |
| script | 2046 |
## Desktop Safari (12.1.1)
| method | firstAF |
| ------------------ | ------- |
| script[async]+hack | 37 |
| script[defer]+hack | 37 |
| script[defer] | 38 |
| script+hack | 38 |
| script[async] | 38 |
| script | 38 |
iOS 12 之前的 Safari 版本(包括 9/10/11)
async
属性还可能会略有效果,但到了 iOS 12
async
和
defer
属性全部无效,hack 方案虽然有效但仍远远慢于桌面版浏览器。
解决方案
要避免的渲染阻塞,最有效的解决方式是在 raf 回调中加载脚本,示例代码如下:
<html>
<head>
<link href="/bundle.js" target="_blank" rel="external nofollow" rel="preload" as="script" crossorigin />
</head>
<body>
<!-- 页面底部内联 -->
<script>
{
const loadScript = () => {
var script = document.createElement('script')
script.crossOrigin = 'anonymous'
script.src = '/bundle.js'
document.body.appendChild(script)
}
const isPageVisible =
(document.visibilityState || document.webkitVisibilityState) ===
'visible'
if (isVisible) {
requestAnimationFrame(loadScript)
} else {
loadScript()
}
// 略微快一些的方案,在 raf 内嵌套一个非 microtask 回调(可利用 postMessage/MessageChannel/ImageOnLoad 实现)
/* if (isPageVisible) {
requestAnimationFrame(() => scheduleNextTick(loadScript))
} else {
loadScript()
} */
// 或者可以简单一些,双层 raf 组合:
/* if (isPageVisible) {
requestAnimationFrame(() => requestAnimationFrame(loadScript))
} else {
loadScript()
} */
}
</script>
</body>
</html>
值得注意的几点说明:
- head 中使用了 preload link 来提示浏览器提前下载 JS 文件,因为通常浏览器会并行下载页面所需的静态资源(CSS/JS),处于下载状态 CSS 文件会阻塞其后所有 JS 代码的执行,即上面内联代码的执行会被推迟到 CSS 下载完成之后。
- 判断可见性是必须的,因为在文档处于 prerender/hidden 模式时 raf 不会执行 —— 根据统计,其他的非 raf 回调在后台页面也都有可能超时。
- 通常理解的 raf 回调应在渲染前被触发,回调内的代码执行会造成阻塞,单个 raf 能工作可能是 Safari 的另一个 bug,它会优先渲染页面。
- 完全避免 raf 回调中代码执行影响的方法是再添加一个合适的非 microtask 回调函数(使回调在渲染阶段之后的下一个事件循环中执行),或者再加一个 raf 使代码在下一帧执行,前者会略好于后者。
关于 Safari 中 raf 回调执行与期望不一致的情况可以用这个例子来展示:
<pre id="debug"></pre>
<script>
const log = text => {
debug.textContent += text + 'n'
}
const sleep = ms => {
const start = performance.now()
while (performance.now() - start < ms) {}
}
requestAnimationFrame(() => {
log('raf start')
sleep(1000)
log('raf end')
})
</script>
<p>initial text</p>
Chrome 会先白屏再显示页面内容,而 Safari 无论是移动版还是桌面版都会先显示内容。
如果使用 Safari 开发工具的 Timeline 来观察比较几种不同的加载区别,会得到如下结果:

上图录制的三种加载方式分别是:
- Frame 0~8:script 标签尾部加载,第 4 帧 Script Evaluated(紫色),第 5 帧首次 paint(绿色)
- Frame 9~20:hack 方案(填充内容),第 10 帧 Script Evaluated,第 4 帧首次 paint
- Frame 21~28:raf 动态加载,第 7 帧 Script Evaluated,第 2 帧首次 paint
关于回调函数的补充和验证
浏览器内回调函数除开 raf、setTimeout 和 requestIdleCallback 之外,还可以借用一些 observer/event 来创建回调,比如:
export const queueMicrotask =
typeof queueMicrotask === 'function'
? queueMicrotask
: callback => Promise.resolve().then(callback)
export const queueMutationObserver = callback => {
const observer = new MutationObserver(() => {
callback()
observer.disconnect()
})
const node = document.createTextNode('')
observer.observe(node, {characterData: true})
node.nodeValue = '1'
}
export const queuePostMessage = callback => {
const data = Date.now() + Math.random()
const handler = e => {
if (e.data === data) {
callback()
window.removeEventListener('message', handler)
}
}
window.addEventListener('message', handler)
window.postMessage(data, '*')
}
export const queueMessageChannel = callback => {
const {port1, port2} = new MessageChannel()
port2.onmessage = callback
port1.postMessage('')
port1.close()
}
export const queueImageOnLoad = callback => {
const el = new Image()
el.src = 'data:,'
el.onload = el.onerror = callback
}
export const queueDOMReady = callback => {
if (/interactive|complete/.test(document.readyState)) {
callback()
} else {
document.addEventListener('DOMContentLoaded', callback)
}
}
export const requestAnimationFrame2 = callback =>
requestAnimationFrame(() => requestAnimationFrame(callback))
这是首次加载时对不同回调的执行顺序做比较的结果(表头 + 号表示相对它之后执行的概率):
## iOS 12.4
| method | setTimeout+ | raf+ | queueMicrotask+ | queueDOMReady+ |
| ------------------------------------ | ----------- | ---- | --------------- | -------------- |
| queueMicrotask | 0% | 0% | 0% | 0% |
| queueMutationObserver | 0% | 0% | 52% | 0% |
| queuePostMessage | 1% | 0% | 100% | 7% |
| queueImageOnLoad | 25% | 0% | 100% | 11% |
| queueDOMReady | 26% | 0% | 100% | 0% |
| setTimeout | 0% | 0% | 100% | 74% |
| queueMessageChannel | 63% | 0% | 100% | 100% |
| requestAnimationFrame | 100% | 0% | 100% | 100% |
| queueAnimationFrameAndPostMessage | 100% | 100% | 100% | 100% |
| queueAnimationFrameAndImageOnLoad | 100% | 100% | 100% | 100% |
| requestAnimationFrame2 | 100% | 100% | 100% | 100% |
| queueAnimationFrameAndMessageChannel | 100% | 100% | 100% | 100% |
## Chrome 74
| method | setTimeout+ | raf+ | queueMicrotask+ | queueDOMReady+ |
| ------------------------------------ | ----------- | ---- | --------------- | -------------- |
| queueMicrotask | 0% | 0% | 0% | 0% |
| queueMutationObserver | 0% | 0% | 53% | 0% |
| requestAnimationFrame | 1% | 0% | 100% | 1% |
| queuePostMessage | 0% | 98% | 100% | 0% |
| queueMessageChannel | 0% | 98% | 100% | 0% |
| queueImageOnLoad | 0% | 99% | 100% | 0% |
| setTimeout | 0% | 99% | 100% | 1% |
| queueDOMReady | 99% | 99% | 100% | 0% |
| queueAnimationFrameAndImageOnLoad | 100% | 100% | 100% | 100% |
| queueAnimationFrameAndPostMessage | 100% | 100% | 100% | 100% |
| queueAnimationFrameAndMessageChannel | 100% | 100% | 100% | 100% |
| requestAnimationFrame2 | 100% | 100% | 100% | 100% |
| requestIdleCallback | 100% | 100% | 100% | 100% |
一些总结:
- 只有
属于 microtask,它与MutationObserver
相对顺序取决于谁先执行,它们始终先被执行。queueMicrotask
- Safari 中 raf 执行时机相比 Chrome 要晚许多,Safari 在 domready 之后(complete 阶段),Chrome 可能早至 loading 阶段。
- raf 内嵌套回调的执行顺序基本满足了期望(优先于双重 raf),例外是 Safari 中嵌套
可能(约一半概率)慢于双重 raf(测试多个版本仅发现 iOS 10 正常)MessageChannel
- 除了在 raf 加其他回调的组合能避免阻塞,还有其他的组合也可以,比如 domready + postMessage、postMessage + postMessage 等等,它们都不如 raf 的组合有效
而对不同回调分别连续多次调用的效率测试结果是:
## iOS 12.4
| name | ops | mspo | ms range |
| -------------------------------------- | --------- | ----- | ------------- |
| queueMicrotask | 300000.00 | 0.00 | 0.000~1.000 |
| queueMutationObserver | 75000.00 | 0.01 | 0.000~1.000 |
| queuePostMessage | 9090.91 | 0.11 | 0.000~1.000 |
| queueImageOnLoad | 6521.74 | 0.15 | 0.000~2.000 |
| queueMessageChannel | 4054.05 | 0.25 | 0.000~1.000 |
| setTimeout | 224.55 | 4.45 | 1.000~6.000 |
| requestAnimationFrame | 60.89 | 16.42 | 1.000~24.000 |
| queueAnimationFrameAndMessageChannel | 60.70 | 16.47 | 1.000~27.000 |
| queueAnimationFrameAndImageOnLoad | 60.66 | 16.49 | 1.000~24.000 |
| queueAnimationFrameAndPostMessage | 60.64 | 16.49 | 2.000~25.000 |
| requestAnimationFrame2 | 30.14 | 33.18 | 14.000~46.000 |
## Chrome 74
| name | ops | mspo | ms range |
| -------------------------------------- | --------- | ----- | ------------- |
| queueMicrotask | 276497.70 | 0.00 | 0.000~0.020 |
| queueMutationObserver | 80753.70 | 0.01 | 0.005~0.230 |
| queueMessageChannel | 24772.91 | 0.04 | 0.020~0.745 |
| queuePostMessage | 18808.78 | 0.05 | 0.030~0.990 |
| queueImageOnLoad | 11175.27 | 0.09 | 0.065~0.870 |
| setTimeout | 205.04 | 4.88 | 1.095~5.340 |
| requestAnimationFrame | 60.22 | 16.61 | 2.795~19.040 |
| queueAnimationFrameAndMessageChannel | 60.11 | 16.64 | 11.205~18.265 |
| queueAnimationFrameAndPostMessage | 60.07 | 16.65 | 14.370~18.725 |
| queueAnimationFrameAndImageOnLoad | 59.92 | 16.69 | 10.335~35.640 |
| requestAnimationFrame2 | 30.04 | 33.29 | 28.025~35.045 |
| requestIdleCallback | 20.79 | 48.11 | 0.540~55.255 |
一些总结:
-
连续调用时延迟通常会由 1ms 增加到 4ms。setTimeout
- 双层 raf 的作用相当于 30fps 的 throttle 函数。
- raf 内嵌套回调与顺序测试一样满足了期望效果,它也可以如单个 raf 一般达到 60fps,这是一种防止 raf 中代码执行阻塞的有效手段。
- 发现 Safari 中如果每次调用
时创建不同实例,性能达不到期望基准(iOS 10 除外),如果有这种需要创建一个共享的实例来使用会更好。MessageChannel