Vue 服務(wù)端渲染(SSR)

什么是服務(wù)端渲染,簡(jiǎn)單理解是將組件或頁(yè)面通過(guò)服務(wù)器生成html字符串,再發(fā)送到瀏覽器,最后將靜態(tài)標(biāo)記"混合"為客戶端上完全交互的應(yīng)用程序。于傳統(tǒng)的SPA(單頁(yè)應(yīng)用)相比,服務(wù)端渲染能更好的有利于SEO,減少頁(yè)面首屏加載時(shí)間,當(dāng)然對(duì)開(kāi)發(fā)來(lái)講我們就不得不多學(xué)一些知識(shí)來(lái)支持服務(wù)端渲染。同時(shí)服務(wù)端渲染對(duì)服務(wù)器的壓力也是相對(duì)較大的,和服務(wù)器簡(jiǎn)單輸出靜態(tài)文件相比,通過(guò)node去渲染出頁(yè)面再傳遞給客戶端顯然開(kāi)銷是比較大的,需要注意準(zhǔn)備好相應(yīng)的服務(wù)器負(fù)載。

一、一個(gè)簡(jiǎn)單的例子

// 第 1 步:創(chuàng)建一個(gè) Vue 實(shí)例
const Vue = require('vue')
const app = new Vue({
  template: `<div>Hello World</div>`
})
// 第 2 步:創(chuàng)建一個(gè) renderer
const renderer = require('vue-server-renderer').createRenderer()
// 第 3 步:將 Vue 實(shí)例渲染為 HTML
renderer.renderToString(app, (err, html) => {
  if (err) throw err
  console.log(html)
  // => <div data-server-rendered="true">Hello World</div>
})

上面例子利用 vue-server-renderer npm 包將一個(gè)vue示例最后渲染出了一段 html。將這段html發(fā)送給客戶端就輕松的實(shí)現(xiàn)了服務(wù)器渲染了。

const server = require('express')()
server.get('*', (req, res) => {
  // ... 生成 html
  res.end(html)
})
server.listen(8080)


二、官方渲染步驟

上面例子雖然簡(jiǎn)單,但在實(shí)際項(xiàng)目中往往還需要考慮到路由,數(shù)據(jù),組件化等等,所以服務(wù)端渲染不是只用一個(gè) vue-server-renderer npm包就能輕松搞定的,下面給出一張Vue官方的服務(wù)器渲染示意圖:


流程圖大致意思是:將 Source(源碼)通過(guò) webpack 打包出兩個(gè) bundle,其中 Server Bundle 是給服務(wù)端用的,服務(wù)端通過(guò)渲染器 bundleRenderer 將 bundle 生成 html 給瀏覽器用;另一個(gè) Client Bundle 是給瀏覽器用的,別忘了服務(wù)端只是生成前期首屏頁(yè)面所需的 html ,后期的交互和數(shù)據(jù)處理還是需要能支持瀏覽器腳本的 Client Bundle 來(lái)完成。

三、具體怎么實(shí)現(xiàn)

實(shí)現(xiàn)過(guò)程就是將上面的示意圖轉(zhuǎn)化成代碼實(shí)現(xiàn),不過(guò)這個(gè)過(guò)程還是有點(diǎn)小復(fù)雜的,需要多點(diǎn)耐心去推敲每個(gè)細(xì)節(jié)。

1、先實(shí)現(xiàn)一個(gè)基本版

項(xiàng)目結(jié)構(gòu)示例:

├── build
│   ├── webpack.base.config.js     # 基本配置文件
│   ├── webpack.client.config.js   # 客戶端配置文件
│   ├── webpack.server.config.js   # 服務(wù)端配置文件
└── src
    ├── router          
    │    └── index.js              # 路由
    └── views             
    │    ├── comp1.vue             # 組件
    │    └── copm2.vue             # 組件
    ├── App.vue                    # 頂級(jí) vue 組件
    ├── app.js                     # app 入口文件
    ├──  client-entry.js           # client 的入口文件
    ├──  index.template.html       # html 模板
    ├──  server-entry.js           # server 的入口文件
├──  server.js           # server 服務(wù)

其中:
(1)、comp1.vue 和 copm2.vue 組件

<template>
    <section>組件 1</section>
</template>
<script>
    export default {
        data () {
            return {
                msg: ''
            }
        }
    }
</script>

(2)、App.vue 頂級(jí) vue 組件

<template>
    <div id="app">
        <h1>vue-ssr</h1>
        <router-link class="link" to="/comp1">to comp1</router-link>
        <router-link class="link" to="/comp2">to comp2</router-link>
 
        <router-view class="view"></router-view>
    </div>
</template>
 
<style lang="stylus">
    .link
        margin 10px
</style>

(3)、index.template.html html 模板

