本文对应的 SSR demo 项目仓库为: https://github.com/njleonzhang/play-vue-ssr
前文开坑,继续填,😂。
普通的非SSR Vue 项目
先从我们熟悉的普通的 vue 项目出发, 假设我们有一个 vue 项目,只有这个一个 vue 实现的页面:
// app.vue
<template>
<div>
hello world
<div v-if='show'>show me</div>
<div>
<button @click='toggle'>toggle</button>
</div>
</div>
</template>
<script>
export default {
data() {
return {
show: true
}
},
methods: {
toggle() {
this.show = !this.show
}
}
}
</script>
为了让这个页面在浏览器里跑起来,我们要为这个 vue 文件写一个入口 js 文件 main.js
, 一个 index.html
模板文件 和 一个 webpack
配置文件。整个过程大致如下图:
简单实现一下这几个文件:
// main.js
import Vue from 'vue'
import App from './App.vue'
new Vue({
render: h => h(App)
}).$mount('#app')
// webpack config file
const path = require('path')
const { VueLoaderPlugin } = require('vue-loader')
const isProd = process.env.NODE_ENV === 'production'
module.exports = {
entry: {
app: './src/entry-client.js'
},
output: {
path: path.resolve(__dirname, '../dist'),
publicPath: '/dist/',
filename: 'bundle.js'
},
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader',
},
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
},
]
},
plugins: [
new VueLoaderPlugin()
],
}
这样 webpack
编译后,就会生成 bundle.js
了, 然后我们粗暴地来手动 inject
一下 js
。
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<script src='/bundle.js'>
</body>
</html>
👌,现在把这个 index.html
文件和 bundle.js
发布出去,这个 demo 站就好了。
改造成SSR
请出大神的图:
忽略 Store
和 Router
, 那么尤大的这个图和我们上面的非SSR版本的图的主要区别在于以下2点:
-
生成的
bundle.js
文件有2个这点比较好理解,前文中我们就提到过: SSR 的架构中,页面需要在后台被渲染好然后返回给前台,再由前台的
bundle js
hydrate 后接管页面。所以我们的源码既需要跑在前台,也需要跑在后台。跑在后台时,就是要跑在 Node 环境,跑在前台时就需要跑在浏览器环境, 那么我们自然需要打包出2份不一样的 bundle js 咯.我们改造一下
webpack
文件, 以生成2份 bundle。client
和非 SSR 版本的配置文件基本一致,server
版的配置文件指明目标是 node.// webpack.client.config.js const config = merge(base, { entry: { app: './src/entry-client.js' }, output: { filename: 'client-bundle.js' }, .... )
// webpack.server.config.js const config = merge(base, { target: 'node', // 目标是 node entry: './src/entry-server.js', output: { filename: 'server-bundle.js', libraryTarget: 'commonjs2' // 编译成 commonjs }, .... )
-
和 entry 相关的 js 文件有3个 (app.js, client entry, server entry)
这个问题类似于 Vue component 的 data 属性为什么需要使用函数。当前的例子里还没有引入
Store
和Route
, 所有只从当前的例子来看还不太好理解。我们想象一下,每次有用户来访问我们网站的时候,后台都给他渲染一个页面,如果 Node 服务器始终用一个 Vue app 实例去渲染,则很容易不同用户之间数据串掉的问题,所以 Node 服务器这个 Bundle 需要是一个工厂函数,每次用户来访问,我都生成一个新的 Vue app 实例,并用这个新的,干净的实例去渲染页面。尤大的解释可能更抽象一点: 当编写纯客户端(client-only)代码时,我们习惯于每次在新的上下文中对代码进行取值。但是,Node.js 服务器是一个长期运行的进程。当我们的代码进入该进程时,它将进行一次取值并留存在内存中。这意味着如果创建一个单例对象,它将在每个传入的请求之间共享。
如果,你暂时不能理解原因,那也没关系,先记住好了,Node 服务器端的这个 bundle 里的 Vue app 需要是一个工厂方法。后面我们说到 Router 和 State 的时候,我们再回过头来看这个问题。
我们改造非 SSR版本的
main.js
, 我们抽取一个公共函数,用于生成 Vue app 的实例:// app.js import Vue from 'vue' import App from './App.vue' // 导出一个工厂函数,用于创建新的 export function createApp () { const app = new Vue({ // 根实例简单的渲染应用程序组件。 render: h => h(App) }) return { app } }
对于客户端的 bundle,我们还是只需要一份 vue app,所以直接创建一个 app, 然后 mount 就好. 代码逻辑和原来非 SSR 的版本实际上是一样的。
// entry-client.js import { createApp } from './app' const { app } = createApp() // 这里假定 App.vue 模板中根元素具有 `id="app"` app.$mount('#app')
对于服务器版本的 bundle, 我们需要一个工厂方法:
// entry-server.js import { createApp } from './app' export default context => { const { app } = createApp() return app }
至此,这个非 SSR 的 vue 项目到 SSR 版本的改造基本完成,我们还缺个 Node 服务器用于做后台渲染,当然这个是 SSR 架构所特有的。
Node 服务器
这里我只是把尤大的例子稍微改了改:
const Vue = require('vue')
const express = require('express')
const server = express()
const createRenderer = require('vue-server-renderer').createRenderer
const app = require('./dist/server-bundle')
const renderer = createRenderer({
template: require('fs').readFileSync('./index.template.html', 'utf-8'),
})
server.use(express.static('dist')) // 为了让 client-bundle.js 能够被加载
server.get('*', (req, res) => {
const context = {
title: 'hello',
meta: `
<meta charset="utf8">
`
}
renderer.renderToString(app.default(), context, (err, html) => {
if (err) {
res.status(500).end('Internal Server Error')
return
}
res.send(html)
})
})
server.listen(8080, () => {
console.log(`server started at localhost:8080`)
})
可以看到用户对我们页面的访问,会最终由 renderer.renderToString
做一次渲染,渲染的结果就是我一直提到的真的(完整的)壳子html
.这个渲染出来的壳子html
被返回给前台后,它需要加载 client bundle,进而进行 hydrate。所以在这个壳子里需要注入 client bundle。从简单阐述的目的出发,我也是做了暴力的手动 inject:
// index.template.html
<html>
<head>
<!-- 使用双花括号(double-mustache)进行 HTML 转义插值(HTML-escaped interpolation) -->
<title>{{ title }}</title>
<!-- 使用三花括号(triple-mustache)进行 HTML 不转义插值(non-HTML-escaped interpolation) -->
{{{ meta }}}
</head>
<body>
<!--vue-ssr-outlet-->
<script src='/client-bundle.js'></script> // 暴力 inject。为了让这个请求能成功
// node server 里特别加了一个静态文件的配置
// server.use(express.static('dist'))
</body>
</html>
效果
首先,后台返回的内容里确实是一个完整的页面,SEO 无忧啦!首屏白屏无忧啦!
再看一下前端 vue 是否接管了页面:
Bingo!!! @click
和 v-if
可以正常工作。
读者可以自己尝试下:
git clone https://github.com/njleonzhang/play-vue-ssr.git
npm install
git checkout level1
npm run start
或者直接看这个commit的代码。
总结
我们搭架了一个特别简单的 SSR 项目,没有 Router, 没有 State, 没有各种 dev 和 pro 处理。但是有时候简单例子却最能说明问题的本质。后面的几节里,我们会慢慢加上 Router,State,数据预取等功能,关于 dev 和 pro 处理等工程化实践的内容依然不会讨论,如果你需要了解相关内容,可以直接去看 Nuxt.js 的文档,Nuxt.js 可能是 Vue SSR 实际工程使用的最佳实践。
系列文章: