Skip to content

轻松迁移博客到AMP

2017年8月29日

AMP 全称 Accelerated Mobile Pages ,是由 Google 提出的一种移动端页面的规范。相比普通 HTML 而言,最大的特点是对页面中可用元素进行了严格的限制,以确保高性能。此外,Google 还对 AMP 页面提供了高速缓存,如果从 Google 搜索中打开 AMP 页面,速度非常快,几乎是秒开。

前几天将我的博客完全迁移到了 AMP,在此也做一个记录。

作为单独页面存在的 AMP

在很长一段时间内,我都以为 AMP 只适用于移动端某些特殊场景,至少文章页应该是适用的,于是自然而然就想到了博客应该是非常适合 AMP 的场景。因为我的博客使用的是著名的静态博客程序 Hexo,所以也就很自然地想到了,会不会有人已经写好了 Hexo 的 AMP 插件?搜了一下,果然没有失望,找到了一个名为 hexo-generator-amp的插件。

这个插件不会修改已有的文章页面,而是会为文章页面再生成一个合法的 AMP 页面。这两个页面可以互相引用,表示一个是普通页面,一个是 AMP 页面。这种方式也是 Google 搜索等平台认可的 AMP 生成方式。

按照插件的文档,使用起来还是比较简单的,具体的方式就不写了,直接参考文档即可。有几个值得注意的点:

  1. 一定要修改文章页的模板,添加link[rel=amphtml]链接,要不然搜索无法找到 AMP 页面
  2. AMP 页面会自动在head中添加canonical链接回原有的文章页

这个插件支持自己修改模板。鉴于我对它的样式并不是很满意,没有修改的欲望,于是也就没有去改它。如果你需要修改 AMP 模板的话,可以在网址中添加#development=1,然后在控制台中查看 AMP 验证结果。

最后效果:

Google搜索显示AMP

从 Google 搜索的时候能看到明显的“AMP”和闪电标记

AMP使用Google域名缓存

打开后就能看到这个插件提供的默认模板长什么样。

另外可以看到,从搜索中直接点击时,访问的域名是 Google 的,这便是经常被说到的,Google 对 AMP 页面提供了高速缓存。按照 Google 的说法,这种状态下,页面的逻辑仍然会运行,此时页面既是在 Google 那里的,也是在网站作者的控制下的。

hexo-generator-amp默认样式

这是插件默认模板的 footer,可以说是相当大,而且个人不太喜欢。但不管怎么说,我的博客有 AMP 页面了,并且能在 Google 搜索结果中被标识出来,点击时还能秒开,这确实是一种非常不错的使用体验。

全站 AMP

后来我看到了《澄清对AMP的十个误解》这篇文章,才知道原来 AMP 既不是移动端的专属应用,也不是能力非常受限的技术。因此萌生了将博客整站都改成 AMP 的想法。

首先,既然全站都要改成 AMP 了,那么为文章页再单独生成一个 AMP 页面就显得不必要了,因此我去掉了上面说的 hexo-generator-amp 插件,而使用完全手工修改的方式来改造。

1. 照葫芦画瓢

前面说过,当在地址栏中加上#development=1时,浏览器控制台就会出现 AMP 的验证信息。于是我想着那就先打开这个验证信息,然后跟着错误一个一个改吧,于是也就直接访问了http://localhost:4000/#development=1,结果控制台空空如也。通过查询文档,才知道原来要打开 AMP 验证,至少还是得做一点前置工作的,最起码你得让浏览器知道“我是打算变成 AMP 的,请按 AMP 的标准来要求我”。于是按照文档一一照做:

  • Doctype 是必须要有:无需改动
  • 包含一个顶层的<html ⚡>或者<html amp>:打开 Hexo 模板,给html加上闪电属性
  • 包含<head><body>:无需改动
  • <head>的第一个子节点是<meta charset="utf-8">:无需改动
  • <head>的第二个子节点是<script async src="https://cdn.ampproject.org/v0.js"></script>:照做
  • <head>中通过<link rel="canonical" href="$SOME_URL">指向非 AMP 版本的页面,如果只有 AMP 版本则指向自身:照做,指向自身
    html
    link(rel="canonical",href=url_for(page.path))
    link(rel="canonical",href=url_for(page.path))
  • <head>中包含<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1">:无需改动
  • 包含一段 AMP 必须有的代码:照做,注意这里不要对代码进行格式化,按原样一行复制下来就好,否则会验证不通过

