2019年9月24日星期二

如何让 MailChimp 订阅对话框在点击时弹出

问题描述

我的博客提供 MailChimp 订阅,大家点击订阅链接后需要转跳到 MailChimp 的页面输入 email。MailChimp 官方提供直接嵌入在网页内的订阅对话框,我希望读者在博客上点击订阅链接后直接打开对话框,让订阅流程变得更顺畅,这样可能能够提高订阅率。(为了保持兼容性,如果读者在 RSS 阅读器或邮件内点击订阅链接,则还是正常打开订阅页面。)

问题在于 MailChimp 官方不提供订阅对话框通过点击触发的选项。对话框的触发条件只有简单的几种:页面加载后弹出、用户滚动到页面底部时弹出……(可能 MailChimp 是设计给非程序员用的,所以不提供任何需要写点代码才能完成的整合,只提供包装好简单易用的方案。)为此,我需要修改 MailChimp 官方提供的对话框嵌入代码,使得它能在订阅链接点击时触发。

解决方案

MailChimp 提供的嵌入代码包含两个 <script> 标签。第一个 <script> 标签不需要做改动,第二个 <script> 标签需要把内含的代码需要嵌入到一个函数中去,然后就可以在链接的点击事件里通过调用这个函数触发订阅对话框了。以下是参考代码:

在上述代码中,我没有改动第一个<script> 标签,第二个<script> 标签里面有一个函数,第三个 <script> 标签对页面上的订阅链接添加点击事件响应函数并在那里调用第二个 <script> 标签里定义的函数。

第二个 <script> 标签里定义的函数,包含了 MailChimp 官方提供的代码中的 window.dojoRequire(...) 这一行。如果你要使用我的代码,你必须把这一行替换成 MailChimp 给你的同一行代码,然后才会显示你的对话框而不是我的对话框。

在这一行前面,我插入了两行代码用来删除 MailChimp 会插入的两个 cookie:一个是在用户关闭对话框后加入,另一个是在用户订阅后加入。只要有这两个 cookie 中的一个,对话框就不再显示。这对页面打开就触发的订阅对话框来说是合理的,谁也不希望每次打开页面都会看到对话框弹出。但如果用户主动点击链接,则应该总是打开对话框。为此我们要删除上述两个 cookie,保证对话框总是能打开。

第三个 <script> 标签是针对我的博客定制的,并不适用于其它人。我在我的博客页面内寻找所有指向我 MailChimp 订阅页面的链接,然后绑定这些链接的点击事件。用户点击链接的话,就调用第二给 <script> 标签内的函数,打开对话框,同时取消正常链接点击带来的页面转跳。

如果你想测试一下这项功能,请点击邮件订阅链接。如果你是在博客上阅读本文的话,应该会看到对话框。对话框打开后,记得输入 email 然后订阅哦!

2019年9月14日星期六

Career Coaching 名额报满

Career Coaching 名额报满

我从 2018 年 4 月开始用 Patreon,一开始就设立了两档服务,包括 $100 一个小时的 career coaching 服务。我在 2018 年 6 月有了第一个学生,接下来花了一年多的时间,到 2019 年 9 月才把我的五个学生名额招满。我在 9 月 10 日晚上发了一条 tweet,说我又开了五个新的名额,只是打折少了,价格提高到 $150 一个小时。(我在知乎上也发了同一条消息。)

完全出乎我意料之外的是,在短短的 24 个小时之内,这五个名额就被报满了!这真的是让我非常的震惊,因为上一批的五个名额花了一年多才被报满,就算用户增长存在加速度,也应该要花好几个月才会被报满啊。我完全没有预料到用户增长的加速度会如何巨大!

超级感谢大家对我 career coaching 项目的支持,尤其是学生们对我的支持,这包括想报名但这次没有抢到名额的学生们。没有大家的支持,就没有这个项目的快速成长。在这个项目上,我有机会接触到大家在 career 不同阶段遇到的问题,然后尝试为跟大家一起解决这些问题,这其实是非常有意思。

接下来我会花一段时间来观察迅速增加无名学生对我时间开销的影响,然后再决定是否能够开放更多名额以及开放多少名额。如果你想报名但没抢到名额,欢迎你到 Patreon 上来 follow 我,保证我开放新名额是你能够第一时间获取到资讯。此外,通过邮件RSS/Atom 订阅我的博客也能收到消息。

2019年9月13日星期五

博客文章阅读分析

最近在和朋友研究做什么样的 podcast(纯音频)节目内容合适,于是分析了一下自己曾经写过的内容哪些比较热门。以下是对我博客园文章的分析,之所以用博客园,是因为那里的人气最旺,所以统计数字更可靠。


首先我们看一下阅读数量的前十:

