Koa2 + Vue 从零开始搭建服务端渲染应用
前言
最近公司要求做一个类似官网的应用,为了解决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 bundle
和client 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 环境下使用模版
- 在根目录下创建
index.html
文件
<!DOCTYPE html>
<html lang="en">
<head>
<title>Hello</title>
</head>
<body>
<!--vue-ssr-outlet-->
<!--这里将是应用程序 HTML 标记注入的地方 -->
</body>
</html>
- 修改
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为app
的DOM
结构中。
// 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
知识的好机会。
参考文献
转载自:https://juejin.cn/post/6844903991260741639