快速搭建「服務端渲染」的網站 vue ssr

1、什麼是「服務端渲染」?

1. 傳統ssr:

當客戶端瀏覽器發起一個地址請求時,服務端直接返回完整的HTML內容給瀏覽器進行渲染。javascript

2. vue ssr:

將本來Vue.js (構建客戶端應用程序的框架)輸出在瀏覽器中的 Vue 組件由服務器端()渲染爲 HTML 字符串,將它們直接發送到瀏覽器,最後將這些靜態標記"激活"爲客戶端上徹底可交互的應用程序。css

2、什麼狀況須要「服務端渲染」?

1. 相比傳統 SPA (單頁應用程序 (Single-Page Application)) ,服務器端渲染 (SSR) 的優點主要在於:

  • 更好的 SEO(搜索引擎爬蟲抓取工具能夠直接查看徹底渲染的頁面。目前Google 和 Bing 能夠很好對同步 JavaScript 應用程序進行索引):html

    若是你的應用程序初始展現 loading 菊花圖,而後經過 Ajax 獲取內容,抓取工具並不會等待異步完成後再行抓取頁面內容。也就是說,若是 SEO 對你的站點相當重要,而你的頁面又是異步獲取內容,則你可能須要服務器端渲染(SSR)解決此問題。前端

  • 更快的內容到達時間 (time-to-content,無需等待全部的 js都下載並執行完,才顯示完整的數據,因此用戶將會更快速地看到完整渲染的頁面):vue

    網絡或設備運行緩慢的狀況一般能夠改善的用戶體驗,而且對於那些「內容到達時間(time-to-content) 與轉化率直接相關」的應用程序而言,服務器端渲染 (SSR) 相當重要,能夠幫助你實現最佳的初始加載性能。java

2. 服務器端渲染 (SSR)須要注意:

  • 開發條件所限。瀏覽器特定的代碼,只能在某些生命週期鉤子函數 (lifecycle hook) 中使用;一些外部擴展庫 (external library) 可能須要特殊處理,才能在服務器渲染應用程序中運行。node

  • 涉及構建設置和部署的更多要求。與能夠部署在任何靜態文件服務器上的徹底靜態單頁面應用程序 (SPA) 不一樣,服務器渲染應用程序,須要處於 Node.js server 運行環境。webpack

  • 更多的服務器端負載。在 Node.js 中渲染完整的應用程序,顯然會比僅僅提供靜態文件的 server 更加大量佔用 CPU 資源 (CPU-intensive - CPU 密集),所以若是你預料在高流量環境 (high traffic) 下使用,請準備相應的服務器負載,並明智地採用緩存策略。ios

3、「預渲染」VS「服務端渲染」

若是你的項目只有少數營銷頁面須要SEO ,那麼你可能只須要預渲染。在構建時 (build time) 針對特定路由簡單地生成靜態 HTML 文件。預渲染優勢是:設置更簡單,並能夠將你的前端做爲一個徹底靜態的站點,無需使用 web 服務器實時動態編譯 HTML。web

4、快速搭建vue ssr

1. 一個簡單好理解的demo

準備:

  • 推薦使用 Node.js 版本 6+。
  • vue-server-renderer 和 vue 必須匹配版本。
  • vue-server-renderer 依賴一些 Node.js 原生模塊,所以只能在 Node.js 中使用。
npm install vue
npm install vue vue-server-renderer --save
npm install express --save

開始:server.js

//引入
const Vue = require('vue')
const server = require('express')()
const renderer = require('vue-server-renderer').createRenderer()

server.get('*', (req, res) => {
// 第 1 步:建立一個 Vue 實例
    const app = new Vue({
        data: {
            hello: 'hello,vue ssr'
        },
        template: `<div>{{ hello }}</div>`
    })
// 第 3 步:將 Vue 實例渲染爲 HTML 字符串
    renderer.renderToString(app, (err, html) => {
        if (err) {
            res.status(500).end('Internal Server Error')
            return
        }
//第 4 步:將拼接好的完整HTML發送給客戶端讓瀏覽器直接渲染
        res.end(`
      <!DOCTYPE html>
      <html lang="en">
        <head><title>Hello</title></head>
        <body>${html}</body>
      </html>
    `)
    })
})
//監聽端口
server.listen(8080)

運行:

node server.js

結果:能夠看到服務器返回給瀏覽器的HTML有個data-server-rendered="true"表示這段內容是服務端渲染

 

2.結合webpack的完整demo

