关于动态加载js的一些细节

621 阅读3分钟

23年使用 Nuxt3 开发 ssr 项目过程中遇到了加载脚本相关的问题, 这类问题应该很多人遇到过, 但搜索后发现少有讨论, 即使提到也有点浮于表面 (可能是我搜索方式不对), 以下内容是我对这个问题的总结.

背景

ssr 是首屏优化的一个方式, 一般的 ssr 页面首屏是服务端渲染, 接下来的页面使用 csr 方式渲染. 假设有 ab 两个页面, b页面的 js 代码是这样的

// <script setup>
 onMounted(() => {
    useHead({
      script: [ 
        { src: '/api/js/1.js', tagPosition: 'bodyClose' },
        { src: '/api/js/2.js', tagPosition: 'bodyClose' }, 
        { src: '/api/js/3.js', tagPosition: 'bodyClose' }
      ]
    })
  })
// <script>

在这里 useHead 的作用是在这个组件加载时把资源放在页面相应位置, ssr 渲染时会把

由于业务需要在 b 页面引第三方播放视频的 sdk , 这些 js 资源有执行顺序的要求, 乱序执行会报错, 所以希望可以按加载顺序执行脚本. 

js动态加载脚本的方式

首先探讨有哪些 js 加载脚本方式, 以及这些方式的特点.  

并行加载

这是比较常用的方式, 优点是同时下载, 总时间 = max(每个脚本加载执行时间), 缺点是不一定哪个先执行

  const links = [
    '/api/js/1.js', // console.log(1)
    '/api/js/2.js', // console.log(2)
    '/api/js/3.js', // console.log(3)
  ]
  
  loadJS(links)

  // 并行加载, 乱序执行
  function loadJS (links) {
    for (let i = 0; i < links.length; i++) {
      const script = document.createElement('script')
      script.src = links[i]
      document.body.appendChild(script)
    }
  } 

串行加载

这也是比较常用的方式, 早期学习 js 时写过类似的代码, 这个方法缺点是加载时间相对更长, 一个脚本加载并执行后才加载下一个脚本, 总时间 = sum(每个脚本加载执行时间)

  const links = [
    '/api/js/1.js', // console.log(1)
    '/api/js/2.js', // console.log(2)
    '/api/js/3.js', // console.log(3)
  ]

  loadJS(links)

  // 串行加载脚本
  async function loadJS (links) {
    for (let i = 0; i < links.length; i++) {
      await new Promise((resolve) => {
        const script = document.createElement('script')
        script.src = links[i]
        script.onload = resolve 
        document.body.appendChild(script)
      })
    }
  }  

并行加载, 顺序执行

关于async和defer

有没有可能让脚本同时下载, 顺序执行呢. 首先要研究为什么 js 生成的script 会乱序执行, 通过查阅文档, 发现这样一句话, 通过 createElement 创建的 script 标签其 async 属性默认为 true.

async 是下载完就执行, defer 会按照 html 相对顺序执行, 所以是不是给script 设置 defer 属性就好, 于是在项目中进行了尝试, 发现还是没有按照顺序执行.

已经设置了 defer=true, async=false 为什么会不起作用, 最初我们认为是 useHead 忽略了 false 的属性, 所以进行了尝试.

这个尝试引出两个问题: 

  1. async 和 defer 都是 true 的情况下, 会怎样表现? 通过搜索发现这样一句话 async 的优先级比 defer 高, 也就是如果同时存在这2个属性, 那么浏览器将会以 async 的特性去加载此脚本。 所以一定要把 async 设置为 false. 

  2. 已经设置了async: false, 为什么没有生效? 仔细观察上图 Element 中 script的样子, 是不是有点不对, 一般见到的 defer 和 async 是这样的 <script src="xxx" defer async></script>, 怎么回事呢, async 文档有这样一句话, **这是一个布尔属性:元素上存在布尔属性表示为true值,缺少该属性表示为false值。**也就是说 setAttribute 第二个参数 true false 是不对的, 布尔值 Attribute 只有存在和不存在两种, 那么默认为 true 的 async 如何设置为 false 呢?

正确设置 defer 和 async 有两种方法, 这两种方式都可以达成并行加载顺序执行的效果. 

第一种: 先设置 async 属性, 再移除 setAttribute('async', ''); removeAttribute('async')