<!DOCTYPE html>
<html lang="zh_CN">
<head>
    <title>{{ title }}</title>
    <meta charset="utf-8"/>
    <meta name="mobile-web-app-capable" content="yes"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge, chrome=1"/>
    <meta name="renderer" content="webkit"/>
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui"/>
    <meta name="theme-color" content="#f60"/>
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>

(4)、上面基礎(chǔ)代碼不解釋,接下來(lái)看
路由 router

import Vue from 'vue'
import Router from 'vue-router'
import comp1 from '../views/comp1.vue'
import comp2 from '../views/comp2.vue'
Vue.use(Router)
export function createRouter () {
    return new Router({
        mode: 'history',
        scrollBehavior: () => ({ y: 0 }),
        routes: [
            {
                path: '/comp1',
                component: comp1
            },
            {
                path: '/comp2',
                component: comp2
            },
            { path: '/', redirect: '/comp1' }
        ]
    })
}

app.js app 入口文件

import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
 
export function createApp (ssrContext) {
    const router = createRouter()
    const app = new Vue({
        router,
        ssrContext,
        render: h => h(App)
    })
    return { app, router }
}

我們通過(guò) createApp 暴露一個(gè)根 Vue 實(shí)例,這是為了確保每個(gè)用戶能得到一份新的實(shí)例,避免狀態(tài)污染,所以我們寫了一個(gè)可以重復(fù)執(zhí)行的工廠函數(shù) createApp。 同樣路由 router 我們也是一樣的處理方式 createRouter 來(lái)暴露一個(gè) router 實(shí)例

(5)client-entry.js client 的入口文件

import { createApp } from './app'
 
const { app, router } = createApp()
router.onReady(() => {
    app.$mount('#app')
})

客戶端代碼是在路由解析完成的時(shí)候講 app 掛載到 #app 標(biāo)簽下

(7)server-entry.js server 的入口文件

import { createApp } from './app'
 
export default context => {
    // 因?yàn)檫@邊 router.onReady 是異步的,所以我們返回一個(gè) Promise
    // 確保路由或組件準(zhǔn)備就緒
    return new Promise((resolve, reject) => {
        const { app, router } = createApp(context)
        router.push(context.url)
        router.onReady(() => {
            resolve(app)
        }, reject)
    })
}

服務(wù)器的入口文件我們返回了一個(gè) promise

2、打包

在第一步我們大費(fèi)周章實(shí)現(xiàn)了一個(gè)帶有路由的日常功能模板代碼,接著我們需要利用webpack將上面的代碼打包出服務(wù)端和客戶端key的代碼,入口文件分別是 server-entry.js 和 client-entry.js

(1)、 webpack構(gòu)建配置
一般配置分為三個(gè)文件:base, client 和 server?;九渲?base config)包含在兩個(gè)環(huán)境共享的配置,例如,輸出路徑(output path),別名(alias)和 loader。服務(wù)器配置(server config)和客戶端配置(client config),可以通過(guò)使用 webpack-merge 來(lái)簡(jiǎn)單地?cái)U(kuò)展基本配置。

webpack.base.config.js 配置文件

const path = require('path')
const webpack = require('webpack')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
 
module.exports = {
    devtool: '#cheap-module-source-map',
    output: {
        path: path.resolve(__dirname, '../dist'),
        publicPath: '/dist/',
        filename: '[name]-[chunkhash].js'
    },
    resolve: {
        alias: {
            'public': path.resolve(__dirname, '../public'),
            'components': path.resolve(__dirname, '../src/components')
        },
        extensions: ['.js', '.vue']
    },
    module: {
        noParse: /es6-promise\.js$/,
        rules: [
            {
                test: /\.(js|vue)/,
                use: 'eslint-loader',
                enforce: 'pre',
                exclude: /node_modules/
            },
            {
                test: /\.vue$/,
                use: {
                    loader: 'vue-loader',
                    options: {
                        preserveWhitespace: false,
                        postcss: [
                            require('autoprefixer')({
                                browsers: ['last 3 versions']
                            })
                        ]
                    }
                }
            },
            {
                test: /\.js$/,
                use: 'babel-loader',
                exclude: /node_modules/
            },
            {
                test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
                use: {
                    loader: 'url-loader',
                    options: {
                        limit: 10000,
                        name: 'img/[name].[hash:7].[ext]'
                    }
                }
            },
            {
                test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
                use: {
                    loader: 'url-loader',
                    options: {
                        limit: 10000,
                        name: 'fonts/[name].[hash:7].[ext]'
                    }
                }
            },
            {
                test: /\.css$/,
                use: ['vue-style-loader', 'css-loader']
            },
            {
                test: /\.json/,
                use: 'json-loader'
            }
        ]
    },
    performance: {
        maxEntrypointSize: 300000,
        hints: 'warning'
    },
    plugins: [
        new webpack.optimize.UglifyJsPlugin({
            compress: { warnings: false }
        }),
        new ExtractTextPlugin({
            filename: 'common.[chunkhash].css'
        })
    ]
}


