写在最前

在博客魔改过程中,不可避免的会引入大量的第三方脚本(js),而基于页面读取js的加载顺序,每当浏览器在加载html的过程中遇到<script>js代码片段</script>这样的标签时,浏览器会暂停继续构建html,而是优先执行当前的js脚本,等执行完毕后再继续加载后面的html。

至于外部脚本 <script src="js外链"></script>这样的写法,更是要先下载脚本,然后再执行,之后才能继续处理剩余的页面。

无形中,多出了一大把的加载时间。这也是我们常说魔改是对博客速度的负优化的原因之一。

因此可以通过给<script></script>添加deferansyc属性来实现异步加载,调整js的加载时间和顺序,确保浏览器构建HTML的过程一切顺利。

参考内容

  1. javascript.INFO——script-async-defer
  2. script中defer跟async是什么?
  3. CSS异步加载最简单的实现方式
  4. 异步加载CSS

原理分析

首先要清楚defer、async是什么,有什么区别。
deferasync<script>标签的两个属性,用来控制js脚本的加载。
以下先引用参考教程的原文。

教程原文

defer

defer特性告诉浏览器不要等待脚本。相反,浏览器将继续处理HTML,构建DOM。脚本会在后台下载,然后等DOM构建完成后,脚本才会执行。

这是与上面那个相同的示例,但是带有defer特性:

1
2
3
4
5
6
<p>...content before script...</p>

<script defer src="https://javascript.info/article/script-async-defer/long.js?speed=1"></script>

<!-- 立即可见 -->
<p>...content after script...</p>

换句话说:

  1. 具有defer特性的脚本不会阻塞页面。
  2. 具有defer特性的脚本总是要等到DOM解析完毕,但在DOMContentLoaded事件之前执行。
    下面这个示例演示了上面所说的第二句话:
1
2
3
4
5
6
7
8
9
<p>...content before scripts...</p>

<script>
document.addEventListener('DOMContentLoaded', () => alert("DOM ready after defer!"));
</script>

<script defer src="https://javascript.info/article/script-async-defer/long.js?speed=1"></script>

<p>...content after scripts...</p>
  1. 页面内容立即显示。

  2. DOMContentLoaded事件处理程序等待具有defer特性的脚本执行完成。它仅在脚本下载且执行结束后才会被触发。
    具有defer特性的脚本保持其相对顺序,就像常规脚本一样。

假设,我们有两个具有defer特性的脚本:long.js在前,small.js在后。

1
2
<script defer src="https://javascript.info/article/script-async-defer/long.js"></script>
<script defer src="https://javascript.info/article/script-async-defer/small.js"></script>

浏览器扫描页面寻找脚本,然后并行下载它们,以提高性能。因此,在上面的示例中,两个脚本是并行下载的。small.js可能会先下载完成。

……但是,defer特性除了告诉浏览器不要阻塞页面之外,还可以确保脚本执行的相对顺序。因此,即使 small.js先加载完成,它也需要等到long.js执行结束才会被执行。

当我们需要先加载JavaScript库,然后再加载依赖于它的脚本时,这可能会很有用。

defer特性仅适用于外部脚本
如果<script>脚本没有src,则会忽略defer特性。

async

async特性与defer有些类似。它也能够让脚本不阻塞页面。但是,在行为上二者有着重要的区别。

async特性意味着脚本是完全独立的:

  1. 浏览器不会因async脚本而阻塞(与defer类似)。
  2. 其他脚本不会等待async脚本加载完成,同样,async脚本也不会等待其他脚本。
  3. DOMContentLoaded和异步脚本不会彼此等待:
    • DOMContentLoaded可能会发生在异步脚本之前(如果异步脚本在页面完成后才加载完成)
    • DOMContentLoaded也可能发生在异步脚本之后(如果异步脚本很短,或者是从HTTP缓存中加载的)
      换句话说,async脚本会在后台加载,并在加载就绪时运行。DOM和其他脚本不会等待它们,它们也不会等待其它的东西。async脚本就是一个会在加载完成时执行的完全独立的脚本。就这么简单,现在明白了吧?

