詳解Vue服務端渲染

詳解Vue服務端渲染

1、服務端渲染 - 簡介

所謂服務端渲染就是將代碼的渲染交給服務器,服務器將渲染好的html字符串返回給客戶端,再由客戶端進行顯示。javascript

服務器端渲染的優勢

  • 有利於SEO搜索引擎優化,由於服務端渲染是將渲染好的html字符串返回給了客戶端,因此其能夠被爬蟲爬取到;
  • 加快首屏渲染時間,不會出現白屏;

服務器端渲染的缺點

  • SSR會佔用更多的CPU和內存資源
  • Vue中一些經常使用的瀏覽器API可能沒法使用,好比Vue的生命週期在服務器端渲染只能使用beforeCreate()和created(),由於服務端呈現的僅僅是html字符串是沒有所謂的mount的。

2、服務端渲染 - 初體驗

使用Vue的服務端渲染功能,須要引入Vue提供的服務端渲染模塊vue-server-renderer,其做用是建立一個渲染器,該渲染器能夠將Vue實例渲染成html字符串css

用Koa來搭建一個web服務器來實現:
① 目錄結構
圖片描述html

② 建立一個server.js 文件vue

const Koa = require("koa");
const Router = require("koa-router");
const fs = require("fs");

const app = new Koa(); // 建立服務器端app實例
const router = new Router(); // 建立服務器端路由

const Vue = require("vue");
const VueServerRender = require("vue-server-renderer"); // 引入服務端渲染模塊
const vm = new Vue({ // 建立Vue實例
    data() {
        return {msg: "hello vm"}
    },
    template: `<div>{{msg}}</div>` // 渲染器會將vue實例中的數據填入模板中並渲染成對應的html字符串
});
const template = fs.readFileSync("./server.template.html", "utf8"); // 讀取基本的html結構
const render = VueServerRender.createRenderer({
    template
}); // 建立渲染器並以server.template.html做爲html頁面的基本結構