我们可以粗略看看这返影读者的什么倾向:首先,读者都比较关心跟自己职业发展相关的话题,所以面试和职业发展路线的文章能够排到前面,擦边相关的英语也正好能上榜。其实,读者对大公司比较好奇,所以头四篇都是讲不同公司面试风格和体验的。最后,科普型的技术内容也偶尔能吸引大量阅读。


接下来我们再看看评论数量的前十:

这跟阅读数量的前十有 40% 的重合,意味着这两者存在一定的相关性。从标题上来看,能够刺激用户评论的往往是具备争议性的话题:要么我直接抛出一个非常逆主流的观点,要么我提一个宽广的问题。这两者都能很好地刺激读者通过评论发表他们自己的观点,前者会带出主流观点来和我争论,后者会给每个人空间来陈述他们独有的经历。总的来说,其实很多读者是有自己的观点的,文章更多是设立一个巨大的舞台,让读者觉得自己的观点是相关的,于是上来展现自己的观点。


综上所述,要吸引读者阅读和评论,内容最好能覆盖到以下一个或多个要素:

  • 跟读者职业发展相关
  • 满足读者对行业(尤其是大公司)的好奇心
  • 科普型的信息
  • 逆主流(contrarian)的观点
  • 广阔的话题

这会成为接下来我们研究 podcast 节目方向的依据。如果你好奇我们在做什么 podcast,或想在我们发布后第一时间知道,欢迎通过邮件RSS/Atom 订阅我的博客。

2019年9月12日星期四

《牛油果烤面包》播客开播!

我跟朋友做了一个播客,名叫《牛油果烤面包》。我们两个主持人都是湾区科技行业工作者,我们喜欢在一起聊聊现在的行业趋势,于是我们开了一个播客跟大家一起聊。

我们的第一期节目趁着 Apple Special Event 发布,聊聊我们对 Apple 新品的看法,顺便蹭个热点。以下是我们这一期节目,欢迎直接点击收听。如果你喜欢的话,请记得订阅哦,我们下周就会发布第二期节目。

如果你使用 Spotify,也可以在 Spotify 内收听,这样会更方便你订阅及在通勤时收听。如果你使用其它 podcast apps,你可以直接通过《牛油果烤面包》的 RSS 进行订阅,主流的 podcast apps 都支持手工添加 RSS 进行订阅。(此时此刻,我们的节目还不能在各大 podcast 平台内搜索到,因为这些平台的发布和审核需要时间。)


说一下我们这个《牛油果烤面包》这个节目背后的故事吧,首先说说这个名字是怎么来的。我们昨天刚刚录制了第一期节目其实不是原计划的第一期话题,而是我突发选择的 Apple Special Event 蹭热点节目。我们在吃早餐时还没定下来名字,于是我朋友就说:「既然要讲 Apple,我们就学习 Apple 当初命名的方式来为我们的节目命名吧。你刚才点了什么?」我刚刚点了一个 advocado toast,于是我们就有了《牛油果烤面包》这样的名字。

我们在订好名字之后,我就决定要搞一个 advocado toast 的封面。我有两个想法,一个是拍一张 advocado toast 的照片,另一个是画一个 advocado toast 样子的图标。碰巧我今天冰箱里还真有 advocado,于是我决定早餐就做一份 advocado toast 然后拍下来,这就成了我们现在封面照片。如果你要问照片里的 advocado toast 去哪里了,我会指着我的肚子说「在这里」。

为了做这个节目,我们之前做了一些研究,考察哪个播客平台更好。因为我们不确定这个节目能做多大,只是想要低成本地快速启动,所以我们选择了最近刚刚被 Spotify 收购了的 Anchor。我自己比较喜欢 Anchor 的理念:现在的云存储价格已经如此之低,每个月收你 $10 甚至更多来帮你存储和发布音频文件其实很不合理,因为这个价格远超出实际的成本,所以应该提供一个免费平台给大家发布播客,然后再通过广告或其它方式进行营收和分成。

说起来 Anchor 这个平台有点像是专门为 podcast 打造的 YouTube,首先为大家提供免费的工具和存储空间,鼓励大家创造内容,然后等大家有流量后再盈利和分成。跟 YouTube 起步时不一样的是,podcast 市场已经有非常多固定的玩法,例如主流的 podcast apps 都有自己的内容平台,你自己默默发布一个新的节目别人都不会收录自然也就没有听众。Anchor 为了解决这个问题,提供自动发布到各个平台的功能。这个功能好不好用暂时还不知道,因为各个平台都要独立审核新节目,尽管 Anchor 已经把我的节目推给这些平台了,但现在还没有审核结果也不知道是否会出问题。(由于此时此刻除 Spotify 外各大平台还没有收录我们的节目,所以还是要麻烦大家手动订阅 RSS。)

Anchor 的坏处是不支持团队播客,也就是说不能几个个人帐号共同管理一个节目。Anchor 必须是一个帐号就一个节目,为此我们专门注册了一个共用的帐号来管理这个节目。如果我们以后要开更多节目的话,我们必须注册更多帐号。(希望到后 Anchor 已经支持一个帐号管理多个节目。)

当然我们先不管那么多啦,任何项目要上马都要趁着初期有势头赶紧上,希望我们能够趁着第一期节目把我们录制、编辑和发布的流程定下来,之后我们就可以重复这个流程可持续地发布节目了。等节目做得越来越好,听众越来越多了,再想办法优化流程,到时候再重新评估 Anchor 是不是最适合的平台吧。


虽然我们的节目发布在 Anchor 上,也有自己的 RSS。但我也不介意在自己的博客 RSS 上带上同样的音频内容,希望这样能让更多人接触到这期节目,希望更多人因此而选择在自己常用的 podcast app 内订阅我们的节目。如果你想要订阅我的博客本身,你可以选择通过邮件RSS/Atom 进行订阅。

2019年9月9日星期一

Echo Auto 试用体验

Echo Auto 暂时还不完全公开售卖,需要向 Amazon 申请邀请,被选中后才能购买。我 2018 年 9 月申请的,等待了差不多一年,上个月终于收到邀请了。Echo Auto 零售价为 $50,我们这种早期用户可以半价购买。

Echo Auto 的安装非常简单。它提供了夹住空调出风口的架子,其作用跟同类型的手机架差不多,直接插进空调出风口夹住就行。架子通过磁贴吸住 Android Auto,然后接上 Micro-USB 的电源就能启动了。(Echo Auto 附送上述架子、Micro-USB 电源线和一个双 USB 的车载电源。)

Echo Auto 启动后就可以跟手机上的 Alexa app进行配对了。我在这个流程中折腾了两次,第三次才搞好了。第一次,是搜了很久也没搜到 Echo Auto 这个设备。第二次,连上后 Echo Auto 会调用你的手机播放一个欢迎的音乐,这个音乐应该通过蓝牙在车载音响中播放出来,Echo Auto 听到后就能计算播放声音的延迟。因为当时我车载音响的音量不大,Echo Auto 没听清,就卡住了。第三次,我把音响调到最大,把自己吓了一跳,但终于配对通过了。

配对后 Echo Auto 会出现在 Alexa app 的 Echo 设备列表上,有意思的是 Echo Auto 被分类到「附件」那里去了,而不认为只一个独立的 Echo 设备。在使用了一段时间后,我才明白到为什么这样分类。

Echo Auto 试用体验

在汽车启动后,Echo Auto 会启动并自动通过蓝牙连接手机,之后就可以如同操作其它 Echo 设备一样用「Alexa」唤醒它并进行语音对话。跟 CarPlay 和 Android Auto 类似,这些语音对话其实都是在手机上进行处理的,需要使用网络时也都是使用手机的网络,所以本质上 Echo Auto 只是一个更好的麦克风:第一,它可以在你的手机没打开 Alexa app 的时候通过「Alexa」来唤醒;第二,它比手机的麦克风更适用于收听来自车内的语音命令。

Echo Auto 接收到指令后,后台实际处理的还是 Alexa app,本质上跟打开 Alexa app 然后进入语音界面再发布命令没有区别,但省去了上述手动步骤。在对着 Echo Auto 呼叫「Alexa」后,手机前台的 Google Maps 可以继续导航,手机后台的 Music app 可以继续播放音乐,Alexa app 会降低音乐的音量以便收听指令并播放回复。Echo Auto 的优势就在于这两个地方:第一,不需要用点击屏幕,增加驾驶安全;第二,不影响手机上正在运作的 app。


我在使用了几周之后,发现 Echo Auto 拥有一些比其它 Echo 设备要好的使用场景,但使用频率还是不够频繁。

第一类使用场景是「出门后才想起来忘记要做的事情」,例如说「灯都关了吗?」或者是「让 Roomba 吸尘吧」。这些事情可以对应的 app 来做,不过有 Echo Auto 后我喊一句就可以了,不需要在屏幕上操作。

第二类使用场景是控制音乐、podcast 和 audiobook 的播放。因为我的车没有 CarPlay 或 Android Auto,所以我通常要在开车前在手机上选好播什么。有了 Echo Auto 之后我就可以喊它来帮我了。这意味着 Echo Auto 跟 CarPlay 和 Android Auto 是直接竞争关系,如果我换一辆新车,我就不需要 Echo Auto 了。(现在也有在老车上加装 CarPlay 和 Android Auto 的模块,但价格比 Echo Auto 贵很多。)

