VueSSR的一些理解和詳細配置

如果是靜態頁面,例如官網的SSR處理可直接使用prerender-spa-plugin插件實現預渲染,參考我之前的博客:vue單頁面通過prerender-spa-plugin插件進行SEO優化

以下是基於[email protected]生成的工程相關的結構改造。文章最後有異步請求的例子可供參考。

概念

流程圖

這是具體的流程圖,如何實現,後續配置會詳解
VueSSR流程介紹

編譯圖解

結合上面的流程圖來理解編譯的過程圖,因爲服務端渲染只是一個可以等待異步數據的預渲染,最終用戶交互還是需要Client entry生成的js來控制,這就是爲什麼需要兩個entry文件,但是擁有同樣的入口(app.js)的原因。串聯server和client的樞紐就是store,server將預渲染頁面的sotre數據放入window全局中,client再進行數據同步,完成異步數據預渲染

官方編譯介紹圖

相關配置

知道大致步驟和流程,再去理解VueSSR的配置就不會那麼突兀了。

目錄結構

目錄結構

index.html 的改動

需要加入<!–vue-ssr-outlet–>佔位符

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <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">
    <link rel="icon" href="" type="image/x-icon" />
    <title>{{title}}</title>
</head>
<body>
    <div id="app">
        <!--vue-ssr-outlet-->
    </div>
</body>
</html>

需要安裝的依賴

多了一些相關的依賴需要添加一下,這裏做一個彙總:

npm install memory-fs chokidar [email protected] lru-cache serve-favicon compression route-cache vuex-router-sync --save
注意: vue-server-renderer要和工程的vue版本一致。

server.js

開發模式會調用setup-dev-server中的熱更新插件,實時編譯

const fs = require('fs'); //讀取文件
const path = require('path');
const express = require('express');
const app= express();
const LRU = require('lru-cache');  //封裝緩存的get set方法
/*
* 處理favicon.ico文件:作用:
* 1. 去除這些多餘無用的日誌
* 2. 將icon緩存在內存中,防止從因盤中重複讀取
* 3. 提供了基於icon 的 ETag 屬性,而不是通過文件信息來更新緩存
* 4. 使用最兼容的Content-Type處理請求
 */
const favicon = require('serve-favicon');
const compression = require('compression');  //壓縮
const microcache = require('route-cache');  //請求緩存
const resolve = (file) => path.resolve(__dirname, file);  //返回絕對路徑
const {createBundleRenderer} = require('vue-server-renderer');

const isProd = process.env.NODE_ENV === 'production';
const useMicroCache = process.env.MICRO_CACHE !== 'false';
const serverInfo =
    `express/${require('express/package').version}` +
    `vue-server-renderer/${require('vue-server-renderer/package').version}`;

let renderer;
let readyPromise;

