Vue同構賦能之 VUE SSR 篇

👋👋今天和你們分享一下VUE同構方面的相關的內容
同構應用既服務器渲染應用,相比起先後端分離應用,好處固然不言而喻
更快的首屏輸出,更好的SEO優化,對低版本瀏覽器的兼容等等javascript

不過,對於我來講VUE服務端渲染最大優點是它既能擁有直輸型web應用的能力
還能享受MVVM先後端分離框架開發的效率與便利
最妙的是SSR首屏渲染輸出後,前端就被VUE接管,優雅地變成了單頁應用css

我對 VUE 同構方面的內容還蠻感興趣的,多是我以前作過幾年 .net 的緣故吧😂
也前先後後投產過幾個SSR項目,有些經驗能夠分享給你們,本身也好從新整理下相關知識。html

demo放在github,地址在文章末尾前端

原理

其實咱們拋開先後端分離,同構
不管是前端動態生成的DOM,亦或是後端輸出HTML片斷
其實咱們想要的結果是生成HTML給瀏覽器去渲染
因此咱們要作的就是,在後端幫用戶跑一遍VUE,而後輸出HTMLvue

咱們知道在瀏覽器端,
VUE 在 mount 方法中執行 render 函數生成 vnode,
而後在 Watcher 中執行 vm._update 生成真實的DOM
在服務端是不行的,由於沒有瀏覽器上下文java

咱們須要額外的方法,就是這個包: vue-server-renderernode

來看看示例webpack

const Vue = require('vue')
const renderer = require('vue-server-renderer').createRenderer()

const app = new Vue({
data: {
  url: req.url
},
template: `<div>訪問的 URL 是: {{ url }}</div>`
})

renderer.renderToString(app, (err, html) => {
    // 輸出HTML
    console.log(html);
})

複製代碼

能夠看到,就是這麼簡單ios

vue app > serverRender > htmlgit

固然若是全是這種方式輸出HTML,估計頭會被打爆
首先vue頁面沒有提取出來,不能和前端共用
也不能處理樣式,多組件狀況下更是要命...

很顯然,還有另外一種構建方式

const createApp = require('/path/to/built-server-bundle.js')
const { createBundleRenderer } = require('vue-server-renderer')


const renderer = createBundleRenderer(serverBundle, {
  runInNewContext: false, // 推薦
  template, // (可選)頁面模板
  clientManifest // (可選)客戶端構建 manifest
})

  const context = { url: req.url }
  // 這裏無需傳入一個應用程序,由於在執行 bundle 時已經自動建立過。
  // 如今咱們的服務器與應用程序已經解耦!
  renderer.renderToString(context, (err, html) => {
    // 處理異常……
    console.log(html)
  })
複製代碼

這樣看起來就順眼多了,那麼built-server-bundle.js哪裏來的呢

沒錯,就是webpack構建出來的

到這裏,咱們就有一個大概的思路了
使用 webpack 對咱們編寫的 VUE APP 打先後端兩個包
後端構建完Render以後,根據url生成html和相關依賴並輸出給前端,以後前端接管

我畫了一張圖來更好的理解

目錄結構

那麼首先,咱們看一下VUE SSR項目的目錄結構

首先是config,這裏放的是webpack的三份打包配置
dist是打包以後生成的文件
server是服務端的代碼
src是前端VUE的代碼

entry-client.js
entry-server.js

這兩個,就是webpack打包的入口文件
接下來咱們就能夠開始編碼了

漫漫webpack路

漫漫webpack路是對整個vue ssr 構建流程的評價
能夠說有很大一部分時間必須來和webpack配置搏鬥,須要沉下心來慢慢調試
通常來講,推薦三份webpack配置
首先須要一份先後端公用的配置,好比通用的vueloader,一些cssloader,圖片處理等等
而後先後端再分別寫一份webpack配置

這裏有幾個點要特別注意:

先後端分別使用VueSSRClientPlugin,VueSSRServerPlugin兩個插件來構建
由於咱們須要分別生成
vue-ssr-client-manifest.jsonvue-ssr-server-bundle.json

// 前端
plugins: [
        new VueSSRClientPlugin()
    ],


//後端
  plugins: [
    new VueSSRServerPlugin()
  ]
複製代碼

熱加載

