目前所有版本的 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