//生成renderer函數
function createRenderer(bundle, options) {
    return createBundleRenderer(bundle, Object.assign(options, {
        cache: new LRU({
            max: 1000,
            maxAge: 1000 * 60 * 15
        }),
        basedir: resolve('./dist'),
        runInNewContext: false
    }));
}
function render(req, res) {
    const s = Date.now();
    res.setHeader('Content-type', 'text/html');
    res.setHeader('Server', serverInfo);
    const handleError = err => {
        if (err.url) {
            res.redirect(err.url)
        } else if(err.code === 404) {
            res.status(404).send('404 | Page Not Found')
        } else {
            // Render Error Page or Redirect
            res.status(500).send('500 | Internal Server Error')
            console.error(`error during render : ${req.url}`)
            console.error(err.stack)
        }
    };

    const context = {
        title: 'ssr標題',
        url: req.url
    };
    renderer.renderToString(context, (err, html) => {
        console.log(err);
        if (err) {
            return handleError(err);
        }
        res.send(html);
        if (!isProd) {
            console.log(`whole request: ${Date.now() - s}ms`);
        }
    })
}
const templatePath = resolve('./index.html');
if (isProd) {
     const template = fs.readFileSync(templatePath, 'utf-8');
     const bundle = require('./dist/vue-ssr-server-bundle.json');

     const clientManifest = require('./dist/vue-ssr-client-manifest.json');
     renderer = createRenderer(bundle, {
         template,
         clientManifest
     })
 } else {
     readyPromise = require('./build/setup-dev-server')(
         app,
         templatePath,
         (bundle, options) => {
             renderer = createRenderer(bundle, options)
         }
     )
 }
 const serve = (path, cache) => express.static(resolve(path), {
     maxAge: cache && isProd ? 1000 * 60 * 60 * 24 * 30 : 0
 });
 //靜態文件壓縮,支持gzip和deflate方式(原來這步是nginx做的),threshold: 0, 0kb以上的都壓縮,即所有文件都壓縮,
 //可通過filter過濾
 //TODO
 app.use(compression({threshold: 0}));
 app.use(favicon('./favicon.ico'));
 app.use('/dist', serve('./dist', true));
 app.use('/static', serve('./static', true));
 app.use('/service-worker.js', serve('./dist/service-worker.js', true));
 //TODO
 app.use(microcache.cacheSeconds(1, req => useMicroCache && req.originalUrl));

 app.get('*', isProd ? render : (req, res) => {
     readyPromise.then(() => render(req, res));
 });
 // 監聽
app.listen(8082, function () {
  console.log('success listen...8082');
});

entry-server.js

在路由resolve之前,做數據預渲染

import { createApp } from './app'

const isDev = process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'sit';

// This exported function will be called by `bundleRenderer`.
// This is where we perform data-prefetching to determine the
// state of our application before actually rendering it.
// Since data fetching is async, this function is expected to
// return a Promise that resolves to the app instance.
export default context => {
    return new Promise((resolve, reject) => {
        const s = isDev && Date.now();
        const { app, router, store } = createApp();

        const { url } = context;
        const { fullPath } = router.resolve(url).route;

        if (fullPath !== url) {
            return reject({ url: fullPath })
        }

        // set router's location
        router.push(url);

        // wait until router has resolved possible async hooks
        router.onReady(() => {
            const matchedComponents = router.getMatchedComponents()
            // no matched routes
            if (!matchedComponents.length) {
                return reject({ code: 404 })
            }
            // Call fetchData hooks on components matched by the route.
            // A preFetch hook dispatches a store action and returns a Promise,
            // which is resolved when the action is complete and store state has been
            // updated.
            Promise.all(matchedComponents.map(({ asyncData }) => asyncData && asyncData({
                store,
                route: router.currentRoute
            }))).then(() => {
                isDev && console.log(`data pre-fetch: ${Date.now() - s}ms`)
                // After all preFetch hooks are resolved, our store is now
                // filled with the state needed to render the app.
                // Expose the state on the render context, and let the request handler
                // inline the state in the HTML response. This allows the client-side
                // store to pick-up the server-side state without having to duplicate
                // the initial data fetching on the client.
                context.state = store.state;
                resolve(app)
            }).catch(reject)
        }, reject)
    })
}

entery-client.js

window.INITIAL_STATE 就是服務端存在html中的store數據,客戶端做一次同步,router.onReady在第一次不會觸發,只有router接管頁面之後纔會觸發,在beforeResolved中手動進行數據請求(否則asyncData中的請求不會觸發)

import {createApp} from './app';

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


// prime the store with server-initialized state.
// the state is determined during SSR and inlined in the page markup.
if (window.__INITIAL_STATE__) {
    store.replaceState(window.__INITIAL_STATE__)
}

router.onReady(() => {
    // Add router hook for handling asyncData.
    // Doing it after initial route is resolved so that we don't double-fetch
    // the data that we already have. Using router.beforeResolve() so that all
    // async components are resolved.
    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))
        });
        const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _);
        if (!asyncDataHooks.length) {
            console.log('there no client async');
            return next()
        }

        // bar.start()
        console.log('client async begin');
        Promise.all(asyncDataHooks.map(hook => hook({ store, route: to }))).then(() => {
                // bar.finish()
            console.log('client async finish');
                next()
            }).catch(next)
    });

    //以激活模式掛載,不會改變瀏覽器已經渲染的內容
    app.$mount('#app');
});

