大前端进阶-如何搭建vue ssr站点

为何需要ssr

  1. 解决单页应用首屏渲染慢的问题。
  2. 解决SEO爬虫抓取时无法获取单页应用完全渲染的页面问题。

完整样例

vue-ssr

基本用法

服务端ssr渲染最基础的功能就是将Vue组件渲染成HTML并返回到浏览器进行展示。

此处需要用到两个依赖包:

npm install vue vue-server-renderer --save

依赖添加完成后,在项目目录下添加index.js文件,该文件作为node启动程序的入口。

const Vue = require('vue')
const { createRenderer } = require('vue-server-renderer')
const app = new Vue({
    template: `<div>Hello World</div>`
})
const renderer = createRenderer()
renderer.renderToString(app, (err, html) => {
    if (err) throw err
    console.log(html)
})

利用renderer提供的renderToString方法可以将vue组件渲染成html字符串,渲染后的结果如下:

image.png

可以看到,渲染完成的html节点添加了data-server-rendered,该属性在后续将用于区分是服务端渲染还是正常的客户端渲染。

完成将vue实例渲染成html字符串之后,还需要搭建一个服务,用于响应用户的请求,并将html字符串返回浏览器用于展示。

此处使用node express

npm install express --save

将上面的渲染代码进行改造,添加服务:

const Vue = require('vue')
const express = require('express')
const { createRenderer } = require('vue-server-renderer')
const { request, response } = require('express')
const app = new Vue({
    template: `<div>Hello World</div>`
})
const renderer = createRenderer()

// 创建服务
const server = express()
server.get('*', async (request, response) => {
    try {
        // 支持中文
        response.setHeader('Content-type',   'text/html;charset=utf-8')
        const html = await renderer.renderToString(app)
        response.end(`
            <!DOCTYPE html>
                <html lang="en">
                <head><title>Hello</title></head>
                <body>${html}</body>
            </html>
        `)
    } catch{
        response.status(500).end('Internal Server Error')
    }
})
// 启动服务 
server.listen(3000)

此时访问http://localhost:3000/就可以正常看到内容被渲染输出到浏览器上了。

使用模版

在基础用例中,我们将vue实例渲染成html字符串,如果需要浏览器能够正常显示,必须用HTML页面包裹这个html字符串。

response.end(`
    <!DOCTYPE html>
    <html lang="en">
        <head><title>Hello</title></head>
        <body>${html}</body>
    </html>
`)

此处明显使用html模版更为方便,在createRenderer创建renderer的时候支持传入模版index.template.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Hello</title>
</head>
<body>
     <!--html字符串注入入口-->
     <!--vue-ssr-outlet-->
</body>
</html>

在模版中必须包含<!--vue-ssr-outlet-->,renderer在生成html字符串之后,会插入到这个地方。

在生成renderer的地方,添加页面模版参数:

const renderer = require('vue-server-renderer').createRenderer({
    template:require('fs').readFileSync('./index.template.html', 'utf-8')
})

模版插值表达式

在html模版中,title, description,meta信息支持用户自定义的,可以在模版中使用插值表达式。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{title}}</title>
    <!--三个大括号表示不需要转译,直接输出-->
    {{{meta}}}
</head>
<body>
    <!--html字符串注入入口-->
    <!--vue-ssr-outlet-->
</body>
</html>

在调用renderToString方法的时候可以传入渲染上下文对象,对象中包含响应插值表达式信息:

const html = await renderer.renderToString(app, {
    title: 'Hello SSR',
    meta: `<meta name="description" content="搭建ssr">`
})

完整结构

在上面的例子中,虽然实现了简单的服务端渲染输出并在浏览器上展示,但是当我们为vue实例添加事件等,会发现页面中相应事件并没有执行成功。

正常的一个ssr系统应该是服务端渲染首评,客户端接管接下来的页面使其成为单页应用。在我们的例子中,通过查看页面中的源码发现服务端返回的html页面并没有加载任何用于客户端接管的js代码,因此出现上面说的注册事件但是没有成功执行。