做完回头看一下,其实改动并不是很多。然后再次刷新浏览器,就能看到验证信息了:

AMP验证截图

在图上,我们可以看到大概有这样几个问题:

  • 使用link[rel-stylesheet]加载了一个 CDN 域名上的字体
  • script 标签不允许(3次)
  • imgsrc属性缺失
  • img不允许出现,可能需要amp-img

对这些问题一一进行解释和解决。

2. 样式表

第一个问题,它说我加载了一个 CDN 域名上的字体,这是怎么回事呢?同样,首先查看文档,发现文档上说,只允许加载以下几个域名的自定义字体:

但下面也说,在自定义 CSS 中,可以使用@font-face来引用字体,这种方式不受域名限制。

我的博客主题确实用到了自定义字体,而且确实是在我自己的 CSS 中定义的,而我的字体托管域名并不是上面那几个域名,所以肯定是无法加载了。于是我将字体的定义从CSS中移到了<head>中:

html
<style>
@font-face {
    font-family: 'sourcesanspro';
    src: url('//toobug.s.f2er.info/font/sourcesanspro.woff2') format('woff2'),
         url('//toobug.s.f2er.info/font/sourcesanspro.woff') format('woff');
    font-weight: normal;
    font-style: normal;
}
</style>
<style>
@font-face {
    font-family: 'sourcesanspro';
    src: url('//toobug.s.f2er.info/font/sourcesanspro.woff2') format('woff2'),
         url('//toobug.s.f2er.info/font/sourcesanspro.woff') format('woff');
    font-weight: normal;
    font-style: normal;
}
</style>

满心希望地刷新了一下,结果发现这个错误依然存在。在花了三十分钟百思不得其解之后,我终于想到了去翻一下 AMP 关于样式的说明,文档中明确说:“AMP pages can’t include external stylesheets, with the exception of custom fonts”,即 AMP 页面中不允许加载外部样式,除了自定义字体。

再明确一下,AMP 中不允许使用 CSS 样式表加载样式。唯一可以用 CSS 来加载的只有字体,而用来加载字体的 CSS 必须是上面的三个网址之一。

那么解决方式就简单了,使用<style amp-custom>将样式文件内联进来即可,具体到 jade 模板中,只要include一下就好:

style(amp-custom)
    include ../../source/css/apollo.css
style(amp-custom)
    include ../../source/css/apollo.css

3. 脚本

AMP 页面中不允许以我们熟悉的方式引入脚本,或者也可以先简单理解为不允许使用脚本。而我的页面上使用了 3 个脚本:

  1. 用于将 HTTP 访问跳转到 HTTPS 的脚本
  2. 用于图片 lazyload 的脚本
  3. Google Analytics 统计脚本

第 1 个有一定历史原因,因为最早将博客托管在 Gitlab.com 上,不支持自动 HTTPS 跳转,所以只能使用脚本。现在使用了自己的服务器,可以直接使用301跳转,并支持 HSTS,所以这个脚本直接去掉即可。

第 2 个是自己写的一个简单的图片 lazyload 的脚本,即构建时将imgsrc属性换成data-src,然后在图片滚动到当前视野中时再加载。因为 AMP 并不支持img,且amp-img有 lazyload 的特性,所以直接去掉。(关于图片的问题下文详述。)

第 3 个,GA 统计的脚本,AMP 有官方的组件可以支持,通过查看文档,只要先引入amp-analytics组件脚本,然后将 GA 的代码替换掉就可以解决:

html
<!-- 在head区 AMP 脚本之前引入 -->
<script async custom-element="amp-analytics" src="https://cdn.ampproject.org/v0/amp-analytics-0.1.js"></script>

<!-- 将统计脚本替换成如下代码 -->
<amp-analytics type="googleanalytics" id="UA-XXXXXXXX">
    <script type="application/json">
    {
        "vars": {
            "account": "UA-XXXXXXXX"
        },
        "triggers": {
            "trackPageview": {
                "on": "visible",
                "request": "pageview"
            }
        }
    }
    </script>