// service worker
if ('https:' === location.protocol && navigator.serviceWorker) {
    navigator.serviceWorker.register('/service-worker.js')
}

app.js

import Vue from 'vue';
import App from './App.vue';
import {createStore} from './store';
import {createRouter} from './router';
import {sync} from 'vuex-router-sync';

export function createApp() {

    const store = createStore();
    const router = createRouter();

    // sync the router with the vuex store.
    // this registers `store.state.route`
    sync(store, router);

    // create the app instance.
    // here we inject the router, store and ssr context to all child components,
    // making them available everywhere as `this.$router` and `this.$store`.
    const app = new Vue({
        router,
        store,
        render: h => h(App)
    });

    // expose the app, the router and the store.
    // note we are not mounting the app here, since bootstrapping will be
    // different depending on whether we are in a browser or on the server.
    return {app, router, store}
}

router.js 和 store.js

防止數據污染,每次都要創造新的實例

// store.js
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export function createStore () {
    return new Vuex.Store({
      state: {
        token,
      },
      mutations: {},
      actions: {}
    })
}

//router.js
import Vue from 'vue'
import Router from 'vue-router'

/* 按需加載 */
const Table = r => require.ensure([], () => r(require('@/views/Table.vue')), 'chunkname1')
const page1 = r => require.ensure([], () => r(require('@/views/page1.vue')), 'chunkname1')

Vue.use(Router)

export const constantRouterMap = [
  { path: '/', component: Table, hidden: true },
  { path: '/page1', component: page1, hidden: true }
];

export function createRouter() {
    return new Router({
        mode: 'history',
        fallback: false, // 設置瀏覽器不支持history.pushState時,不回退
        linkActiveClass: 'open active',
        scrollBehavior: () => ({ y: 0 }),
        routes: constantRouterMap
    })
}

setup-dev-server.js

const fs = require('fs');
const path = require('path');
//文件處理工具
const MFS = require('memory-fs');
const webpack = require('webpack');
//對fs.watch的包裝,優化fs,watch原來的功能
const chokidar = require('chokidar');
const clientConfig = require('./webpack.client.config');
const serverConfig = require('./webpack.server.config');

const readFile = (fs, file) => {
    try {
        return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8');
    } catch (e) {
    }
};

module.exports = function setupDevServer(app, templatePath, cb) {
    let bundle;
    let template;
    let clientManifest;

    let ready;
    const readyPromise = new Promise(r => ready = r);

    //1. 生成新的renderer函數; 2. renderer.renderToString();
    const update = () => {
        if (bundle && clientManifest) {
            //執行server.js中的render函數,但是是異步的
            ready();
            cb(bundle, {
                template,
                clientManifest
            })
        }
    };

    template = fs.readFileSync(templatePath, 'utf-8');
    //模板改了之後刷新 TODO
    chokidar.watch(templatePath).on('change', () => {
        template = fs.readFileSync(templatePath, 'utf-8');
        console.log('index.html template updated');
        update();
    });

    clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app];
    clientConfig.plugins.push(
        new webpack.HotModuleReplacementPlugin(),
        new webpack.NoEmitOnErrorsPlugin()
    );

    const clientComplier = webpack(clientConfig);
    const devMiddleware = require('webpack-dev-middleware')(clientComplier, {
        publicPath: clientConfig.output.publicPath,
        noInfo: true
    });
    app.use(devMiddleware);
    clientComplier.plugin('done', stats => {
        stats = stats.toJson();
        stats.errors.forEach(err => console.log(err));
        stats.warnings.forEach(err => console.log(err));
        if (stats.errors.length) return;
        clientManifest = JSON.parse(readFile(
            devMiddleware.fileSystem,
            'vue-ssr-client-manifest.json'
        ));
        update();
    });
    app.use(require('webpack-hot-middleware')(clientComplier, {heartbeat: 5000}));

    const serverCompiler = webpack(serverConfig);
    const mfs = new MFS();

    serverCompiler.outputFileSystem = mfs;
    //監聽server文件修改 TODO
    serverCompiler.watch({}, (err, stats) => {
        if (err) throw err;
        stats = stats.toJson();
        if (stats.errors.length) return;

        // read bundle generated by vue-ssr-webpack-plugin
        bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json'));
        update();
    });

    return readyPromise;
};

