算法 简单的算法-解决页面脚本异步加载顺序问题

lanzhiheng · January 20, 2019 · 5931 hits

CoffeeScript

这几天稍微扫了一下CoffeeScript的部分源码,发现了一条挺有意思的算法,它解决了页面异步加载脚本时遇到的顺序问题。只是当初都没想过可以这样优雅地去处理这方面的问题。异步加载的脚本之间可能会有依赖关系,因此加载顺序就异常重要了。

一。场景分析 - 同步与异步

1. 同步加载

假如浏览器需要引入多个 JavaScript 资源,我们一般会在页面上嵌入如下代码

<body>
  <script src="http://xxxxx.com/global.js"></script>
  <script>
    <!-- Global 全局变量是从global.js脚本中引入 -->
    window.Global.user_id = "x223345333445"
  </script>
  <script src="http://xxxxx.com/main.js"></script>
</body>

默认情况下script标签里面的资源会自动加载,并且这个过程是同步的,我们并不需要担心第一个script标签请求完成之前浏览器就去执行第二个script标签中的代码 (超时或者是加上了 aync 这些属性的情况要另当别论了)。这种场景我姑且称之为脚本的同步加载。

2. 异步加载

上述场景中script标签相当于自动加上了type="text/javascript"这样一个属性值,浏览器会自动识别这类资源并进行加载。但是如果加载的不是 JavaScript 资源呢?假设我们要加载 CoffeeScript 资源,或许就会把代码写成这样

<body>
  <script type="text/coffeescript" src="http://xxxxx.com/global.coffee"></script>
  <script type="text/coffeescript">
    <!-- Global 全局变量是从global.coffee脚本中引入 -->
    window.Global.user_id = "x223345333445"
  </script type="text/coffeescript">
  <script src="http://xxxxx.com/main.coffee"></script>
</body>

这种情况下,浏览器就不会自动加载script标签里面的资源了,毕竟浏览器无法直接解析这种类型的脚本。

如果要加载这类资源我们则需要手动编写代码来遍历所有包含属性值type="text/coffeescript"script标签,如果是带有src属性则异步请求资源,如果没有src属性则直接获取标签包裹的内容。通过特殊的脚本来执行加载好的 CoffeeScript 代码。

这种场景中第一和第三个标签都需要通过发送请求来获取资源,这就会导致一种现象,如果不加特殊处理第二个标签里面的代码会比另外两个脚本先执行,而这个时候变量Global还没有被定义,就会导致脚本出错。这种就是异步加载脚本的场景,异步虽好,它不会堵塞页面,不过要处理各个脚本之间的依赖关系也是个头疼的问题。

二。解决方案

在不考虑使用打包工具的情况下我暂且提出这三个解决方案

1. 回调

回调无疑是最为简单粗暴的方式,以上的案例中只有 3 个 JavaScript 资源,构建一条完整的回调链似乎没什么问题。不过还是会使代码变得难懂,且恶心。回调很简单这里就不贴代码了。

如果我把script标签增加到 10 个,并且其中包含几个内嵌脚本的话你应该不会再想用回调来解决了吧?案例如下

<body>
  ...
  <script type="text/coffeescript" src="http://xxxxx.com/extern1.coffee"></script>
  <script type="text/coffeescript"><!-- 内嵌脚本 --></script>
  <script type="text/coffeescript" src="http://xxxxx.com/extern2.coffee"></script>
  <script type="text/coffeescript" src="http://xxxxx.com/extern3.coffee"></script>
  <script type="text/coffeescript"><!-- 内嵌脚本 --></script>
  <script type="text/coffeescript" src="http://xxxxx.com/extern4.coffee"></script>
  <script type="text/coffeescript" src="http://xxxxx.com/extern5.coffee"></script>
  <script type="text/coffeescript"><!-- 内嵌脚本 --></script>
  <script type="text/coffeescript" src="http://xxxxx.com/extern6.coffee"></script>
  <script type="text/coffeescript"><!-- 内嵌脚本 --></script>