下面是一个类似于我们在讲defer时所看到的例子:long.jssmall.js两个脚本,只是现在defer变成了async

它们不会等待对方。先加载完成的(可能是small.js)—— 先执行:

1
2
3
4
5
6
7
8
9
10
<p>...content before scripts...</p>

<script>
document.addEventListener('DOMContentLoaded', () => alert("DOM ready!"));
</script>

<script async src="https://javascript.info/article/script-async-defer/long.js"></script>
<script async src="https://javascript.info/article/script-async-defer/small.js"></script>

<p>...content after scripts...</p>

  1. 页面内容立刻显示出来:加载写有async的脚本不会阻塞页面渲染。
  2. DOMContentLoaded可能在async之前或之后触发,不能保证谁先谁后。
  3. 较小的脚本small.js排在第二位,但可能会比long.js这个长脚本先加载完成,所以small.js会先执行。虽然,可能是long.js先加载完成,如果它被缓存了的话,那么它就会先执行。换句话说,异步脚本以加载优先的顺序执行。
    当我们将独立的第三方脚本集成到页面时,此时采用异步加载方式是非常棒的:计数器广告等,因为它们不依赖于我们的脚本,我们的脚本也不应该等待它们:
    1
    2
    <!-- Google Analytics 脚本通常是这样嵌入页面的 -->
    <script async src="https://google-analytics.com/analytics.js"></script>

原理拆解

按照上面的原理我画了一张图来解释加载顺序和对HTML加载时间的影响。

可以看到,总的HTML加载时间,下载脚本的时间,执行脚本的时间是固定的。不同之处在于HTML阻塞的时间以及执行脚本的次序。

  1. 不加任何asyncdefer的情况,页面总加载时间最长,是

    HTML加载时间+下载脚本时间+执行脚本时间
  2. 加了asyncdefer的时间,在加载HTML时间足够长的情况下,所有静态资源总的加载时间都是

    HTML加载时间+执行脚本时间

适用内容

然后,必须考虑到页面加载时间和脚本加载顺序,以及各个脚本直接存在的依赖关系。

从参考文档来看,

  1. defer特性除了告诉浏览器不要阻塞页面之外,还可以确保脚本执行的相对顺序。
    • 这个很适合使用到Vue和jquery等js框架的js脚本,给它们添加defer属性以后,可以确保HTML加载完毕,且js下载完毕后,各个js脚本继续按照引入的顺序执行,从而确保不会因为依赖缺失而报错。
  2. 其他脚本不会等待async脚本加载完成,同样,async脚本也不会等待其他脚本。
    • 这个适合使用原生js没有引用任何js框架,自己独立就能运行,且体量相对较小的js脚本,随页面加载同步下载执行。

使用范例

此处以我使用的Butterfly主题中添加的几个js为例。

这里首先说一下我个人的想法,不一定对,本着尽可能减少页面阻塞资源的情况下,我建议:

  1. 尽可能给每个script标签都加上deferasync
  2. 确定为独立脚本或原生脚本的情况下,使用async。这一条并不适用于大型js(例如busuanzi.js)。原因可以看上面的原理拆解图,大型js的执行时间依然会造成大面积的HTML阻塞。
  3. 如果实在不清楚应该添加哪个,则以defer求稳,确保依赖项不会缺失。
  4. 总的来说,async加在那些非必要的,起装饰或者优化效果的js上,defer加在那些确保页面完整性的必要js上。
  5. 来自Heo的建议,不要给影响页面生成的js(例如util.js、main.js、lazy_load.js、vue.js、jquery.js)添加异步加载标签(不论是async还是defer都不要加),不然会造成大面积页面功能模块失效。推测是由于部分HTML元素需要由js动态写入,抑或部分整合在各个pug中的script片段无法添加defer导致。