因爲後端嚴重依賴於wepack構建的前端打包文件
因此開發時,熱加載變得尤其重要,不然每次都須要從新編譯
這裏咱們後端判斷是不是dev環境,監聽webpack的事件,
來從新構建server-bundle.json和前端client-manifest.json

const webpack = require('webpack')
const MFS = require('memory-fs')
const clientConfig = require('../config/client.config')
const serverConfig = require('../config/server.config')

const clientCompiler = webpack(clientConfig) // 執行webpack

clientCompiler.watch({}, (err, stats) => {
    if (err) throw err
    stats = stats.toJson()
    stats.errors.forEach(err => console.error(err))
    stats.warnings.forEach(err => console.warn(err))
});

clientCompiler.plugin('done', () => {
    const clientBundlePath = path.join(serverConfig.output.path, 'vue-ssr-client-manifest.json')
    clientManifest = JSON.parse(fs.readFileSync(clientBundlePath, 'utf-8'))

    console.log('client update...')
    if (serverBundle) {
        build.renderer = createBundleRenderer(serverBundle, {
            runInNewContext: false, // 推薦
            template,
            clientManifest
        });
    }
})

// 監聽 server renderer
const serverCompiler = webpack(serverConfig)
const mfs = new MFS() // 內存文件系統,在JavaScript對象中保存數據。
serverCompiler.outputFileSystem = mfs
serverCompiler.watch({}, (err, stats) => {
    if (err) throw err
    stats = stats.toJson()
    stats.errors.forEach(err => console.error(err))
    stats.warnings.forEach(err => console.warn(err))

    // 讀取使用vue-ssr-webpack-plugin生成的bundle(vue-ssr-bundle.json)
    const bundlePath = path.join(serverConfig.output.path, 'vue-ssr-server-bundle.json')
    serverBundle = JSON.parse(mfs.readFileSync(bundlePath, 'utf-8'))
    console.log('server update...')
    if (clientManifest) {
        build.renderer = createBundleRenderer(serverBundle, {
            runInNewContext: false, // 推薦
            template,
            clientManifest
        });
    }
})

複製代碼

提取公用css

這個功能咱們平時構建前端應用的時候是經常使用到的,提取出來css做爲單獨的chunk
咱們使用的是webpack4來構建,按照VUESSR官方教程,咱們使用extract-text-webpack-plugin
卻發現會報錯,因而接下來我又搜索了不少

mini-css-extract-plugin
extract-css-chunks-webpack-plugin

但是在最後構建的時候,都會報錯
去翻了nuxt源碼,發現其使用的是 extract-css-chunks-webpack-plugin
是前端配置了,後端構建的時候沒有配置這個插件

// 前端
 rules: [
            {
                test: /\.(css|scss)$/,
                use: isDev ? ['vue-style-loader', 'css-loader', 'postcss-loader', 'sass-loader'] :
                    [ExtractTextPlugin.loader, 'css-loader', 'postcss-loader', 'sass-loader']
            }
        ]


//後端
rules: [
      {
        test: /\.(css|scss)$/,
        use: ['vue-style-loader', 'css-loader', 'postcss-loader', 'sass-loader']
      }
    ]

複製代碼

後端服務器集成

好,搞定了webpack以後,至關於前期準備工做已經作好了
接下來咱們須要和後端集成,請求到後端時,將請求的url傳給 renderer.renderToString() 執行,而後輸出html
這裏選擇express或者koa都是能夠的,咱們選擇的是koa(先暫時忽略cache緩存這塊邏輯)

const Koa = require('koa');
const app = new Koa();
const path = require('path');
const staticServer = require('koa-static-server');
const dev = require('./dev.js');
require('./routers')(app);
const config = require('./config');
const cache = require('./cache');

// 解析器
let build = dev();
// 靜態資源路徑
const distPath = path.join(__dirname, '../dist');
// 靜態資源
app.use(staticServer({ rootDir: distPath, rootPath: '/dist' }));

app.use(async (ctx, next) => {
    try {
        if (!build.renderer) {
            return ctx.body = "構築中……";
        }
        let out = await cache(ctx.request, build.renderer);
        ctx.set('Content-Type', 'text/html; charset=utf-8');
        ctx.body = out;
    } catch (e) {
        console.error(e);
        let redirect = '/error';
        if (e.code === 404) redirect += '?code=404';
        ctx.redirect(redirect);
    }
});

