什么是服务器端渲染 (SSR)?
Vue.js 是构建客户端应用程序的框架。默认情况下,可以在浏览器中输出 Vue 组件,进行生成 DOM 和操作 DOM。然而,也可以将同一个组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上完全可交互的应用程序。
这个其实跟我们普遍采用 PHP, JAVA 等后端语言渲染模板类似,只不过允许在我们写的同一套 Vue 代码在客户端/服务端分别执行,替代了之前两端使用不同技术栈的尴尬情况。
为什么使用服务器端渲染 (SSR)?
与传统 SPA (单页应用程序 (Single-Page Application)) 相比,服务器端渲染 (SSR) 的优势主要在于:
- 更好的 SEO:如果 SEO 对你的站点至关重要,而你的页面又是异步获取内容,则你可能需要服务器端渲染(SSR)解决此问题。
- 更快的内容到达时间 (time-to-content)
CSR:客户端渲染
SSR:服务端渲染
可以看出,用户看见界面的时间提前了!
你真的需要服务器端渲染 (SSR)吗?
服务器端渲染 (SSR) 并不是银弹,使用他时需要有一些权衡之处:
- 使用生命钩子时需要更加谨慎:不同的环境造成了某些代码只能在指定的生命钩子执行
- 外部扩展库 (external library) 可能需要特殊处理,才能在服务器渲染应用程序中运行:服务端无 window,无dom,生命钩子不同等
- 如果在高流量的环境下使用,需要注意“服务器负载”,“缓存策略”,“安全控制”等后端概念
由此可以看出:使用 SSR 这种技术,将使原本简单的 Vue 项目变得非常复杂,项目的可维护性会降低,代码问题的追溯也会变得困难。
所以,使用 SSR 在解决问题的同时,也会带来非常多的副作用,有的时候,这些副作用的伤害比起 SSR 技术带来的优势要大的多。从个人经验上来说,我一般建议大家,除非你的项目特别依赖搜索引擎流量,或者对首屏时间有特殊的要求,否则不建议使用 SSR。
如果你认为你的网站特别适合 SSR 的场景,那么就接着往下看吧!
服务器端渲染 vs 预渲染 (SSR vs Prerendering)
如果你调研服务器端渲染 (SSR) 只是用来改善少数营销页面(例如 /, /about, /contact 等)的 SEO,那么你可能需要预渲染(Prerendering)。使用 prerender-spa-plugin 轻松地添加预渲染。
同时,网络上也有一些大量页面预渲染的方案选择,例如:prerender.io/
价格不贵,原理是大量缓存动态页面,在享受 SSR 带来的提升外,规避 SSR 的风险。
服务器端渲染 (SSR) 原理
下面这张图来源于 Vue & SSR: The best practices - Sebastien Chopin at VueConf.US 他完整地阐述了 SSR 的编译过程:
- 跟往常一样在 /src 中编写你的应用,但为了适应服务端的渲染,你可能需要编写一些额外代码,例如配合 vue-router 在 beforeRouteUpdate 上 mixin 一个新的 asyncData 生命钩子,用于获取数据(client/server)
- webpack 通过 webpack[client/server].config.js 找到对应的 entry 来打包程序,生成 server Bundle 与 client Bundle
- 访问页面时,服务端吐出一堆被标记(markup)的 HTML,然后在客户端通过 Hydrate 操作变为可响应的页面
SSR 之所以能够实现,本质上是因为虚拟 DOM 的存在 由于在 SSR 的工程中,React 代码会在客户端和服务器端各执行一次。但由于环境不同例如在 Node 中,如果你的代码或者/第三方库中有直接修改 DOM/window 对象的行为,会导致执行错误。这就需要将操作 DOM 变为操作 Virtual DOM,在 Node 中输出字符串,在 Client 输出真实 DOM
服务器端渲染 (SSR) 执行流程
vue-hackernew 2.0 SSR 版到底是怎样运作的?
源码仓库:vue-hackernews-2.0
package.json
可以看出主要有两类操作:node server(启动服务器) 与 npm run build(编译静态资源 Client/Server)
"scripts": {
"dev": "node server",
"start": "cross-env NODE_ENV=production node server",
"build": "rimraf dist && npm run build:client && npm run build:server",
"build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js --progress --hide-modules",
"build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js --progress --hide-modules",
"postinstall": "npm run build"
}
那么我们先从 node server 开始,看看服务端做了什么操作。
Server.js
// 以下是被简化过的代码
let renderer
const templatePath = resolve('./src/index.template.html')
// 由 webpack.server.config.js - new VueSSRServerPlugin() 生成
const bundle = require('./dist/vue-ssr-server-bundle.json')
// 由 webpack.client.config.js - new VueSSRClientPlugin() 生成
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
// createRenderer 实现
function createRenderer (bundle, options) {
// https://github.com/vuejs/vue/blob/dev/packages/vue-server-renderer/README.md#why-use-bundlerenderer
return createBundleRenderer(bundle, Object.assign(options, {
// 配置路由缓存
cache: LRU({
max: 1000,
maxAge: 1000 * 60 * 15
}),
// this is only needed when vue-server-renderer is npm-linked
basedir: resolve('./dist'),
// recommended for performance
runInNewContext: false
}))
}
// 生成 renderer 实例
renderer = createRenderer(bundle, {
template,
clientManifest
})
function render (req, res) {
const context = {
title: 'Vue HN 2.0',
url: req.url
}
// 根据 url 找到对应的组件渲染为字符串输出
renderer.renderToString(context, (err, html) => {
if (err) {
return handleError(err)
}
res.send(html)
})
}
// 路由监听
// 我们的服务器代码使用了一个 * 处理程序,它接受任意 URL。
// 这允许我们将访问的 URL 传递到我们的 Vue 应用程序中,然后对客户端和服务器复用相同的路由配置!
app.get('*', render)
const port = process.env.PORT || 8080
app.listen(port, () => {
console.log(`server started at localhost:${port}`)
})
过程很简单,Server.js 手持 templatePath、bundle、clientManifest 生成 renderer,服务端监听任意路由,最后由 renderer.renderToString(传入 context.url) 输出对应的 HTML 字符串到客户端。
你或许会困惑 clientManifest、bundle 到底哪里来的?
**clientManifest:**由 webpack.client.config.js - entry-client.js - new VueSSRClientPlugin() 生成
**bundle:**由 webpack.server.config.js - entry-server.js - new VueSSRServerPlugin() 生成
当 renderer 具有了服务器(bundle)和客户端(clientManifest)的构建信息,它可以自动推断和注入资源预加载 / 数据预取指令(preload / prefetch directive),以及 css 链接 / script 标签到所渲染的 HTML。
那么在运行服务端之前,我们必须先编译静态资源
build:client / build:server
由于在 SSR 环境中,Vue、router、Vuex 都需要在两端公用,所以在 CSR 模式中的入口 app.js 只是作为一个工厂函数输出带有独立 Context 的 app, router, store。
为什么要输出独立的 Context ?在客户端来看,在他们的生命周期内都只会有一个实例,但在服务端,需要服务成百上千个应用,如果没有独立的 Context,状态便会乱成一锅粥。
// app.js
import Vue from 'vue'
import App from './App.vue'
import { createStore } from './store'
import { createRouter } from './router'
import { sync } from 'vuex-router-sync'
// 示例代码 - createStore
export function createStore() {
return new Store({...Options})
}
// 示例代码 - createRouter
export function createRouter() {
return new Router({...Options})
}
export function createApp () {
const store = createStore()
const router = createRouter()
// 允许 'store.state.route' 获取路由信息
sync(store, router)
const app = new Vue({
router,
store,
render: h => h(App)
})
return { app, router, store }
}
取代 app.js 成为入口文件的则分别是 entry-client.js,entry-server.js
entry-client.js
要理解 entry-client.js 究竟怎样运作的,必须先了解 **asyncData **这个钩子(hook)
asyncData 钩子时由 entry-client.js mixin 到每个组件中,并注入 store,router
Vue.mixin({
beforeRouteUpdate (to, from, next) {
const { asyncData } = this.$options
if (asyncData) {
asyncData({
store: this.$store,
route: to
}).then(next).catch(next)
} else {
next()
}
}
})
这个 asyncData 钩子将作为客户端/服务端获取数据的约定 hook
// ItemView.vue
asyncData ({ store, route: { params: { id }}}) {
return store.dispatch('FETCH_ITEMS', { ids: [id] })
}
开始执行 entry-client.js
// 下面单独解释这个是什么东西
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
// 该方法把一个回调排队,在路由完成初始导航时调用,
// 这意味着它可以解析所有的异步进入钩子和路由初始化相关联的异步组件。
// 这可以有效确保服务端渲染时服务端和客户端输出的一致。
router.onReady(() => {
// 当前所有异步组件已被 resolved
router.beforeResolve((to, from, next) => {
const matched = router.getMatchedComponents(to)
const prevMatched = router.getMatchedComponents(from)
let diffed = false
const activated = matched.filter((c, i) => {
return diffed || (diffed = (prevMatched[i] !== c))
})
const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _)
if (!asyncDataHooks.length) {
return next()
}
bar.start()
Promise.all(asyncDataHooks.map(hook => hook({ store, route: to })))
.then(() => {
bar.finish()
next()
})
.catch(next)
})
// actually mount to DOM
app.$mount('#app')
})
在确保所有组件(同步/异步)都解析完毕后,获取当前组件所有的 asyncDataHooks 所返回的数据,渲染页面
entry-client.js 是客户端的入口,跟一般的 CSR 没太大区别,只是多了个 asyncData hook 作为获取数据的约定。
entry-server.js
还记得之前 vue-ssr-server-bundle.json 吗?这个入口就是生成这玩意的。
export default context => {
return new Promise((resolve, reject) => {
const s = isDev && Date.now()
const { app, router, store } = createApp()
// 为什么这里 context 会有 url 这个属性?
// 还记得 server.js 里
// const context = { title: 'Vue HN 2.0',url: req.url }
// renderer.renderToString(context, (err, html) => {} 吗?
const { url } = context
const { fullPath } = router.resolve(url).route
if (fullPath !== url) {
return reject({ url: fullPath })
}
// set router's location
router.push(url)
// wait until router has resolved possible async hooks
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
// no matched routes
if (!matchedComponents.length) {
return reject({ code: 404 })
}
// 同样是获取当前组件 asyncData 所返回的数据
Promise.all(matchedComponents.map(({ asyncData }) => asyncData && asyncData({
store,
route: router.currentRoute
}))).then(() => {
isDev && console.log(`data pre-fetch: ${Date.now() - s}ms`)
// 这里是将服务端已经异步获取完毕的数据 store.state 替换 context.state
context.state = store.state
resolve(app)
}).catch(reject)
}, reject)
})
}
entry-server.js 生成完 bundle 后,被 renderer 拿去实例化,在 renderer.renderToString(context, (err, html) => {} 时传入的 context 完成了大宇宙的一统。
window.__INITIAL_STATE__
你还未解释这个是什么意思呢?
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
这是服务端通过 asyncData hook 获取了初始化的数据,渲染完后推送到 HTML 模板中。这时候该到客户端渲染了,(⊙o⊙)…没有服务端的初始化数据,怎么办?
这时候服务端通过
- context.state = store.state
- renderer.renderToString 时将 window._INITIAL STATE = context.state
- 客户端检测到 window._INITIAL STATE 存在并用之替换掉自己的 store
这样就完成了服务端数据与客户端数据的共享
dist 出物
红框是 entey-client.js 入口所编译出的东西
绿框是 entey-server.js 入口所编译出的东西
部署
npm run build && npm run start
have fun!
总结
可以看出,要制作一个 SSR 的网站并不简单,涉及了 webpack express/koa Vue Vuex Router 等诸多知识点。还好这些麻烦事有 Nuxt/Next 帮我们处理了
我们的目标是创建一个灵活的应用框架,你可以基于它初始化新项目的基础结构代码,或者在已有 Node.js 项目中使用 Nuxt.js。
你可以认为这个是 SSR 版本的 vue-cli / Create React App,你只需要专注于编写业务代码就好(前提是你需要遵循框架约定好的目录)。
参考文章
React 中同构(SSR)原理脉络梳理
Vue.js 服务器端渲染指南
你不知道的webpack和webpack-dev-server高级玩法