第三类使用场景是控制导航,准确来说是查找新的导航目标。Echo Auto 可以让 Alexa app 去搜索一个新的地点,然后在 Google Maps 里面打开这个地点,但我还是需要点一下才能开始导航到这个地点。我不能使用 Echo Auto 来调整当前的导航,例如帮我在路线上搜索一个加油站,这还是要用 Google Maps 内置的 Assistant 来完成。

综上所述,Echo Auto 如果 $25 卖给那些没有 CarPlay 和 Android Auto 的车主,我觉得还是值得的。($50 就有点贵了。)现在有一个几年的窗口给 Echo Auto 发展,如果找不到跟 CarPlay 和 Android Auto 竞争的突破口,等新车都装上了 CarPlay 和 Android Auto,那 Echo Auto 就没有存在的意义了。


如果你对同类的科技产品感兴趣的话,欢迎通过邮件RSS/Atom 订阅我的博客。如果你想支持我写作的话,欢迎到 Patreon 付费,每个月 $1 就能让你提前阅读我的文章。

2019年9月7日星期六

推送的四种类型:中断、提醒、任务、垃圾

iOS push notification

一年之前我曾经在英文博客发表了一篇《Divide Notifications into Interrupt, Reminder and Backlog》,现在我觉得可以补充一下并翻译为中文版。我实际需要解决的问题是信息过载,尤其是手机上各种应用推送而来的信息。很多这些推送信息其实并不需要我实时处理,但如果它们展现的方式都如此类似的话,我就会习惯性地实时处理它们,例如说看看不重要的邮件和 Facebook 更新,结果浪费很多时间。

为了解决这个问题,我把所有的推送系统化地划分为 4 个类型:

  • 中断:我不能错过这种类型的推送,看到后可能会中断我手头上正在做的事情。这种类型的推送应该是极其罕见的。举例:关键人物发送给我的消息。
  • 提醒:在恰当的时机(例如恰当的时间和恰当的地点)提醒我做某件事情。举例:日历和待办事项的提醒。
  • 任务:可以积压的任务,不需要立即处理,甚至长时间不处理也不会有问题。举例:Facebook 内提醒和绝大多数 Gmail 的邮件。
  • 垃圾:广告之类的垃圾信息,最好完全不要出现。举例:国内应用很常见的各种活动和推广信息。

因为在 iOS 里面只能针对 app 来设置推送,所以不可能对所有的推送进行完美的分类,只可能针对 app 来做分类。我的目标是让这 4 类推送的体验产生明显地有所差别,使得我能习惯性地针对不同的推送做不同的事情:

  • 中断:iPhone 和 Apple Watch 上有声音,静音情况下有震动,未处理的话可以在应用图标上显示未读数量。
  • 提醒:iPhone 和 Apple Watch 上有声音,提醒时需要提供「snooze」按钮让我推延这个提醒。
  • 任务:iPhone 上没有声音,静音情况下不震动,不在 Apple Watch 上出现,不在应用图标上显示未读数量。
  • 垃圾:禁用推送,不显示未读数量。

做了这样的设置后,我能够减少我浪费在手机上的时间。

很多推送由于没有声音没有震动而不再可能获得我的实时关注,只有在我闲下来的时候我才会去看有什么。这对于我的一对一面谈非常有帮助,我不再会因为推送而分神,我可以专心跟别人聊完了,再回头看我收到了什么推送。如果我在一对一面谈过程中知道有新的推送,就算我不看,我也会分神去想一下这可能是什么推送,那对方的体验就很不好了。

这些同送同时由于关闭了未读数量,我不会再浪费时间去查那些有未读数量的应用,只有我真的觉得有需要从应用里面获取信息时我才会去打开它,否则我就忙别的事情去了。举例来说,我不再会因为 Facebook 或 Gmail 上显示的数字而想要打开应用,因为这个数字消失了,心里就变得平静很多。

以上是我应对信息过载的方案,如果你有不一样的方案,或者遇到了类似但又不完全一样的问题,欢迎在评论区进行讨论。如果你喜欢我写的文章,欢迎你在 Patreon 上付费支持我。如果你对我的博客感兴趣的话,欢迎通过邮件RSS/Atom 进行订阅。

2019年9月5日星期四

薅 Amazon No-Rush 羊毛

首先让我来简单介绍一下 Amazon No-Rush 是如何操作的,然后再介绍如何薅羊毛。

