轻松迁移博客到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 生成方式。
按照插件的文档,使用起来还是比较简单的,具体的方式就不写了,直接参考文档即可。有几个值得注意的点:
- 一定要修改文章页的模板,添加
link[rel=amphtml]链接,要不然搜索无法找到 AMP 页面 - AMP 页面会自动在
head中添加canonical链接回原有的文章页 
这个插件支持自己修改模板。鉴于我对它的样式并不是很满意,没有修改的欲望,于是也就没有去改它。如果你需要修改 AMP 模板的话,可以在网址中添加#development=1,然后在控制台中查看 AMP 验证结果。
最后效果:

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

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

这是插件默认模板的 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 版本则指向自身:照做,指向自身htmllink(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 必须有的代码:照做,注意这里不要对代码进行格式化,按原样一行复制下来就好,否则会验证不通过
 
做完回头看一下,其实改动并不是很多。然后再次刷新浏览器,就能看到验证信息了:

在图上,我们可以看到大概有这样几个问题:
- 使用
link[rel-stylesheet]加载了一个 CDN 域名上的字体 script标签不允许(3次)img的src属性缺失img不允许出现,可能需要amp-img
对这些问题一一进行解释和解决。
2. 样式表 
第一个问题,它说我加载了一个 CDN 域名上的字体,这是怎么回事呢?同样,首先查看文档,发现文档上说,只允许加载以下几个域名的自定义字体:
但下面也说,在自定义 CSS 中,可以使用@font-face来引用字体,这种方式不受域名限制。
我的博客主题确实用到了自定义字体,而且确实是在我自己的 CSS 中定义的,而我的字体托管域名并不是上面那几个域名,所以肯定是无法加载了。于是我将字体的定义从CSS中移到了<head>中:
<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.cssstyle(amp-custom)
    include ../../source/css/apollo.css3. 脚本 
AMP 页面中不允许以我们熟悉的方式引入脚本,或者也可以先简单理解为不允许使用脚本。而我的页面上使用了 3 个脚本:
- 用于将 HTTP 访问跳转到 HTTPS 的脚本
 - 用于图片 lazyload 的脚本
 - Google Analytics 统计脚本
 
第 1 个有一定历史原因,因为最早将博客托管在 Gitlab.com 上,不支持自动 HTTPS 跳转,所以只能使用脚本。现在使用了自己的服务器,可以直接使用301跳转,并支持 HSTS,所以这个脚本直接去掉即可。
第 2 个是自己写的一个简单的图片 lazyload 的脚本,即构建时将img的src属性换成data-src,然后在图片滚动到当前视野中时再加载。因为 AMP 并不支持img,且amp-img有 lazyload 的特性,所以直接去掉。(关于图片的问题下文详述。)
第 3 个,GA 统计的脚本,AMP 有官方的组件可以支持,通过查看文档,只要先引入amp-analytics组件脚本,然后将 GA 的代码替换掉就可以解决:
<!-- 在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目录放到了主题的根目录下,同样会被执行。
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 评论组件。
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 相关的代码:
<!-- 用于显示评论框和评论列表的容器 -->
<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>我已经在代码中标上了注释,这里面有几个关键点需要理解:
- 这个页面将会被
amp-iframe引用,因此 AMP 页面是父页面,本页面是指上面的这段代码所在页面 - disqus 的脚本在加载后,会将一个新的 iframe 写到容器
#disqus_thread中 - disqus 会从新的 iframe 中发消息,告知本页面自己的状态,其中一种消息是尺寸
 - disqus 需要知道 AMP 页面对应的 url、标题等信息,我们通过本页面的
location.search获取 amp-iframe可以接受本页面的消息,动态设置高度
第二步,需要将这个页面放到一个单独的域名上,不能和 AMP 页面同一个域名。刚好我使用了一个 CDN ,有独立的域名,因此下面就直接用 CDN 的域名进行引入。
第三步,使用amp-iframe将这个页面引入,并且在 url 中传入文章的相关参数:
<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>值得注意的点:
layout写responsive以便自适应宽度sandbox需要写明权限,否则可能导致评论无法操作- 要有
resizable属性,这样会接受 iframe 中页面的消息,重新计算高度 - 要有
div[overflow]子元素,否则会有报错“Overflow element must be defined for resizable frames” 
这样就完成了 disqus 评论的改造。

图:amp-iframe加载中

图:disqus加载中

图:disqus评论正常显示
回顾一下完整的原理:
- 使用
amp-iframe来包含评论的逻辑 - 接受 disqus 的消息,如果是高度变更了,那么向 AMP 页面发送一个消息,要求重新计算高度
 - 通过
amp-iframe的src参数来指定disqus相关的参数 
改造完之后发现一个疑似 AMP 的 bug:在移动端 Chrome 上访问时,评论框显示不完整,也就是说,高度调整并没有完成。按照文档中的说法,说这个高度调整不一定是立即完成,AMP 会判断需要调整的时候再做调整。估计是这个判断什么时候进行调整有 bug 。如果选中文字往下拖到底,则有一定机率高度会变正常。目前尚未找到更好的解决方案。
结 
以上就是本博客的改造过程。将博客改造成全站 AMP 并不是很困难。一方面是因为基本上没有什么逻辑,另一方面以文章为主的站点非常符合 AMP 的定位。
如果你的站点逻辑非常多,或者并不是以文章、资讯为主的站点,可能还需要再考量一下是否有必要。
最后再附一张改造之后的 Google AMP 缓存访问的图:

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