webpack.base.config.js

vue-loader.conf就是vue腳手架自動生成的文件,就不再貼出了

const path = require('path');
var utils = require('./utils');
var config = require('../config');
var vueLoaderConfig = require('./vue-loader.conf');
var ExtractTextPlugin = require('extract-text-webpack-plugin');
var OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin');
var FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin');
var CopyWebpackPlugin = require('copy-webpack-plugin')

function resolve(dir) {
    return path.join(__dirname, '..', dir)
}

const isProd = process.env.NODE_ENV === 'production';

module.exports = {
    devtool: isProd
        ? false
        : '#cheap-module-source-map',
    output: {
        path: path.resolve(__dirname, '../dist'),
        publicPath: '/dist/',
        filename: isProd ? utils.assetsPath('js/[name].[chunkhash].js') : utils.assetsPath('[name].js'),
        chunkFilename: isProd ? utils.assetsPath('js/[id].[chunkhash].js') : utils.assetsPath('[id].js')
    },
    resolve: {
        extensions: ['.js', '.vue', '.json'],
        alias: {
            'vue$': 'vue/dist/vue.esm.js',
            '@': resolve('client'),
            'src': path.resolve(__dirname, '../client'),
            'assets': path.resolve(__dirname, '../client/assets'),
            'components': path.resolve(__dirname, '../client/components'),
            'views': path.resolve(__dirname, '../client/views'),
            'api': path.resolve(__dirname, '../client/api'),
            'utils': path.resolve(__dirname, '../client/utils'),
            'router': path.resolve(__dirname, '../client/router'),
            'vendor': path.resolve(__dirname, '../client/vendor'),
            'static': path.resolve(__dirname, '../static'),

        }
    },
    externals: {
        jquery: 'jQuery'
    },
    module: {
        rules: [
            // {
            //     test: /\.(js|vue)$/,
            //     loader: 'eslint-loader',
            //     enforce: "pre",
            //     include: [resolve('src'), resolve('test')],
            //     options: {
            //         formatter: require('eslint-friendly-formatter')
            //     }
            // },
            {
                test: /\.vue$/,
                loader: 'vue-loader',
                options: vueLoaderConfig
            },
            {
                test: /\.js$/,
                loader: 'babel-loader?cacheDirectory',
                include: [resolve('client'), resolve('test')]
            },
            {
                test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
                loader: 'url-loader',
                query: {
                    limit: 10000,
                    name: utils.assetsPath('img/[name].[hash:7].[ext]')
                }
            },
            {
                test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
                loader: 'url-loader',
                query: {
                    limit: 10000,
                    name: utils.assetsPath('fonts/[name].[ext]')
                }
            },
            ...utils.styleLoaders({sourceMap: config.dev.cssSourceMap})
        ]
    },
    plugins: isProd
        ? [
            // new webpack.optimize.ModuleConcatenationPlugin(),
            new ExtractTextPlugin({
                filename: 'common.[chunkhash].css'
            }),
            // Compress extracted CSS. We are using this plugin so that possible
            // duplicated CSS from different components can be deduped.
            new OptimizeCSSPlugin(),
            // copy custom static assets
            new CopyWebpackPlugin([
                {
                    from: path.resolve(__dirname, '../static'),
                    to: config.build.assetsSubDirectory,
                    ignore: ['.*']
                }
            ]),
        ]
        : [
            new FriendlyErrorsPlugin(),
            // copy custom static assets
            new CopyWebpackPlugin([
                {
                    from: path.resolve(__dirname, '../static'),
                    to: config.build.assetsSubDirectory,
                    ignore: ['.*']
                }
            ]),
        ]
};