假设你在 Amazon 购买的商品符合免费当天、一天或两天送达,在结帐时 Amazon 会让你选择送达速度。你可以选择上述免费快速送达,但你也可以选择一个叫做 No-Rush 的选项,意思是主动放弃免费快速送达,改为需要一周时间的慢速送达。为了鼓励大家使用慢速送达,No-Rush 的选项伴随着奖励机制。只要你选择了 No-Rush,在商品发货时你可以得到对应的奖励,例如 $1 的数字商品(Kindle 书、电影等)折扣或 $5 Prime Now 折扣等。(具体你能得到哪种奖励,你需要在结帐流程时看看,每个人能得到的类型不一样。)

如果你要在 Amazon 购买多件商品,并且:

  1. 在结帐时 Amazon 显示这些商品能一起送,也就是一个 package 而不是分开多个 package。
  2. 其中至少一件商品就算单独购买也能符合免费快速送达。

这时候你可以一件一件商品分开结帐,除了上述那一件商品外其它商品都选择 No-Rush。这时候你会在 Amazon 产生多张订单,然而 Amazon 系统内部会对此进行优化,把所有的订单合并到一个 package 内,降低 Amazon 的送货成本。这个 package 内有一个商品需要快速送达,所以整个 package 会被快速送达,但你能得到其它商品的 No-Rush 奖励。

举个例子,假设你要买三件商品,它们都符合「超过 $35 就能 Free Prime Same-Day Delivery」,它们的价格分别是 $35、$25、$10。如果你可以使用 Free Prime Same-Day Delivery 购买 $35 那一件,然后剩下两件分作两张 No-Rush 的订单处理,这三件商品还是会在同一个 package 里面同一天送达,但你能赚到两个 No-Rush 奖励。

如果你对我的博客感兴趣的话,欢迎通过邮件RSS/Atom 进行订阅。如果你对这个话题有什么问题或者观点的话,欢迎对本文发表评论。

2019年8月30日星期五

2019 年邀请链接(referral links)

一年时间过去了,又到了无心工作只想玩乐的夏季。跟去年一样,针对我用过且觉得好用的服务,我提供它们的 referral link,如果你使用它们注册的话,你和我都能获得到一些好处。我会简单介绍每项服务是做什么的以及为何我觉得好用,你可以看看是否符合自己需要。

我今年推荐的服务包括:

  1. Robinhood
  2. American Express Platinum Card
  3. Stitch Fix
  4. Wealthfront

以下是它们的详细介绍。

Robinhood

Robinhood 是一个免费交易股票的平台,跟其它老牌股票交易平台最大的不同就是完全没有手续费!(此前以低手续费著称的 Charles Schwab 每笔交易收 $4.95 的手续费。)这也正是我喜欢它的地方。

我主要的投资手段是基金,股票只是拿来练手的,所以只放了几千块钱在 Robinhood 里面。我不希望手续费影响我是否做一笔交易,同时我的单子不会大到能够完全无视交易费,所以我选择使用 Robinhood。

如果你使用我的邀请链接的话:

  • 你得到的好处:免费获得一股随机的股票,价值在 $2.50 到 $200 之间
  • 我得到的好处:免费获得一股随机的股票。累积总额达到 $500 后我将不能再获得免费股票。

邀请链接:http://chen.cat/robinhood-invite

American Express Platinum Card

Amex 白金卡的好处:

  • 酒店和机票 5 倍积分。(酒店必须在 Amex Travel 上订,机票必须在 Amex Travel 或航空公司官网订。)
  • 免费使用机场贵宾室,包括 Amex 自己的贵宾室及任何接受 Priority Pass 的贵宾室,且可以免费携带两名亲友。
  • 每年送 $200 用于航空公司小额消费。(不能用于机票,只能用于各种服务消费或各式各样的 fee。)
  • 每年送 $200 用于 Uber 及 Uber Eats,每个月 $15,十二月额外多 $20。
  • 每年送 $100 用于 Saks Fifth Avenue,上半年下半年各 $50。
  • 每五年送 $100 用于 Global Entry 或 $85 用于 TSA Pre-Check。

Amex 白金卡的坏处也很明显,就是 $550 的年费。不过我们算一下,如果上述免费赠送的金额都花光的话,这 $550 差不多也就回来了。之后如果多出差多旅游的话,机场贵宾室和 5 倍积分都是额外的好处。

如果你使用我的邀请链接的话:

  • 你得到的好处:没有。
  • 我得到的好处:在你成功申请信用卡后,我能获得 $100。

邀请链接:https://chen.cat/american-express-invite (这个邀请链接会显示所有的 Amex 卡,打开后你需要手动选择白金卡,或者是其它你想要申请的卡。)

Stitch Fix

Stitch Fix 是一个帮你搭配衣服的服务。注册时它会让你填写众多衣服尺码和喜好的信息,多到简直让人想要放弃注册。然后它会根据这些信息帮你挑选 5 件单品并邮寄给你。(在它帮你挑选前,你可以留言说明一下有没有什么你特别想要的。)