app.listen(config.port, () => {
    console.log(`server ${config.port} listened!`);
});

複製代碼

那麼接下來,咱們執行代碼,不出意外已經能夠在瀏覽器中看到咱們服務端渲染出來的頁面了
固然此時是空空如也的,那麼接下來,咱們就是須要填充數據

異步數據加載

想一想咱們平時寫前端VUE代碼,通常咱們會在created方法內請求後臺方法進行數據初始化
可是在SSR應用中,咱們會發現一個問題
created生命週期是在服務端執行的,以後便立刻輸出HTML給前端接管了
此時就算異步數據在服務端加載完成,前端也是得不到的
因此須要另一種方式,官方推薦的是使用vue-router + vuex 搭配使用加載異步數據

咱們在每一個vue組建內定義 asyncData 方法,內部調用vuex狀態改變方法填充數據
不過vuex方法內,咱們須要返回一個Promise

接着咱們在先後端的入口文件內分別添加vue-router鉤子函數,
等待咱們自定義的asyncData函數執行完畢以後才輸出HTML
此時異步數據是加載完畢了的,能夠正確輸出
要值得注意的是 咱們能夠添加這段代碼

store.replaceState(window.__INITIAL_STATE__);

前端接管以後,填充vuex數據

// entry-server.js

import { createApp } from './src/app.js'

export default context => {
    return new Promise((resolve, reject) => {
        const { app, router, store } = createApp()

        router.push(context.url)

        router.onReady(() => {
            const matchedComponents = router.getMatchedComponents()
            if (!matchedComponents.length) {
                return reject({ code: 404 })
            }

            // 對全部匹配的路由組件調用 `asyncData()`
            Promise.all(matchedComponents.map(Component => {
                if (Component.asyncData) {
                    // 注入request
                    // store.$request = context;
                    return Component.asyncData({
                        store,
                        route: router.currentRoute,
                        request: context
                    })
                }
            })).then(() => {
                // 在全部預取鉤子(preFetch hook) resolve 後,
                // 咱們的 store 如今已經填充入渲染應用程序所需的狀態。
                // 當咱們將狀態附加到上下文,
                // 而且 `template` 選項用於 renderer 時,
                // 狀態將自動序列化爲 `window.__INITIAL_STATE__`,並注入 HTML。
                context.state = store.state
                // server端自動注入標題
                context.title = router.currentRoute.meta.title;
                resolve(app)
            }).catch(reject)
        }, reject)
    })
}

// entry-client.js
import { createApp } from './src/app.js'
import Vue from 'vue'


const { app, router, store } = createApp()


// a global mixin that calls `asyncData` when a route component's params change
Vue.mixin({
    beforeRouteUpdate(to, from, next) {
        const { asyncData } = this.$options
        if (asyncData) {
            asyncData({
                store: this.$store,
                route: to
            }).then(next).catch(next)
        } else {
            next()
        }
    }
})


router.onReady(() => {
    // 添加路由鉤子函數,用於處理 asyncData.
    // 在初始路由 resolve 後執行,
    // 以便咱們不會二次預取(double-fetch)已有的數據。
    // 使用 `router.beforeResolve()`,以便確保全部異步組件都 resolve。
    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))
        })

        if (!activated.length) {
            return next()
        }

        // 這裏若是有加載指示器 (loading indicator),就觸發

        Promise.all(activated.map(c => {
            if (c.asyncData) {
                return c.asyncData({ store, route: to })
            }
        })).then(() => {

            // 中止加載指示器(loading indicator)
            next()
        }).catch(next)
    })

    app.$mount('#app')
})

if (window.__INITIAL_STATE__) {
    store.replaceState(window.__INITIAL_STATE__)
}

複製代碼

製做一個DEMO

好了,webpack設置好,服務端集成了,異步數據也能加載了,咱們能夠開始寫一個demo了
首先編寫vuex的store,咱們這裏區分module編寫,方便隔離各個業務,webpack也能單獨打包chunk

module/news.js

import http from '$http';