</amp-analytics>
<!-- 在head区 AMP 脚本之前引入 -->
<script async custom-element="amp-analytics" src="https://cdn.ampproject.org/v0/amp-analytics-0.1.js"></script>

<!-- 将统计脚本替换成如下代码 -->
<amp-analytics type="googleanalytics" id="UA-XXXXXXXX">
    <script type="application/json">
    {
        "vars": {
            "account": "UA-XXXXXXXX"
        },
        "triggers": {
            "trackPageview": {
                "on": "visible",
                "request": "pageview"
            }
        }
    }
    </script>
</amp-analytics>

至此,脚本的问题解决了。

4. 图片

AMP 有一个很大的特点,就是强调页面的静态布局。

举个例子,当浏览器加载一张图片时,如果图片没有被显式指定宽高,此时图片的占位大小是不确定的,因此浏览器会先对后面的内容进行排版,等图片加载完之后再回来重新计算图片占的位置,此时就会造成页面布局的变化。

而 AMP 强调页面布局应该是确定的,因此不允许像上面这样的页面布局变化存在。针对图片,AMP 中需要使用<amp-img>元素来替代<img>,并且强制要求一定要指定图片的布局方式和宽高。

首先第一张要处理的图是博客顶部的 Logo,直接在模板中将它修改成<amp-img>

a.logo-link(href=url_for())
    amp-img(layout="fixed",width="60",height="60",src=theme.logo)
a.logo-link(href=url_for())
    amp-img(layout="fixed",width="60",height="60",src=theme.logo)

这里我使用了layout="fixed",表示这张图片的大小是固定的。

接下来要处理的文章正文中的图片,我利用了 Hexo 主题的勾子(Script)特性。按官方文档的说法,只要在与source同级的目录创建一个scripts目录,里面的脚本就会被执行。但是我是将scripts目录放到了主题的根目录下,同样会被执行。

javascript
var imageSize = require('image-size');
var path = require('path');

hexo.extend.filter.register('after_render:html', (source) => {
    return source.replace(/<img src="(.+?)"/g, function(str, src){
        var imagePath = path.join(process.cwd(), 'source' , src);
        var size = imageSize(imagePath);
        if(!size){
            size = {
                width: 800,
                height: 500
            };
        }
        return `<amp-img layout="responsive" width="${size.width}" height="${size.height}" src="//toobug.s.f2er.info${src}"`;
    });
});
var imageSize = require('image-size');
var path = require('path');

hexo.extend.filter.register('after_render:html', (source) => {
    return source.replace(/<img src="(.+?)"/g, function(str, src){
        var imagePath = path.join(process.cwd(), 'source' , src);
        var size = imageSize(imagePath);
        if(!size){
            size = {
                width: 800,
                height: 500
            };
        }
        return `<amp-img layout="responsive" width="${size.width}" height="${size.height}" src="//toobug.s.f2er.info${src}"`;
    });
});

这段代码注册了一个勾子,在渲染 HTML 之后执行,所以可以对 HTML 中的<img>进行替换。

因为<amp-img>要求必须指定宽高,因此使用了image-size这个 npm 模块来获取图片宽高。最后将<img>替换成<amp-img>即可。因为是文章正文中的图片,layout指定了responsive,这样就可以在不同宽度下自适应。最后我在替换的时候顺手加了上 CDN 的前缀。

至此,首页就已经改造完成了,刷新一下,看到错误信息已经没有了。

5. 评论框

进入文章详情页,仍然会有一个使用了脚本的提示,这是因为引入了 disqus 评论组件。

javascript
var disqus_shortname = '#{theme.disqus}';
var disqus_identifier = '#{page.path}';
var disqus_title = '#{page.title}';
var disqus_url = '#{config.url}/#{page.path}';
(function() {
    var dsq = document.createElement('script'); dsq.type = 'text/javascript'; dsq.async = true;
    dsq.src = '//' + disqus_shortname + '.disqus.com/embed.js';
    (document.getElementsByTagName('head')[0] || document.getElementsByTagName('body')[0]).appendChild(dsq);
})();
var disqus_shortname = '#{theme.disqus}';
var disqus_identifier = '#{page.path}';
var disqus_title = '#{page.title}';
var disqus_url = '#{config.url}/#{page.path}';
(function() {
    var dsq = document.createElement('script'); dsq.type = 'text/javascript'; dsq.async = true;
    dsq.src = '//' + disqus_shortname + '.disqus.com/embed.js';
    (document.getElementsByTagName('head')[0] || document.getElementsByTagName('body')[0]).appendChild(dsq);
})();