在收到包裹后,你可以试穿然后决定 5 件中哪些你想要留下哪些你想要退回去。如果你 5 件都要了,全单 7.5 折;如果你 5 件都不要,你需要给 $20 的最低消费;否则你只需要对你要的那几件买单。(Stitch Fix 有一项服务叫做 Style Pass,一次性给 $50,购买一整年的服务。在这一年里,每个包裹不再有 $20 的最低消费,全年一次性的 $50 最低消费可以在任意多个包裹里抵消掉。)

我今年买了 Style Pass,然后选择了让他们每个月给我寄一个包裹。有时候包裹里没有任何一件是我想要的,但也没所谓,最重要是节省了我去逛街买衣服的时间,我在家里试穿就可以了。这是我喜欢 Stitch Fix 的原因。

如果你使用我的邀请链接的话:

  • 你得到的好处:$25,用于首次消费。
  • 我得到的好处:$25,用于下次消费。

邀请链接:http://chen.cat/catchen-stitchfix-invite

Wealthfront

Wealthfront 提供投资账户和储蓄账户两种功能:

  • 投资账户是一个 robo adviser,你把钱放进去,它帮你管理 portfolio,买进股票、债券等 ETF (Exchange-Traded Fund)。具体回报率跟你选择的风险等级相关,风险系数越高股票占 portfolio 的比例越大。
  • 储蓄账户也就是 savings account,提供超高的利率(此时此刻 APR 是 2.32%)。必须至少投入 $5000 注册投资帐号后才能使用储蓄帐号。

Wealthfront 的投资帐号有管理费但储蓄帐号没有。投资帐号中的资产头 $10k 是免管理费的,之后是每年 0.25%。也就是说,如果有 $20k 资产在里面,头 $10k 免费然后之后的 $10k 每年收 $25($10k * 0.25%),然后分摊到 12 个月来收,每个月 $2 多。

我用 Wealthfront 的原因是它储蓄帐号的利率足够高(虽然此时此刻不是市面上最高的)。考虑到现在股市可能在高位,我个人并没有把太多钱存放在投资帐号里,现在更多的是把闲钱放到储蓄帐号里。如果哪一天股市大跌,我就可以在 Wealthfront 里面迅速把储蓄帐号的钱挪进投资帐号,趁低买入。

如果你使用我的邀请链接的话:

  • 你得到的好处:免管理费资产额度增加 $5k,也就是说注册帐号就有 $15k 免管理费资产额度。
  • 我得到的好处:免管理费资产额度增加 $5k。

邀请链接:http://chen.cat/wealthfront-invite

最后,如果你对我的博客感兴趣的话,欢迎通过邮件RSS/Atom 进行订阅。如果你对这个话题有什么问题或者观点的话,欢迎对本文发表评论。

2019年6月14日星期五

使用 AppleScript 批量发送带附件的邮件

最近某人要代表学校批量发送邮件欢迎新生,而且有几个特殊的要求:

  1. 邮件必须每个人独立发一封,因为给每个人的邮件要正确的称呼这个人的名字。因此邮件必须批量发送,而不能够以 bcc 的形式一封邮件发给所有人。
  2. 邮件需要带上附件。
  3. 邮件没办法提供「退订」链接。这导致我们不能够使用 MailChimp,因为 MailChimp 根据法律要求邮件列表必须提供退订链接。

为此我想到了用 AppleScript 来控制 Mac 上的 Mail 来批量发送邮件。搜索一番后,写出如下的 AppleScript 来:

在这个 AppleScript 里,我通过 theRecipients 提供一个姓名及邮件的列表,然后打开系统对话框选择一个用作附件的邮件,接着利用循环逐一发送邮件。代码里面最 tricky 的一行是 delay 1,如果没有这一行代码附件会添加失败,但邮件文本会被发送出去。我也无法解释为什么会这样子,使用 delay 1 是我从网上搜索回来的解决方案,如果你知道为什么要这样做,请在评论告诉我。

最后,现在 AppleScript 的文档实在难找。例如说我想找 Mail 支持的所有 AppleScript 操作现在已经找不到了,Apple 网站上一些原有的 AppleScript 文档链接已经失效。现在能做的也就是看着别人过去留下的 AppleScript 自己慢慢摸索。如果你有更好的 AppleScript 文档,请在评论分享一下。

2019年6月5日星期三

Patreon 付费读者交流群

如果你是我的 Patreon 付费读者,你现在可以加入付费读者专属的 Discord 交流群。加入办法很简单:首先,如果你还没有 Discord 帐号的话可以先注册一个;然后,你可以在 Patreon 的 App 设置页关联你的 Discord 帐号。接下来 Patreon 会自动利用 Discord bot 把你添加到我的 Discord 服务器及对应的付费读者专属群。(这是此功能的 Patreon 官方帮助。)