export default {
    namespaced: true,
    state: {
        list: {},
        count: 0
    },
    actions: {
        fetchList({ commit }, { pageIndex, request }) {
            return http.post(`/api/news/list/${pageIndex}`, request).then((data) => {
                commit('setList', data.data);
            });
        }
    },
    mutations: {
        setList(state, { list, count }) {
            state.list = list;
            state.count = count;
        }
    }
}

複製代碼

接着編寫vue頁面 Info.vue

<template>
  <div> <vmenu type="info" /> <div class="info-container"> <div class="info-title">熱門信息</div> <ul class="info-content"> <li v-for="item in list" :key="item.title"> <section class="info-content-r">{{ item.publishDate }}</section> <section class="info-content-l">{{ item.title }}</section> </li> </ul> <vpage :count="count" url="info" :pageIndex="pageIndex" /> </div> </div> </template> <script> import menu from "../Menu.vue"; import page from "../common/Page.vue"; // 單獨打包chunk import news from "../../store/modules/news.js"; import { mapState } from "vuex"; export default { components: { vmenu: menu, vpage: page }, data() { return { pageIndex: this.$route.params.pageIndex || 1 }; }, watch: { $route: function(n) { this.pageIndex = n.params.pageIndex; } }, computed: { list() { return this.$store.state.news.list || []; }, count() { return this.$store.state.news.count; } }, asyncData({ store, route, request }) { store.registerModule("news", news); return store.dispatch("news/fetchList", { pageIndex: route.params.pageIndex || 1, request }); }, destroyed() { if (this.$store._modules.root._children["news"]) this.$store.unregisterModule("news"); }, mounted() {} }; </script> 複製代碼

分頁組件 Page.vue

<template>
  <div class="page-container">
    <section>
      <router-link :to="{ name: url, params: { pageIndex: 1 } }">
        第一頁
      </router-link>
    </section>
    <section
      v-for="(i, ix) in pageCount"
      :key="i"
      :class="{ current: pageIndex == ix + 1 }"
    >
      <router-link :to="{ name: url, params: { pageIndex: ix + 1 } }">
        {{ ix + 1 }}
      </router-link>
    </section>
    <section>
      <router-link :to="{ name: url, params: { pageIndex: pageCount } }">
        最後一頁
      </router-link>
    </section>
  </div>
</template>

<script>
export default {
  name: "page",
  props: {
    pageSize: { default: 10 },
    pageIndex: { default: 1 },
    count: { default: 100 },
    url: { default: "" }
  },
  computed: {
    pageCount() {
      return this.count % this.pageSize == 0
        ? this.count / this.pageSize
        : Math.floor(this.count / this.pageSize) + 1;
    }
  },
  mounted() {},
  created() {}
};
</script>

複製代碼

後端數據咱們也本身提供接口,
固然生產上多是其餘後端提供的接口
這裏咱們就是很簡單的讀一個txt文件,而後輸出

newsController.js

const fs = require('fs');
const path = require('path');

module.exports = {

    async list(ctx) {
        let { page = 1, size = 10 } = ctx.params;
        let data = JSON.parse(fs.readFileSync(path.join(__dirname, './tmp.txt')));
        ctx.body = { list: data.slice((page - 1) * size, page * size), count: data.length };
    }

}

複製代碼

來看下運行結果

嗯,還行,再看看生成的HTML

數據都已經在後端加載完成

錯誤處理/鑑權

咱們平時前端請求數據時,偶爾會發生超時或者其餘緣由等異常狀況
固然,後端異步請求的數據也會發生各類錯誤,咱們須要來處理
按照咱們如今這套流程,其實處理起來是相對容易的
咱們在vue-router的鉤子函數內catch,而後reject指定的錯誤碼
而後在咱們服務端集成的代碼內進行處理,好比咱們進行一個簡單的重定向

try {
        // ...
    } catch (e) {
        console.error(e);
        let redirect = '/error';
        if (e.code === 404) redirect += '?code=404';
        ctx.redirect(redirect);
    }

複製代碼

對應的前端前端路由也須要catch住asyncData函數內的錯誤進行一樣的處理

不然先後端渲染表現不一致

Vue.mixin({
    beforeRouteUpdate(to, from, next) {
        const { asyncData } = this.$options
        if (asyncData) {
            asyncData({
                store: this.$store,
                route: to
            }).then(next).catch(()=>{
                router.push('/error');
            })
        } else {
            next()
        }
    }
})
router.beforeResolve((to, from, next) => {
        
        //...

        Promise.all(activated.map(c => {
            if (c.asyncData) {
                return c.asyncData({ store, route: to })
            }
        })).then(() => {

            // 中止加載指示器(loading indicator)
            next()
        }).catch(()=>{
            router.push('/error');
        })
    })

