博客同构渲染实践

作为拖延症晚期患者,在磨叽了两周之后,总算是完成了整个博客的同构渲染重构工作(只是勉强能跑起来,很多小功能后面慢慢完善)。这篇文章用来整理相关的工作思路和流程,也可以当做是《同构JavaScript应用开发》读后实践。

<!—more—>

整个项目放在github,目前位于master和koa分支上面。

1. 关于同构

重构博客的原因来自于换了一台配置更低的服务器。由于SSR对于服务器的性能有一定影响(虽然我没有测试过),加上对于Vue-SSR相关的工作原理并没有深入了解,Nuxt框架对于SEO的支持也不是十分完美(不能针对于单个页面设置keywordsdescrible),所以决定折腾一下(至于SSR和虚拟DOM相关的东西,后面肯定要去整理一下的)。

我对于同构的理解比较简陋:第一次访问时通过服务端渲染页面(对SEO友好),后续站内链接通过异步请求数据,在前端渲染模板(响应快,用户体验更好)。

2. 后端

之前接触过的NodeJS的框架主要是restify(用来写接口)和express,实际上express我也用的很少,基本上没在项目中实践过。由于同构渲染需要后端服务器具备模板渲染的能力,因此需要排除restify。稍作考虑,我选择了Koa作为后端框架。

2.1. Koa

选择Koa的原因很简单:之前没用过,我想试一试~

好吧,跟express相比,Koa更加精简,大部分功能都是通过中间件来实现,这更符合项目的需求:我只需要一个可以中转路由、可以渲染模板、可以支持静态资源的web服务器即可。相关使用方法可参考Koa官方文档

主要依赖下面几个中间件

const Koa = require("koa")
const app = new Koa()

// 支持静态资源 
const serve = require('koa-static')
app.use(serve(path.join(__dirname)))

// 模板渲染
const views = require("koa-views")
app.use(views(__dirname + '/views', {
    extension: "swig",
    // options: swigOptions
}))

// 自定义路由
const router = require("./router")
app.use(router.routes())
    .use(router.allowedMethods())

// 开启服务器
app.listen(3000)

没错,还是熟悉的3000端口号,Nuxt的默认端口号哈哈,这样就不用改nginx的配置了~

2.2. MVC

最近半年基于Laravel和CI框架写了不少PHP代码,对于web开发的MVC还是稍微有了一点理解。因此整个项目的后端也采用常规的框架思路进行组建:

  • 通过router.js自定义路由,并映射到对应的控制器方法
  • 在控制器中根据模板参数调用模型方法,获取相关数据
  • 将数据填充到对应的模板上,返回生成的HTML字符串

整个同构的核心在于我们需要在前端进行操作时覆盖上面的这三步:

  • 拦截需要跳转的a链接,转而请求对应路径的数据
  • 在控制器方法中根据请求方式进行判断,常规请求返回模板渲染的HTML,异步请求返回对应的json数据
  • 确定模板在服务端渲染还是在浏览器渲染的时机

后面的章节我会阐述相关的处理方式。

2.3. nvm管理node版本

另外Koa的asyncawait用起来也很舒服,由于需要高版本的node支持,这里推荐使用nvm进行node版本管理和切换。

# 查看已安装的版本
nvm ls

# 安装指定版本
nvm install v8.9.0

# 切换版本
nvm use 8.9.0

# 卸载指定版本
nvm uninstall v8.9.0# 安装

安装版本时会自动更新npm版本,如果安装比较慢,可以考虑切换镜像,参考

如果是windows,在 nvm 的安装路径下,找到 settings.txt,在后面加上这两行

node_mirror: https://npm.taobao.org/mirrors/node/
npm_mirror: https://npm.taobao.org/mirrors/npm/

3. 前端

前面提到,我们需要拦截超链接的跳转,转而请求数据,只需要拦截a标签的点击事件然后阻止默认事件即可

$(document).on("click", "a", function () {
    let href = $(this).attr("href")
    // 加载对应路由的数据和模板,并进行渲染
    // self.loadPage(href)
    return false;
})

由于爬虫不会执行JS代码,因此在收录整个站点时,访问到的是正常的页面,因此对SEO是十分友好的;而在loadPage这个方法中,我们会实现整套前端渲染的逻辑,并为用户增加过渡动画和渲染特效等,体验也会相应提高。

上面拦截整个网站的超链接跳转可能有些不理智,比如友链、外部参考链接什么的,可以使用data-*自定义属性进行区分。

3.1. 前端路由

由于拦截了超链接的跳转,我们需要手动实现一个前端路由,这可以通过History API来实现,下面是开发时我列出的一些Todo事项,目前已经完成了一个简陋版本

