开发一个基于 Workbox 的插件
Jekyll 默认生成的站点由多个静态页面组成(虽然可以通过某些插件实现 SPA)。不同于之前介绍的 SSR,对于这类静态站点的缓存思路并不复杂,几乎不需要对已有站点结构进行任何改造:
- 预缓存静态资源和部分关键页面,例如主页和最新的文章
- 对于非关键页面,进行访问后的动态缓存
以上思路需要在 Service Worker 中实现预缓存和动态缓存机制,使用 Workbox 将让一切变得简单。
Workbox 是什么?
Workbox 吸收了之前包括 sw-precache 和 sw-toolbox 在内的类库,也就是预缓存和动态缓存的实现。提供了一系列工具帮助开发者快速生成 Service Worker。根据项目的构建流程,可以选择对应的 Webpack 或者 Gulp 插件,如果没有使用这类构建工具,也可以选择使用 Workbox-CLI 命令行工具。对于想自己编写 SW 的开发者,也可以直接调用封装好的 API,包括预缓存的方案细节,根据资源类型选择不同的策略。总之 Workbox 能极大减少开发 SW 的成本。
可以观看开发者在Google Dev Summit 上的介绍或者前往官网了解更多技术细节。
值得一提的是 Workbox 拆分成了多个功能模块,例如预缓存、动态缓存、策略、缓存更新插件等。由于项目仍处于开发阶段,部分文档还不是很全面,有些配置需要深入代码才能搞懂。我在使用时对其中的更新插件产生了理解上的偏差,通过ISSUE得到作者的回应才搞清楚。在看代码的过程中,我深刻感受到 Google 开发者对于问题考虑的全面性,就拿跨域资源的动态缓存来说,
插件细节
我的博客使用了 Gulp 作为构建工具,按理说使用 Workbox 提供的插件足矣。但是考虑到不是所有使用者都会使用基于 Node.js 的构建工具,而且类似“缓存最近5篇文章”这样的需求是和 Jekyll 本身构建流程密切相关的,所以集成到默认的构建流程中是很有必要的。
之前在在 Jekyll 中使用代码高亮一文中,我使用了 Jekyll 插件中的Tags类型来处理 Markdown 中的代码块。这次我们将使用Hooks类型插件介入构建流程。
Jekyll Hooks
Jekyll 暴露了多个构建阶段的钩子,我们要介入的阶段包括:
- 在站点生成阶段,生成 sw-register.js,负责为当前页面注册 Service Worker
- 在站点生成阶段,生成 service-worker.js,根据配置项,调用 Workbox API 注入预缓存和动态缓存代码
- 在每一个页面生成阶段,插入引入 sw-register.js 的代码块
module Jekyll
Hooks.register :pages, :post_render do |page|
# append <script> for sw-register.js in <body>
SWHelper.insert_sw_register_into_body(page)
end
Hooks.register :documents, :post_render do |document|
# append <script> for sw-register.js in <body>
SWHelper.insert_sw_register_into_body(document)
end
Hooks.register :site, :post_write do |site|
pwa_config = site.config['pwa'] || {}
sw_helper = SWHelper.new(site, pwa_config)
sw_helper.write_sw_register()
sw_helper.generate_workbox_precache()
sw_helper.write_sw()
end
end
这里有一个问题,为什么要额外生成一个 sw-register.js 负责注册 Service Worker 呢?
在页面中,我们会加上时间戳类似sw-register.js?v= new Date()
保证浏览器不会对sw-register.js
进行缓存。而sw-register.js
中注册service-worker.js
时,会加上构建版本号保证service-worker.js
的更新。
更多详细细节可以参考之前同事写的如何优雅注册 SW这篇文章。
预缓存资源的注入
首先根据配置项中的 glob 过滤出要缓存的资源,这一点通过标准库Dir.glob
就能完成:
# find precache files with glob
precache_files = []
patterns.each do |pattern|
Dir.glob(File.join(directory, pattern)) do |filepath|
precache_files.push(filepath)
end
end
precache_files = precache_files.uniq
然后我们可以加上最近 N 篇文章,由于.md
文件最终会生成.html
页面,我们需要同时记录下url
和path
。要注意,最终添加进预缓存列表的是url
,而path
是根据文件内容生成版本号时用到的。
# precache recent n posts
posts_path_url_map = {}
if recent_posts_num
precache_files.concat(
@site.posts.docs
.reverse.take(recent_posts_num)
.map do |post|
posts_path_url_map[post.path] = post.url
post.path
end
)
end
最后就是关键的步骤了,我们需要根据静态资源的内容生成 md5 版本号,这样能够保证每次 Service Worker 安装时,只会请求发生变动的新资源并缓存,同时清理掉已经不在列表中的旧资源。
# generate md5 for each precache file
md5 = Digest::MD5.new
precache_files.each do |filepath|
md5.reset
md5 << File.read(filepath)
if posts_path_url_map[filepath]
url = posts_path_url_map[filepath]
else
url = filepath.sub(@site.dest, '')
end
@precache_list.push({
url: @site.baseurl + url,
revision: md5.hexdigest
})
end
这样我们就完成了预缓存列表的创建,由于符合Workbox.precache
的参数要求,我们直接把列表序列化作为参数传入就行了,在运行时 Workbox 会完成请求资源,清理缓存的工作。
# generate precache list
precache_list_str = @precache_list.map do |precache_item|
precache_item.to_json
end
.join(",")
# insert into precache function
<<-SCRIPT
workboxSW.precache([#{precache_list_str}]);
SCRIPT
动态缓存也是根据配置项,调用workboxSW.router.registerRoute
,这里就不再赘述了。
使用 RubyGem 发布
说起来这是我第一次发布一个 RubyGem,不过按照文档说明就行了。gemspec 类似 npm 中的 package.json,虽然按照出现的时间顺序应该是反过来。Ruby 中有很多东西都被其他语言借鉴,我记得以前类似 Hibernate 这样的 ORM 都是有参考 RoR 中的 ActiveRecord。里面大部分字段都很熟悉,甚至和 package.json 都是一致的,name
version
files
等等。
# jekyll-pwa-plugin.gemspec
Gem::Specification.new do |s|
s.name = 'jekyll-pwa-plugin'
s.version = '1.0.0'
s.date = '2017-11-09'
s.summary = "PWA support for Jekyll."
s.description = "This plugin provides PWA support for Jekyll. Generate a service worker and provides precache with Google Workbox."
s.authors = ["Pan Yuqi"]
s.email = 'pyqiverson@gmail.com'
s.files = ["lib/jekyll-pwa-plugin.rb", "lib/vendor/broadcast-channel-polyfill.js", "lib/vendor/workbox-sw.prod.v2.1.1.js"]
s.homepage =
'https://github.com/lavas-project/jekyll-pwa'
s.license = 'MIT'
end
在发布之前当然要先做好测试工作,我是直接把写好的插件放在项目/_plugins
下,确认本地可以正常使用。
后续优化
目前至少有三点可以优化:
- 提供一个离线页面,当用户离线访问不在缓存中的页面时展示
- 目前的 Service Worker 根据配置自动生成,可以支持用户传入自定义的模板,使用占位符的方式注入预缓存和动态缓存内容,这样更加灵活
- 开发模式下禁用缓存,不然在写作过程中使用 browser-sync 之类自动刷新的功能就无效了