複製代碼

頭部注入

咱們順即可以作一些其餘事情,好比咱們將title配置在前端路由內,而後先後端都加載title

這裏你若是想注入meta標籤,頭部等等都是能夠的

// 前端
router.afterEach((to, from, next) => {
    document.title = to.meta.title;
});

//後端
// 對全部匹配的路由組件調用 `asyncData()`
Promise.all(matchedComponents.map(Component => {
    if (Component.asyncData) {
        // 注入request
        // store.$request = context;
        return Component.asyncData({
            store,
            route: router.currentRoute,
            request: context
        })
    }
})).then(() => {
    // 在全部預取鉤子(preFetch hook) resolve 後,
    // 咱們的 store 如今已經填充入渲染應用程序所需的狀態。
    // 當咱們將狀態附加到上下文,
    // 而且 `template` 選項用於 renderer 時,
    // 狀態將自動序列化爲 `window.__INITIAL_STATE__`,並注入 HTML。
    context.state = store.state
    // server端自動注入標題
    context.title = router.currentRoute.meta.title;
    resolve(app)
}).catch(reject)

複製代碼

cookie穿透

還有另外一個問題也是比較重要的,因爲咱們是後端請求的數據
後端收到的請求來源IP是咱們的服務器出口IP,cookie也是丟失的
咱們須要從新設置這些

這裏咱們使用一個技巧
咱們經過webpack的alias爲先後端的加載數據模塊設置不一樣的引用
接着在服務端的文件內進行header的改寫
IP的話,咱們是使用這兩個請求頭來標識客戶正真IP

X-Forwarded-For 和 X-real-ip

固然若是咱們的應用前還有其餘應用處理,已經設置過這些頭了
咱們就跳過

import axios from 'axios';

var http = axios.create({
    baseURL: 'http://localhost:8070'
})   // {}中放入上文中的配置項


export default {
    // server端重寫header
    post(url, params, request) {
        if (!request && params) [request, params] = [params, request];
        // 若是已是重定向過的,不作處理
        if (!request.headers["X-Forwarded-For"]) {
            request.headers["X-Forwarded-For"] = request.req.connection.remoteAddress;
            request.headers["X-real-ip"] = request.req.connection.remoteAddress;
        }
        return http.post(url, params, {
            headers: request.headers
        });
    },
    get: http.get
};

複製代碼

其實後端的數據請求方法不必定要用http,
若是內部溝通好使用RPC進行通訊是最合適的

第三方組件 與 NOSSR

平時開發中咱們會用到不少第三方的VUE組件
固然在VUE SSR項目中,咱們也是一樣能用的,咱們試一下經常使用的element-ui

<div class="index-swipe">
      <el-carousel trigger="click">
        <el-carousel-item v-for="item in 4" :key="item">
          <h3 class="small"><img style="width:100%;" :src="urls[item]" /></h3>
        </el-carousel-item>
      </el-carousel>
    </div>

<script>
import { Carousel, CarouselItem } from "element-ui";
import "element-ui/lib/theme-chalk/index.css";

export default {
  components: {
    [Carousel.name]: Carousel,
    [CarouselItem.name]: CarouselItem
  },
  data() {
    return {
      urls: [
        "https://fuss10.elemecdn.com/a/3f/3302e58f9a181d2509f3dc0fa68b0jpeg.jpeg",
        "https://fuss10.elemecdn.com/1/34/19aa98b1fcb2781c4fba33d850549jpeg.jpeg",
        "https://fuss10.elemecdn.com/0/6f/e35ff375812e6b0020b6b4e8f9583jpeg.jpeg",
        "https://fuss10.elemecdn.com/9/bb/e27858e973f5d7d3904835f46abbdjpeg.jpeg"
      ]
    };
  }
};
</script>

複製代碼

運行一下,嗯,不錯

但很遺憾,因爲SSR特殊的什麼週期和執行環境
並非全部第三方組件都對SSR支持的比較友好,好比你可能會常常遇到這樣的錯誤

