博客重构总结:React 服务端渲染结合单页面应用

最近抽时间重构了博客,一方面是因为之前纯粹的单页应用对 SEO 太差了,搜索引擎基本上只收录了首页,写博客当然是希望被收录的;另一方面是因为现在换成了腾讯云服务器,比起之前租用的虚拟主机灵(ang)活(gui)太多,所以为了不冤枉购买云服务器的钱,就打算重写一个页面直出的构建方式,同时也可以熟悉下 Linux 操作系统。

框架选型

如果纯粹是为了 SEO 而重构,直接选用 WordPress 现有的 PHP 模板就好了,或者自己写一个难度也不大,但是对于前端开发而言,可任意发挥的前提下当然是选择 JavaScript 了。目前流行的服务端渲染直出页面的方案主要有使用 React 的 nextjs 和 Vue 的 nuxtjs(看名字就知道这俩的关系了),我选择了 nextjs,因为 nextjs 更早更成熟,使用的人数更多,Zeit 团队在国外非常出名,开源项目都是精品,文档中介绍的特性非常吸引人。

简单列下 nextjs 的主要优点:

  • 整合 React、Babel、webpack 和 Scoped CSS,拿来即用省去了繁琐的配置;
  • 整套全家桶很好的做到了 code split,不会渲染和输出没有用到的数据和页面;
  • 自带路由功能,可以同时支持服务端渲染 + 单页面应用,速度和页面切换体验很好;

博客文章书写后台采用的是 WordPress,因为后台不需要自己再去费劲设计数据库了,而且 WordPress 足够优秀。后端数据查询是用 Nodejs 完成的,总流程就是:Nodejs 查询 WordPress 数据 -> React 页面组件构建虚拟 DOM -> nextjs 输出 html string。

为什么要用服务端渲染 + SPA 的模式

nextjs 的使用方法不是本文的介绍内容,没用过的可以移步官方网站:Zeit/next.js,这里主要是介绍如何使用服务端渲染结合单页面来进行对首屏打开速度和页面切换体验的优化的思路。

服务端渲染的特点是浏览器从发送请求到服务器响应,下载回来的文档已经是完整的页面(HTML 结构)了,打开速度要比本地渲染快,也称为:更快的内容到达时间(time-to-content)。影响服务端渲染的速度除了服务器的内存和带宽等硬件条件外还跟数据在服务端查询所花费的时间有关,因为只要查询没有完成,响应就不会发生,页面就会是空白的等待,这个缺点在站内切换页面的时候特别明显,每当切换到另一个页面的时候必然会有或长或短的一段空白等待。

而 SPA 渲染的特点是服务端渲染不具备的:可以自由控制页面切换的交互,没有空白等待用户体验好;可以复用相同的 DOM 节点,不必重新下载和渲染相同的 HTML 结构,比如博客文章之间的切换,仅仅只是博客文章的内容数据不同而已,所以没必要把整个页面重新渲染一遍。

如果可以将服务端渲染和 SPA 渲染结合起来,不就“完美”了么?幸运的是,nextjs 很好的支持了这两种模式的共存。

通过缓存输出优化首屏加载速度

缩短空白的等待时间是服务端渲染需要解决的主要问题,服务端在没有缓存输出情况下访问博客首页的 TTFB (Time To First Byte, 首字节响应时间,也就是服务器处理数据的时间,Mysql 查询数据、输出数据的处理都在这段时间内执行) 为 2 秒左右:

缓存输出的思路其实很简单,就是每次将 nextjs/React 输出的页面 html string 根据 url 建立 map 映射做下缓存即可,大致逻辑如下:

let key = decodeURIComponent(req.url)

if (cache.has(key)) {
    console.log(`Server from cache: ${key}`)
    res.setHeader('Render-From', 'ssr-cache')
    res.end(cache.get(key))
    return
}

