Koa2 + Vue 从零开始搭建服务端渲染应用

2,572 阅读9分钟

前言

最近公司要求做一个类似官网的应用,为了解决SEO的问题准备使用 node 做服务端渲染。也尝试了好几个现成的应用级框架,但是本着菜鸡需要学习的心情,准备从零开始鲁出来一个简单的服务端渲染应用,本次我选择Koa作为后端开发框架,首先试着模版渲染, 之后又尝试了 Vue SSR

一 项目跑起来

创建空的文件夹,npm init

新建 server/app.js 作为启动服务的文件

// 1. 安装依赖 npm i koa 
// 2. 修改package.json文件中 scripts 属性如下
"scripts": {
  "start": "node server/app.js"
}

// 3. app.js写入如下代码
const Koa = require('koa');
let app = new Koa();
app.use(ctx => {
  ctx.body = 'hello node!'
});
app.listen(3000, () => {
  console.log('服务器启动 http://127.0.0.1:3000');
});

// 4 npm start 浏览器访问 http://127.0.0.1:3000 查看效果

二 路由

创建 server/routes.js, 使用 koa-router 中间件配置路由

// 引包
const router = require('koa-router')()
//创建路由规则
router.get('/', (ctx, next) => {
ctx.body = 'home'
});
// 导出路由备用
module.exports = router
// server/app.js 中写入
// 引入koa
const Koa = require('koa')
const routers = require('./routes.js')
// 实例化koa对象
const app = new Koa()

// 初始化路由中间件
app.use(routers.routes()).use(routers.allowedMethods());

// 监听3000端口
app.listen(3000, () => {
    console.log('服务器启动 http://127.0.0.1:3000')
})

路由也可以说进行嵌套

创建 server/router 文件夹,新建一个 user.js 路由文件

// server/router.js
const router = require('koa-router')()
const user = require('./router/user')

//创建路由规则
router.get('/', (ctx, next) => {
    ctx.body = 'home'
});
// 挂载 user 路由
router.use('/user', user.routes(), user.allowedMethods())

module.exports = router
// server/router/user.js
const userRouter = require("koa-router")();

userRouter.get("/", (ctx, next) => {
  ctx.body = "user";
});

module.exports = userRouter;
// npm start 浏览器访问 http://127.0.0.1:3000/user 查看效果

三 模版渲染

使用 koa-views 中间件和 ejs 实现模版渲染

# 安装koa模板使用中间件
npm install --save koa-views

# 安装ejs模板引擎
npm install --save ejs

创建 server/views/index.ejs 文件

<!DOCTYPE html>
<html>
  <head>
    <title><%= title %></title>
  </head>
  <body>
    <h1><%= title %></h1>
    <p>EJS Welcome to <%= title %></p>
  </body>
</html>
// server/app.js 中加入中间件配置
const path = require("path");
const views = require('koa-views')
// 配置服务端模板渲染引擎中间件
app.use(views(path.join(__dirname, './views'), {
  extension: 'ejs'
}))

// 在上面的 server/router/user.js 改成
userRouter.get("/", async (ctx, next) => {
  // ctx.body = "user";
  const title = 'hello koa2'
  await ctx.render('index', {
    title,
  })
});
module.exports = userRouter;
// npm start 浏览器访问 http://127.0.0.1:3000/user 查看效果

四 Vue SSR

在这个时候,对于我这个vue/react的框架熟练工来说,深深感觉到模版的恶意,实在不习惯。所以在看了Vue SSR的文档之后打算尝试一下。

首先了解一下 Vue SSR

如图所示, 这其实是一个同构的概念,创建vue应用之后使用webpack进行打包,生成了server bundleclient bundle。server bundle用于服务器渲染返回html字符串,而客户端代码对页面进行激活,这样就可以直接使用vue开发服务端渲染应用了。(当然也可以直接尝试 Nuxt.js)

初体验

Vue SSR 只要是依靠官方推出的vue-server-renderer,让我们先使用它写一个简单的🌰:

// 第 1 步:创建一个 Vue 实例
const Vue = require('vue')
const app = new Vue({
  template: `<div>Hello Vue SSR</div>`
})