[Vue warn]: Error in beforeCreate hook: "ReferenceError: document is not defined"

[Vue warn]: Error in beforeCreate hook: "ReferenceError: window is not defined"

等等……
看一下錯誤,哦,服務端環境內確定是沒有window,document這些的

這裏咱們有兩種解決方案

  • 修改這些組件的源碼,將在服務端的hook內訪問瀏覽器環境的代碼移到瀏覽器端的hook內

  • 咱們能夠取捨一下,看看這個第三方組件是否能夠不服務端渲染

若是能夠的話,咱們再前端動態加載這個組件,這個組件的全部生命週期都是在瀏覽器端執行了
就不會報錯了,可是這個組件直輸的HTML也將會沒有了

咱們能夠編寫一個通用的NOSSR組件來實現

NoSSR.vue

<template>
  <div>
    <component :is="component">
      <slot></slot>
    </component>
  </div>
</template>

<script>
import NoSSRTMP from "./NoSSR_TMP.vue";

export default {
  components: {
    NoSSRTMP
  },
  data() {
    return {
      component: ""
    };
  },
  mounted() {
    this.component = "NoSSRTMP";
  }
};
</script>

<style>
</style>

複製代碼

NoSSR_TMP.vue

<template>
  <div>
    <slot></slot>
  </div>
</template>
<script>
export default {};
</script>

複製代碼

能夠看到,其實就是很簡單的
咱們在mounted的時候,動態加載了這個組件
我用經常使用的markdown編輯器組件mavon-editor來試一下

<NoSSR>
      <div id="editor"> <mavon-editor style="height: 100%"></mavon-editor> </div> </NoSSR>

複製代碼

來看下效果

能夠看到,組件能被正確的加載了
看看輸出的HTML

<div><!----></div>

輸出成了註釋,這就是咱們須要取捨考慮的地方

緩存、性能

好,通過上面這些步驟,咱們的應用大致已經成形了
固然還不能直接用於生產,還須要用webpack打包一份生產配置
還有一點問題,咱們服務端渲染每次請求都會執行一次服務端渲染
顯然這些重複的開銷是不值得的,咱們能夠作個緩存模塊來處理

cache.js

const config = require('./config');
const isDev = process.env.NODE_ENV === "development";


module.exports = async function (request, renderer) {
    if (isDev) return renderer.renderToString(request);
    const redis = require('redis').createClient(config.redis);
    const lru = require('redis-lru');
    const cache = lru(redis, 100);
    let out = await cache.get(request.url);
    if (!out) {
        out = await renderer.renderToString(request);
        await cache.set(request.url, out);
    }
    return out;

}

複製代碼

這裏咱們用一個簡單的size爲100的redis lru來進行緩存
常訪問的100個url都會被緩存下來直接輸出

組件級別緩存

vue-ssr還提供了組件級別的緩存
createRenderer的時候傳入緩存的對象
須要實現get(),set()
接着咱們在編寫VUE的時候,指定ServerCacheKey就能夠實現組件級別的緩存

const renderer = createRenderer({
  cache: //...
})


export default {
  name: 'item', // 必填選項
  props: ['item'],
  serverCacheKey: props => props.item.id,
  render (h) {
    return h('div', this.item.id)
  }
}

複製代碼

固然緩存設置是一個複雜的事情,要針對具體的場景進行緩存策略選擇
這裏這是一個簡單的示例

設置完緩存以後,服務端渲染的性能會有一個質的突進
若是部署的時候能再加上多機負載,上個CDN就更加美滋滋了

雖然VUE SSR比傳統的後端字符串模板引擎效率相較而言低一些
可是它所帶來的的便利是大於這一些性能損耗的,
尤爲是當你的SSR項目越大越複雜的時候,這點就體現的更加明顯
並且咱們的優化空間仍是很大的,因此不用一開始就太擔心性能😂

結語

至此,VUE SSR整個流程就講完了

其實整個流程若是從頭至尾都配置一遍,是有一點繁瑣
可是好處是每一個環節咱們均可以進行修改,自由度更高
固然也是爲了深刻了解SSR的整個生命週期和各類細節
歡迎你們期待下一篇NUXT的分享,那一篇應該會精簡許多。

DEMO地址:github.com/kungithub/s…

相關文章
相關標籤/搜索