天天看点

页面文本框写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 除外),如果有这种需要创建一个共享的实例来使用会更好。