// 第 2 步:创建一个 renderer
const renderer = require('vue-server-renderer').createRenderer()

// 第 3 步:将 Vue 实例渲染为 HTML
renderer.renderToString(app, (err, html) => {
  if (err) throw err
  console.log(html)   // <div data-server-rendered="true">Hello Vue SSR</div>
})

在 Node 环境下使用模版

  1. 在根目录下创建 index.html 文件
<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Hello</title>
  </head>
  <body>
    <!--vue-ssr-outlet-->
    <!--这里将是应用程序 HTML 标记注入的地方 -->
  </body>
</html>

  1. 修改 server/app.js, 加入
const router = require("koa-router")();
const Vue = require('vue')

// 创建一个 renderer
const renderer = require('vue-server-renderer').createRenderer({
    // 读取 index.html 文件形成模版
    template: require("fs").readFileSync(path.join(__dirname, '../index.html'), "utf-8")
});

// 创建路由
router.get("/", ctx => {
  const app = new Vue({
    data: {
      msg: 'Hello Vue SSR'
    },
    template: `<div>{{msg}}</div>`
  })
  renderer.renderToString(app, (err, html) => {
    ctx.res.end(html);
  });
});
// 初始化路由中间件
app.use(router.routes()).use(router.allowedMethods());

// npm start 浏览器访问 http://127.0.0.1:3000 查看效果

Webpack 打包代码

接下来用vue编写客户端代码, 使用webpack打包文件

app # 客户端代码
├── App.vue
├── app.js
├── entry-client.js # 仅运行于浏览器
└── entry-server.js # 仅运行于服务器

加入客户端代码

// app/App.vue
<template>
    <div id="app">
        <span>times: {{times}}</span>
        <button @click="add">+</button>
        <button @click="sub">-</button>
    </div>
</template>

<script>
    export default {
        name: "app",
        data: function () {
            return {
                times: 0
            }
        },
        methods: {
            add: function () {
                this.times = this.times + 1;
            },
            sub: function () {
                this.times = this.times - 1;
            }
        }
    }
</script>

上面的部分是一个非常简单的Vue组件,也是服务端和客户端渲染的通用代码。

在服务器渲染中,app.js 仅会对外暴露一个工厂函数,用来每次都调用的都会返回一个新的组件实例用于渲染。具体的其他逻辑都被各自转移到客户端和浏览器端的入口文件中。

// 客户端渲染 app/app.js
import Vue from 'vue'
import App from './App.vue'

export function createApp() {
    return new Vue({
        render: h => h(App)
    })
}
// entry-server.js
import { createApp } from './app'

export default context => {
    const app = createApp()
    return app
}

client-server.js 用将其挂载到id为appDOM结构中。

// client-server.js
import { createApp } from './app'

var app = createApp();

app.$mount('#app')

webpack 配置

build
  ├── webpack.base.config.js  # 基础通用配置
  ├── webpack.client.config.js  # 客户端打包配置
  └── webpack.server.config.js  # 服务器端打包配置

webpack.base.config.js 存放基础配置

const path = require("path");
const { VueLoaderPlugin } = require("vue-loader");

module.exports = {
  output: {
    // 打包到根目录下 `dist` 文件夹
    path: path.resolve(__dirname, "../dist"),
    publicPath: "/",
    filename: "[name].[chunkhash].js"
  },
  module: {
    // 解析 .vue 文件
    rules: [
      {
        test: /\.vue$/,
        loader: "vue-loader"
      },
      {
        test: /\.js$/,
        loader: "babel-loader",
        exclude: /node_modules/
      }
    ]
  },
  plugins: [new VueLoaderPlugin()]
};
// webpack.server.config.js
const path = require("path");
const merge = require("webpack-merge");
const base = require("./webpack.base.config");
// 用来打包生成的服务器端的bundle
// 最终可以将所有文件打包成一个json文件,最终传给服务器renderer使用。
const VueSSRServerPlugin = require("vue-server-renderer/server-plugin");

function resolve(name) {
  return path.resolve(__dirname, "..", name);
}