官网提出的解决方案是区分服务端和客户端入口文件,分别打包,然后利用renderer将打包的结果结合到一起。

image.png

因此我们在项目中添加如下结构的文件:

build
├── webpack.base.config.js
├── webpack.client.config.js
├── webpack.server.config.js
src
├── App.vue
├── app.js # 通用入口
├── entry-client.js # 客户端入口
└── entry-server.js # 服务端入口

App.vue

<template>
    <div id="app">Vue SSR</div>
</template>
<script>
export default {
    name: 'App',
}
</script>
<style>
</style>

app.js

app.js是应用的通用入口,在此文件中导出vue实例。在客户端,将vue实例挂载到Dom上;在服务端,将其渲染为html字符串。

import Vue from 'vue'
import App from './App.vue'
// 导出创建app的工具函数,防止服务端多实例之间相互影响。
export function createApp() {
    const app = new Vue({
        render: h => h(App)
    })
    return { app }
}

entry-client.js

此文件是客户端入口,当客户端执行的时候,vue内部会根据根节点是否包含data-server-rendered属性,如果包含那么就说明页面已经在服务端渲染过,此时客户端只是单纯接管页面。否则就挂载Dom。

import createApp from './app'
// 创建vue实例
const { app } = createApp()
// 挂载
app.$mount('#app')

entry-server.js

此文件服务端入口,在此文件中只是导出一个函数,此函数用于创建vue实例。

import { createApp } from './app'
export default context => {
    const { app } = createApp()
    return app
}

webpack构建

此项目使用webpack进行打包处理,webpack打包需要三个配置文件:

  1. webpack.base.config.js
  2. webpack.client.config.js
  3. webpack.server.config.js

此三个文件详细内容可参考:配置文件

安装依赖包

npm i -D webpack webpack-cli webpack-merge webpack-node-externals @babel/core @babel/plugin-transform-runtime @babel/preset-env babel-loader css-loader url-loader file-loader rimraf vue-loader vue-template-compiler friendly-errors-webpack-plugin

npm i cross-env

添加npm scripts:

"build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js",
"build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js",
"build": "rimraf dist && npm run build:client && npm run build:server",

在命令行中执行npm run build,可以看到在dist目录中构建了2个json文件:

image.png

这两个文件用于后续渲染html字符串。

server.js

由于此时项目打包生成了相应的json文件,所以创建renderer和渲染html的方式发生些许变化。

在项目中添加server.js用于服务端node启动文件:

const express = require('express')
const fs = require('fs')
const { createBundleRenderer } = require('vue-server-renderer')
const serverBundle = require('./dist/vue-ssr-server-bundle.json')
const template = fs.readFileSync('./index.template.html', 'utf-8')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
const renderer = createBundleRenderer(serverBundle, {
    template,
    clientManifest
})
// 创建服务
const server = express()
server.use('/dist', express.static('./dist'))
server.get('/', async (request, response) => {
    try {
        // 支持中文
        response.setHeader('Content-type', 'text/html;charset=utf-8')
        const html = await renderer.renderToString({
            title: 'Hello SSR',
            meta: `<meta name="description" content="搭建ssr">`
})
        response.end(html)
    } catch(e){
        response.status(500).end('Internal Server Error')
    }
})
// 启动服务
server.listen(3000, () => {
    console.log('server running at port 3000.')
})

在命令行中执行 node server.js, 此时能够正常启动站点,并且在浏览器中访问时能够正常展示页面。

开发模式优化

在执行node server.js之后,虽然站点是能够正常访问,但是每次修改之后都需要重新打包,重新启动web服务,这样非常麻烦,也不利于提高开发效率,此时我们希望能够优化开发构建模式,当代码发生变化的时候能够自动构建、自动重启web服务、自动刷新浏览器。