inject配置项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
bottom:
- <script defer https://npm.elemecdn.com/vue@2.6.11"></script>
# Vue.js作为依赖项,必须确保在所有使用到它提供的方法的诸多js之前引入,并且添加defer
- <script defer src="/live2d-widget/autoload.js"></script>
# 看板娘使用到了jquery依赖,所以需要defer
- <script async src="/js/dytitle.js"></script>
# 动态网页标题是原生js,且体量小,可以直接async
- <script async src="/js/mouse_snow.min.js"></script>
# 鼠标滑动雪花是原生js,且体量小,可以直接async
- <script defer data-pjax src="/clock/js/clock.js"></script>
# 首页电子钟用到了vue依赖,所以需要defer
- <script defer data-pjax src="/magnet/catalogMagnet.js"></script>
# 首页磁贴用到了vue依赖,所以需要defer
- <script defer src="/botui/botui.js"></script>
- <script defer data-pjax src="/botui/botui_init.js"></script>
# 侧栏聊天窗用到了vue依赖,所以需要defer
- <script defer src="/runtime/flipcountdown.js"></script>
- <script defer data-pjax src="/runtime/runtime.js"></script>
# 页脚计时器用到了jquery依赖,所以需要defer
- <script defer src="https://npm.elemecdn.com/artitalk"></script>
- <script defer data-pjax src="/artitalk/artitalkkey.js"></script>
- <script defer data-pjax src="/artitalk/artitalk.js"></script>
# 侧栏说说依赖于artitalk.js,必须确保其加载顺序,在保证页面引入顺序的同时添加defer
- <script async src="/js/redirect.js"></script>
- <script async src="/js/mirror.js"></script>
# 404和镜像站重定向都是原生js,可以直接async
- <script defer data-pjax src="https://cdn.jsdelivr.net/gh/Akilarlxh/Akilarlxh.github.io@v3.3.3_3/gitcalendar/js/gitcalendar.js"></script>
# gitcalendar用到了vue依赖,所以需要defer

CDN配置项

CDN配置项的引入先于inject,至于如何给script标签添加deferasync属性,则要先找到引入位置。
[Blogroot]\themes\butterfly\layout\includes\additional-js.pug中修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
div
script(src=url_for(theme.CDN.jquery))
script(src=url_for(theme.CDN.utils))
script(src=url_for(theme.CDN.main))

if theme.translate && theme.translate.enable
script(defer src=url_for(theme.CDN.translate))

if theme.medium_zoom
script(defer src=url_for(theme.CDN.medium_zoom))
else if theme.fancybox
script(defer src=url_for(theme.CDN.fancybox))

if theme.instantpage
script(src=url_for(theme.CDN.instantpage) type="module" defer)

if theme.lazyload.enable
script(src=url_for(theme.CDN.lazyload))

if (theme.snackbar && theme.snackbar.enable)
script(defer src=url_for(theme.CDN.snackbar))

if theme.pangu && theme.pangu.enable
!=partial('includes/third-party/pangu.pug', {}, {cache:theme.fragment_cache})

//- search
if theme.algolia_search.enable
script(defer src=url_for(theme.CDN.algolia_js))
else if theme.local_search.enable
script(defer src=url_for(theme.CDN.local_search))

拓展内容,CSS的异步加载

页面载入并渲染的流程可以简单理解为以下情况:

加载HTML资源->解析HTML->加载CSS资源,同时构建DOM树->解析CSS,同时渲染DOM树


与js的加载执行过程十分相似,加载CSS时也会造成HTML加载阻塞。

既然js可以异步加载,那CSS理论上应该也可以。虽然不能像js一样直接通过asyncdefer来定义加载顺序那么方便。

目前有效手段有两种,一种是通过定义一个无效media,使得该CSS引入优先级最低,再用onload="this.media='all'"在页面加载完成后纠正media,并加载CSS。