module.exports = merge(base, {
  target: "node",
  mode: 'production',
  // entry-server.js 作为入口
  entry: resolve("app/entry-server.js"),
  output: {
    libraryTarget: "commonjs2"
  },
  plugins: [new VueSSRServerPlugin()]
});

// webpack.client.config.js
const webpack = require("webpack");
const merge = require("webpack-merge");
const path = require("path")
const base = require("./webpack.base.config");
// 类似于VueSSRServerPlugin插件
// 主要的作用就是将前端的代码打包成bundle.json,然后传值给renderer
const VueSSRClientPlugin = require("vue-server-renderer/client-plugin");

function resolve(name) {
  return path.resolve(__dirname, "..", name);
}

module.exports = merge(base, {
  mode: 'production',
  entry: {
    app: [resolve("app/entry-client.js")]
  },
  plugins: [
    // extract vendor chunks for better caching
    new VueSSRClientPlugin(),
  ],
  optimization: {
    // 我们需要将运行环境提取到一个单独的 manifest 文件中
    runtimeChunk: {
      name: "mainifest"
    },
    // 相当于以前的 CommonsChunkPlugin 插件
    // 拆分 node_modules 代码形成 vendors.[hash].js 文件
    splitChunks: {
      chunks: "async",
      minSize: 30000,
      minChunks: 1,
      maxAsyncRequests: 5,
      maxInitialRequests: 3,
      automaticNameDelimiter: "~",
      name: true,
      cacheGroups: {
        vendor: {
          test: /node_modules\/(.*)\.js/,
          name: "vendors",
          chunks: "initial",
          priority: -10,
          reuseExistingChunk: false
        }
      }
    }
  }
});

分别打包两个webpack文件, 生成 dist 文件夹, 目录下分别出现以下文件:

有了这些文件,现在需要在 server/app.js中添加以下

const statics = require("koa-static");
const bundle = require("../dist/vue-ssr-server-bundle.json");

// 添加静态文件的中间件, 指向 dist 文件夹
app.use(statics(path.join(__dirname, "../dist")));

const renderer = createBundleRenderer(bundle, {
  template: require("fs").readFileSync(resolve("../index.html"), "utf-8"),
  clientManifest: require("../dist/vue-ssr-client-manifest.json")
});

router.get("/", ctx => {
  renderer.renderToString({}, (err, html) => {
    ctx.res.end(html);
  });
});

npm start启动服务器,渲染后的 index.html 文件如下所示

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Hello</title>
  <link rel="preload" href="/mainifest.bfe7c0725f559a154244.js" as="script"><link rel="preload" href="/vendors.4617e00e6036ba4dec5e.js" as="script"><link rel="preload" href="/app.645788af3f428d46b800.js" as="script"></head>
  <body>
    <div id="app" data-server-rendered="true"><span>times: 0</span> <button>-</button> <button>+</button>
  
  <div id="app"><span>times: 0</span> <button>+</button> <button>-</button> <div>312312</div> <title>312312</title></div></div>
  <script src="/mainifest.bfe7c0725f559a154244.js" defer></script>
  <script src="/vendors.4617e00e6036ba4dec5e.js" defer></script>
  <script src="/app.645788af3f428d46b800.js" defer></script>
    <!--这里将是应用程序 HTML 标记注入的地方 -->
  </body>
</html>

这里只是简单的使用最简单的Vue服务器渲染示例并在客户端对应将其激活,服务器渲染的其他部分比如路由、状态管理等部分可以自行谷歌,本文不多赘述。

五 GET & POST 请求

构建了上面的项目,接下来试试在.vue组件中调用ajax 请求。

<script>
import axios from "axios";
created() {
    axios
      .post("http://localhost:3001/user", {
        name: "you",
        age: 100
      })
      .then(res => {
        console.log("\n【API - get 数据】");
        console.log(res);
        this.name = res.data.title;
      })
      .catch(function(err) {
        console.log(err);
      });
  },
<script>

接下来在服务端代码里/user的路由中添加相关代码, 此时后端模仿传统的MVC模式对server文件夹进行改造