</body>

当然,上面的只是示范代码,正常情况下我们不可能这样去写代码。这种情况如果用回调去解决加载顺序问题的话,估计是个人都会崩溃了。我们需要寻找更好的解决方案。

2. 填充队列,队列满了再执行

为了异步加载 CoffeeScript 资源,我先把伪代码写成这样

// 用于运行CoffeeScript代码
function handleCoffeeScript(cfCodeString) {
 ...
}

// 用于异步请求资源,返回Promise
function ajax(url) {
}

document.querySelectorAll('[type="text/coffeescript"]').forEach((item) => {
  if (item.src) {
    ajax(item.src).then((content) => {
        handleCoffeeScript(content)
    })
  } else {
    handleCoffeeScript(item.innerHTML)
  }
})

这代码咋一看似乎没什么问题,尤其是这 10 个script标签所涵盖的 CoffeScript 代码的业务逻辑彼此间没有任何依赖关系的时候,上诉代码完全可以直接使用。然而,一旦它们之间有依赖关系,这样去加载脚本就会报错。我给 10 个脚本分别编号 1-10,假设所有脚本都能够顺利加载,那么会出现下面的情况

2, 5, 8, 10 // 同步的内嵌脚本先加载运行

1, 3, 4, 6, 7, 9 // 需要异步请求的脚本后运行

PS: 这只是一种情况,我们永远无法保证先发送的请求会先响应,毕竟每个接口的响应时间都不一样,假设编号 1 中的资源比较大响应时间较长,那么执行顺序可能会变成 3, 4, 1, 6, 7, 9。

通常为了解决这种问题我们需要维护一个。初始化一个特定长度的队列,初始值值都是 undefined。由于异步脚本都会在同步脚本之后才能被执行,为此可以在每次异步请求结束时都去检测队列是否已经满了,如果满了就证明所有脚本都已经加载完毕。接着依次执行队列中的每一项所包含的 CoffeeScript 资源。

// 用于运行CoffeeScript代码
function handleCoffeeScript(cfCodeString) {
 ...
}

// 用于异步请求资源,返回Promise
function ajax(url) {
 ...
}

const sources = document.querySelectorAll('[type="text/coffeescript"]')
// 初始化队列
let queue = new Array(sources.length)

// 检测队列是否已经满了
function checkQueueFull(queue) {
    for(let i = 0; i < sources.length; i ++) {
        if (queue[i] === undefined) return false
    }
    return true
}

sources.forEach((item, i) => {
  if (item.src) {
    ajax(item.src).then((content) => {
        // 队列填充
        queue[i] = content
        // 队列如果塞满的话则依次运行所有脚本
        if (checkQueueFull(queue)) {
            queue.forEach(item => handleCoffeeScript(item))
        }
    })
  } else {
    // 队列填充
    queue[i] = item.innerHTML
  }
})

这个脚本确实能够解决异步加载资源时遇到的顺序问题了,但是它显得有点笨拙,它必须要等到所有脚本加载完成后才能够依次去执行所有脚本

假设编号为 5 的资源并不是那么重要,而且加载时间会比较长,这种方式就会导致所有资源都需要等待编号 5 的资源加载完毕之后才有机会执行,这会导致脚本层面的堵塞。接下来我们进一步优化这个流程,看如何规避这种问题。

3. 填充队列,让脚本尽可能早地去运行

为了优化这个过程,除了上述的队列我们还需要另外维护一个索引,每次异步请求完成之后检测当前索引所在位置的资源,如果这个资源已经加载好了,则执行当前位置的脚本,索引自增,再检测下一个索引所对应的资源是否能够执行,以此类推,直到遇到某个不可用的资源则停止执行。当再次发生异步请求的候重复上述过程,会根据索引值从之前停止的地方重新开启检测。这一切可以以递归的方式实现,伪代码大概如下

