2018年11月23日星期五

把我的个人网站推倒重来(Part 6 - hreflang)

因为我在上一篇文章讲 Open Graph 元数据时提到 hreflang,我可以用这篇文章简单讲一讲如何支持 herflang。使用 hreflang 好处的是让 Google 知道多个页面其实是同一内容的不同语言版本,这样在用户搜索时 Google 就可以尽量提供正确的语言版本。

Google 官方对 hreflang 提供了详尽的解释。要让网站支持 hreflang 有三种做法:HTML 标签、HTTP header 以及 Sitemap。我选择了 HTML 标签,因为在我添加 hreflang 的时候 Google 还没提供另外两种做法。如果让我现在重新选择的话,我很有可能选择使用 Sitemap 从而减少页面中对用户没有价值的字节。

我在之前的篇文章解释了我的网站是如何支持多语言的,我的 hreflang 实现同样依赖于我做的这个基于 Harp 的多语言方案。对于每一个页面 /page,我都有 /zh/page/en/page 对应其中文版和英文版。这时候根据 Google 的要求,hreflang="x-default" 应该指向 /page,然后 hreflang="zh" 应该指向 /zh/page,英文版同理。举个例子,首页的 hreflang 标签是这样子的:

<link rel="alternate" hreflang="x-default" href="https://catchen.me/">
<link rel="alternate" hreflang="en" href="https://catchen.me/en/">
<link rel="alternate" hreflang="zh" href="https://catchen.me/zh/">

为了让 Harp 生成这组标签,我在模板中先取出当前页面语言无关的名称(也就是 /zh/page 中的 page),然后以此生成 x-default 的标签。接着我再遍历网站支持的语言,逐一生成对应语言的 hreflang 标签。具体代码可以在 GitHub 上看到。因为我让 Harp 遍历网站支持的所有语言,将来如果我添加了新的语言,只要让 Harp 重新便宜网站新的语言便会出现,不需要我做任何的手工修改。

hreflang 就这么简单的搞掂了。接下来让我们对网站加上 Google Analytics 和 Facebook Pixel,好让我们统计网站的访问来源。如果你想要继续关注这个系列的话,欢迎通过邮件RSS/Atom 订阅我的博客。

2018年11月17日星期六

把我的个人网站推倒重来(Part 5 - Open Graph 元数据)

网站发布之后我开始做各种细小的优化,其中一项是为网站加上 Open Graph 元数据( metadata),使得网站在被 Facebook 抓取时能够显示正确的预览信息。(Google 在抓取时也会参考 Open Graph 元数据,虽然 Open Graph 是 Facebook 提出的标准。)

Open Graph 标准

Open Graph 标准本身并不复杂,看着官方的标准信息把每一项相关的属性都加上。以下是我个人网站上使用的 Open Graph 信息:

<meta property="og:type" content="profile">
<meta property="og:title" content="Cat Chen">
<meta property="og:description" content="Cat Chen's personal website.">
<meta property="og:url" content="https://catchen.me/">
<meta property="og:image" content="https://catchen.me/images/profile_picture_360_360_1x.jpg">

Facebook 专门提供了一个工具来验证网页上的 Open Graph 元数据。在把元数据添加好之后,我用这个工具测试了一下 Facebook 抓取到的数据。

Screenshot 2018-11-10 16.35.56

Open Graph 文本属性其实随便怎么填都可以,URL 属性需要保证是正确的地址。以下是两个常用的 URL 属性:

og:image 属性

这个属性用来设置 Facebook 预览时显示的图片。我选择了用来显示我的头像,因为这也是我显示在网站首页左侧的图片。

og:url 属性

这个属性用来指定所谓的「Canonical URL」,我为这个属性折腾了很久。这是因为 Facebook 把 Canonical URL 看作重定向,如果一个 URL1 返回的网页使用 og:url 属性指向另一个 URL2,在 Facebook 看来这根 URL1 返回 302 重定向到 URL2 一样。这导致 Facebook 在抓取我的页面时陷入了无限重定向循环。