nextjs.renderToHTML(req, res, path, query).then(htmlString => {
    if (htmlString) {
        cache.set(key, htmlString)
        console.log(`Stored into cache: ${key}`)
        console.log(`Current of caches: ${cache.keys().length}`)
    }
    res.setHeader('Render-From', 'ssr-first')
    res.end(htmlString)
}).catch(e => {
    nextjs.renderError(e, req, res, path, query)
})

例如第一次访问页面 A,在服务端输出页面的同时,将 A 页面的 html string 放到内存中,等到下一次访问 A 页面的时候先判断 A 页面是否有缓存的副本,如果有直接响应/输出副本,不需要任何的数据查询和处理,此时的 HTTP 响应速度是非常快的,通常在几十毫秒左右,这是我缓存第一次输出结果后的首页响应速度:

可以看到 TTFB 缩短到只有几十毫秒了,只要不清除服务端的缓存,无论是否是新访客无论访问多少次,TTFB 都只有几十毫秒左右,如果所有的页面都进入了缓存,那么页面的切换就像是原生应用一般了,此时肉眼几乎感觉不到空白的等待。

不过这种缓存方式比较粗暴,适合数据变动不频繁的场景比如博客或者新闻详情等,因为每次出取缓存页面时是不能确定被缓存的页面是否是最新的,很有可能在文章后台改了某个标题,换了某种颜色,如果没有控制好缓存的清理时机,输出的将总是旧的页面。对于我的博客而言,这一点没什么问题,因为博客的更新还没重启服务的次数多 :) 直接设定缓存有效期为一天,如果业务上需要处理更细粒度的缓存机制,可以细化缓存方案,比如监测文件的变动、获取数据更新的通知,然后只清除变动数据对应的缓存节点即可……

共存 SPA 模式优化页面切换体验

虽然在服务端缓存渲染结果解决了首屏打开的速度问题,但是无论页面切换的速度再快,从一个页面跳转到另一个页面总是有那么点突兀的感觉。利用 nextjs 的 Router 模块来定义站内路由,可以将页面的切换转成路由模块控制(此时 nextjs 将进入 SPA 模式,但并不影响每一页的 SEO),跳转过程中 nextjs 将会自动把下一个路由地址对应的组件和所需数据全部加载好(也支持 prefetch),等待路由完成就会触发对应的路由钩子,这样从页面切换开始到结束,还有出错控制,都可以在页面加上 loading 或其他提示,让用户感觉到页面的切换进度,这样就不会有切换后页面像是突然蹦出来的感觉。

踩坑小结

服务端和前端共用一套代码是 nextjs 以及其他服务端渲染方案的利好之处,但同时也是一个容易踩坑的地方,因为在这种情况下,React 组件的生命周期函数有的会在浏览器调用,有的会在服务端调用,有的则两端都会调用,例如在 componentWillMount 和 render 函数中不小心用了 window 或 document,在服务端渲染的时候就会报错,这些问题在两种环境公用代码的情况下是不可避免的,特别是有些不报错的代码,开发时察觉不到,却很有可能导致内存泄漏。

服务端渲染 + SPA 共存的模式看起来非常棒,但也是有缺点的:第一,要同时保证两种模式共存的情况和两种模式独立的情况下都能够表现一致,开发和测试比较繁琐;第二,多页应用(不用 Router 或刷新页面)的情况下,每一页都拥有完全独立的 window, document,但是共存模式下用 Router 切换到 SPA 后,由于单页面应用的特点,他们将会共用 window 和没有变化的 DOM,这个需要在开发每个页面每一个模块的时候特别注意对一些数据的销毁和事件的解绑,避免页面被切换掉之后旧逻辑依然生效,这个可能会引发很多意想不到的问题,而且难以排查。要避免出现这些问题,就需要很好的、统一的开发规范和踩坑意识,如果项目比较大比较复杂,开发成本就会比采用单个模式大得多。

评论列表

除了广告和敏感话题言论之外,可以畅所欲言。
为自己起个简短易记的名字。
方便我可以联系到你,绝对不会被公开。
你的个人主页,链接会加在昵称上方便大家访问。