第二种: 涉及 Attribute 和 Property 的关系

  1. Attribute 就是 DOM 节点自带属性,例如我们在 HTML 中常用的 id,class,src,title,alt 等。而 property 则是这个 DOM 元素作为对象,其附加的属性或者内容,例如 childNodes,firstChild等。

  2. 《 Vuejs 设计与实现 》关于 Attribute 和 Property 的描述中, 有这样一个结论, HTML Attributes 的作用是设置与之对应的 DOM Properties 的初始值. 简单说 Attribute 是html标签上的属性, 表示 script DOM对象 Properties 的初始值, 最终生效的值放在script DOM元素上, 所以我们可以不用管 Attribute, 直接赋值 Property, script.async = false.

    // 方法一
    const links1 = [
      '/api/js/1.js', // console.log(1)
      '/api/js/2.js', // console.log(2)
      '/api/js/3.js', // console.log(3)
      '/api/js/4.js', // console.log(4)
      '/api/js/5.js', // console.log(5)
    ]
    
    loadJS1(links1) // 1 - 5
    
    // 对 Attribute 操作
    // 1. 布尔值 attribute 的添加方式是 
    //    setAttrubute('async', '') -> <script async>
    // 2. createElement 创建的 script 上 async 属性的默认值为 true
    //    我们测试过, 直接调用 removeAttribute('async') 不生效
    // 3. 必须先添加 async 属性, 再移除 async 属性
    function loadJS1 (links) {
      for (let i = 0; i < links.length; i++) {
        const script = document.createElement('script')
        script.src = links[i]
        script.setAttribute('async', '') // 设置 async 属性  <script async> 
        script.removeAttribute('async')  // 移除 async      <script>
        script.setAttribute('defer', '') // 设置 defer 属性  <script defer>
        document.body.appendChild(script)
      }
    } 
    
    
    
    // 方法二
    const links2 = [
      '/api/js/6.js', // console.log(6)
      '/api/js/7.js', // console.log(7)
      '/api/js/8.js', // console.log(8)
      '/api/js/9.js', // console.log(9)
      '/api/js/10.js', // console.log(10)
    ]
    
    loadJS2(links2) // 6 - 10
    
    // 对 Property 操作
    function loadJS2 (links) {
      for (let i = 0; i < links.length; i++) {
        const script = document.createElement('script')
        script.src = links[i]
        script.async = false
        script.defer = true
        document.body.appendChild(script)
      }
    }
    

Nuxt3项目中应用

以上得到的结论在 useHead 中怎么使用, useHead 并没有直接给到 script 对象, 查看 useHead 的源码有几个关键操作, 创建dom, 调用 'dom:renderTag' hook(hook 的参数 ctx 上的 $el 属性对应script 的 DOM元素 ), 将dom插入页面.

所以在 'dom:renderTag' hook 中设置 defer 属性即可, 类似下边的代码, 实际使用还需要更多判断

// <script setup>
onMounted(() => {  const head = injectHead()
  head.hooks.hook('dom:renderTag', ctx => {
    if (ctx.tag.tag === 'script') {
      ctx.$el.defer = true
      ctx.$el.async = false
    }
  })
  
  useHead({
    title: 'dom:renderTag',
    script: [
      { src: '/api/js/1.js', tagPosition: 'bodyClose' },
      { src: '/api/js/2.js', tagPosition: 'bodyClose' },
      { src: '/api/js/3.js', tagPosition: 'bodyClose' },
      { src: '/api/js/4.js', tagPosition: 'bodyClose' },
      { src: '/api/js/5.js', tagPosition: 'bodyClose' },
    ],
  })
})
// </script>

我的需求情况有些特殊, 需要在脚本加载完成后调用初始化视频并播放, 这时应该如何操作?

来看 script onload 事件的定义, onload 用于在脚本加载完成后执行特定的代码。这个事件处理器可以确保在脚本中的函数或变量被使用之前,脚本已经被完全加载和解析。

简单说就是脚本加载执行后才会调用onload, 由于已经实现了异步加载顺序执行, 所以需要在最后一个脚本 onload 后初始化.

  // <script setup>
// 注意这个组件应用场景是clientonly的, 这种写法未在ssr环境下测试
// hook 中修改 defer 逻辑在其他组件, 这里省略了代码
await new Promise((resolve) => {
  useHead({
    link: [
      { href: 'xxx.css', rel: 'stylesheet'},
    ],
    script: [
      { src: '/api/js/1.js', tagPosition: 'bodyClose' },
      { src: '/api/js/2.js', tagPosition: 'bodyClose' },
      { src: '/api/js/3.js', tagPosition: 'bodyClose', onload: resolve },
    ]
  })
})

onMounted(() => {
  const video = initVideo()
  video.play()
}) // </script>

以上代码就解决了项目中的问题

小结

本文从一个开发问题出发, 提出了三种异步加载脚本的方式, 分别是

  1. 并行加载, 乱序执行 

  2. 串行加载, 顺序执行 

  3. 并行加载, 顺序执行 

在这中间讨论了async 和 defer 的细节, Attribute 和 Property 的关系, 布尔值 Attribute 的特殊性, 通过这一系列知识点得到 问题出现的本质原因, 以及解决问题的基本思路. 最终通过对 useHead 源码分析解决问题. 

另一个问题

 2023年8月25日,Nuxt.js 官方团队正式官宣 Nuxt 3.7 正式发布, 这个版本中才在创建DOM元素和插入页面中间调用 'dom:renderTag' hook, 我的项目开发在 23 年前半年开发, 当时的加载js源码是这样

可以看到在创建 script DOM和插入页面之间没有 hook , 经过尝试发现 defer 属性在插入页面后修改不起作用, 所以需要在创建 DOM 插入页面中间修改 defer 属性, 那么在不修改源码的情况下有没有办法操作?

最后

这篇文章的内容是23年9月写的, 这一年没关注Nuxt3的更新, 不知道这部分内容有没有变更. 第一次发文章, 文笔有限, 如有错误欢迎指正, 欢迎大家一起交流.