// todo 修改历史栈, history API
// todo 高亮当前链接
// todo 页面切换过渡动画及加载动画,使用animate.css和progress-bar
// todo 关联页面、路由和模板,可以通过一个hash表实现
// todo 缓存模板,不缓存数据

代码大概有100来行,这里就不贴出来了(接下来可能会进行调整,这块我觉得有BUG),整个路由核心代码位于项目的/public/src/js/router.js下。

4. 同构部分

4.1. 共用模板

《同构JavaScript应用开发》读书笔记)这篇文章中提到过,如果前后端管理两套模板,工作量和bug量都会增加,因此最好的办法是前后端共用同一套模板。

庆幸的是社区已经提供了很多可以NodeJS和浏览器环境中运行的模板引擎,诸如ejs,swig等,由于Laravel的blade模板引擎的缘故,我非常喜欢模板继承的特性,因此在模板渲染上选择了swig

为了减少前端文件的体积,将公共的模板文件存放在服务器上,然后在第一次请求数据时同时请求对应的模板资源,然后进行缓存,这样就可以实现按需加载模板了。

这里的一个问题是,服务端渲染的模板的一个完整的页面,而前端渲染的往往只是页面某个区域的内容,这样如何才能完成共用呢?

我的解决办法有点不那么优雅:利用swig的模板继承,比如我们渲染/tags页面,后端渲染的模板实际上是

{% extends './layout.swig' %}

{% block main %}
    {% include './_page/tags.swig' %}
{% endblock %}

而前端处理的模板才是./_page/tags.swig,这样可以保证前后端模板一致,且服务端可以完整渲染整个模板。

4.2. 共用模块

随着项目的进行,我们会产出一些与运行环境无关的公共模块,比如日期处理、字符串修饰等辅助函数,这些模块的共用十分简单:

  • 在后端使用CommonJS进行调用
  • 在前端使用webpack进行打包

前面提到采用的模板引擎是swig,而Node环境与浏览器下的版本略有差异,比如浏览器环境下肯定不支持fs文件模块,因此需要为某些接口进行hack处理,而针对于自定义过滤器等公共模块,我们可以采用上述方式按需加载

// filter.js
// 自定义过滤器
let filters = {
    // 标签云
    tagSize(num, idx){
        let fontSize = ""

        if (num <= 2) {
            fontSize =  "text-xs"
        } else if (num > 2 && num <= 5) {
            fontSize = "text-sm"
        } else if (num > 5 && num <= 8) {
            fontSize = "text-md"
        } else {
            fontSize = "text-lg"
        }
        return fontSize
    },
}

module.exports = filters

然后在前后端各自按需引入,需要注意区分不同依赖模块的前后端文件,比如我这里依赖的marked.js,后端可以直接通过npm安装引用 ,而前端需要配置webpackexternals选项然后引入CDN。

let swig = require("swigjs")

// 自定义过滤器
let filters = require("../../../lib/swig")

for(let key in filters){
    swig.setFilter(key, filters[key])
}

module.exports = swig

简而言之,实现模块共用的基本需求是理解JavaScript模块化,然后掌握一个打包工具。

4.3. 共用数据和路由

为了减少路由的重复定义和控制器方法的冗余,我决定在同一个控制器方法中处理是否返回经过服务端渲染的HTML文件,还是单纯的JSON数据,这是通过x-requested-with判断来实现的,也许存在BUG。此外还可以通过传递某个约定的请求参数来实现

module.exports = async function(ctx){
    let url = ctx.request.url,
        data = ctx.state.data

    let header = ctx.request.header;

    if (header['x-requested-with'] === 'XMLHttpRequest'){

        ctx.body = data;

    }else {
        // 需要将路由映射到对应的模板,
        // 这里暂时约定通过ctx.state传递
        await ctx.render(ctx.state.view, data)
    }
}

由于前后端路由对应的模板和数据相同,最终渲染的页面也肯定完全一致。

5. 部署

采用同构渲染的好处是,代码不需要经过二次打包。我的意思是不需要想nuxt那样每次部署前都需要进行编译(编译经常失败也是我折腾同构渲染的一个原因)。

如果移除在浏览器端进行的处理之后,我们可以把整个项目看做纯服务端渲染,这样只需要运行程序然后使用Nginx进行中转端口即可,最后使用forever开启守护进程即可,相关的环境依赖和配置在博客SSR实践总结有提到,这里就不凑字数了。

6. 小结

大概花了两三周的下班时间来折腾这次的同构(大部分时间去查资料和玩游戏了),最后出来的东西也不是很满意,相关的兼容测试也没有进行,总之还有很多需要完善的地方。

今年就剩一个月了,还有一些其他的事情没做,可能要先放一放,不过有BUG肯定还是会改的,哈哈。