天天看點

頁面文本框寫js腳本怎麼驗證是否有效_腳本下載下傳會阻塞 Mobile Safari 首屏渲染

目前所有版本的 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 來觀察比較幾種不同的加載差別,會得到如下結果:

頁面文本框寫js腳本怎麼驗證是否有效_腳本下載下傳會阻塞 Mobile Safari 首屏渲染

上圖錄制的三種加載方式分别是:

  • 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%           |
           

一些總結:

  • 隻有

    MutationObserver

    屬于 microtask,它與

    queueMicrotask

    相對順序取決于誰先執行,它們始終先被執行。
  • Safari 中 raf 執行時機相比 Chrome 要晚許多,Safari 在 domready 之後(complete 階段),Chrome 可能早至 loading 階段。
  • raf 内嵌套回調的執行順序基本滿足了期望(優先于雙重 raf),例外是 Safari 中嵌套

    MessageChannel

    可能(約一半機率)慢于雙重 raf(測試多個版本僅發現 iOS 10 正常)
  • 除了在 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  |
           

一些總結:

  • setTimeout

    連續調用時延遲通常會由 1ms 增加到 4ms。
  • 雙層 raf 的作用相當于 30fps 的 throttle 函數。
  • raf 内嵌套回調與順序測試一樣滿足了期望效果,它也可以如單個 raf 一般達到 60fps,這是一種防止 raf 中代碼執行阻塞的有效手段。
  • 發現 Safari 中如果每次調用

    MessageChannel

    時建立不同執行個體,性能達不到期望基準(iOS 10 除外),如果有這種需要建立一個共享的執行個體來使用會更好。