首先动用搜索,看看 disqus 官方是否向 GA 那样有提供官方组件。结论是……没有。但是,找到一篇官方的 AMP 页面下使用指南

事实上,这篇指南写得并不是十分清楚,并且照做的话会有一些错误信息,也不能正常显示和发布评论。在尝试好几次并做了反复修改之后,才终于弄懂它的含义。它的大致原理是使用amp-iframe组件,将评论放在一个独立的 iframe 中去。这是利用了 AMP 的规则,虽然 AMP 页面不允许有脚本存在,但是可以通过amp-iframe来包含页面,将脚本放到这个单独的页面中即可。

第一步,我们要准备一下这个被嵌入的页面,它将包含主要的 disqus 相关的代码:

html
<!-- 用于显示评论框和评论列表的容器 -->
<div id="disqus_thread"></div>
<script>
// 监听disqus组件传递的消息
// 其中有一个是`resize`,是disqus用于告诉当前页面
// “我加载完了,我的尺寸是XXX”
window.addEventListener('message', receiveMessage, false);
function receiveMessage(event)
{
    if (event.data) {
        var msg;
        try {
            msg = JSON.parse(event.data);
        } catch (err) {
            // Do nothing
        }
        if (!msg)
            return false;

        if (msg.name === 'resize') {
            // 向 AMP 页面发送消息,要求重新设置 amp-iframe的高度
            window.parent.postMessage({
              sentinel: 'amp',
              type: 'embed-size',
              height: msg.data.height
            }, '*');
        }
    }
}
</script>
<script>
    // 用于解析url参数
    function getQueryVariable(variable) {
        var query = window.location.search.substring(1);
        var vars = query.split("&");
        for (var i=0;i<vars.length;i++) {
            var pair = vars[i].split("=");
            if(pair[0] == variable){return pair[1];}
        }
        return(false);
    }
    // 通过url参数获取当前 AMP 页面对应的文章相关参数
    // 并赋值给page变量,稍后disqus将读取page变量
    var disqus_config = function () {
        this.page.title = decodeURIComponent(getQueryVariable("title"));
        this.page.url = decodeURIComponent(getQueryVariable("url"));
        this.page.identifier = decodeURIComponent(getQueryVariable("identifier"));
    };

    // 引入disqus脚本
    (function() {  // DON'T EDIT BELOW THIS LINE
        var d = document, s = d.createElement('script');

        s.src = '//toobug.disqus.com/embed.js';

        s.setAttribute('data-timestamp', +new Date());
        (d.head || d.body).appendChild(s);
    })();
</script>
<!-- 用于显示评论框和评论列表的容器 -->
<div id="disqus_thread"></div>
<script>
// 监听disqus组件传递的消息
// 其中有一个是`resize`,是disqus用于告诉当前页面
// “我加载完了,我的尺寸是XXX”
window.addEventListener('message', receiveMessage, false);
function receiveMessage(event)
{
    if (event.data) {
        var msg;
        try {
            msg = JSON.parse(event.data);
        } catch (err) {
            // Do nothing
        }
        if (!msg)
            return false;

        if (msg.name === 'resize') {
            // 向 AMP 页面发送消息,要求重新设置 amp-iframe的高度
            window.parent.postMessage({
              sentinel: 'amp',
              type: 'embed-size',
              height: msg.data.height
            }, '*');
        }
    }
}
</script>
<script>
    // 用于解析url参数
    function getQueryVariable(variable) {
        var query = window.location.search.substring(1);
        var vars = query.split("&");
        for (var i=0;i<vars.length;i++) {
            var pair = vars[i].split("=");
            if(pair[0] == variable){return pair[1];}
        }
        return(false);
    }
    // 通过url参数获取当前 AMP 页面对应的文章相关参数
    // 并赋值给page变量,稍后disqus将读取page变量
    var disqus_config = function () {
        this.page.title = decodeURIComponent(getQueryVariable("title"));
        this.page.url = decodeURIComponent(getQueryVariable("url"));
        this.page.identifier = decodeURIComponent(getQueryVariable("identifier"));
    };

    // 引入disqus脚本
    (function() {  // DON'T EDIT BELOW THIS LINE
        var d = document, s = d.createElement('script');

        s.src = '//toobug.disqus.com/embed.js';

        s.setAttribute('data-timestamp', +new Date());
        (d.head || d.body).appendChild(s);
    })();
