Think.js 与 Vue2 服务端渲染

2,522 阅读5分钟
原文链接: smallpath.me

Vue2的SSR并不是仅能用在express和koa中

本文以Think.js为例, 示范一下传统MVC框架如何与Vue2的服务端渲染结合

这里不提供仓库, 读者可以按照博文的命令行和给出的代码完整重现一次Vue2的SSR与Think.js搭配, 完成时会发现, SSR与MVC框架的配合一点也不复杂

初始化环境

首先全局安装Think.js,并初始化一个项目

npm install thinkjs@2 -g --registry=https://registry.npm.taobao.org --verbose
thinkjs new ./thinkjs-demo
cd thinkjs-demo
npm install

再下载博客的前台项目,项目地址在这里, 服务端渲染的前台SPA即项目中的client/front.

由于博客后台依赖mongoDB和redis,为了方便演示,后台直接使用博客线上的proxyPrefix代理前缀来进行数据的获取,这样就不需要本地把RESTful服务器跑起来了。

因此,需要修改一下博客前台的API请求地址

修改client/front/src/store/api.js, 将第6行的host改为如下语句

const host = typeof location === 'undefined' ? 
                process.env.NODE_ENV === 'production' ?
                  'https://smallpath.me/proxyPrefix' :
                'http://localhost:8080/proxyPrefix' : '/proxyPrefix';

之后进入client/front

npm install 
npm run build

现在, 把front项目根目录中的index.html和dist目录中的文件统一移动至thinkjs项目中, 移动策略如下:

  • front/index.html 替换thinkjs-demo/view/home/index_index.html
  • front/dist目录 将front/dist中的四个文件全部粘贴到thinkjs-demo/www/static目录中,再将静态资源front/dist/static/中所有的文件夹也都复制到thinkjs-demo/www/static中。

这里的thinkjs目录应该如下, 注意不要放错, 即thinkjs-demo/www/static中不能存在名为static的文件夹,否则非首屏渲染会出错

└─www
    │  development.js
    │  production.js
    │  README.md
    │  testing.js
    │
    └─static
        │  client-bundle.js
        │  client-vendor-bundle.js
        │  server-bundle.js
        │  styles.css
        │
        ├─css
        ├─fonts
        │      iconfont.eot
        │      iconfont.ttf
        │
        ├─img
        │      iconfont.svg
        │
        └─js

创建SSR环境

首先安装SSR必要的依赖

npm install --save serialize-javascript vue-server-renderer lru-cache vue vue-router vuex vuex-router-sync@3.0 superagent

Thinkjs是传统的MVC框架,它有一个全局入口,用于初始化一些全局操作

thinkjs-demo/src/common/bootstrap/global.js中,添加如下语句

process.env.VUE_ENV = 'server';

import fs from 'fs';
import path from 'path';
const resolve = file => path.resolve(__dirname, file);
import serialize from 'serialize-javascript';
import { createBundleRenderer } from 'vue-server-renderer';

const html = (() => {
  const template = fs.readFileSync(resolve('../../../view/home/index_index.html'), 'utf-8')
  const i = template.indexOf('<div id=app></div>')
  const style = '<link rel=stylesheet href=/static/styles.css>' 
  return {
    head: template.slice(0, i).replace('<link rel=stylesheet href=/dist/styles.css>', style),
    tail: template.slice(i + '<div id=app></div>'.length).replace(/\/dist/g, "/static")
  }
})();

const bundlePath = resolve('../../../www/static/server-bundle.js');
let renderer = createRenderer(fs.readFileSync(bundlePath, 'utf-8'));

function createRenderer (bundle) {
    console.log(bundlePath);
  return createBundleRenderer(bundle, {
    cache: require('lru-cache')({
      max: 1000,
      maxAge: 1000 * 60 * 15
    })
  })
}

global.html = html;
global.renderer = renderer;
global.serialize = serialize;

Think.js与Steam流式输出

现在到了MVC的C层了,它的controller在thinkjs-demo/src/home/controller/目录中 其中,base.js会被其他controller继承, 因此我们可以在这里使用魔术方法, 来实现express和koa的app.use('*', ()=>{})这种中间件的功能

'use strict';

export default class extends think.controller.base {
  /**
   * some base method in here
   */

  __before() {
    console.log('before called')
    if (!renderer) {
      return this.end('waiting for compilation... refresh in a moment.', 'utf-8')
    }

    let s = Date.now();
    const context = { 
      path: this.http.url,
      query: this.http.query,
      url: this.http.url
    };

    const renderStream = renderer.renderToStream(context);

    let firstChunk = true

    this.write(html.head, 'utf-8');

    renderStream.on('data', chunk => {
      if (firstChunk) {
        // embed initial store state
        if (context.initialState) {
          this.write(
            `<script>window.__INITIAL_STATE__=${
              serialize(context.initialState, { isJSON: true })
            }</script>`, 'utf-8'
          )
        }
        firstChunk = false
      }

      this.write(chunk,'utf-8')
    })

    renderStream.on('end', () => {
      this.end(html.tail,'utf-8')
      console.log(`whole request: ${Date.now() - s}ms`)
      console.log('---------------')
    })

    renderStream.on('error', err => {
      console.log(err);
    })
    return think.prevent();
  }

}

这里使用了think.js 全局对象think, 以及controller中this.http的使用。

现在,在thinkjs-demo目录中npm start, 本地localhost:8360的页面上就可以正确渲染出博客首屏了

注意, 这里如果点进其他链接, 没有数据是正常现象, 因为此时请求由客户端superagent发出, 在没设置跨域或代理前, 无法使用本博客的代理前缀https://smallpath.me/proxyPrefix.

修正非首页的首屏渲染问题

但是, 非首页刷新时是会直接显示404的, 我们需要将404去除以适应SPA的H5模式。

这是SPA的H5模式的特点之一, 即要求路由由客户端接管,仅在客户端匹配到没有的路径时才由客户端应用显示一个全局404页面,服务端禁止返回404这种状态码。

thinkjs-demo/src/home/controller/base.js复制到thinkjs-demo/src/common/controller文件夹中, 并修改thinkjs-demo/src/common/controller/error.js,将class声明语句替换为:

import Base from './base.js';

export default class extends Base {

即可去除全局404错误。现在, 除了标签页并没有做服务端渲染外, 其他页面刷新后也能够正确显示数据, 这说明所有SSR已经在Think.js上正确配置了.

之后, 就是配置线上的nginx代理, 为了达到点击其他页面能直接从博客代理前缀获取数据的目的, 可以参考这样的nginx配置策略

一点思考

对于thinkjs这种传统的MVC框架, 应该统一在错误处理那里返回SSR渲染出来的界面, 因此src/home/controller/base.js其实没有必要修改,只需要保证所有请求都能被错误处理捕获即可。

除此之外, SPA使用了服务端渲染后, 有些逻辑完全就可以放到服务端上来. 例如, 同样用Think.js写的屈屈大神的博客, 他使用了服务端获取请求的数据再异步转发给谷歌统计这种策略, 避免了国内用户使用谷歌统计不稳定的问题. 还有, 还可以将一些与RESTful语义有冲突的操作放到SSR服务端, 比如sitemap定时生成任务.

另外, 本文虽然使用了lru-cache, 但是并没上vue2 SSR的组件级缓存, 这里完全可以去除lru-cache, 并用think.js自带缓存进行无缝替代. 在进行SSR组件级缓存和不考虑缓存更新的前提下, SSR博客前台很容易达到首次访问1000毫秒而二次访问仅仅100毫秒这种访问速度. 由于博客不会经常更新, 因此组件级缓存非常适合这种场景