webpack.server.config.js

const webpack = require('webpack');
const merge = require('webpack-merge');
const config = require('../config');
const baseConfig = require('./webpack.base.config');
// const nodeExternals = require('webpack-node-externals')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');

let env, NODE_ENV = process.env.NODE_ENV;
if (NODE_ENV === 'development') {
    env = config.dev.env;
} else if (NODE_ENV === 'production') {
    env = config.build.prodEnv;
} else {
    env = config.build.sitEnv;
}

module.exports = merge(baseConfig, {
    target: 'node',
    devtool: '#source-map',
    entry: './client/entry-server.js',
    output: {
        filename: 'server-bundle.js',
        libraryTarget: 'commonjs2'
    },
    plugins: [
        new webpack.DefinePlugin({
            'process.env': env,
            'process.env.VUE_ENV': '"server"',
        }),
        new VueSSRServerPlugin()
    ]
});

webpack.client.config.js

const webpack = require('webpack');
const merge = require('webpack-merge');
const config = require('../config');
const utils = require('./utils');
const base = require('./webpack.base.config');
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin');

// TODO
// const SWPrecachePlugin = require('sw-precache-webpack-plugin');

let env, NODE_ENV = process.env.NODE_ENV;
if (NODE_ENV === 'development') {
    env = config.dev.env;
} else if (NODE_ENV === 'production') {
    env = config.build.prodEnv;
} else {
    env = config.build.sitEnv;
}

module.exports = merge(base, {
    entry: {
        app: './client/entry-client.js'
    },
    plugins:
        NODE_ENV !== 'development' ? [
            new webpack.DefinePlugin({
                'process.env': env,
                'process.env.VUE_ENV': '"client"'
            }),
            new webpack.optimize.UglifyJsPlugin({
                compress: {warnings: false}
            }),
            // extract vendor chunks for better caching
            new webpack.optimize.CommonsChunkPlugin({
                name: 'vendor',
                minChunks: function (module) {
                    // a module is extracted into the vendor chunk if...
                    return (
                        // it's inside node_modules
                        /node_modules/.test(module.context) &&
                        // and not a CSS file (due to extract-text-webpack-plugin limitation)
                        !/\.css$/.test(module.request)
                    )
                }
            }),
            // extract webpack runtime & manifest to avoid vendor chunk hash changing
            // on every build.
            new webpack.optimize.CommonsChunkPlugin({
                name: 'manifest'
            }),
            new VueSSRClientPlugin(),
        ] : [
            new webpack.DefinePlugin({
                'process.env': env,
                'process.env.VUE_ENV': '"client"'
            }),
            new VueSSRClientPlugin(),
        ],
});

啓動命令

開發模式:npm run dev
生產模式:npm run build & npm run start

	"scripts": {
	    "dev": "cross-env NODE_ENV=development supervisor server/app.js",
	    "start": "cross-env NODE_ENV=production node server/app.js",
	    "build": "npm run build:client && npm run build:server",
	    "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"
	  },

異步處理例子

store.js

//store.js
import {TableRequest} from '@/api/Table';
export default {
  state: {
    userName: ''
  },
  mutations: {
    GETUSERNAME(state, data) {
      state.userName = data;
    }
  },
  actions: {
    GetUserName({commit}) {
      return TableRequest().then(res => {
        commit('GETUSERNAME', res.data.content);
      })
    }
  }
}
// api.js  請求可以用node做一個
export function GetUserName (user, password) {
  const data = {
    user,
    password
  }
  return fetch({
    url: '/apis/getUserName',
    data
  })
}

vue頁面

<template>
    <div>
        {{$store.state.userName}}
    </div>
</template>

<script>
    export default {
        name: 'APP',
        asyncData({store, route}) {
            return  store.dispatch('fetchItem', route.params.id);
        },
    }
</script>

server.js添加請求處理