結合官網示例,操做須要注意的說明都有打註釋,沒有出如今代碼裏的注意項會單獨寫出來。這裏只貼出了與SPA項目不一樣的代碼。

項目結構:

開發環境運行配置示例:build/setup-dev-server.js

const fs = require('fs')
const path = require('path')
const MFS = require('memory-fs')
const webpack = require('webpack')
/*chokidar 是封裝 Node.js 監控文件系統文件變化功能的庫。解決nodeJs原生監控文件系統的問題:
* 1.事件處理有大量問題
* 2.不提供遞歸監控文件樹功能
* 3.致使 CPU 佔用高
*/
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 })
    const update = () => {
        if (bundle && clientManifest) {
            ready()
            cb(bundle, {
                template,
                clientManifest
            })
        }
    }

    // read template from disk and watch
    template = fs.readFileSync(templatePath, 'utf-8')
    chokidar.watch(templatePath).on('change', () => {
        template = fs.readFileSync(templatePath, 'utf-8')
        console.log('index.html template updated.')
        update()
    })

    // modify client config to work with hot middleware
    clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app]
    clientConfig.output.filename = '[name].js'
    clientConfig.plugins.push(
        new webpack.HotModuleReplacementPlugin(),
        new webpack.NoEmitOnErrorsPlugin()
    )

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

    // hot middleware
    app.use(require('webpack-hot-middleware')(clientCompiler, { heartbeat: 5000 }))

    // watch and update server renderer
    const serverCompiler = webpack(serverConfig)
    const mfs = new MFS()
    serverCompiler.outputFileSystem = mfs
    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
}

生產環境客戶端打包配置示例:build/webpack.client.config.js:

const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
//用於使用service workers緩存您的外部項目依賴項。它將使用sw-precache生成一個服務工做者文件,並將其添加到您的構建目錄中。
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

const config = merge(base, {
    entry: {
        app: './src/entry-client.js'
    },
    optimization: {
        splitChunks: {
            cacheGroups: {
                commons: {
                    name: 'vendor',
                    minChunks: 1
                }
            }
        }
    },
    plugins: [
        new webpack.DefinePlugin({
            'process.env.VUE_ENV': '"client"'
        }),
        // 此插件在輸出目錄中
        // 生成 `vue-ssr-client-manifest.json`。
        new VueSSRClientPlugin()
    ]
})

module.exports = config

生產環境服務端打包配置示例:build/webpack.server.config.js

const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
//用於使用service workers緩存您的外部項目依賴項。它將使用sw-precache生成一個服務工做者文件,並將其添加到您的構建目錄中。
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

const config = merge(base, {
    entry: {
        app: './src/entry-client.js'
    },
    optimization: {
        splitChunks: {
            cacheGroups: {
                commons: {
                    name: 'vendor',
                    minChunks: 1
                }
            }
        }
    },
    plugins: [
        new webpack.DefinePlugin({
            'process.env.VUE_ENV': '"client"'
        }),
        // 此插件在輸出目錄中
        // 生成 `vue-ssr-client-manifest.json`。
        new VueSSRClientPlugin()
    ]
})

module.exports = config

狀態管理模塊示例:src/store/modules/test.js

export default {
    namespaced: true,
    // 重要信息:state 必須是一個函數,
    // 所以能夠建立多個實例化該模塊
    state: () => ({
        count: 1
    }),
    actions: {
        inc: ({ commit }) => commit('inc')
    },
    mutations: {
        inc: state => state.count++
    }
}

狀態管理使用示例:src/views/Home.vue

<template>
    <section>
        這裏是:views/Home.vue
        狀態管理數據{{fooCount}}
        <hello-world></hello-world>
    </section>
</template>

<script>
    import HelloWorld from '../components/HelloWorld.vue'
    // 在這裏導入模塊,而不是在 `store/index.js` 中
    import fooStoreModule from '../store/modules/test'

    export default {
        asyncData ({ store }) {
            store.registerModule('foo', fooStoreModule);
            return store.dispatch('foo/inc')
        },

        // 重要信息:當屢次訪問路由時,
        // 避免在客戶端重複註冊模塊。
        destroyed () {
            this.$store.unregisterModule('foo')
        },

        computed: {
            fooCount () {
                return this.$store.state.foo.count
            }
        },
        components: {
            HelloWorld
        }
    }
</script>

通用入口:src/app.js:

注意:router、store、vue實例的建立要封裝成構造函數,以便每次訪問時服務端返回的是一個全新的實例對象