webpack.client.config.js 配置文件

const path = require('path')
const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const glob = require('glob')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
 
const config = merge(base, {
    entry: {
        app: './src/client-entry.js'
    },
    resolve: {
        alias: {
            'create-api': './create-api-client.js'
        }
    },
    plugins: [
        new webpack.DefinePlugin({
            'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
            'process.env.VUE_ENV': '"client"',
            'process.env.DEBUG_API': '"true"'
        }),
        new webpack.optimize.CommonsChunkPlugin({
            name: 'vendor',
            minChunks: function (module) {
                return (
                    /node_modules/.test(module.context) && !/\.css$/.test(module.require)
                )
            }
        }),
        new webpack.optimize.CommonsChunkPlugin({
            name: 'manifest'
        }),
        // 這是將服務(wù)器的整個(gè)輸出
        // 構(gòu)建為單個(gè) JSON 文件的插件。
        // 默認(rèn)文件名為 `vue-ssr-server-bundle.json`
        new VueSSRClientPlugin()
    ]
})
module.exports = config


webpack.server.config.js 配置文件

const path = require('path')
const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const nodeExternals = require('webpack-node-externals')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
 
module.exports = merge(base, {
    target: 'node',
    devtool: '#source-map',
    entry: './src/server-entry.js',
    output: {
        filename: 'server-bundle.js',
        libraryTarget: 'commonjs2'
    },
    resolve: {
        alias: {
            'create-api': './create-api-server.js'
        }
    },
    externals: nodeExternals({
        whitelist: /\.css$/
    }),
    plugins: [
        new webpack.DefinePlugin({
            'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
            'process.env.VUE_ENV': '"server"'
        }),
        new VueSSRServerPlugin()
    ]
})

webpack 配置完成,其實(shí)東西也不多,都是常規(guī)配置。需要注意的是 webpack.server.config.js 配置,output是生成一個(gè) commonjs 的 library, VueSSRServerPlugin 用于這是將服務(wù)器的整個(gè)輸出構(gòu)建為單個(gè) JSON 文件的插件。

(2)、 webpack build poj

build 代碼

webpack --config build/webpack.client.config.js
webpack --config build/webpack.server.config.js 

打包后會(huì)生成一些打包文件,其中 server.config 打包后會(huì)生成 vue-ssr-server-bundle.json 文件,這個(gè)文件是給 createBundleRenderer 用的,用于服務(wù)端渲染出 html 文件