首先在build文件夹下添加setup-dev-server.js文件,该文件用于监控文件变化,当文件发生变化之后,执行用户传入的回调函数:

const fs = require('fs')
const path = require('path')
const chokidar = require('chokidar')
const webpack = require('webpack')
const devMiddleware = require('webpack-dev-middleware')
const hotMiddleware = require('webpack-hot-middleware')
module.exports = function (server, callback) {
    let ready
    const onReady = new Promise(resolve => ready = resolve)
    let serverBundle
    let clientManifest
    let template
    const update = () => {
        if (template && serverBundle && clientManifest) {
            // 构建完成
            ready()
            callback(serverBundle, template, clientManifest)
        }
    }
    // 监听模版文件变化
    const templatePath = path.resolve(__dirname, '../index.template.html')
    template = fs.readFileSync(templatePath, 'utf-8')
    update()
    chokidar.watch(templatePath).on('change', () => {
        template = fs.readFileSync(templatePath, 'utf-8')
        update()
    })
    // 构建监控
    const serverConfig = require('./webpack.server.config')
    const serverCompiler = webpack(serverConfig)
    const serverDevMiddleware = devMiddleware(serverCompiler, {
        logLevel: 'silent' // 关闭日志输出,由 FriendlyErrorsWebpackPlugin 处理
    })

    serverCompiler.hooks.done.tap('server', () => {
        serverBundle = JSON.parse(
            // 读取内存中的数据
            serverDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-server-bundle.json'), 'utf-8')
        )
        update()
    })
    const clientConfig = require('./webpack.client.config')
    // 添加HRM plugin
    clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin())
    clientConfig.entry.app = [
        'webpack-hot-middleware/client?quiet=true&reload=true', // 和服务端交互处理热更新一个客户端脚本
        clientConfig.entry.app
    ]
    clientConfig.output.filename = '[name].js' // 热更新模式下确保一致的 hash
    const clientCompiler = webpack(clientConfig)
    const clientDevMiddleware = devMiddleware(clientCompiler, {
        publicPath: clientConfig.output.publicPath,
        logLevel: 'silent' // 关闭日志输出,由 FriendlyErrorsWebpackPlugin 处理
    })
    clientCompiler.hooks.done.tap('client', () => {
        clientManifest = JSON.parse(
            clientDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-client-manifest.json'), 'utf-8')
        )
        update()
    })
    server.use(hotMiddleware(clientCompiler, {
        log: false // 关闭它本身的日志输出
    }))
    server.use(clientDevMiddleware)
    return onReady
}

当添加完监控代码后,需要修改server.js,将开发模式和生产模式分离:

const express = require('express')
const fs = require('fs')
const setupDevServer = require('./build/setup-dev-server')
const { createBundleRenderer } = require('vue-server-renderer')
const isProd = process.env.NODE_ENV === 'production'
// 创建服务
const server = express()
let renderer
// 获取构建监控Promise
let onReady
if (isProd) {
    const serverBundle = require('./dist/vue-ssr-server-bundle.json')
    const template = fs.readFileSync('./index.template.html', 'utf-8')
    const clientManifest = require('./dist/vue-ssr-client-manifest.json')
    renderer = createBundleRenderer(serverBundle, {
        template,
        clientManifest
    })
} else {
    onReady = setupDevServer(server, (serverBundle, template, clientManifest) => {
        renderer = createBundleRenderer(serverBundle, {
            template,
            clientManifest
        })
    })
}
server.use('/dist', express.static('./dist'))
async function render(request, response) {
    try {
        // 支持中文
        response.setHeader('Content-type', 'text/html;charset=utf-8')
        const html = await renderer.renderToString({
            title: 'Hello SSR',
            meta: `<meta name="description" content="搭建ssr">`
        })
        response.end(html)
    } catch (e) {
        response.status(500).end('Internal Server Error')
    }
}
server.get('*', isProd ?
    render :
    async (req, res) => {
    // 开发模式下,需要等待构建完成之后再执行
    await onReady
    render(req, res)
})
// 启动服务
server.listen(3000, () => {
    console.log('server running at port 3000.')
})