</script>

我已经在代码中标上了注释,这里面有几个关键点需要理解:

  1. 这个页面将会被amp-iframe引用,因此 AMP 页面是父页面,本页面是指上面的这段代码所在页面
  2. disqus 的脚本在加载后,会将一个新的 iframe 写到容器#disqus_thread
  3. disqus 会从新的 iframe 中发消息,告知本页面自己的状态,其中一种消息是尺寸
  4. disqus 需要知道 AMP 页面对应的 url、标题等信息,我们通过本页面的location.search获取
  5. amp-iframe 可以接受本页面的消息,动态设置高度

第二步,需要将这个页面放到一个单独的域名上,不能和 AMP 页面同一个域名。刚好我使用了一个 CDN ,有独立的域名,因此下面就直接用 CDN 的域名进行引入。

第三步,使用amp-iframe将这个页面引入,并且在 url 中传入文章的相关参数:

html
<amp-iframe
    width="600"
    height="140"
    layout="responsive"
    sandbox="allow-scripts allow-same-origin allow-modals allow-popups allow-forms"
    resizable
    src="https://toobug.s.f2er.info/amp/disqus/toobug.html?title=#{page.title}&url=#{config.url}/#{page.path}&identifier=#{page.path}">
    <div
        overflow
        tabindex=0
        role=button
        aria-label="Disqus Comments"
    >Disqus Comments</div>
</amp-iframe>
<amp-iframe
    width="600"
    height="140"
    layout="responsive"
    sandbox="allow-scripts allow-same-origin allow-modals allow-popups allow-forms"
    resizable
    src="https://toobug.s.f2er.info/amp/disqus/toobug.html?title=#{page.title}&url=#{config.url}/#{page.path}&identifier=#{page.path}">
    <div
        overflow
        tabindex=0
        role=button
        aria-label="Disqus Comments"
    >Disqus Comments</div>
</amp-iframe>

值得注意的点:

  1. layoutresponsive以便自适应宽度
  2. sandbox需要写明权限,否则可能导致评论无法操作
  3. 要有resizable属性,这样会接受 iframe 中页面的消息,重新计算高度
  4. 要有div[overflow]子元素,否则会有报错“Overflow element must be defined for resizable frames”

这样就完成了 disqus 评论的改造。

amp-iframe加载中

图:amp-iframe加载中

disqus加载中

图:disqus加载中

disqus评论

图:disqus评论正常显示

回顾一下完整的原理:

  • 使用amp-iframe来包含评论的逻辑
  • 接受 disqus 的消息,如果是高度变更了,那么向 AMP 页面发送一个消息,要求重新计算高度
  • 通过amp-iframesrc参数来指定disqus相关的参数

改造完之后发现一个疑似 AMP 的 bug:在移动端 Chrome 上访问时,评论框显示不完整,也就是说,高度调整并没有完成。按照文档中的说法,说这个高度调整不一定是立即完成,AMP 会判断需要调整的时候再做调整。估计是这个判断什么时候进行调整有 bug 。如果选中文字往下拖到底,则有一定机率高度会变正常。目前尚未找到更好的解决方案。

以上就是本博客的改造过程。将博客改造成全站 AMP 并不是很困难。一方面是因为基本上没有什么逻辑,另一方面以文章为主的站点非常符合 AMP 的定位。

如果你的站点逻辑非常多,或者并不是以文章、资讯为主的站点,可能还需要再考量一下是否有必要。

最后再附一张改造之后的 Google AMP 缓存访问的图:

Google AMP 缓存

hexo-generator-amp生成的样式顺眼多啦。