轻松迁移博客到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.css
style(amp-custom)
include ../../source/css/apollo.css
3. 脚本
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
生成的样式顺眼多啦。