如果你还不是我的付费读者,你可以使用邀请连接加入我的 Discord 服务器。相对于非付费读者来说,付费读者享受以下两项福利:

  1. 付费读者能够加入到我服务器上的 #early-reader 群,我会在上面跟大家讨论任何跟我文章相关的问题。
  2. 付费读者拥有高亮的用户名,在我服务器上的开放群拥有更高的可识别度。

如果你现在还不是我的付费读者,欢迎通过我的 Patreon 成为付费读者。现在付费读者有两档:

  • Early Reader: 每个月 $1,能够比其它人提前一个星期读到我发布的文章。如果你喜欢我写的文章,这是支持我写作的最佳途径。
  • Career Coaching: 每个月 $100,我每两周跟你面谈 30 分钟(面对面或视频),对你的职业成长进行辅导。如果你是从事互联网和科技相关的工作,或者你计划毕业后从事相关工作,我们可以试着聊一下看看我对你的职业成长是否能够提供帮助。

最后,如果你是非美国用户,觉得 Patreon 付费不方便(例如说不接受你的信用卡或你没有美元信用卡),请在评论留言告诉我,我会想办法提供其它付费方式。感谢大家对我一直以来的支持!

2019年4月19日星期五

如何重置 macOS 的蓝牙模块

问题现象

我买了新的 Bose QC35 II 主动降噪耳机,买的原因是我老的 QC25 不支持蓝牙且磨损到一定程度了。新耳机到手后,跟 iPhone 配对没问题,但跟我的一台 MacBook Pro 就是无论如何也配对不上。通过跟另外一台 MacBook Pro 配对,证明问题发生在这台 MacBook Pro,而 Bose QC35 本身没问题。

无法配对的具体现象是,在 macOS 设置的蓝牙面板中无论如何也搜索不到 Bose QC35。(而且也搜索不到周边的其他蓝牙设备。但 Apple 自己的蓝牙键盘鼠标之前能正常配对,因为那是通过 Lightning 线配对的。)我尝试过重启 macOS,但依然搜索不到任何蓝牙设备。

解决方案

解决方案就是重置 macOS 的蓝牙。我搜索后发现,老的 macOS(准确来说还是 Mac OS X)需要删除 Bluetooth.plist 来重置,新的 macOS 已经不用那么麻烦。同时按住 Shift 和 Option,然后点击菜单栏上的蓝牙图标,这时候下拉菜单就会显示更多信息,包括一个「Debug」子菜单。打开「Debug」子菜单,然后选择「Reset the Bluetooth module」,并且确认真的要重置,最后重启 macOS,问题就解决了。之后我能在蓝牙面板中搜索到 Bose QC35,配对后播放音乐没有任何问题。

2019年2月26日星期二

把我的个人网站推倒重来(Part 8 - Sitemap)

为了让 Google 更好地发现和索引我的网站,我会使用 Google Search Console 提交 Sitemap。Sitemap 本质上是一个 XML 文件,描述网站包含的网页信息,帮助 Google 了解网站上都有哪些网页,以及这些网页的重要程度和更新频率。(Google 会参考这些信息,但我们不能通过 Sitemap 直接控制 Google 爬虫或排名。)我上一版的个人网站就包含了 Sitemap,所以重新新版本时也会把 Sitemap 加上。

上一个版本的 Sitemap 是我手工维护的 XML 文件,既然新版本的网站使用 Harp 进行编译,我希望 Sitemap 的 XML 文件也是编译时自动生成的。Harp 要使用模板生成 XML 文件很简单,把文件的后缀写成 .jade.xml 就可以了,Harp 从文件名可以推断出这个文件需要使用 Jade 模板来编译,结果保存为 XML 文件。我希望这个文件尽可能不需要手工维护,最好自己根据语言和页面的组合生成所有有效页面的链接。

每一个有效页面背后都有一个同路径的 .jade 文件,例如说 /en/resume 背后存在对应的 /en/resume.jade,这个文件会生成 /en/resume.html,然后 Netlify 会美化 URL 删掉不必要的 .html 后缀。如果我需要找出所有的有效页面,其中一个方法就是遍历项目目录,找出所有这些 .jade 文件,然后把它们的路径映射过去。因为遍历所有目录需要用到递归,我就写了一个 mixin 来做这个事情,原因是 Jade 里面的 mixin 类似于函数,可以自己调用自己从而实现递归。