要解释这件事情,首先要解释我在 Netlify 上做了什么重定向配置,这是我在上一篇文章中没有提到的。我在之前一篇文章中说了,我的网站支持多语言(暂时只有英文和中文),不同语言使用不同的目录,例如说中文版在 /zh/* 英文版在 /en/*。这造成了一个问题,网站首页 / 应该显示中文版还是英文版?为此我让 Netlify 根据用户浏览器提供的 accept-language header 来选择正确的语言。(如果这个 header 不存在则默认为英文。)这是当时的 netlify.toml 配置文件。

如果只有这一种重定向,无限循环并不出现。无限循环之所以出现,是因为我把 /zh//en/ 的 Canonical URL 都指向了 /。为什么要指向 / 呢?因为我认为中文版和英文版都只是特定语言的版本而已,只有未指定语言的 / 才有资格叫做 Canonical URL。

最终我撤销了第一种重定向(302),保留了第二种重定向(Canonical URL)。撤销第一种重定向后,我把 / 改了了内容代理,也就是说如果浏览器的 accept-language header 选择了中文,那 / 显示的就是中文版,内容跟 /zh/ 完全一样但不改变 URL。这使得我的网站被分享到 Facebook 时指向的都是未指定语言的 URL,每一个用户实际打开时看到的语言都由他浏览器的设置来决定。我觉得这是我能做到的对用户最便利的选择。

这是我修改后的 netlify.toml。在 Netlify 的配置中使用 200 重定向意思就是代理目标页面内容——用户看不到重定向的发生,但实际返回的内容时目标页面的内容。做了这一件事情后,接下来要支持 hreflang 就很方便了,我在下一篇文章里就会讲述 hreflang 的设置。如果你对此感兴趣的话,欢迎通过邮件RSS/Atom 订阅我的博客。

2018年11月7日星期三

NPM 打包时该忽略哪些文件?

最近在写一个新的 JavaScript 库,叫做 dice-chance,用来分析掷骰子的概率。计划是库写完了就用 PWA 封装一下发布给大家用。因为在写的时候用到了 Flow 做类型声明,所以源代码文件不能不经处理直接被调用,必须经过 flow-remove-types 处理一下删除 Flow 类型声明。

为了保证在包发布时 Flow 类型会被删除掉,我在 package.json 中定义了 build 脚本,然后设置了 prepublish 事件触发 build 脚本:

"scripts": {
  "build": "flow-remove-types src/ -d lib/",
  "prepublish": "yarn run build"
},

奇怪的是,在执行 npm publish 时我明明看到了 build 脚本被触发了但打包时却没有引入 lib 目录。这样打包出来的库不能用,因为 index.js 里面引用的文件都来自于 lib 目录而非 src 目录。打包时的输出时这样子的:

$ npm publish --dry-run

> dice-chance@2.0.1 prepublish .
> npm run build

> dice-chance@2.0.1 build .
> flow-remove-types src/ -d lib/

src/Analyzer.js
 ↳ lib/Analyzer.js
src/DiceChance.js
 ↳ lib/DiceChance.js
src/Parser.js
 ↳ lib/Parser.js
src/Tokens.js
 ↳ lib/Tokens.js
src/__tests__/Analyzer-test.js
 ↳ lib/__tests__/Analyzer-test.js
src/__tests__/DiceChance-test.js
 ↳ lib/__tests__/DiceChance-test.js
src/__tests__/Parser-test.js
 ↳ lib/__tests__/Parser-test.js
npm notice
npm notice 📦  dice-chance@2.0.1
npm notice === Tarball Contents ===
npm notice 1.1kB   package.json
npm notice 48B     .babelrc
npm notice 58B     .flowconfig
npm notice 232B    .travis.yml
npm notice 48B     index.js
npm notice 1.1kB   LICENSE
npm notice 1.5kB   README.md
npm notice 112.4kB yarn.lock
npm notice 6.1kB   src/__tests__/Analyzer-test.js
npm notice 2.8kB   src/__tests__/DiceChance-test.js
npm notice 2.6kB   src/__tests__/Parser-test.js
npm notice 2.0kB   src/Analyzer.js
npm notice 1.4kB   src/DiceChance.js
npm notice 1.3kB   src/Parser.js
npm notice 949B    src/Tokens.js
npm notice === Tarball Details ===
npm notice name:          dice-chance
npm notice version:       2.0.1
npm notice package size:  37.7 kB
npm notice unpacked size: 133.7 kB
npm notice shasum:        5d7c0aca59b63aef43e32885fd7d254676a6db8f
npm notice integrity:     sha512-4kzv5srVKgrYJ[...]ynoZKx48wAKfw==
npm notice total files:   15
npm notice
+ dice-chance@2.0.1

这到底是为什么呢?一番搜索后我才发现,NPM 默认会用 .gitignore 来决定打包时忽略哪些文件。因为 lib 是构建的产物,不应该属于源代码的一部分,所以我用 .gitignore 文件把 lib 目录忽略掉了。NPM 因此在打包时也把 lib 忽略掉了,但其实 lib 必须被打包,而 src 反而可以被忽略掉(因为 src 中的源文件不会被 index.js 引用。

为了解决这个问题,我需要引入 .npmignore 文件。这个文件的格式跟 .gitignore 的格式一致,只要这个文件存在 NPM 打包时就用它来决定忽略什么,不再理会 .gitignore。我把 .gitignore 先复制为 .npmignore,再把里面的 lib 替换成 src,然后打包就再也没有问题了。以下是正确的打包输出:

$ npm publish --dry-run

> dice-chance@2.0.1 prepublish .
> yarn run build

yarn run v1.12.1
$ flow-remove-types src/ -d lib/
src/Analyzer.js
 ↳ lib/Analyzer.js
src/DiceChance.js
 ↳ lib/DiceChance.js
src/Parser.js
 ↳ lib/Parser.js
src/Tokens.js
 ↳ lib/Tokens.js
src/__tests__/Analyzer-test.js
 ↳ lib/__tests__/Analyzer-test.js
src/__tests__/DiceChance-test.js
 ↳ lib/__tests__/DiceChance-test.js
src/__tests__/Parser-test.js
 ↳ lib/__tests__/Parser-test.js
✨  Done in 0.26s.
npm notice
npm notice 📦  dice-chance@2.0.1
npm notice === Tarball Contents ===
npm notice 1.1kB   package.json
npm notice 48B     .babelrc
npm notice 58B     .flowconfig
npm notice 232B    .travis.yml
npm notice 48B     index.js
npm notice 1.1kB   LICENSE
npm notice 1.5kB   README.md
npm notice 112.4kB yarn.lock
npm notice 6.1kB   lib/__tests__/Analyzer-test.js
npm notice 2.8kB   lib/__tests__/DiceChance-test.js
npm notice 2.6kB   lib/__tests__/Parser-test.js
npm notice 2.0kB   lib/Analyzer.js
npm notice 1.4kB   lib/DiceChance.js
npm notice 1.3kB   lib/Parser.js
npm notice 949B    lib/Tokens.js
npm notice === Tarball Details ===
npm notice name:          dice-chance
npm notice version:       2.0.1
npm notice package size:  37.6 kB
npm notice unpacked size: 133.7 kB
npm notice shasum:        218703ab30ffad27bc76c1c9a6a2852838e0fb58
npm notice integrity:     sha512-gammoNvPgDcyd[...]BpngI1zA2X7dg==
npm notice total files:   15
npm notice
+ dice-chance@2.0.1

2018年10月7日星期日

把我的个人网站推倒重来(Part 4 - Responsive Image)

网站整体完成后,我就可以开始做各种小优化了。其中一个优化是使用 responsive image 来适应不同分辨率和不同像素密度的屏幕,用到的是 <img /> 新增的 srcsetsizes 属性以及新增的 <picture /> 元素。因为现在有多套新旧并存的 responsive image 方案,而且它们使用的属性存在重叠,所以要搞清楚到底这些属性如何运作,还是要动手实验。

sizes 属性

<img srcset="elva-fairy-320w.jpg 320w,
             elva-fairy-480w.jpg 480w,
             elva-fairy-800w.jpg 800w"
     sizes="(max-width: 320px) 280px,
            (max-width: 480px) 440px,
            800px"
     src="elva-fairy-800w.jpg" alt="Elva dressed as a fairy">

这是一段来自 MDN 的代码。虽然大家都是先写 srcset 再写 sizes,但其实更符合直觉的阅读顺序是先读 sizessizes 的值是一组类似 media query 的命令,它们描述了在什么情况下这个 <img /> 应该有多宽。拿上面这段代码举例:如果屏幕宽度是 320px 或以下,图片宽度为 280px;如果屏幕宽度是 480px 或以下(但 320px 以上),图片宽度为 440px;其它屏幕宽度,图片宽度默认为 800px。

这一切看起来都很简单,但这是因为我们只在讨论分辨率没在讨论像素密度。如果是 2 倍像素密度的 Retina Display,上述图片宽度计算是否保持不变?答案是跟 media query 一样保持不变。无论像素密度是多少,sizes 关注的都是 CSS 像素而不是物理像素。我觉得这个设计是合理的,因为在描述 <img /> 宽度时,我们的思维模式跟在写 CSS 时一样,所以应该使用 CSS 像素。

尽管在 MDN 的例子中 sizes 属性的取值都是固定值,但其实这里可以使用 calc() 表达式进行复杂的计算,如我的代码中就用到了 calc(100vw - 30px),意思是 100% 的 viewport 宽度减去屏幕两侧各 15pxmargin

srcset 属性

看完 sizes 接着看 srcset。在上面这段代码里,我们看到了一个神奇的单位叫做 w,这是指代图片文件的像素宽度。文件图片的像素宽度跟 <img /> 的 CSS 像素宽度不是 1:1 对应的吗?这需要看像素密度。如果 <img /> 的宽度是 100px,像素密度是 1 时最佳图片文件宽度是 100w;像素密度是 2 时最佳图片文件宽度是 200w;像素密度是 3 时最佳文件宽度是 300w;如此类推。

在上面这段代码中,srcset 描述了 3 个图片文件地址,它们的文件图片像素宽度分别是 320w480w800w。这也就是说,如果在一个 1000px 宽 1 像素密度的屏幕上,根据 sizes 这个 <img /> 的宽度应该是 800px,因此应该选择 800w 的图片文件地址;如果在一个 480px 宽 2 像素密度的屏幕上,根据 sizes 这个 <img /> 的宽度应该是 440px,但因为像素密度为 2 所以最佳图片文件宽度是 880w,由于找不到 880w 的图片文件地址所以选用略差一档的 800w 图片文件地址。

为什么 sizessrcset 这两个属性要如此设计呢?因为在之前的标准(如 CSS media query)里,我们需要在代码中描述如何根据两个变量(屏幕宽度和像素密度)来选择正确的图片文件地址,这个过程超级复杂,看这篇文章就能理解为什么这样的标准不好用。为了解决这个问题,新的标准让我们把这两件事情分离开来:sizes 决定图片在 CSS 布局中的大小,但跟像素密度无关;srcset 提供文件图片像素宽度,浏览器会自行根据 sizes 的结果和像素密度作出最佳选择,我们根本不需要知道像素密度。

(如果 <img /> 在布局中的大小永恒不变,可以不设置 sizes 属性,然后在 srcset 中使用 x 单位描述像素密度而不使用 w 单位。这时候 2x3x 可以对应不同的图片文件地址,浏览器会作出正确的选择。之所以我不选择这样做,是因为我的 <img /> 大小本身需要 responsive,所以必须用 sizes。因为 wx 这两种单位不能在同一个 <img /> 上混合使用,所以我用了 w。)

src 属性

看完 sizessrcset 这两个属性后,最后我们看 src 属性。这是给那些看不懂前两个属性的老浏览器看的,也就是默认的图片文件地址。

<picture /> 元素

上述 <img /> 元素属性能够实现同一张图片适应不同的屏幕尺寸和像素密度,但做不到根据屏幕尺寸现实不同的图片。我的网站首页布局本身是 responsive 的:如果屏幕宽度至少有 768px,使用左右两栏布局;否则使用一栏布局。

Screenshot 2018-09-30 15.21.45

左侧栏只显示我的个人照片,所以在能够使用两栏布局时我希望显示正方形(1:1)的剪裁。同样的剪裁显示在只能显示一栏的手机屏幕上就会显得很占地方,因此我需要换个剪裁方式(3:2)减少它占用的垂直高度,把更多的首屏垂直高度留给文本信息。

Screenshot 2018-09-30 15.22.05

为了实现根据屏幕尺寸使用不同的剪裁,我必须引入 <picture /> 元素然后在里面放入 <source /><img />

<picture>
  <source media="(max-width: 799px)" srcset="elva-480w-close-portrait.jpg">
  <source media="(min-width: 800px)" srcset="elva-800w.jpg">
  <img src="elva-800w.jpg" alt="Chris standing up holding his daughter Elva">
</picture>

这也是一段来自 MDN 的代码。浏览器在碰到 <picture /> 后,就开始按顺序看里面的 <source />。每个 <source /> 元素都有 media 属性,浏览器就如同执行 media query 一样来判断这个 media 属性的值是否通过,通过了就使用这个 <source /> 来显示图片,后面的子元素都会被忽略掉。如果所有 <source /> 都无法通过 media query 检测,最后那个 <img /> 就会用来显示。(不兼容 <picture /><source /> 的老浏览器只会显示 <img />。)

由于 <source /> 支持上述 <img /> 特有的 sizessrcset 属性,所以就算是放在 <source /> 中的图片也可以用上述方式支持不同的像素密度。考虑到大多数移动浏览器都会获得及时更新,能够支持 <picture /><source />,所以我选择了把正方形的剪裁当作默认剪裁放在 <img /> 里面,而针对小屏幕的剪裁放在 <source /> 里面。老旧的桌面浏览器如果打开我的网站首页,就算什么新元素和属性都不认识,至少能够根据 img.src 显示默认图片(像素密度为 1 的正方形剪裁)。

总结一下,如果需要针对不同的屏幕尺寸显示不同的图片(尤其是剪裁不一样的),必须使用 <picture /> 配合 <source /> 来选择正确的图片。一旦选定图片后,根据屏幕尺寸和像素密度设定图片尺寸只需要用到 sizessrcset 属性。

我的图片优化不仅仅用到了首页的个人照片上,也用到了项目页面的截图上。在图片之后,我们还有很多其它东西可以优化的,欢迎通过邮件RSS/Atom 订阅我的博客,继续关注后继文章。

2018年9月23日星期日

把我的个人网站推倒重来(Part 3 - 用 Netlify 做静态网站发布)

之前两篇文章讲述了我用 Harp 和 Bootstrap 搭建新版个人网站的过程,执行 harp compile 进行构建,输出的 www 目录就是我们想要的静态网站。我可以找个传统的静态网站 host,然后通过 FTP 这种古老的方式把文件上传上去。然而这真是我在第一篇文章中说到的一个痛点,我不希望通过 FTP 部署,最好是好像我熟悉的 Heroku 那样通过 GitHub 触发部署,我每次本地更新后执行一下 git push 就行。

Heroku

因为我已经熟悉 Heroku,所以我想到的第一个服务自然是 Heroku。Heroku 官方提供一个静态网站 buildpack,把 www 目录扔进去就可以了。当然这样还是不够简单,最好是把源代码扔进去,然后 buildpack 自动帮我调用 harp compile。曾经有人做过这样一个 Harp 专用 buildpack,可惜现在已经不维护了。基于 Heroku 官方的静态网站 buildpack 自己写一个 Harp 专用 buildpack 也不是很难,而且还能继承静态网站 buildpack 那一堆 header、redirect 等配置功能。

然而 Heroku 的免费版有两个问题:

  1. 应用会自动休眠——一段时间没有请求后它就会让你的服务进入睡眠状态,直到有新请求时再唤醒。要避免这个问题,需要每个月花 $7 来升级到业余爱好者版。有一个方法能够绕过这个问题,那就是在 Heroku 上加载某些用来监控服务可靠性的 add-on,因为这种 add-on 会定期访问你的网站检查可靠性,相当于帮你不停地阻止休眠。
  2. 不支持自定义域名 HTTP——同样是必须每个月花 $7 升级才能解决。绕过的办法也不是没有,那就是用 Cloudflare 做负载均衡器,然后用它免费提供的 HTTPS 服务。(Hue Explorer 就是用这个方法做 HTTPS 的。)引入 Cloudflare 的代价是,我必须把 DNS nameserver 迁到 Cloudflare,不能继续用 Google Domains 自带的 nameserver。

尽管每个月 $7 也不是很贵,但因为我知道我的个人网站很有可能部署后就几年都不碰一下,还要惦记着在 Heroku 上保持有效的付款方式好像有点麻烦,而一旦忘记了就可能导致网站下线。因此 Heroku 不是一个很好的解决方案。

因为我最近读完了《Working with Static Sites: Bringing the Power of Simplicity to Modern Sites》,所以就翻开了看看书中还推荐哪些静态网站的 host,然后就找到了以下两个服务。

Surge.sh

Surge.sh 超级简单,它甚至没有一个基于 web 的应用管理界面,它只提供一个命令行工具把你指定的目录发布到 CDN 上。没错,Surge.sh 提供的是非常直白的 CDN 服务,你提供一个要发布的目录,它就保证所有文件都能通过一个特定的域名在 CDN 上访问到。没有 Heroku 那么复杂的功能,连动态服务器都没有,一切都是静态的。

我安装了 Surge.sh 的命令行工具,把我老版本的个人网站部署上去了,然后发现它存在两个问题:

  1. 不支持自定义域名 HTTP——这跟 Heroku 一样,只是付费的专业版更贵,每个月要 $30。此外 Surge.sh 不像 Heroku 那样交钱了它就自动调用 LetsEncrypt 帮你认证域名然后申请证书,它需要你手动申请证书。我希望部署后永远不需要维护的,这跟我的理想有点差距。
  2. 每次都要重复上传所有文件——习惯了 git 只上传 delta 的速度,体验了几次 Surge.sh 整个目录重新上传的速度,然后就不想再用了。我的理想还是执行 git push 就能完成发布。(git 利用 rsync 实现 delta 上传,很早就有人提议 Surge.sh 用 rsync 但至今尚未实现。)

Surge.sh 有意思的地方是,所有的配置都在你上传的文件里面了。需要指定域名?写一个 CNAME 文件。需要配置重定向?写一个 ROUTE 文件。

Netlify

Netlify 给我的第一印象就是 Heroku 和 Surge.sh 中间的平衡点。它的服务性质跟 Surge.sh 一样,为静态网站提供 host,而不像 Heroku 那样可以用来 host 动态网站。然而它的管理界面更像是 Heroku,配置能够在 web 上面完成,既能拖放上传整个目录,也能关联 GitHub 在 git push 时触发部署。

git push 部署

在设置好 GitHub 关联后,我尝试用 Netlify 自动部署我现有的代码。Heroku 有 buildpack 的概念,可以在 buildpack 中执行很复杂的事情,例如说指定依赖于 Node 平台上的 Harp 然后执行 harp compile。因为 Netlify 没有 buildpack 的概念,只有一个简单的「build command」文本框,我一开始还以为它做不了很复杂的事情。在研究了一番文档和别人的开源项目后,我发现只需要在「build command」填入 npm install -g harp && harp compile 就可以了。因为 Netlify 的构建系统内置了常见的脚本语言平台,所以不需要 buildpack 这样复杂的概念,直接就有现成的 npm 可以调用。接着「publish directory」填入 www,输出的静态文件目录就会成为服务的根目录。

在 Netilfy 上成功部署一次 GitHub 代码后,接下来的就很简单了。它跟 Heroku 一样,每次我本地修改完源代码只需要 git push 就会触发它部署。因为它的部署过程比 Heroku 的 buildpack 简单,所以部署速度非常快。如果我留意到部署没有成功,我可以打开 Netlify 看日志,看看什么导致部署失败了。很多时候是我写的 Harp 页面有问题,导致 harp compile 执行出错,后来我养成了 git commit 前先调用 harp compile 验证的习惯,也就是没问题了。

HTTPS 证书

Netlify 可以配置自定义域名,而且免费支持 HTTPS。(当然也是调用 LetsEncrypt 颁发证书。)可能因为我在 Netlify 添加域名时还在用 Cloudflare,没有正规地把域名指向 Netlify 的地址,只是把流量倒过去了,所以导致证书申请卡住了。后来我把 DNS 指向 Netlify 地址了,然后找他们客服帮忙,很快就搞掂了证书的问题。

这时候我发现 Cloudflare 已经没有存在的意义了,就考虑把 Cloudflare 撤销掉,但在撤销之前还是做了几个比较。第一个比较是证书本身,Cloudflare 免费版让多个网站共用一张 LetsEncrypt 证书,然后通过 SNI 支持多域名。Netlify 专门为我的 catchen.me 申请一张独立的 LetsEncrypte 证书,然后用 SNI 在这张证书上添加子域名(其实只有 www.catchen.me 一个子域名)。尽管大多数用户并不会去留意证书的细节,但我自己一张证书这感觉就很爽。

CSS/JS 打包

在上一篇文章里我说到,我把 Bootstrap 和 jQuery 的 CSS/JS 文件都放到了我自己的部署目录当中去,这样浏览器就不需要连接不同的域名和 IP 来下载这些文件。Netlify 提供一组优化选项,帮我打包和压缩这些文件。因为 harp compile 并不对 CSS/JS 进行打包和压缩,所以我就选择了让 Netlify 进行优化。

这是 Netlify 比 Heroku 和 Surge.sh 要好的地方,照顾到了 Harp 这种不自带 Webpack 且不进行优化的构建流程。使用 Cloudflare 的话,它可以帮忙进行压缩,但没有打包的选项。同样是免费的 HTTPS,这方面 Netlify 比 Cloudflare 有优势。

CDN

尽管这些 CSS/JS 文件在部署时都存在于我网站的 /css//js/ 目录当中,但一旦启用 Netlify 打包功能这些文件就不会从我的域名进行加载,打包后的版本只存在于 Cloudfront 上面,也就是 AWS 的 CDN。我尝试拿它跟 Cloudflare 的 CDN 进行对比,发现 AWS 的节点数量略微比 Cloudflare 的多一些但也没有绝对的优势。

基于上述对比,我最终选择了使用 Netlify 来发布我新版的个人网站,并且不使用 Cloudflare 来做优化,全盘依赖于 Netlify 自身的优化。这一步做完之后,新版网站就重新上线了。接下来还有很多细节可以优化,如果你想要了解更多的话,欢迎通过邮件RSS/Atom 订阅我的博客。

2018年9月3日星期一

把我的个人网站推倒重来(Part 2 - 用 Bootstrap 做移动网页)

配置好 Harp 做静态网站构建后,就可以开始做网页了。上一个版本的个人网站样式是我自己设计的,当年用的还是 Macromedia/Adobe Fireworks,做出来一个 PNG 文件然后导出为不同的小图片。这次我也有考虑过要不要自己重新设计一个新的样式,但考虑到新设计不如解决其他几大问题重要,于是决定推迟样式设计。现在的计划是,先用 Bootstrap 解决绝大部分的问题,将来有时间重新设计样式了再做成 Bootstrap 主题。

导航栏

我的个人网站对组件的需求很简单:

  1. 要有一个导航栏,显示「首页」、「简历」、「项目」、「文章」。过去后面两项是「作品」和「更多」,我觉得改成新的名字会更贴近我现在能展示的内容。
  2. 要有一个语言切换菜单,能在英文和中文之间切换。过去我还区分繁体中文和简体中文,现在决定框架上要做到支持任意多的语言,但首要目标是支持英文和中文。

这两项需求对 Bootstrap 来说不是什么难事,直接扔一个 navbar 上去就解决问题了。首先把 4 大页面放在 navbar 上,然后再加一个选择语言的下拉菜单显示 2 种语言。因为 Bootstrap 内置了移动网页的适配,navbar 移动浏览器上会自动变成一个可缩放的菜单,完全不需要我做任何优化。

内容分栏

不同的页面有不同的分栏需求,首页左侧只显示大头照,简历页的每一项左侧时间右侧详情。Bootstrap 的 grid system 能够很好地解决我的问题。为了进一步优化不同尺寸屏幕上的体验,我又针对不同屏幕尺寸做了不同的布局,借助 Bootstrap 的 responsive breakpoint 这超级容易实现。

首先,分栏仅对宽度至少有 768px 的屏幕生效,这对应 Bootstrap 中 md (medium) 的定义。然后所有分栏的 CSS 类都会加上 md 限定条件,例如 col-md-4col-md-8。尺寸小于 md 定义的屏幕,统统只会看到一栏布局,首页就会把我的头像放上面,简历页也会把时间放在详情上面。

首页里面的「联系方式」用到了分栏内嵌分栏,这个分栏无论屏幕大小都会存在。我想要尽力保证美观的前提下不产生换行,然而每一栏都很窄,为此我要针对多种不同的屏幕尺寸按不同的比例来分栏了:col-lg-3 col-sm-4 col-5。这一行代码的意思是:这一栏在大屏幕时宽度为 3/12、中屏幕时为 4/12、默认(小屏幕)时为 5/12。(Bootstrap grid system 把屏幕分成 12 栏,所以分母永远是 12。)

字体大小

Bootstrap 默认的 h1h6 字体都挺大的。因为我用 h2h3 做小节标题,但又不希望出现巨型的文字,所以选择了把 h2h3 的尺寸调小。在 md 或以上屏幕上,因为存在分栏布局,内容条例已经很清晰,h2h3 大小可以很接近正文大小。在更小的屏幕上,我还是需要依赖它们来划分小节的,大小跟正文太过接近反而会把人弄晕。为此我特意写了个很小的 CSS 文件来调整字体大小:

h2 {
  font-size: 2em;
}
h3 {
  font-size: 1.5em;
}

@media (min-width: 768px) {
  h2 {
    font-size: 1.5em;
  }
  h3 {
    font-size: 1em;
  }
}

理想的做法是写 Sass 而非 CSS,然后用 @include media-breakpoint-up(md) 而非 @media (min-width: 768px),这样可以保证我的代码跟 Bootstrap 的代码同步。Harp 是支持编译 Sass 文件的,但它不支持 Bootstrap 所依赖的 Autoprefixer,不做这一步就意味着 -webkit- 这类 prefix 不会被自动加上,影响浏览器兼容性。我现在的选择是,用编译好的 Bootstrap CSS,然后我自己额外写的也是 CSS,只要我不手动更新 Bootstrap 版本就没有不同步的风险。

CSS/JS 文件来源

Bootstrap 所依赖于的 CSS/JS 文件可以不下载直接使用,这是 Bootstrap 官网提供的 CDN 地址

https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css
https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js
https://code.jquery.com/jquery-3.3.1.slim.min.js
https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js

观察一下这有什么问题?它们分别指向 3 个不同的域名啊,如果解释出来的 IP 不一样这可是 3 个独立的 HTTPS 连接啊,多浪费时间!我用 dig 命令查了一下,发现这 3 个域名确实指向不同的 IP,尽管非 Cloudflare 的那两个都指向了 hwcdn.net,但是是不同 IP。(我还特意查了一下这 hwcdn.net 是哪家 CDN 的,原来是一家叫做 Highwinds 的公司,现在已经被整合到 StackPath。这两个名字我之前都没听说过。)

为了解决这个问题,我把 Bootstrap 4.1.3 和 jQuery 3.3.1 下载下来了,然后放到我的本地目录中来使用。这样子所有 CSS/JS 文件都能够在同一个域名中获取,无需建立多个 HTTPS 连接浪费多重 RTT。

CSS/JS 文件体积

使用 Bootstrap 的话,默认需要下载的 CSS/JS 体积太大了,有些功能完全用不到但也需要下载。

最容易去掉的是 Popper.js,它存在于一个独立的 JS 文件中,直接不引用就行。根据 Bootstrap 的官方文档,只要用了 dropdown 就需要使用 Popper.js。但仔细看 Boostrap 源代码就会发现,在 navbar 中使用 dropdown 是不需要依赖于 Popper.js 的。(可能因为 navbar 的 dropdown 永远只能朝下展开,没有任何选项可言。)干掉 Popper.js 能省 6.9kb (Brotli 压缩后体积)。

最应该去掉的是 Bootstrap CSS 内众多我没用到的模块,但这又回到了我们之前的那个问题——如果我让 Harp 来编译 Bootstrap,我就失去了 Autoprefixer。我需要解决对 Autoprefixer 的依赖,然后才能让 Harp 编译 Bootstrap,接着我就可以把我没有用到的 Bootstrap 模块全部干掉了。这项优化留待我将来做吧。

折腾完 Bootstrap,我的网站模板也就没问题了,内容可以慢慢填充。接下来我需要解决静态网站发布的问题了,因为网站做好了总需要找个地方放啊。如果你想要继续跟着我一起折腾的话,欢迎通过邮件RSS/Atom 订阅我的博客。

2018年8月31日星期五

把我的个人网站推倒重来(Part 1 - 用 Harp 做模板引擎)

根据模板和数据生成静态网站的框架有很多,例如说 HarpJekyllHugo 等等。我对比了一下,最终选择了用 Harp,因为它是用 JavaScript 写的,如果我真的需要做什么改动我可以轻易地去改它的源代码。用 Harp 的坏处也很明显,这个项目在 GitHub 的源代码上已经很久没更新,搞不好将来不再有人维护。

安装 Harp 和用 Harp 编译生成静态页面很容易。因为 Harp 是「convention over configuration」的框架,所以每个页面在使用数据时会优先在里自己目录里的 _data.json 文件,然后看项目根目录里的 harp.json。然而有一个问题是 Harp 没解决的:本地化(多国语言文字)。

本地化

我用每种语言一个目录的方式解决本地化问题:

public/
  ├ _data.json
  ├ index.jade
  ├ _partials/
  │   ├ index.jade
  │   └ resume.jade
  ├ en/
  │   ├ _data.json
  │   ├ index.jade
  │   └ resume.jade
  └ zh/
      ├ _data.json
      ├ index.jade
      └ resume.jade

无论是哪个语言的 index.jade,调用的代码都是 public/_partials/index.jade,只不过把自己当前所拥有的 _data 传过去。无论是哪个语言的哪个文件,都调用 public/_partials/ 下的同名文件,因此所有这些特定语言特定文件都只有一行相同的代码

至于背后实际干活的那个 public/_partials/ 下的文件,它需要同时看 public/_data.jsonpublic/{locale}/_data.json 来进行渲染:前者为它提供语言无关的数据,后者为它提供语言相关的数据。

一开始的时候我严格执行 public/_data.json 只放语言无关数据,后来我发现这样编辑起来很麻烦,因为一个模板往往同时涉及两种数据,我需要修改两个 _data.json 文件。于是我把英文数据和语言无关数据都放到了 public/_data.json 里面,写文档时专注于写英文版。英文版完成后再翻译为其他语言,这时候 public/{locale}/_data.json 里的翻译就会覆盖 public/_data.json 中的同名数据,语言无关的数据自然不会被覆盖。

为了实现「覆盖」这一项功能,我还专门实现了一个 deepCopy 函数用来深复制 JSON 数据。我希望 Harp 能够内置这个功能:_data.json 从文件所在目录开始层层往上覆盖,一直覆盖到 harp.json 为止。在 Harp 支持这个功能之前,我只能自己先实现一个版本来方便我做本地化。

调试技巧

有时候我们实在想看一下 Harp 可访问的数据结构。我们知道 public 对象是任何页面都能访问的,public/ 目录下每一个子目录都会产生一个同名子对象,每一个文件的文件名会以字符串形式出现在对应子目录对象的 _content 中,每一个 _data.json 文件的内容都会展开在对应子目录对象的 _data 中。说这么多,还不如直接把整个 public 打印出来看看!

为此我专门做了一个 debug.jade 的 partial,需要打印 Harp 运行时变量的话就调用一下 != partial('debug', { data: public }),变量立即从 JavaScript 中打印出来。(其中 public 可以被替换为任何我想要查看的变量。)

关于 Harp 的部分到此结束,虽然我还没有把所有改重写的页面都写完,但我觉得 Harp 应用上的问题都已经解决完了。在下一篇文章里,我开始要解决页面布局问题了,如果你喜欢跟着我一起折腾的话,欢迎通过邮件RSS/Atom 订阅我的博客。