服务端将Vue组件渲染为HTML 字符串,并将html字符串直接发送到浏览器,最后将这些静态标记"激活"为客户端上彻底可交互的应用程序。javascript
|—— components // 子组件
| |—— Foo.vue
| |—— Bar.vue
|
|—— App.vue // 根组件
|—— index.js // 入口文件
|—— webpack.config.js
复制代码
代码很简单就是一个很普通的vue项目(包括一些点击事件,数据绑定), 典型的客户端渲染。php
刚开始接触web开发,都是以html页面为模板,把后端数据塞到模板中,像.php、.jsp文件。还有与node 结合使用的artTemplate,ejs等。css
而Vue 的服务端渲染也分为两步:html
官方提供一个插件 vue-server-renderer 能够直接将vue 实例渲染成 Dom 标记vue
const Vue = require('vue')
const app = new Vue({
template: `<div>Hello World</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 World</div>
})
// 在 2.5.0+,若是没有传入回调函数,则会返回 Promise:
renderer.renderToString(app).then(html => {
console.log(html)
}).catch(err => {
console.error(err)
})
复制代码
与服务端结合, 经过请求返回html 页面java
const Vue = require('vue')
const Koa = require('koa');
const Router = require('koa-router');
const renderer = require('vue-server-renderer').createRenderer()
const app = new Koa();
const router = new Router();
router.get('*', async (ctx, next) => {
const app = new Vue({
data: {
url: ctx.request.url
},
template: `<div>访问的 URL 是: {{ url }}</div>`
})
renderer.renderToString(app, (err, html) => {
if (err) {
ctx.status(500).end('Internal Server Error')
return
}
ctx.body = ` <!DOCTYPE html> <html lang="en"> <head><title>Hello</title></head> <body>${html}</body> </html> `
})
})
app
.use(router.routes())
.use(router.allowedMethods());
app.listen(8080, () => {
console.log('listen 8080')
})
复制代码
从demo1 能够看出vue-server-renderer 方法返回的是一个html 片断 官方叫标记(markup), 并非完整的html 页面。 咱们必须像demo2中那样用一个额外的 HTML 页面包裹容器,来包裹生成的 HTML 标记。node
咱们能够提供一个模板页面。例如webpack
<!DOCTYPE html>
<html lang="en">
<head><title>Hello</title></head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
复制代码
注意 <!--vue-ssr-outlet-->
注释这里将是应用程序 HTML 标记注入的地方。 这是插件提供的,若是不用 <!--vue-ssr-outlet-->
也是能够的,那就要本身去简单处理一下了。好比demo3git
<!DOCTYPE html>
<html lang="en">
<head><title>Hello</title></head>
<body>
{injectHere}
</body>
</html>
复制代码
const template = require('fs').readFileSync(path.resolve(__dirname, './index.template.html'), 'utf-8')
ctx.body = template.replace('{injectHere}', html)
复制代码
既然在客户端和服务端上都能运行,那应该有两个入口文件。一些 Dom, Bom 的操做在服务端确定是不行的.github
一般 Vue 应用程序是由 webpack 和 vue-loader 构建,而且许多 webpack 特定功能不能直接在 Node.js 中运行(例如经过 file-loader 导入文件,经过 css-loader 导入 CSS)
app.js
import Vue from 'vue'
import App from './App.vue'
export function createApp() {
const app = new Vue({
render: h => h(App)
})
return { app }
}
复制代码
enter-client.js
import { createApp } from './app.js'
const { app } = createApp()
// App.vue 模板中根元素具备 `id="app"`
app.$mount('#app')
复制代码
enter-server.js
import { createApp } from './app.js';
export default context => { // koa 的 context
const { app } = createApp()
return app
}
复制代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>服务端渲染</title>
</head>
<body>
<!--vue-ssr-outlet-->
<!-- 引入客户端打包后的js文件(client.bundle.js) -->
<script type="text/javascript" src="<%= htmlWebpackPlugin.options.files.js %>"></script>
</body>
</html>
复制代码
webpack.server.config.js
const path = require('path');
const merge = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const base = require('./webpack.base.config');
module.exports = merge(base, {
// 这容许 webpack 以 Node 适用方式(Node-appropriate fashion)处理动态导入(dynamic import),
// 而且还会在编译 Vue 组件时,
// 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
target: 'node',
entry: {
server: path.resolve(__dirname, '../entry-server.js')
},
output: {
// 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports)
libraryTarget: 'commonjs2'
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '../../index.ssr.html'),
filename: 'index.ssr.html',
files: {
js: 'client.bundle.js' // index.ssr.html 中引入的js文件是客户端打包出来的client.bundle.js。这是由于 Vue 须要在浏览器端接管由服务端发送的静态 HTML,使其变为由 Vue 管理的动态 DOM。这个过程官方称为客户端激活
},
excludeChunks: ['server']
})
]
});
复制代码
webpack.client.config.js
const path = require('path')
const merge = require('webpack-merge')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const base = require('./webpack.base.config')
module.exports = merge(base, {
entry: {
client: path.resolve(__dirname, '../entry-client.js')
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '../../index.html'),
filename: 'index.html'
})
]
})
复制代码
这是比较完整的 客户端接管由服务端渲染Vue 实例发送的静态 HTML,并由 Vue 管理的动态Dom 的例子。完整代码 03
Vue 项目的路由管理由vue-router 来负责,和 02 项目同样, 服务端返回渲染后的html, 剩下的就交给Vue了。
router.js
import Vue from 'vue'
import Router from 'vue-router'
import Bar from "./components/Bar.vue";
import Foo from "./components/Foo.vue";
const routes = [
{ path: '/foo', component: Foo },
{ path: '/bar', component: Bar }
]
Vue.use(Router)
export function createRouter() {
// 建立 router 实例,而后传 `routes` 配置
// 你还能够传别的配置参数, 不过先这么简单着吧。
return new Router({
mode: 'history',
routes
})
}
复制代码
app.js 引入router
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
// 导出一个工厂函数,用于建立新的
// 应用程序、router 和 store 实例
export function createApp() {
// 建立 router 实例
const router = createRouter()
const app = new Vue({
// 注入 router 到根 Vue 实例
router,
// 根实例简单的渲染应用程序组件。
render: h => h(App)
})
return { app, router }
}
复制代码
这样就能够了吗, 显然还不够,Vue 优化上,咱们通常会选择惰性加载组件,而不是一会儿所有加载。那咱们就须要简单修改一下entry-server.js 和 router.js 文件了。
router.js
import Vue from 'vue'
import Router from 'vue-router'
const routes = [
// webpack.base.config.js 中须要配置 @babel/plugin-syntax-dynamic-import
{ path: '/foo', component: () => import('./components/Foo.vue') },
{ path: '/bar', component: () => import('./components/Bar.vue') }
]
Vue.use(Router)
export function createRouter() {
// 建立 router 实例,而后传 `routes` 配置
// 你还能够传别的配置参数, 不过先这么简单着吧。
return new Router({
mode: 'history',
routes
})
}
复制代码
因为加入了异步路由钩子函数或组件,因此咱们将返回一个 Promise,以便服务器可以等待全部的内容在渲染前,就已经准备就绪。 咱们如今的entry-server.js 更新成这样
entry-server.js
import { createApp } from './app.js';
export default context => {
// 由于有可能会是异步路由钩子函数或组件,因此咱们将返回一个 Promise,
// 以便服务器可以等待全部的内容在渲染前,
// 就已经准备就绪。
return new Promise((resolve, reject) => {
const { app, router } = createApp()
if (context.url.indexOf('.') === -1) { // 防止匹配 favicon.ico *.js 文件
router.push(context.url)
}
// 设置服务器端 router 的位置
console.log(context.url, '******')
// 等到 router 将可能的异步组件和钩子函数解析完
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
// 匹配不到的路由,执行 reject 函数,并返回 404
if (!matchedComponents.length) {
return reject({ code: 404 })
}
// Promise 应该 resolve 应用程序实例,以便它能够渲染
resolve(app)
}, reject)
})
}
复制代码
entry.client.js
import { createApp } from './app.js'
const { app, router } = createApp()
router.onReady(() => {
// 这里假定 App.vue 模板中根元素具备 `id="app"`
app.$mount('#app')
})
复制代码
因为用到了,异步路由这个时候,打包的bundle.js不包括异步组件的js文件。还按照上面直接引入 server.bundle.js 的话,会报错找不到相关的异步组件的js文件。
因此这里咱们用vue-server-renderer下的插件vue-server-renderer/server-plugin把server.entry.js文件打包成一个json 文件, 而json 文件中会把全部的异步组件和相关的js一一map。
从上面几个例子能够看到,在服务器端渲染(SSR)期间,咱们本质上是在渲染一个静态文件,后续的交互仍是交给了客户端的vue,因此若是应用程序依赖于一些须要初始化的异步数据,那么在开始渲染过程以前,须要先预取和解析好这些数据。
还有一个问题是在挂载 (mount) 到客户端应用程序以前,须要获取到与服务器端应用程序彻底相同的数据 - 不然,客户端应用程序会由于使用与服务器端应用程序不一样的状态,而后致使混合失败。
为了解决这个问题,获取的数据须要位于视图组件以外,即放置在专门的数据预取存储容器(data store)或"状态容器(state container))"中。首先,在服务器端,咱们能够在渲染以前预取数据,并将数据填充到 store 中。此外,咱们将在 HTML 中序列化(serialize)和内联预置(inline)状态。这样,在挂载(mount)到客户端应用程序以前,能够直接从 store 获取到内联预置(inline)状态。
即在全部预取钩子(preFetch hook) resolve 后,咱们的 store 已经填充入渲染应用程序所需的状态。当咱们将状态附加到上下文,而且 template
选项用于 renderer 时,状态将自动序列化为 window.__INITIAL_STATE__
,并注入 HTML。在客户端咱们就能够经过全局变量window.__INITIAL_STATE__拿到数据。
咱们用官方的状态管理库 的VueX 。
store.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
// 一个能够返回 Promise 的 API
import { fetchItem } from './api'
export function createStore () {
return new Vuex.Store({
state: {
items: {}
},
actions: {
fetchItem ({ commit }, id) {
// `store.dispatch()` 会返回 Promise,
// 以便咱们可以知道数据在什么时候更新
return fetchItem(id).then(item => {
commit('setItem', { id, item })
})
}
},
mutations: {
setItem (state, { id, item }) {
Vue.set(state.items, id, item)
}
}
})
}
复制代码
app.js
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import { createStore } from './store'
// 导出一个工厂函数,用于建立新的
// 应用程序、router 和 store 实例
export function createApp() {
const router = createRouter()
const store = createStore()
const app = new Vue({
router,
store,
// 根实例简单的渲染应用程序组件。
render: h => h(App)
})
return { app, router, store }
}
复制代码
那么,咱们在哪里放置「dispatch 数据预取 action」的代码?
咱们须要经过访问路由,来决定获取哪部分数据 - 这也决定了哪些组件须要渲染。事实上,给定路由所需的数据,也是在该路由上渲染组件时所需的数据。因此在路由组件中放置数据预取逻辑,是很天然的事情。
咱们将在路由组件上暴露出一个自定义静态函数 asyncData。注意,因为此函数会在组件实例化以前调用,因此它没法访问 this。须要将 store 和路由信息做为参数传递进去, 因此如今咱们的 entry-server.js 如今变成这样
entry-server.js
import { createApp } from './app.js';
export default context => {
// 由于有可能会是异步路由钩子函数或组件,因此咱们将返回一个 Promise,
// 以便服务器可以等待全部的内容在渲染前,
// 就已经准备就绪。
return new Promise((resolve, reject) => {
const { app, router, store } = createApp()
if (context.url.indexOf('.') === -1) {
// 设置服务器端 router 的位置
router.push(context.url)
}
console.log(context.url, '******')
// 等到 router 将可能的异步组件和钩子函数解析完
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
// 匹配不到的路由,执行 reject 函数,并返回 404
if (!matchedComponents.length) {
router.push('/foo') // 能够加个默认页面, 或者是404页面
// return reject({ code: 404 })
}
Promise.all(matchedComponents.map(component => {
if (component.asyncData) {
return component.asyncData(
{
store,
route: router.currentRoute
})
}
})).then(() => {
// 当使用 template 时,context.state 将做为 window.__INITIAL_STATE__ 状态,
//自动嵌入到最终的 HTML 中。而在客户端,在挂载到应用程序以前,store 就应该获取到状态
context.state = store.state
// Promise 应该 resolve 应用程序实例,以便它能够渲染
resolve(app)
}).catch(reject)
}, reject)
})
}
复制代码