写法如下:
未加入异步加载:

1
<link rel="stylesheet" href="/example.css">

加入异步加载后:
1
<link rel="stylesheet" href="/example.css" media="defer" onload="this.media='all'">

注意此处的media=”defer”中的defer并无意义,是个无效media(只是方便理解才这么写),目的是让浏览器认为这个CSS文件优先级非常低,从而在不阻塞的情况下进行加载。

加载完成以后,样式转为对所有设备生效是onload="this.media='all'",但是其实还有其他的设备可供选择。

描述
screen计算机屏幕(默认)。
tty电传打字机以及类似的使用等宽字符网格的媒介。
tv电视机类型设备(低分辨率、有限的滚屏能力)。
projection放映机。
handheld手持设备(小屏幕、有限带宽)。
print打印预览模式/打印页面。
braille盲人点字法反馈设备。
aural语音合成器。
all适用于所有设备。

还有一种利用rel="preload"属性的方案,但是目前只有Chrome浏览器可以完美支持,等推广还需要很长一段时间,写法如下:

1
<link rel="preload" href="cssfile.css" as="style" onload="this.rel='stylesheet'">

CSS整合

以下内容仅针对butterfly主题。其他主题可以理解原理后进行操作。实际上就是使用@import引入自定义样式。

相信很多小伙伴在看了上述的CSS异步加载方案后,肯定迫不及待的去给自己博客的魔改自定义样式添加异步加载属性了,就算不是,现在也给我演一下,配合我的工作,这么做虽然可以减少HTML页面阻塞,但是很可能会出现首屏页面有那么几秒钟存在大片无样式的板块的情况。

所以我们可以确立一条原则,为了追求视觉体验,不要给index.css等涉及首页样式的CSS文件添加异步加载。

然而事实上,相比于给CSS添加异步加载,不如将我们的魔改样式整合到index.css文件内,减少对服务器的请求次数。这样更能节省加载时间。

我的做法如下:

  1. [Blogroot]\themes\butterfly\source\css\路径下新建_custom文件夹,然后把魔改样式的CSS文件拖动进去。文件目录层级可以表示为以下情况:

    1
    2
    3
    4
    5
    6
    7
    source
    |__ css
    |__ _custom
    |__ custom1.css
    |__ custom2.css
    |__ custom3.css
    |__ index.styl
  2. [[Blogroot]\themes\butterfly\source\css\index.styl中新增一行代码:@import '_custom/*.css',表示引入_custom文件夹下的所有CSS文件。(此处为了确保自定义样式不会被覆盖,放在最底下。)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
        @import 'nib'
    @import '_third-party/*.css'
    // project
    @import 'var'
    @import '_global/*'
    @import '_highlight/highlight'
    @import '_page/*'
    @import '_layout/*'
    @import '_tags/*'
    @import '_mode/*'
    + @import '_custom/*.css'
  3. 如果是使用的外链css,也可以在这里引入。同样是修改 [Blogroot]\themes\butterfly\source\css\index.styl代码,使用@import逐行引入(此处为了确保自定义样式不会被覆盖,放在最底下。):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
        @import 'nib'
    @import '_third-party/*.css'
    // project
    @import 'var'
    @import '_global/*'
    @import '_highlight/highlight'
    @import '_page/*'
    @import '_layout/*'
    @import '_tags/*'
    @import '_mode/*'
    + @import 'https://cdn.jsdelivr.net/gh/username/repo/css/example.css'
  4. 这样一来,每个魔改方案的CSS依然可以在独立的CSS文件中找到并修改(如果是手动添加整合的话,只能用注释分割,显然很不利于后续查找修改),而在每次提交时,运行hexo g的过程中就会将所有CSS文件都整合到index.css,可以在主题配置文件的CDN配置项里给index.css加上jsdelivr进一步提升加载速度(注意刷新jsdelivr的缓存)。