/*app.js通用入口。
 *核心做用是建立Vue實例。相似SPA的main.js。
*/
import Vue from 'vue'
//導入跟頁面
import App from './App.vue'
// 導入路由生成器
import {createRouter} from "./router";
// 導入狀態管理生成器
import {createStore} from "./store";
import {sync} from 'vuex-router-sync'

//建立並導出 vue實例生成器
export function createApp() {
    // 生成路由器
    let router = createRouter();
    // 生成狀態管理器
    let store = createStore();
    // 同步路由狀態(route state)到 store
    sync(store, router);
    let app = new Vue({
        //將路由器掛載到vue實例
        router,
        //將狀態管理器掛載到vue實例
        store,
        // 生成App渲染
        render: h => h(App)
    });
    //返回生成的實例們
    return {app, router, store}
}

客戶端渲染入口文件:src/entry-client.js

/** entry-client.js客戶端入口。
 * 僅運行於瀏覽器
 * 核心做用:掛載、激活app。將服務器剛剛返回給瀏覽器的完整HTML替換爲spa
 */
// 導入App生成器
import {createApp} from "./app";
//建立實例們
const {app, router,store} = createApp();
//當使用 template 時,context.state 將做爲 window.__INITIAL_STATE__ 狀態,自動嵌入到最終的 HTML 中。而在客戶端,在掛載到應用程序以前,store 就應該獲取到狀態
if (window.__INITIAL_STATE__) {
    store.replaceState(window.__INITIAL_STATE__);
}
// 路由就緒後
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實例掛載到#app對應的DOM節點。在沒有 data-server-rendered 屬性的元素上向 $mount 函數的 hydrating 參數位置傳入 true,強制使用應用程序的激活模式:app.$mount('#app', true)
    app.$mount('#app');
});

服務端渲染入口文件:src/entry-server.js

/** entry-server.js服務端入口。
 * 僅運行於服務器。
 * 核心做用是:拿到App實例生成HTML返回給瀏覽器渲染首屏
 */
//導入App生成器
import {createApp} from "./app";
/*
context:「服務器」調用上下文。如:訪問的url,根據url決定未來createApp里路由的具體操做
 */
export default context => {
    return new Promise((resolve, reject) => {
        //建立App實例,router實例
        const {app, router, store} = createApp();
        //進入首屏:約定node服務器會將瀏覽器請求的url放進上下文context中,使用router.push()將當前訪問的url對應的vue組件路由到App實例當前頁
        router.push(context.url);
        //路由準備就緒後
        router.onReady(() => {
            const matchedComponents = router.getMatchedComponents();
            // 匹配不到的路由,執行 reject 函數,並返回 404
            if (!matchedComponents.length) {
                return reject({code: 404})
            }
            // 對全部匹配的路由組件調用 `asyncData()`
            Promise.all(matchedComponents.map(Component => {
                if (Component.asyncData) {
                    return Component.asyncData({
                        store,
                        route: router.currentRoute
                    })
                }
            })).then(() => {
                // 在全部預取鉤子(preFetch hook) resolve 後,
                // 咱們的 store 如今已經填充入渲染應用程序所需的狀態。
                // 當咱們將狀態附加到上下文,
                // 而且 `template` 選項用於 renderer 時,
                // 狀態將自動序列化爲 `window.__INITIAL_STATE__`,並注入 HTML。
                context.state = store.state;
                context.title = router.currentRoute.name;
                //將渲染出來的App返回
                resolve(app);
            }, reject)
        });
    });
}

服務端渲染模板:index.template.html

注意:data-server-rendered 特殊屬性,讓客戶端 Vue 知道這部分 HTML 是由 Vue 在服務端渲染的,而且應該以激活模式進行掛載。注意,這裏並無添加 id="app",而是添加 data-server-rendered 屬性:你須要自行添加 ID 或其餘可以選取到應用程序根元素的選擇器,不然應用程序將沒法正常激活。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8"/>
    <meta http-equiv="X-UA-Compatible" content="ie=edge"/>
    <title>vue ssr</title>
</head>
<body>
<div id="app">
    <!--vue-ssr-outlet-->
</div>
</body>

項目運行入口文件:server.js

//nodeJs 服務器
const fs = require('fs');
const path = require('path');
const express = require('express');
//建立 express實例
const server = express();
//導入渲染器插件
const { createBundleRenderer } = require('vue-server-renderer');
const resolve = file => path.resolve(__dirname, file);
const templatePath = resolve('./src/index.template.html');
//獲取 npm run 後面的命令
const isProd = process.env.NODE_ENV === 'production';
/**
 * 建立Renderer渲染器
 */