路由

Vue项目基本上离不开路由系统,vue ssr支持vue-router,只需要在少许地方作出修改,就能兼容单页应用中的路由写法。

准备工作:

  1. 添加两个路由页面Home, About。
  2. 添加路由注册js
  3. 在App.vue中添加路由出口 <router-view/>

修改app.js,将路由添加到组件实例上:

import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router/index'
// 导出创建app的工具函数,防止服务端多实例之间相互影响。
export function createApp() {
    const router = createRouter()
    const app = new Vue({
        router,
        render: h => h(App)
    })
    return { app, router }
}

修改entry-server.js, 当首屏渲染的时候,在服务端通过router.push跳转到相应路由页面

import { createApp } from './app'
export default async context => {
    const { app, router } = createApp()
    console.log(context.url)
    router.push(context.url)
    // 当路由完全解析之后再执行渲染
    await new Promise(router.onReady.bind(router))
    return app
}

在server.js中,获取当前请求的url,并添加到渲染上下文中:

const html = await renderer.renderToString({
    url: request.url,
    title: 'Hello SSR',
    meta: `<meta name="description" content="搭建ssr">`
})

页面管理

我们期望在每个路由页面中能够自定义页面title和meta信息,此处可以采用第三方解决方案vue-meta。

在通用入口app.js中添加vue-meta引入

import VueMeta from 'vue-meta'
Vue.use(VueMeta)

在路由页面Home.vue中添加meta信息:

{
    metaInfo: {
        title: '首页'
    }
}

在服务端入口entry-server.js中获取meta信息添加到渲染上下文中:

const meta = app.$meta()
router.push(context.url)
context.meta = meta

修改页面模版index.template.html:

<head>
    {{{ meta.inject().title.text() }}}
    {{{ meta.inject().meta.text() }}}
</head>

数据预取

在服务端渲染过程中,只支持beforeCreate和created声明周期,但是服务端渲染不会等待其内部的异步数据访问,并且获取的数据也不是响应式的,所以通常在生命周期中获取数据并更新页面的方式无法在服务端渲染过程中使用。

服务端给出的解决方案就是在服务端渲染期间获取到的数据存储到Vuex中,然后把容器中的数据同步到客户端。

创建store:

import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'
Vue.use(Vuex)
export const createStore = () => {
    return new Vuex.Store({
        state: {
            posts: [] // 文章列表
        },
        mutations: {
            // 修改容器状态
            setPosts(state, data) {
                state.posts = data
            }
        },
        actions: {
            async getPosts({ commit }) {
                const { data } = await axios({
                    method: 'GET',
                    url: 'https://cnodejs.org/api/v1/topics'
                })
                commit('setPosts', data.data)
            }
        }
    })
}

在app.js中引入store:

export function createApp() {
    const router = createRouter()
    const store = createStore()
    const app = new Vue({
        router,
        store,
        render: h => h(App)
    })
    return { app, router, store }
}

在组件中引入相应state并添加预定义方法serverPrefetch:

computed: {
    ...mapState(['posts'])
},
serverPrefetch () {
    return this.getPosts()
},
methods: {
    ...mapActions(['getPosts'])
}

在服务端入口文件entry-server.js中为渲染上下文添加:

context.rendered = () => {
    // Renderer 会把 context.state 数据对象内联到页面模板中
    // 最终发送给客户端的页面中会包含一段脚本:window.__INITIAL_STATE__ = context.state
    // 客户端就要把页面中的 window.__INITIAL_STATE__ 拿出来填充到客户端 store 容器中
    context.state = store.state
}

客户端入口entry-client.js中将内联的window.__INITIAL_STATE__数据更新到state中:

const { app, router, store } = createApp()
if (window.__INITIAL_STATE__) {
    store.replaceState(window.__INITIAL_STATE__)
}
阅读 225

推荐阅读