mixin tree(current, path)
  for value, key in current
    if key === '_contents'
      for file in value
        if /\.html$/.test(file) && !/^\d{3}\.html$/.test(file)
          url
            loc
              | https://catchen.me#{path}#{file.replace(/(index)?\.html$/, '')}
            changefreq
              | hourly
            priority
              if path === '/' && file === 'index.html'
                | 1.0
              else
                | #{public._data[file.replace(/\.html$/, '')].priority}
    else if !/^[._]/.test(key)
      mixin tree(value, path + key + '/')

这个 mixin 接收两个参数,第一个是代表当前目录的对象,第二个是当前目录所在路径。在递归的入口,我会调用 mixin tree(public, '/'),把代表根目录的 public 对象和对应的路径字符串 '/' 传进去,然后这个 mixin 就会遍历所有子目录把所有编译后的 .html 文件找出来。它找到的每一个 .html 文件都会被添加到 Sitemap 里面。

这个 mixin 在把 .html 文件映射为 URL 时,它还做了以下的特殊处理:

  1. 删除 .html 后缀,因为 Netlify 在美化 URL 时也会进行这个操作。如果我们在浏览器中打开 https://catchen.me/zh/resume.html,Netlify 会返回 301 重定向到 https://catchen.me/zh/resume,保证用户看到的是不包含 .html 的路径。
  2. 删除 index 默认文件名,因为 https://catchen.me/zh/index.htmlhttps://catchen.me/zh/ 等价,同样会被 URL 美化删除。
  3. 过滤掉 404.html 等状态码特殊页面,因为这个页面是专门给 Netlify 返回对应状态码时使用的,不应该被索引。

最终的 sitemap.jade.xml 其实并不复杂,记得加上 doctype xmlurlset(xmlns="http://www.sitemaps.org/schemas/sitemap/0.9") 这类必要的元素就行了。编译后立即可以发布到 Netlify,然后在 Google Search Console 进行手工添加 Sitemap。数天后 Google Search Console 就会显示这个 Sitemap 已经被抓取了,然后还能看到 Sitemap 中页面的抓取情况。

这有可能是这个系列的最后一篇文章了,将来如果我对我的个人网站继续有更新,我会继续写这个系列。如果你对我的博客感兴趣的话,欢迎通过邮件RSS/Atom 进行订阅。如果你对这个话题有什么问题或者观点的话,欢迎对本文发表评论。

2019年1月17日星期四

把我的个人网站推倒重来(Part 7 - Google Analytics & Facebook Pixel)

网站上线之后,我自然关心访客的数量和来源,于是我决定加上 Google Analytics。同时纯粹出于好奇,我把 Facebook Pixel 也加上了。

Google Analytics 和 Facebook Pixel 都需要插入 JavaScript 到每一个页面上,因此把代码加到 _layout.jade 是最合适的,因为这是所有页面共享的模板。

Google Analytics

在 Google Analytics 创建好「property」后,复制 Google Analytics 生成的代码到 _layout.jade 其实就完事了。(Google 把独立的每一个网站和 app 叫做「property」,这样使得一个 Google 帐号可以管理多个网站和 app。)

因为我想知道我的网站是否有 JavaScript 出错,所以我特意通过 window.onerrorwindow.onunhandledrejection 事件把错误信息上报到 Google Analytics。如果 Google Analytics 在创建 property 时选择的是网站而不是 app,默认是不会有报告显示 exception 事件的。这时候我们需要手工在 Google Analytics 添加一份自定义报告,然后选择显示 exception 事件,并把 exception message 展示出来。这样我就能看到是否存在 JavaScript 错误,以及具体在哪个页面发生什么错误了。

Facebook Pixel

Facebook Pixel 跟 Google Analytics 类似,在 Facebook 上配置后然后复制粘贴代码就可以了。需要注意的是,一个 Facebook 广告帐号只能创建一个 Pixel,不像 Google Analytics 那样可以创建多个 property。如果需要跟踪多个网站,那就需要创建多个广告帐号,或者一个 Pixel 用不同的事件来跟踪不同的网站。

在配置好 Facebook Pixel 并运行一段事件后,我发现 Facebook Pixel 并不能提供 Google Analytics 那样复杂的分析功能,更多是为了跟踪广告效果和生成广告受众群体。例如说,如果我在我的博客上使用 Facebook Pixel,然后我就能生成一个访问我博客的受众群体,然后在 Facebook 上面向这些受众投放广告让他们来赞我的专页。除此之外,我没有去深入研究 Facebook Pixel 还能做什么别的事情。(如果你知道 Facebook Pixel 还能做什么别的事情,欢迎在评论区讨论。)

搞掂 Google Analytics 和 Facebook Pixel 后,我还想做的是支持 Sitemaps 好让 Google 抓取所有页面。如果你对此感兴趣的话,欢迎通过邮件RSS/Atom 订阅我的博客,关注接下来的文章。

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 订阅我的博客。