开发一个基于 Workbox 的插件

Jekyll 默认生成的站点由多个静态页面组成(虽然可以通过某些插件实现 SPA)。不同于之前介绍的 SSR,对于这类静态站点的缓存思路并不复杂,几乎不需要对已有站点结构进行任何改造:

  1. 预缓存静态资源和部分关键页面,例如主页和最新的文章
  2. 对于非关键页面,进行访问后的动态缓存

以上思路需要在 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 暴露了多个构建阶段的钩子,我们要介入的阶段包括:

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页面,我们需要同时记录下urlpath。要注意,最终添加进预缓存列表的是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下,确认本地可以正常使用。

后续优化

目前至少有三点可以优化:

  1. 提供一个离线页面,当用户离线访问不在缓存中的页面时展示
  2. 目前的 Service Worker 根据配置自动生成,可以支持用户传入自定义的模板,使用占位符的方式注入预缓存和动态缓存内容,这样更加灵活
  3. 开发模式下禁用缓存,不然在写作过程中使用 browser-sync 之类自动刷新的功能就无效了

插件项目地址