const { createBundleRenderer } = require('vue-server-renderer')
const renderer = createBundleRenderer('/path/to/vue-ssr-server-bundle.json', {  // ……renderer 的其他選項(xiàng)})

細(xì)心的你還會(huì)發(fā)現(xiàn) client.config 不僅生成了一下客服端用的到 js 文件,還會(huì)生成一份 vue-ssr-client-manifest.json 文件,這個(gè)文件是客戶端構(gòu)建清單,服務(wù)端拿到這份構(gòu)建清單找到一下用于初始化的js腳步或css注入到 html 一起發(fā)給瀏覽器。

(3)、 服務(wù)端渲染

其實(shí)上面都是準(zhǔn)備工作,最重要的一步是將webpack構(gòu)建后的資源代碼給服務(wù)端用來(lái)生成 html 。我們需要用node寫一個(gè)服務(wù)端應(yīng)用,通過(guò)打包后的資源生成 html 并發(fā)送給瀏覽器

server.js

const fs = require('fs')
const path = require('path')
const Koa = require('koa')
const KoaRuoter = require('koa-router')
const serve = require('koa-static')
const { createBundleRenderer } = require('vue-server-renderer')
const LRU = require('lru-cache')
 
const resolve = file => path.resolve(__dirname, file)
const app = new Koa()
const router = new KoaRuoter()
const template = fs.readFileSync(resolve('./src/index.template.html'), 'utf-8')
 
function createRenderer (bundle, options) {
    return createBundleRenderer(
        bundle,
        Object.assign(options, {
            template,
            cache: LRU({
                max: 1000,
                maxAge: 1000 * 60 * 15
            }),
            basedir: resolve('./dist'),
            runInNewContext: false
        })
    )
}
 
let renderer
const bundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
renderer = createRenderer(bundle, {
    clientManifest
})
 
/**
 * 渲染函數(shù)
 * @param ctx
 * @param next
 * @returns {Promise}
 */
function render (ctx, next) {
    ctx.set("Content-Type", "text/html")
    return new Promise (function (resolve, reject) {
        const handleError = err => {
            if (err && err.code === 404) {
                ctx.status = 404
                ctx.body = '404 | Page Not Found'
            } else {
                ctx.status = 500
                ctx.body = '500 | Internal Server Error'
                console.error(`error during render : ${ctx.url}`)
                console.error(err.stack)
            }
            resolve()
        }
        const context = {
            title: 'Vue Ssr 2.3',
            url: ctx.url
        }
        renderer.renderToString(context, (err, html) => {
            if (err) {
                return handleError(err)
            }
            console.log(html)
            ctx.body = html
            resolve()
        })
    })
}
 
app.use(serve('/dist', './dist', true))
app.use(serve('/public', './public', true))
 
router.get('*', render)
app.use(router.routes()).use(router.allowedMethods())
 
const port = process.env.PORT || 8089
app.listen(port, '0.0.0.0', () => {
    console.log(`server started at localhost:${port}`)
})

這里我們用到了最開(kāi)始 demo 用到的 vue-server-renderer npm 包,通過(guò)讀取 vue-ssr-server-bundle.jsonvue-ssr-client-manifest.json 文件 renderer 出 html,最后 ctx.body = html 發(fā)送給瀏覽器, 我們?cè)囍?code>console.log(html) 出 html 看看服務(wù)端到底渲染出了何方神圣:

<!DOCTYPE html>
<html lang="zh_CN">
<head>
    <title>Vue Ssr 2.3</title>
    <meta charset="utf-8"/>
    <meta name="mobile-web-app-capable" content="yes"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge, chrome=1"/>
    <meta name="renderer" content="webkit"/>
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui"/>
    <meta name="theme-color" content="#f60"/>
<link rel="preload" href="/dist/manifest-56dda86c1b6ac68c0279.js" as="script"><link rel="preload" href="/dist/vendor-3504d51340141c3804a1.js" as="script"><link rel="preload" href="/dist/app-ae1871b21fa142b507e8.js" as="script"><style data-vue-ssr-id="41a1d6f9:0">
.link {
  margin: 10px;
}
</style><style data-vue-ssr-id="7add03b4:0"></style></head>
<body>
 
<div id="app" data-server-rendered="true">
<h1>vue-ssr</h1>
<a href="/comp1" class="link router-link-exact-active router-link-active">to comp1</a>
<a href="/comp2" class="link">to comp2</a>
<section class="view">組件 1</section>
</div>
 
<script src="/dist/manifest-56dda86c1b6ac68c0279.js" defer>
</script>
<script src="/dit/vendor-3504d51340141c3804a1.js" defer></script>
<script src="/dist/app-ae1871b21fa142b507e8.js" defer></script>
</body>
</html>

可以看到服務(wù)端把路由下的 組件 1 也給渲染出來(lái)了,而不是讓客服端去動(dòng)態(tài)加載,其次是 html 也被注入了一些 <script 標(biāo)簽去加載對(duì)應(yīng)的客戶端資源。這里再多說(shuō)一下,有的同學(xué)可能不理解,服務(wù)端渲染不就是最后輸出 html 讓瀏覽器渲染嗎,怎么 html 還帶 js 腳本,注意,服務(wù)端渲染出的 html 只是首次展示給用戶的頁(yè)面而已,用戶后期操作頁(yè)面處理數(shù)據(jù)還是需要 js 腳本去跑的,也就是 webpack 為什么要打包出一套服務(wù)端代碼(用于渲染首次html用),一套客戶端代碼(用于后期交互和數(shù)據(jù)處理用)

四、小結(jié)

本篇簡(jiǎn)單了解了 vue ssr 的簡(jiǎn)單流程,上面例子的demo放在github 歡迎提 issue 和 star 。服務(wù)端渲染還有比較重要的一部分是首屏數(shù)據(jù)的獲取渲染,一般頁(yè)面展示都會(huì)有一些網(wǎng)絡(luò)數(shù)據(jù)初始化,服務(wù)端渲染可以將這些數(shù)據(jù)獲取到插入到 html ,由于這部份內(nèi)容涉及到的知識(shí)點(diǎn)也不少,放在下次講。

2018-5-28 更新

評(píng)論區(qū)提到的bug已經(jīng)修復(fù),如果有其他問(wèn)題可以到github對(duì)應(yīng)的工程下提 issues,github 的 issues 有 markdown 格式,方便問(wèn)題描述和討論,問(wèn)題描述可以盡量清楚,方面作者排查問(wèn)題原因

運(yùn)行項(xiàng)目

npm run installnpm run build:client  // 生成 clientBundlenpm run build:server  // 生成 serverBundlenpm run dev           // 啟動(dòng) node 渲染服務(wù)


轉(zhuǎn)載出處:

作者:曾田生z
鏈接:https://juejin.im/post/5a9ca40b6fb9a028b77a4aac
來(lái)源:掘金

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容