博客同构渲染实践
作为拖延症晚期患者,在磨叽了两周之后,总算是完成了整个博客的同构渲染重构工作(只是勉强能跑起来,很多小功能后面慢慢完善)。这篇文章用来整理相关的工作思路和流程,也可以当做是《同构JavaScript应用开发》读后实践。
<!—more—>
整个项目放在github,目前位于master和koa分支上面。
关于同构
重构博客的原因来自于换了一台配置更低的服务器。由于SSR对于服务器的性能有一定影响(虽然我没有测试过),加上对于Vue-SSR相关的工作原理并没有深入了解,Nuxt框架对于SEO的支持也不是十分完美(不能针对于单个页面设置keywords
和describle
),所以决定折腾一下(至于SSR和虚拟DOM相关的东西,后面肯定要去整理一下的)。
我对于同构的理解比较简陋:第一次访问时通过服务端渲染页面(对SEO友好),后续站内链接通过异步请求数据,在前端渲染模板(响应快,用户体验更好)。
后端
之前接触过的NodeJS的框架主要是restify
(用来写接口)和express
,实际上express我也用的很少,基本上没在项目中实践过。由于同构渲染需要后端服务器具备模板渲染的能力,因此需要排除restify
。稍作考虑,我选择了Koa
作为后端框架。
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的配置了~
MVC
最近半年基于Laravel和CI框架写了不少PHP代码,对于web开发的MVC还是稍微有了一点理解。因此整个项目的后端也采用常规的框架思路进行组建:
- 通过
router.js
自定义路由,并映射到对应的控制器方法 - 在控制器中根据模板参数调用模型方法,获取相关数据
- 将数据填充到对应的模板上,返回生成的HTML字符串
整个同构的核心在于我们需要在前端进行操作时覆盖上面的这三步:
- 拦截需要跳转的a链接,转而请求对应路径的数据
- 在控制器方法中根据请求方式进行判断,常规请求返回模板渲染的HTML,异步请求返回对应的json数据
- 确定模板在服务端渲染还是在浏览器渲染的时机
后面的章节我会阐述相关的处理方式。
nvm管理node版本
另外Koa的async
和await
用起来也很舒服,由于需要高版本的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/
前端
前面提到,我们需要拦截超链接的跳转,转而请求数据,只需要拦截a标签的点击事件然后阻止默认事件即可
$(document).on("click", "a", function () {
let href = $(this).attr("href")
// 加载对应路由的数据和模板,并进行渲染
// self.loadPage(href)
return false;
})
由于爬虫不会执行JS代码,因此在收录整个站点时,访问到的是正常的页面,因此对SEO是十分友好的;而在loadPage
这个方法中,我们会实现整套前端渲染的逻辑,并为用户增加过渡动画和渲染特效等,体验也会相应提高。
上面拦截整个网站的超链接跳转可能有些不理智,比如友链、外部参考链接什么的,可以使用data-*
自定义属性进行区分。
前端路由
由于拦截了超链接的跳转,我们需要手动实现一个前端路由,这可以通过History API
来实现,下面是开发时我列出的一些Todo事项,目前已经完成了一个简陋版本
// todo 修改历史栈, history API
// todo 高亮当前链接
// todo 页面切换过渡动画及加载动画,使用animate.css和progress-bar
// todo 关联页面、路由和模板,可以通过一个hash表实现
// todo 缓存模板,不缓存数据
代码大概有100来行,这里就不贴出来了(接下来可能会进行调整,这块我觉得有BUG),整个路由核心代码位于项目的/public/src/js/router.js
下。
同构部分
共用模板
在《同构JavaScript应用开发》读书笔记)这篇文章中提到过,如果前后端管理两套模板,工作量和bug量都会增加,因此最好的办法是前后端共用同一套模板。
庆幸的是社区已经提供了很多可以NodeJS和浏览器环境中运行的模板引擎,诸如ejs
,swig
等,由于Laravel的blade
模板引擎的缘故,我非常喜欢模板继承的特性,因此在模板渲染上选择了swig。
为了减少前端文件的体积,将公共的模板文件存放在服务器上,然后在第一次请求数据时同时请求对应的模板资源,然后进行缓存,这样就可以实现按需加载模板了。
这里的一个问题是,服务端渲染的模板的一个完整的页面,而前端渲染的往往只是页面某个区域的内容,这样如何才能完成共用呢?
我的解决办法有点不那么优雅:利用swig的模板继承,比如我们渲染/tags
页面,后端渲染的模板实际上是
{% extends './layout.swig' %}
{% block main %}
{% include './_page/tags.swig' %}
{% endblock %}
而前端处理的模板才是./_page/tags.swig
,这样可以保证前后端模板一致,且服务端可以完整渲染整个模板。
共用模块
随着项目的进行,我们会产出一些与运行环境无关的公共模块,比如日期处理、字符串修饰等辅助函数,这些模块的共用十分简单:
- 在后端使用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
安装引用 ,而前端需要配置webpack
的externals
选项然后引入CDN。
let swig = require("swigjs")
// 自定义过滤器
let filters = require("../../../lib/swig")
for(let key in filters){
swig.setFilter(key, filters[key])
}
module.exports = swig
简而言之,实现模块共用的基本需求是理解JavaScript模块化,然后掌握一个打包工具。
共用数据和路由
为了减少路由的重复定义和控制器方法的冗余,我决定在同一个控制器方法中处理是否返回经过服务端渲染的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)
}
}
由于前后端路由对应的模板和数据相同,最终渲染的页面也肯定完全一致。
部署
采用同构渲染的好处是,代码不需要经过二次打包。我的意思是不需要想nuxt那样每次部署前都需要进行编译(编译经常失败也是我折腾同构渲染的一个原因)。
如果移除在浏览器端进行的处理之后,我们可以把整个项目看做纯服务端渲染,这样只需要运行程序然后使用Nginx进行中转端口即可,最后使用forever
开启守护进程即可,相关的环境依赖和配置在博客SSR实践总结有提到,这里就不凑字数了。
小结
大概花了两三周的下班时间来折腾这次的同构(大部分时间去查资料和玩游戏了),最后出来的东西也不是很满意,相关的兼容测试也没有进行,总之还有很多需要完善的地方。
今年就剩一个月了,还有一些其他的事情没做,可能要先放一放,不过有BUG肯定还是会改的,哈哈。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。