const fs = require('fs'); //讀取文件
const path = require('path');
const express = require('express');
const app= express();
const LRU = require('lru-cache');  //封裝緩存的get set方法
/*
* 處理favicon.ico文件:作用:
* 1. 去除這些多餘無用的日誌
* 2. 將icon緩存在內存中,防止從因盤中重複讀取
* 3. 提供了基於icon 的 ETag 屬性,而不是通過文件信息來更新緩存
* 4. 使用最兼容的Content-Type處理請求
 */
const favicon = require('serve-favicon');
const compression = require('compression');  //壓縮
const microcache = require('route-cache');  //請求緩存
const resolve = (file) => path.resolve(__dirname, file);  //返回絕對路徑
const {createBundleRenderer} = require('vue-server-renderer');

const isProd = process.env.NODE_ENV === 'production';
const useMicroCache = process.env.MICRO_CACHE !== 'false';
const serverInfo =
    `express/${require('express/package').version}` +
    `vue-server-renderer/${require('vue-server-renderer/package').version}`;

let renderer;
let readyPromise;

//生成renderer函數
function createRenderer(bundle, options) {
    return createBundleRenderer(bundle, Object.assign(options, {
        cache: new LRU({
            max: 1000,
            maxAge: 1000 * 60 * 15
        }),
        basedir: resolve('./dist'),
        runInNewContext: false
    }));
}
function render(req, res) {
    const s = Date.now();
    res.setHeader('Content-type', 'text/html');
    res.setHeader('Server', serverInfo);
    const handleError = err => {
        if (err.url) {
            res.redirect(err.url)
        } else if(err.code === 404) {
            res.status(404).send('404 | Page Not Found')
        } else {
            // Render Error Page or Redirect
            res.status(500).send('500 | Internal Server Error')
            console.error(`error during render : ${req.url}`)
            console.error(err.stack)
        }
    };

    const context = {
        title: 'ssr標題',
        url: req.url
    };
    renderer.renderToString(context, (err, html) => {
        console.log(err);
        if (err) {
            return handleError(err);
        }
        res.send(html);
        if (!isProd) {
            console.log(`whole request: ${Date.now() - s}ms`);
        }
    })
}
const templatePath = resolve('./index.html');
if (isProd) {
     const template = fs.readFileSync(templatePath, 'utf-8');
     const bundle = require('./dist/vue-ssr-server-bundle.json');

     const clientManifest = require('./dist/vue-ssr-client-manifest.json');
     renderer = createRenderer(bundle, {
         template,
         clientManifest
     })
 } else {
     readyPromise = require('./build/setup-dev-server')(
         app,
         templatePath,
         (bundle, options) => {
             renderer = createRenderer(bundle, options)
         }
     )
 }
 const serve = (path, cache) => express.static(resolve(path), {
     maxAge: cache && isProd ? 1000 * 60 * 60 * 24 * 30 : 0
 });
 /** 
 * 開始
 * 測試數據請求的配置,可刪
 **/
 app.get('/getName', function (req, res, next) {
    res.json({
        code: 200,
        content: '我是userName',
        msg: '請求成功'
    })
});
 /** 
 * 結束
 * 測試數據請求的配置,可刪
 **/
 //靜態文件壓縮,支持gzip和deflate方式(原來這步是nginx做的),threshold: 0, 0kb以上的都壓縮,即所有文件都壓縮,
 //可通過filter過濾
 //TODO
 app.use(compression({threshold: 0}));
 app.use(favicon('./favicon.ico'));
 app.use('/dist', serve('./dist', true));
 app.use('/static', serve('./static', true));
 app.use('/service-worker.js', serve('./dist/service-worker.js', true));
 //TODO
 app.use(microcache.cacheSeconds(1, req => useMicroCache && req.originalUrl));

 app.get('*', isProd ? render : (req, res) => {
     readyPromise.then(() => render(req, res));
 });
 // 監聽
app.listen(8082, function () {
  console.log('success listen...8082');
});

結尾

以上就是結合vue-cli改造的一個ssr框架,流程和原理理解了之後可自行改造相關配置。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章