// 用于运行CoffeeScript代码
function handleCoffeeScript(cfCodeString) {
 ...
}

// 用于异步请求资源,返回Promise
function ajax(url) {
}

const sources = document.querySelectorAll('[type="text/coffeescript"]')

// 创建一个等长的队列
let queue = new Array(sources.length)

// 脚本执行索引
let index = 0

// 执行函数,采用递归的方式,检测队列中当前索引的资源是否可用,如果可用则调用`handleCoffeeScript`方法来处理相关的内容,递增索引,并调用自身
function execute() {
    param = queue[index]
    if(param !== undefined) {
        handleCoffeeScript(content)
        index ++
        execute()
    }
}

sources.forEach((item, i) => {
  if (item.src) {
    ajax(item.src).then((content) => {
        queue[i] = content
        // 每次脚本加载完成都触发执行脚本,具体是否需要执行需要执行脚本来判断
        execute()
    })
  } else {
    queue[i] = item.innerHTML
  }
})

我们来幻想一个比较极端的场景,假设 1 号和 9 号的异步请求都是慢请求,9 号脚本的耗时比 1 号脚本长许多(假设是 5s),那么加载程序运行起来会有以下表现

PS:简单起见,我暂时用脚本的状态来对队列中的每一项进行占位。

  • 同步脚本率先被加载进队列,但先不执行
[undefined, "Available", undefined, undefined, "Available", undefined, undefined, "Available", undefined, "Available"]
  • 除了 1 号,9 号脚本之外,其他异步脚本都加载完成并塞进队列中
[undefined, "Available", "Available", "Available", "Available", "Available", "Available", "Available", undefined, "Available"]
  • 1 号脚本加载完成
// 1号脚本加载完毕
["Available", "Available", "Available", "Available", "Available", "Available", "Available", "Available", undefined, "Available"]

后续脚本会依次运行,但由于 9 号脚本加载时间太长,所以在对应的位置会停止执行,并等待

// 然后依次执行
["Executed", "Available", "Available", "Available", "Available", "Available", "Available", "Available", undefined, "Available"]

["Executed", "Executed", "Available", "Available", "Available", "Available", "Available", "Available", undefined, "Available"]

.....

["Executed", "Executed", "Executed", "Executed", "Executed", "Executed", "Executed", "Executed", undefined, "Available"]
  • 等 9 号脚本加载完毕,继续执行余下脚本
["Executed", "Executed", "Executed", "Executed", "Executed", "Executed", "Executed", "Executed", "Available", "Available"]

["Executed", "Executed", "Executed", "Executed", "Executed", "Executed", "Executed", "Executed", "Executed", "Available"]

["Executed", "Executed", "Executed", "Executed", "Executed", "Executed", "Executed", "Executed", "Executed", "Executed"]

这个脚本的性能会比之前的脚本好上一些了,起码它不会等到所有资源都加载完毕之后才去执行。

一方面,2-10 号的资源都需要依赖 1 号资源,它保证了 1 号资源加载并执行完毕之前不会执行任何其他的脚本。另一方面,加载 9 号脚本需要比较长的时间,而我们并不需要等到它加载完了才去运行其他脚本,而是会让在它之前的能够执行的脚本先行执行。只有 10 号脚本会等待 9 号脚本。

总结

这篇文章简单地对异步加载脚本可能遇到的问题以及相关的解决方案做了个简单的阐述,虽说真实环境可能再也不会遇到这种问题了,不过了解一下算法还是有好处的,说不定哪天遇到类似的场景就派上用场了。

本文只是用 JavaScript 写了些伪代码,算法流程也只是用文本来简单阐述,可能会导致有些地方表达不够到位。如果想更全面地了解这个加载脚本我建议直接看Coffeescript里面的源代码。

No Reply at the moment.
You need to Sign in before reply, if you don't have an account, please Sign up first.