└── server # 服务端代码
    ├── controllers # 操作层 执行服务端模板渲染,json接口返回数据,页面跳转
        └──  user-info.js
    ├── models # 数据模型层 执行数据操作
    │   └── user-info.js
    ├── router # 路由层 控制路由
    │   └── user.js
    ├── services # 业务层 实现数据层model到操作层controller的耦合封装
    │   └── user-info.js

定位到 router/user.js 路由中

const userRouter = require("koa-router")();
const userController = require("../controllers/user-info");

userRouter.get("/", userController.getUserInfo);
module.exports = userRouter;
// controllers/user-info.js
module.exports = {
  async getUserInfo(ctx) {
    const formData = ctx.request.body;
 
    ctx.body = {
      success: false,
      message: '',
      data: { ...formData },
    }
  }
};

对于POST请求的处理,koa-bodyparser中间件可以把 koa2上下文的formData数据解析到ctx.request.body

// server/app.js 添加

const bodyParser = require('koa-bodyparser')
app.use(bodyParser())

启动服务器后,查看请求

六 连接 MySQL

1.首先本地安装启动启动 MySQL, 创建user

CREATE TABLE   IF NOT EXISTS  `user` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) DEFAULT NULL,
  `age` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

2.创建 sql/index.js 用于连接 MySQL 数据库

首先测试一下连接

var mysql = require("mysql");

var connection = mysql.createConnection({
  host: "localhost",
  user: "root", //用户名
  password: "*****",  //密码
  database: "node_ssr" //数据库名
});

// 简历连接
connection.connect();

// 查询
connection.query("SELECT * FROM user", function(error, results, fields) {
  if (error) throw error;
  console.log(results);
});

// 插入数据
connection.query(
  "INSERT INTO user(id, name, age) VALUES(0, ?, ?)",
  ["xiatian", "24"],
  function(err, res) {
    if (err) {
      console.log("新增错误:");
      console.log(err);
      return;
    } else {
      console.log("新增成功:");
      console.log(res);
    }
  }
);

// 关闭连接
connection.end();

连接正常之后, 改为如下

var mysql = require("mysql");
var pool = mysql.createPool({
  host: "localhost",
  user: "root",
  password: "****",
  database: "node_ssr"
});

// 暴露 query 方法, 供 model 模块使用
exports.query = (sql, values) => {
  return new Promise((resolve, reject) => {
    pool.getConnection((err, connection) => {
      if (err) {
        console.log(err);
        reject(err);
      }
      connection.query(sql, values, (error, results) => {
        if (error) {
          console.log(error);
          reject(error);
        } else {
          resolve(results);
        }
        connection.release();
      });
    });
  });
};

转到 server/model/user-info.js 文件, 导入 query

const { query } = require("../../sql/index");

const user = {
  // 新建用户
  async create(body) {
    const _sql = "INSERT INTO ?? SET ?";
    const result = await query(_sql, ["user", body]);
    return result;
  }
};

module.exports = user
// server/service/user-info.js
const model = require('../model/user-info')

const user = {
  async create(user) {
    // 调用 model 模版, 返回结果
    const result = await model.create(user)
    return result
  }
}

module.exports = user
// server/controllers/user-info.js
// 请求数据库 -> 获取数据库返回数据, 并返回
const userInfoService = require("../service/user-info");

module.exports = {
  async SignUp(ctx) {
    const formData = ctx.request.body;
    let result = {
      success: false,
      message: '',
      data: null
    }

    let userResult = await userInfoService.create({
      id: 0,
      name: formData.name,
      age: formData.age,
    });
    if ( userResult && userResult.insertId * 1 > 0) {
      result.success = true
    }
    ctx.body = result
  }
};

路由和请求方式也需要跟着改变

再次启动服务器, 查看请求

总结

以上,只是以最简单的方式,尝试开发一个服务端渲染的应用, 完全只是简单的入门,并没有什么难度,但这也是让我从每天在SPA的应用中走出来,学习一点点node知识的好机会。

参考文献

nodejs搭建多页面服务端渲染

《Koa2进阶学习笔记》

带你走近Vue服务器端渲染(VUE SSR)