router.get("/", async ctx => {
    // ctx.body = await render.renderToString(vm);
    ctx.body = await new Promise((resolve, reject) => {
            render.renderToString(vm, (err, html) => { // 將vm實例渲染成html並插入到server.template.html模板中
            console.log(`${html}`);
        });
    );
});

app.use(router.routes()); // 添加路由中間件

app.listen(3000, () => {
    console.log("node server listening on port 3000.");
}); // 監聽3000端口

注意:java

  • server.template.html文件中必須有 <!--vue-ssr-outlet-->佔位符,即將Vue實例vm渲染成的html字符串插入到佔位符所在的位置;
  • render.renderToString(vm)方法不傳回調函數的時候返回的是Promise對象,可是若是傳入了回調函數,那麼就返回void了, 推薦本身建立一個Promise函數;
  • Vue服務端渲染出來的字符串中會包含data-server-rendered="true"這樣一個標識,標識這是由Vue服務端渲染的結果字符
<div data-server-rendered="true">hello vm</div>

3、服務端渲染 - 引入Vue項目

上面初體驗中,咱們已經實現了一個簡單的Vue服務端渲染,可是咱們實際中Vue是一個很大的項目,裏面是包含了不少組件的大型應用,而不是像初體驗中的一個簡單的Vue實例,因此咱們必須引入一個Vue項目,包括Vue的入口文件main.js、App.vue、components、public/index.html等,如:
圖片描述node

經過webpack來打包咱們的整個Vue項目,webpack將以Vue的根實例main.js做爲入口文件,打包出一個合併的最終的bundle.js和一個頁面入口index.html文件,該index.html文件引入bundle.js後就能加載整個Vue項目中的頁面以及頁面中的事件等等,這裏咱們的Vue項目是一個很簡單的模板項目,關鍵在於webpack的配置

// webpack.config.jswebpack

const path = require("path");
const resolve = (dir) => {
    return path.resolve(__dirname, dir);
}
const VueLoader = require("vue-loader/lib/plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
    entry: resolve("./src/main.js"), // webpack 入口, 即Vue的入口文件main.js
    output: {
        filename: "bundle.js", // 打包後輸出的結果文件名
        path: resolve("./dist") // 打包後輸出結果存放目錄
    },
    resolve: {
        extensions: [".js", ".vue"] // 沒有寫擴展名的時候,解析順序
    }, 
    module: {
        rules: [
            {
                test: /\.js$/, 
                use: {
                    loader: "babel-loader", // 將全部的js文件經過babel-loader轉換爲ES5代碼
                    options: {
                        presets: ["@babel/preset-env"]
                    }
                },
                exclude: /node_modules/
            },
            {
                test: /\.css$/, // 解析.vue文件中的css
                use: [
                    "vue-style-loader", "css-loader"
                ]
            },
            {
                test: /\.vue$/, // 解析.vue文件,須要配合其中的插件進行使用
                use: "vue-loader"
            }
        ]
    },
    plugins: [
        new VueLoader(), // 解析.vue文件的插件
        new HtmlWebpackPlugin({
            filename: 'index.html', // 打包後輸出的html文件名
            template: resolve("./public/index.html") // 該模板文件在哪
        })
    ]
}
打包輸出後的dist目錄中會出現兩個文件: bundle.js和index.html, 直接在本地點擊index.html文件便可執行並呈現整個Vue項目

4、服務端渲染 - 將Vue項目分割爲客戶端和服務端

① 在非服務端渲染的時候,咱們使用的打包入口文件是main.js,其主要就是建立了一個Vue實例,而且渲染App.vue,而後將渲染好的App.vue掛載到index.html文件#app元素中,可是咱們的服務端渲染是沒法mount的,也就是說沒法將渲染結果渲染到#app元素上,因此須要改造main.js文件web

// 改造後的main.js文件vue-router

import Vue from "vue";
import App from "./App";
/** 
 1. main.js在服務端渲染中的做用就是提供一個Vue項目的根實例,因此導出一個函數
 2. 讓客戶端和服務端都能獲取到Vue項目的根實例,而後根據須要,
 3. 客戶端經過手動調用$mount()進行掛載
 4. */
export default () => {
    const app = new Vue({
        render: h => h(App)
    });
    return {app}; // 返回整個Vue根實例
}

② 新建兩個入口文件: client-entry.js 和 server-entry.js
// client-entry.jsvuex

import createApp from "./main";
const {app} = createApp(); // 獲取到Vue項目根實例
app.$mount("#app"); // 將根實例掛載到#app上
此時將webpack.config.js的入口文件改爲client-entry.js應該和以前是同樣的

// server-entry.js

import createApp from "./main";
/** 
 * 服務端須要調用當前這個文件產生一個Vue項目的根實例
 * 因爲服務端與客戶端是1對多的關係,因此不能每一個客戶端訪問都返回同一個Vue項目根實例
 * 因此須要返回一個函數,該函數返回一個新的Vue項目根實例
 * */ 
export default () => {
    const {app} = createApp(); // 獲取到Vue項目根實例
    return app;
}
爲何客戶端入口文件就不須要暴露一個一個函數?由於客戶端能夠被訪問屢次,即屢次執行,每次執行返回的都是一個新的Vue項目實例了。而服務器只會啓動一次,可是卻須要每次客戶端訪問都返回一個新的Vue項目實例,因此必須放到函數中

③ 拆分webapck.config.js, 將其分紅兩個配置文件,一樣一個用於客戶端,一個用於服務端打包
因爲客戶端和服務端的webpack配置文件有不少是相同的,因此能夠抽取出一個webpack.base.js

// webpack.base.js

const path = require("path");
const resolve = (dir) => {
    return path.resolve(__dirname, dir);
}
const VueLoader = require("vue-loader/lib/plugin");
module.exports = {
    output: {
        filename: "[name].bundle.js", // 打包後輸出的結果文件名
        path: resolve("./../dist/") // 打包後輸出結果存放目錄
    },
    resolve: {
        extensions: [".js", ".vue"] // 沒有寫擴展名的時候,解析順序
    }, 
    module: {
        rules: [
            {
                test: /\.js$/, 
                use: {
                    loader: "babel-loader", // 將全部的js文件經過babel-loader轉換爲ES5代碼
                    options: {
                        presets: ["@babel/preset-env"]
                    }
                },
                exclude: /node_modules/
            },
            {
                test: /\.css$/, // 解析.vue文件中的css
                use: [
                    "vue-style-loader", "css-loader"
                ]
            },
            {
                test: /\.vue$/, // 解析.vue文件,須要配合其中的插件進行使用
                use: "vue-loader"
            }
        ]
    },
    plugins: [
        new VueLoader(), // 解析.vue文件的插件
    ]
}

// webpack-client.js

const merge = require("webpack-merge");
const base = require("./webpack.base");
const path = require("path");
const resolve = (dir) => {
    return path.resolve(__dirname, dir);
}
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = merge(base, {
    entry: {
        client: resolve("./../src/client-entry.js"), // 給客戶端入口文件取名client,output的時候能夠獲取到該名字動態輸出
    },
    plugins: [
        new HtmlWebpackPlugin({
            filename: 'index.html', // 打包後輸出的html文件名
            template: resolve("./../public/index.html") // 該模板文件在哪
        })
    ]
});

// webpack-server.js

const merge = require("webpack-merge");
const base = require("./webpack.base");
const path = require("path");
const resolve = (dir) => {
    return path.resolve(__dirname, dir);
}
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = merge(base, {
    entry: {
        server: resolve("./../src/server-entry.js"), // 給客戶端入口文件取名client,output的時候能夠獲取到該名字動態輸出
    },
    target: "node", // 給node使用
    output: {
        libraryTarget: "commonjs2" // 把最終這個文件導出的結果放到module.exports上
    },
    plugins: [
        new HtmlWebpackPlugin({
            filename: 'index.server.html', // 打包後輸出的html文件名
            template: resolve("./../public/index.server.html"), // 該模板文件在哪
            excludeChunks: ["server"] // 排除某個模塊, 不讓打包輸出後的server.bundle.js文件引入到index.server.html文件中
        })
    ]
});
服務端webpack配置文件比較特殊,在output的時候須要配置一個libraryTarget,由於默認webpack輸出的時候是將打包輸出結果放到一個匿名自執行函數中的,經過將libraryTarget設置爲commonjs2,就會將整個打包結果放到module.exports上;
服務端webpack打包後輸出的server.bundle.js文件不是直接引入到index.server.html文件中使用的,還須要通過處理渲染成html字符串才能插入到index.server.html文件中,因此打包輸出後,要在html-webpack-plugin中排除對該模塊的引用
因爲webpack配置文件被分割,因此啓動webapck-dev-server的時候須要指定配置文件,在package.json文件中添加腳本
"scripts": {
    "client:dev": "webpack-dev-server --config ./build/webpack.client.js --mode development",
    "client:build": "webpack --config ./build/webpack.client.js --mode development",
    "server:build": "webpack --config ./build/webpack.server.js --mode development"
  },
此時分別指向npm run client:build 和 npm run server:build便可在dist目錄下生成index.html、client.bundle.js, index.server.html、server.bundle.js,其中client.bundel.js被index.html引用,server.bundle.js沒有被index.server.html引入,index.server.html僅僅是拷貝到了dist目錄下,同時server.bundle.js的整個輸出結果是掛在module.exports下的

④ 將打包好的server.bundle.js交給服務器進行渲染並生成html字符串返回給客戶端,和以前初體驗同樣,建立一個web服務器,只不過,此次不是渲染一個簡單的Vue實例,而是渲染整個打包好的server.bundle.js

vue-server-renderer提供了兩種渲染方式:

  • 和初體驗中的同樣,把server.bundle.js看成簡單Vue實例進行渲染,咱們打包後server.bundle.js的內容都是掛到了module.exports上,因此咱們能夠直接require,require返回的結果是一個對象,該對象上只有一個屬性即default,屬性值爲一個函數,執行該函數便可獲取整個Vue項目對應的Vue實例。
// 獲取server.bundle.js中的Vue實例進行渲染
const VueServerRender = require("vue-server-renderer"); // 引入服務端渲染模塊
const template = fs.readFileSync("./server.template.html", "utf8"); // 讀取基本的html結構
const render = VueServerRender.createRenderer({
    template
}); // 建立渲染器並以server.template.html做爲html頁面的基本結構
router.get("/", async ctx => {
const vm = require("./dist/server.bundle").default(); // 執行server.budle的default方法獲取Vue實例,每次請求獲取一個新的Vue實例
    ctx.body = await new Promise((resolve, reject) => {
        render.renderToString(vm, (err, html) => { // 將vm實例渲染成html並插入到server.template.html模板中
            if (err) reject(err);
            console.log(`${html}`);
            resolve(html);
        });
    });
});
require server.bunlde.js以後調用default屬性獲取的方法, 其實就是server.entry.js中導出的方法,這個方法能夠接收路由參數,後面集成路由的時候會用到
  • 經過vue-server-renderer提供的createBundleRenderer()方法進行渲染,該方法須要傳入server.bundle.js中的文件內容字符串, 再傳入模板html便可,因此須要讀取server.bundle.js中的內容:
// 直接渲染server.bundle.js
const VueServerRender = require("vue-server-renderer"); // 引入服務端渲染模塊
// 讀取server.bundle.js中的內容,即文件中的字符串
const ServerBundle = fs.readFileSync("./dist/server.bundle.js", "utf8");
const template = fs.readFileSync("./dist/index.server.html", "utf8"); // 讀取基本的html結構
const render = VueServerRender.createBundleRenderer(ServerBundle, { // 傳入server.bundle.js字符串建立渲染器
    template
});
router.get("/", async ctx => {
    ctx.body = await new Promise((resolve, reject) => {
        render.renderToString((err, html) => { // 將server.bundle.js渲染成html字符串
            if (err) reject(err);
            resolve(html);
        });
    });
});
render.renderToString()執行的時候內部也是要經過ServerBundle獲取到server.entry.js中導出的default()方法獲取到Vue項目實例進行渲染的,總之就是要獲取到Vue項目的實例進行渲染
重啓服務器,再次訪問,查看源碼,能夠看到頁面已經不是一個空的基礎頁面了,而是真實包含html內容的頁面,可是仍然存在一個問題,那就是以前的事件並不起做用了,由於服務器將sever.bundle.js渲染成的是html字符串返回給客戶端的,是不包含事件的,其中的事件執行函數在client.bundle.js中,因此咱們能夠在index.server.html文件中經過script標籤顯式地引入client.bundle.js,如:
<body>
    <!--vue-ssr-outlet-->
    <script src="client.bundle.js"></script>
</body>

注意: 當訪問頁面的時候,就會向服務器請求client.bundle.js文件,因此服務器須要將client.bundle.js以靜態資源的方式發佈出去。

剛纔咱們是手動在index.server.html中經過script標籤引入client.bundle.js, 很是的不方便,vue-server-renderer給咱們提供了兩個插件,vue-server-renderer/client-plugin和vue-server-renderer/server-plugin,能夠在webpack配置文件中引入,那麼打包的時候,會分別生成兩個json文件,vue-ssr-client-manifest.json和vue-ssr-server-bundle.json,這兩個文件主要是生成客戶端和服務端bundle的對應關係,這樣就不須要咱們收到引入client.bundle.js了。
以前是經過讀取server.bundle.js的內容來渲染的,如今能夠直接requirevue-ssr-server-bundle.json文件便可,同時在渲染的時候再添加vue-ssr-client-manifest.json便可,如:
// 直接渲染server.bundle.js
const VueServerRender = require("vue-server-renderer"); // 引入服務端渲染模塊
// 讀取server.bundle.js中的內容,即文件中的字符串
// const ServerBundle = fs.readFileSync("./dist/server.bundle.js", "utf8");
const ServerBundle = require("./dist/vue-ssr-server-bundle.json");
const clientManifest = require("./dist/vue-ssr-client-manifest.json");
const template = fs.readFileSync("./dist/index.server.html", "utf8"); // 讀取基本的html結構
const render = VueServerRender.createBundleRenderer(ServerBundle, { // 傳入server.bundle.js字符串建立渲染器
    template,
    clientManifest
});
使用者兩個插件以後,就不會生成server.bundle.js文件了

5、服務端渲染 - 集成路由

要集成路由,那麼須要在Vue項目中加入路由功能,和客戶端路由配置同樣,只不過不是直接導出路由實例,而是和main.js同樣導出一個方法返回一個新的路由實例,如:

import Vue from "vue";
import VueRouter from "vue-router";
import Foo from "./components/Foo";

Vue.use(VueRouter);

export default () => { // 導出函數返回路由實例
    const router = new VueRouter({
        mode: "history",
        routes: [
            {
                path: "/",
                component: Foo
            },
            {
                path: "/bar",
                component: () => import("./components/Bar.vue")
            }
        ]
    });
    return router;
}

而後在main.js中調用路由方法獲取路由實例並掛到Vue實例上,同時對外暴露,如:

export default () => {
    const router = createRouter();
    const app = new Vue({
        router, // 掛在路由實例到Vue實例上
        render: h => h(App)
    });
    return {app, router}; // 對外暴露路由實例
}
此時Vue項目已經實現路由功能,可是訪問的時候卻會報錯, The client-side rendered virtual DOM tree is not matching server-rendered content,即 客戶端和服務端渲染的頁面不一致,之因此出現這種狀況是由於, 客戶端加了路由功能進行了相應的路由跳轉,可是服務端沒有進行路由跳轉,因此頁面會不一致,解決方法就是, 服務器也要進行相應的路由跳轉
前面提到過createBundleRenderer()方法建立的渲染器在執行renderToString()方法的時候,能夠傳遞一個context上下文對象,能夠將客戶端的訪問url保存到context對象上,而這個context對象會傳到server.entry.js對外暴露函數中,而後在該函數中獲取路由進行相應跳轉便可,如:
// server.entry.js
export default (context) => {
    const {app, router} = createApp(); // 獲取到Vue項目根實例server
    console.log("至關於新建立了一個服務端");
    router.push(context.url); // 在服務端進行路由跳轉
    return app;
}
此時再訪問頁面,就不會出現上述客戶端和服務端渲染頁面不一致的狀況了,可是還有一個問題,那就是咱們在瀏覽器中直接訪問路由路徑的時候,會提示404,由於咱們服務器並無配置相應的路由,因此客戶端定義的路由路徑,須要在服務器端進行相應的配置
還有就是異步組件渲染的問題,咱們如今的server.entry.js中是直接返回Vue實例的,同時在其中進行router跳轉,若是路由跳轉的那個是異步組件,可能還沒跳轉完成,就返回了Vue實例,而出現渲染異常的狀況,因此咱們要返回一個Promise對象,等路由跳轉完成後再返回Vue實例,如:
// 改造後的sever.entry.js
export default (context) => {
    return new Promise((resolve, reject) => {
        const {app, router} = createApp(); // 獲取到Vue項目根實例server
        router.push(context.url);
        router.onReady(() => { // 等路由跳轉完成
            let matchs = router.getMatchedComponents();
            if (matchs.length === 0) {
                reject({code: 404});
            }
            resolve(app);
        }, reject);

    });
}
404頁面的處理,咱們能夠在router.onReady回調中進行處理,能夠根據路由匹配結果進行提示,若是路由匹配結果爲0,那麼就是沒有匹配成功則reject一個錯誤,服務器捕獲到錯誤後進行404提示便可

6、服務端渲染 - 集成Vuex

一樣,要集成Vuex,首先和客戶端渲染同樣,引入Vuex並建立store,只不過是對外暴露一個函數,而後在函數中返回新的store對象,如:
// store.js

import Vue from "vue";
import Vuex from "vuex";

Vue.use(Vuex);

export default () => {
    const store = new Vuex.Store({
        state: {
            name: "even"
        },
        mutations: {
            changeName(state) {
                state.name = "lhb";
            }
        },
        actions: {
            changeName({commit}) {
                console.log("changeName action");
                return new Promise((resolve, reject) => {
                    setTimeout(() => {
                        commit("changeName");
                        resolve();
                    }, 3000);
                });
            }
        }
    });
    return store;
}
而後在main.js中引入並注入到Vue實例中,跟Vue根實例和路由同樣對外暴露。服務端渲染集成Vuex關鍵在於服務端渲染的時候執行mutaion或者action後,Vuex中數據僅在服務器端改變,因此須要將服務器端的狀態數據保存起來,實際上會保存到window對象的__INITIAL_STATE__屬性上,客戶端渲染的時候只須要從window.__INITIAL_STATE__數據中獲取到服務端Vuex的狀態而後進行替換便可。

① 在Foo.vue組件中添加一個asyncData()方法,用於派發action,如:
// Foo.vue

export default {
    asyncData(store) { // asyncData只在服務端執行
        console.log("asyncData");
        return store.dispatch('changeName');
    }
}

② 在server-entry.js中,若是匹配到了Foo.Vue組件,那麼執行該組件的asyncData()方法,此時服務器端的Vuex的狀態就會發生改變,如:
// server-entry.js

export default (context) => {
    return new Promise((resolve, reject) => {
        console.log(context.url);
        const {app, router, store} = createApp(); // 獲取到Vue項目根實例server
        router.push(context.url);
        router.onReady(() => { // 等路由跳轉完成
            let matchs = router.getMatchedComponents();
            Promise.all(matchs.map((component) => {
                if (component.asyncData) { // 若是匹配的組件中含有asyncData方法則執行
                    return component.asyncData(store); // 服務器端Vuex狀態會發生改變
                }
            })).then(() => {
                console.log("success");
                context.state = store.state; // 服務器端store狀態改變後將其掛載到context上,而後會掛載到window的__INITIAL_STATE__上
                resolve(app);
            });
            if (matchs.length === 0) {
                reject({code: 404});
            }
        }, reject);
    });
}
將服務器Vuex狀態保存的時候,必須是保存到context的state屬性上,服務器端渲染完成後,會添加一個script標籤其中只有一行代碼,就是將服務器端Vuex狀態保存到window.__INITIAL_STATE__上
<script>window.__INITIAL_STATE__={"name":"lhb"}</script>

③ 接下來就是須要客戶端去取出window.__INITIAL_STATE__中的狀態數據並替換,在store.js中返回store對象前進行判斷,若是是客戶端執行Vuex,那麼取出window.__INITIAL_STATE__中的狀態數據並替換,如:

if(typeof window !== "undefined" && window.__INITIAL_STATE__) { // 若是是客戶端執行
        store.replaceState(window.__INITIAL_STATE__); // 將服務器端store狀態替換掉客戶端狀態
    }
    return store;
將Vuex中的數據顯示出來,此時再訪問Foo.vue就能夠看到name數據的變化了,咱們如今只有在進行服務器端渲染Foo.vue的時候纔會執行asyncData()方法,數據纔會發生變化,若是在客戶端進行渲染Foo.vue組,那麼不會執行asyncData(),因此能夠在Foo.vue組件mounted的時候派發一個相同的action進行數據改變便可

// Foo.vue

export default {
    mounted () {
          this.$store.dispatch("changeName");  
    }
}
相關文章
相關標籤/搜索