function createRenderer(bundle, options) {
    return createBundleRenderer(
        bundle,
        Object.assign(options, {
            runInNewContext: false
        })
    );
}
let renderer;
//生產環境
if (isProd) {
    const template = fs.readFileSync(templatePath, 'utf-8');
    const serverBundle = require('./dist/vue-ssr-server-bundle.json');
    const clientManifest = require('./dist/vue-ssr-client-manifest.json');
    renderer = createRenderer(serverBundle, {
        template,
        clientManifest
    });
} else {
    readyPromise = require('./build/setup-dev-server.js')(
        server,
        templatePath,
        (bundle, options) => {
            renderer = createRenderer(bundle, options);
        }
    );
}
//當瀏覽器請求 *(任意接口)時
server.get('*', async (req, res) => {
    try {
        const context = {
            url: req.url
        };
        //將url對應的vue組件渲染爲HTML
        const html = await renderer.renderToString(context);
        //將HTML返回給瀏覽器
        res.send(html);
    } catch (e) {
        console.log(e);
        res.status(500).send('服務器內部錯誤');
    }
});
//監聽瀏覽器8080端口
server.listen(8080, () => {
    console.log('監聽8000,服務器啓動成功')
});

package.json:

{
  "name": "webpackstudy",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "nodemon server",
    "build": "npm run build:client && npm run build:server",
    "build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js --progress --hide-modules",
    "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js --progress --hide-modules",
    "mock": "webpack-dev-server --progress --color"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "axios": "^0.19.0",
    "body-parser": "^1.19.0",
    "cheerio": "^1.0.0-rc.3",
    "cookie-parser": "^1.4.4",
    "cookie-session": "^1.3.3",
    "cors": "^2.8.5",
    "express": "^4.17.1",
    "jsonwebtoken": "^8.5.1",
    "mongoose": "^5.7.7",
    "multer": "^1.4.2",
    "nodemailer": "^6.3.1",
    "redis": "^2.8.0",
    "request": "^2.88.0",
    "util": "^0.12.1",
    "vue-router": "^3.1.2",
    "vuex": "^3.1.1",
    "ws": "^7.2.0"
  },
  "devDependencies": {
    "@babel/core": "^7.5.5",
    "@babel/preset-env": "^7.5.5",
    "@vue/cli-plugin-typescript": "^4.0.5",
    "autoprefixer": "^9.6.1",
    "babel-loader": "^8.0.6",
    "clean-webpack-plugin": "^3.0.0",
    "compression": "^1.7.4",
    "cross-env": "^6.0.3",
    "css-loader": "^3.2.0",
    "extract-text-webpack-plugin": "^3.0.2",
    "file-loader": "^4.2.0",
    "friendly-errors-webpack-plugin": "^1.7.0",
    "fs": "0.0.1-security",
    "html-webpack-plugin": "^3.2.0",
    "html-withimg-loader": "^0.1.16",
    "install": "^0.13.0",
    "jsonc": "^2.0.0",
    "less": "^3.10.2",
    "less-loader": "^5.0.0",
    "lru-cache": "^5.1.1",
    "memory-fs": "^0.5.0",
    "mini-css-extract-plugin": "^0.8.0",
    "mocker-api": "^1.8.1",
    "npm": "^6.13.3",
    "optimize-css-assets-webpack-plugin": "^5.0.3",
    "postcss-loader": "^3.0.0",
    "route-cache": "^0.4.4",
    "serve-favicon": "^2.5.0",
    "style-loader": "^1.0.0",
    "sw-precache-webpack-plugin": "^0.11.5",
    "terser-webpack-plugin": "^1.4.1",
    "uglifyjs-webpack-plugin": "^2.2.0",
    "url-loader": "^2.1.0",
    "vue": "^2.6.10",
    "vue-loader": "^15.7.1",
    "vue-server-renderer": "^2.6.10",
    "vue-style-loader": "^4.1.2",
    "vue-template-compiler": "^2.6.10",
    "vuex-router-sync": "^5.0.0",
    "webpack": "^4.39.2",
    "webpack-cli": "^3.3.7",
    "webpack-dev-server": "^3.8.0",
    "webpack-hot-middleware": "^2.25.0",
    "webpack-merge": "^4.2.2",
    "webpack-node-externals": "^1.7.2"
  }
}

這裏僅提供簡單的可運行的代碼,詳細瞭解參見官網

相關文章
相關標籤/搜索