一個極簡版本的 VUE SSR demo

我本人在剛開始看 VUE SSR 官方文檔的時候遇到不少問題,它一開始是創建在你有一個可運行的構建環境的,因此它直接講代碼的實現,可是對於剛接觸的開發者來講並無一個運行環境,因此全部的代碼片斷都沒法運行。那爲何做者不先講構建,再講程序實現呢?我以爲多是由於構建、運行又重度依賴具體的代碼實現,先講構建也不利於理解總體過程,因此是一個不太好平衡的事。javascript

咱們這個 demo 將先講構建過程,其中有些問題可能須要在後面講完之後回頭再看,但力求能將總體過程交待清楚。同時,文章中的每一步都會在這個 DEMO 有體現,經過這個 demo 的不一樣 commit ,能夠快速定位到不一樣階段,具體的 commit id 以下:css

* e06aee792a59ffd9018aea1e3601e220c37fedbd (HEAD -> master, origin/master) 優化:添加緩存
* c65f08beaff1dea1eaf05d02fb30a7e8776ce289 程序開發:初步完成demo
* 2fb0d28ee6d84d2b1bdbbe419c744efdad3227de 程序開發:完成store定義,api編寫和程序同步
* 9604aec0de526726f4fe435385f7c2fa4009fa63 程序開發:第一個可獨立運行版本,無store
* 7d567e254fc9dc5a1655d2f0abbb4b8d53bccfce 構建配置:webpack配置、server.js後端入口文件編寫
* 969248b64af82edd07214a621dfd19cf357d6c53 構建配置:babel 配置
* a5453fdeb20769e8c9e9ee339b624732ad14658a 初始化項目,完成第一個可運行demo
複製代碼

在閱讀、測試的時候,能夠經過 git reset --hard commitid 來切換不一樣的階段,看具體的實現。html

什麼是服務器端渲染(SSR)?

Vue.js 是構建客戶端應用程序的框架。默認狀況下,能夠在瀏覽器中輸出 Vue 組件,進行生成 DOM 和操做 DOM。然而,也能夠將同一個組件渲染爲服務器端的 HTML 字符串,將它們直接發送到瀏覽器,最後將這些靜態標記"激活"爲客戶端上徹底可交互的應用程序。前端

服務器渲染的 Vue.js 應用程序也能夠被認爲是"同構"或"通用",由於應用程序的大部分代碼均可以在服務器客戶端上運行。vue

爲何使用服務器端渲染(SSR)?

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

  • 更好的 SEO,因爲搜索引擎爬蟲抓取工具能夠直接查看徹底渲染的頁面。
  • 更快的內容到達時間(time-to-content),特別是對於緩慢的網絡狀況或運行緩慢的設備。

基本用法

安裝須要用到的模板node

npm install vue vue-server-renderer express --savewebpack

新建 /server.js/src/index.template.htmlios

const server = require('express')()
const Vue = require('vue')
const fs = require('fs')

const Renderer = require('vue-server-renderer').createRenderer({
  template:fs.readFileSync('./src/index.template.html', 'utf-8')
})

server.get('*', (req, res) => {

  const app = new Vue({
    data: {
      name: 'vue app~',
      url: req.url
    },
    template:'<div>hello from {{name}}, and url is: {{url}}</div>'
  })
  const context = {
    title: 'SSR test#'
  }
  Renderer.renderToString(app, context, (err, html) => {
    if(err) {
      console.log(err)
      res.status(500).end('server error')
    }
    res.end(html)
  })
})

server.listen(4001)
console.log('running at: http://localhost:4001');
複製代碼

經過以上程序,能夠看到經過 vue-server-renderer 將VUE實例進行編譯,最終經過 express 輸出到瀏覽器。nginx

但同時也能看到,輸出的是一個靜態的純html頁面,因爲沒有加載任何 javascript 文件,前端的用戶交互也無所實現,因此上面的 demo 只是一個極簡的實例,要想實現一個完整的 VUE ssr 程序,還須要藉助 VueSSRClientPlugin(vue-server-renderer/client-plugin) 將文件編譯成前端瀏覽器可運行的 vue-ssr-client-manifest.json 文件和 js、css 等文件,VueSSRServerPlugin(vue-server-renderer/server-plugin) 將文件編譯成可供node調用的 vue-ssr-server-bundle.json

真正開始以前,須要瞭解一些概念

編寫通用代碼

"通用"代碼時的約束條件 - 即運行在服務器和客戶端的代碼,因爲用例和平臺 API 的差別,當運行在不一樣環境中時,咱們的代碼將不會徹底相同。

服務器上的數據響應

每一個請求應該都是全新的、獨立的應用程序實例,以便不會有交叉請求形成的狀態污染(cross-request state pollution)

組件生命週期鉤子函數

因爲沒有動態更新,全部的生命週期鉤子函數中,只有 beforeCreate 和 created 會在服務器端渲染(SSR)過程當中被調用

訪問特定平臺(Platform-Specific) API

通用代碼不可接受特定平臺的 API,所以若是你的代碼中,直接使用了像 window 或 document,這種僅瀏覽器可用的全局變量,則會在 Node.js 中執行時拋出錯誤,反之也是如此。

構建配置

如何將相同的 Vue 應用程序提供給服務端和客戶端。爲了作到這一點,咱們須要使用 webpack 來打包 Vue 應用程序。

  • 一般 Vue 應用程序是由 webpack 和 vue-loader 構建,而且許多 webpack 特定功能不能直接在 Node.js 中運行(例如經過 file-loader 導入文件,經過 css-loader 導入 CSS)。

  • 儘管 Node.js 最新版本可以徹底支持 ES2015 特性,咱們仍是須要轉譯客戶端代碼以適應老版瀏覽器。這也會涉及到構建步驟。

因此基本見解是,對於客戶端應用程序和服務器應用程序,咱們都要使用 webpack 打包 - 服務器須要「服務器 bundle」而後用於服務器端渲染(SSR),而「客戶端 bundle」會發送給瀏覽器,用於混合靜態標記。

構建過程

下面看具體實現過程

Babel配置

新建 /.babelrc 配置

// es6 compile to es5 相關配置
{
  "presets": [
    [
      "env",
      {
        "modules": false
      }
    ]
  ],
  "plugins": ["syntax-dynamic-import"]
}

npm i -D babel-loader@7 babel-core babel-plugin-syntax-dynamic-import babel-preset-env
複製代碼

webpack 配置

新建一個 build 文件夾,用於存放 webpack 相關的配置文件

/
├── build
│   ├── setup-dev-server.js  # 設置 webpack-dev-middleware 開發環境
│   ├── webpack.base.config.js # 基礎通用配置
│   ├── webpack.client.config.js  # 編譯出 vue-ssr-client-manifest.json 文件和 js、css 等文件,供瀏覽器調用
│   └── webpack.server.config.js  # 編譯出 vue-ssr-server-bundle.json 供 nodejs 調用
複製代碼

先把相關的包安裝

安裝 webpack 相關的包

npm i -D webpack webpack-cli webpack-dev-middleware webpack-hot-middleware webpack-merge webpack-node-externals

安裝構建依賴的包

npm i -D chokidar cross-env friendly-errors-webpack-plugin memory-fs rimraf vue-loader

接下來看每一個文件的具體內容:

webpack.base.config.js

const path = require('path')
const { VueLoaderPlugin } = require('vue-loader')

const isProd = process.env.NODE_ENV === 'production'
module.exports = {
  context: path.resolve(__dirname, '../'),
  devtool: isProd ? 'source-map' : '#cheap-module-source-map',
  output: {
    path: path.resolve(__dirname, '../dist'),
    publicPath: '/dist/',
    filename: '[name].[chunkhash].js'
  },
  resolve: {
    // ...
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: {
          compilerOptions: {
            preserveWhitespace: false
          }
        }
      }
      // ...
    ]
  },
  plugins: [new VueLoaderPlugin()]
}
複製代碼

webpack.base.config.js 這個是通用配置,和咱們以前SPA開發配置基本同樣。

webpack.client.config.js

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

const config = merge(base, {
  mode: 'development',
  entry: {
    app: './src/entry-client.js'
  },
  resolve: {},
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(
        process.env.NODE_ENV || 'development'
      ),
      'process.env.VUE_ENV': '"client"'
    }),
    new VueSSRClientPlugin()
  ]
})
module.exports = config
複製代碼

webpack.client.config.js 主要完成了兩個工做

  • 定義入口文件 entry-client.js
  • 經過插件 VueSSRClientPlugin 生成 vue-ssr-client-manifest.json

這個 manifest.json 文件被 server.js 引用

const { createBundleRenderer } = require('vue-server-renderer')

const template = require('fs').readFileSync('/path/to/template.html', 'utf-8')
const serverBundle = require('/path/to/vue-ssr-server-bundle.json')
const clientManifest = require('/path/to/vue-ssr-client-manifest.json')

const renderer = createBundleRenderer(serverBundle, {
  template,
  clientManifest
})

複製代碼

經過以上設置,使用代碼分割特性構建後的服務器渲染的 HTML 代碼,全部都是自動注入。

webpack.server.config.js

const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const nodeExternals = require('webpack-node-externals') // Webpack allows you to define externals - modules that should not be bundled.
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')

module.exports = merge(base, {
  mode: 'production',
  target: 'node',
  devtool: '#source-map',
  entry: './src/entry-server.js',
  output: {
    filename: 'server-bundle.js',
    libraryTarget: 'commonjs2'
  },
  resolve: {},
  externals: nodeExternals({
    whitelist: /\.css$/ // 防止將某些 import 的包(package)打包到 bundle 中,而是在運行時(runtime)再去從外部獲取這些擴展依賴
  }),
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
      'process.env.VUE_ENV': '"server"'
    }),
    new VueSSRServerPlugin()
  ]
})

複製代碼

webpack.server.config.js 主要完成的工做是:

  • 經過 target: 'node' 告訴 webpack 編譯的目錄代碼是 node 應用程序
  • 經過 VueSSRServerPlugin 插件,將代碼編譯成 vue-ssr-server-bundle.json

在生成 vue-ssr-server-bundle.json 以後,只需將文件路徑傳遞給 createBundleRenderer ,在 server.js 中以下實現:

const { createBundleRenderer } = require('vue-server-renderer')
const renderer = createBundleRenderer('/path/to/vue-ssr-server-bundle.json', {
  // ……renderer 的其餘選項
})
複製代碼

至此,基本已經完成構建

完成第一個可運行實例

安裝 VUE 相關的依賴包

npm i axios vue-template-compiler vue-router vuex vuex-router-sync

新增並完善以下文件:

/
├── server.js # 實現長期運行的 node 程序
├── src
│   ├── app.js # 新增
│   ├── router.js # 新增 定義路由
│   ├── App.vue # 新增
│   ├── entry-client.js # 瀏覽器端入口
│   ├── entry-server.js # node程序端入口
└── views
    └── Home.vue # 首頁
複製代碼

接下來逐個看這些文件:

server.js

const fs = require('fs');
const path = require('path');
const express = require('express');
const { createBundleRenderer } = require('vue-server-renderer');
const devServer = require('./build/setup-dev-server')
const resolve = file => path.resolve(__dirname, file);

const isProd = process.env.NODE_ENV === 'production';
const app = express();

const serve = (path, cache) =>
  express.static(resolve(path), {
    maxAge: cache && isProd ? 1000 * 60 * 60 * 24 * 30 : 0
  });
app.use('/dist', serve('./dist', true));

function createRenderer(bundle, options) {
  return createBundleRenderer( bundle, Object.assign(options, {
      basedir: resolve('./dist'),
      runInNewContext: false
    })
  );
}

function render(req, res) {
  const startTime = Date.now();
  res.setHeader('Content-Type', 'text/html');

  const context = {
    title: 'SSR 測試', // default title
    url: req.url
  };
  renderer.renderToString(context, (err, html) => {
    res.send(html);
  });
}

let renderer;
let readyPromise;
const templatePath = resolve('./src/index.template.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') // 將js文件注入到頁面中
  renderer = createRenderer(bundle, {
    template,
    clientManifest
  });
} else {
  readyPromise = devServer( app, templatePath, (bundle, options) => {
      renderer = createRenderer(bundle, options);
    }
  );
}

app.get('*',isProd? render : (req, res) => {
        readyPromise.then(() => render(req, res));
      }
);

const port = process.env.PORT || 8088;
app.listen(port, () => {
  console.log(`server started at localhost:${port}`);
});

複製代碼

server.js 主要完成了如下工做

  • 當執行 npm run dev 的時候,調用 /build/setup-dev-server.js 啓動 'webpack-dev-middleware' 開發中間件
  • 經過 vue-server-renderer 調用以前編譯生成的 vue-ssr-server-bundle.json 啓動 node 服務
  • vue-ssr-client-manifest.json 注入到 createRenderer 中實現前端資源的t自動注入
  • 經過 express 處理 http 請求

server.js 是整個站點的入口程序,經過他調用編譯事後的文件,最終輸出到頁面,是整個項目中很關鍵的一部分

app.js

import Vue from 'vue'
import App from './App.vue';
import { createRouter } from './router';

export function createApp(context) {
  const router = createRouter();

  const app = new Vue({
    router,
    render: h => h(App)
  });
  return { app, router };
};
複製代碼

app.js 暴露一個能夠重複執行的工廠函數,爲每一個請求建立新的應用程序實例,提交給 'entry-client.js' 和 entry-server.js 調用

entry-client.js

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

entry-client.js 常規的實例化 vue 對象並掛載到頁面中

entry-server.js

import { createApp } from './app';

export default context => {
  // 由於有可能會是異步路由鉤子函數或組件,因此咱們將返回一個 Promise,
  // 以便服務器可以等待全部的內容在渲染前,
  // 就已經準備就緒。
  return new Promise((resolve, reject) => {
    const { app, router } = createApp(context);

    // 設置服務器端 router 的位置
    router.push(context.url);

    // 等到 router 將可能的異步組件和鉤子函數解析完
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents();

      // 匹配不到的路由,執行 reject 函數,並返回 404
      if (!matchedComponents.length) {
        return reject({ code: 404 });
      }

      resolve(app);
    });
  });
};
複製代碼

entry-server.js 做爲服務器入口,最終通過 VueSSRServerPlugin 插件,編譯成 vue-ssr-server-bundle.jsonvue-server-renderer 調用

router.jsHome.vue 爲常規 vue 程序,這裏不進一步展開了。

至此,咱們完成了第一個能夠完整編譯和運行的 vue ssr 實例

數據預取和狀態管理

在此以前完成的程序,只是將預想定義的變量渲染成html返回給客戶端,但若是要實現一個真正可用的web程序,是要有動態數據的支持的,如今咱們開始看如何從遠程獲取數據,而後渲染成html輸出到客戶端。

在服務器端渲染(SSR)期間,咱們本質上是在渲染咱們應用程序的"快照",因此若是應用程序依賴於一些異步數據,那麼在開始渲染過程以前,須要先預取和解析好這些數據。

數據預取存儲容器(Data Store)

先定義一個獲取數據的 api.js ,使用 axios

import axios from 'axios';

export function fetchItem(id) {
  return axios.get('https://api.mimei.net.cn/api/v1/article/' + id);
}
export function fetchList() {
  return axios.get('https://api.mimei.net.cn/api/v1/article/');
}
複製代碼

咱們將使用官方狀態管理庫 Vuex。咱們先建立一個 store.js 文件,裏面會獲取一個文件列表、根據 id 獲取文章內容:

import Vue from 'vue';
import Vuex from 'vuex';
import { fetchItem, fetchList } from './api.js'

Vue.use(Vuex);


export function createStore() {
  return new Vuex.Store({
    state: {
      items: {},
      list: []
    },
    actions: {
      fetchItem({commit}, id) {
        return fetchItem(id).then(res => {
          commit('setItem', {id, item: res.data})
        })
      },
      fetchList({commit}){
        return fetchList().then(res => {
          commit('setList', res.data.list)
        })
      }
    },
    mutations: {
      setItem(state, {id, item}) {
        Vue.set(state.items, id, item)
      },
      setList(state, list) {
        state.list = list
      }
    }
  });
}
複製代碼

而後修改 app.js

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

import { sync } from 'vuex-router-sync'

export function createApp(context) {
  const router = createRouter();
  const store = createStore();

  sync(store, router)

  const app = new Vue({
    router,
    store,
    render: h => h(App)
  });
  return { app, router, store };
};
複製代碼

帶有邏輯配置的組件

store action 定義好了之後,如今來看如何觸發請求,官方建議是放在路由組件裏,接下來看 Home.vue

<template>
  <div>
    <h3>文章列表</h3>
    <div class="list" v-for="i in list">
      <router-link :to="{path:'/item/'+i.id}">{{i.title}}</router-link>
      </div>
  </div>
</template>
<script> export default { asyncData ({store, route}){ return store.dispatch('fetchList') }, computed: { list () { return this.$store.state.list } }, data(){ return { name:'wfz' } } } </script>
複製代碼

服務器端數據預取

entry-server.js 中,咱們能夠經過路由得到與 router.getMatchedComponents() 相匹配的組件,若是組件暴露出 asyncData,咱們就調用這個方法。而後咱們須要將解析完成的狀態,附加到渲染上下文(render context)中。

// entry-server.js
import { createApp } from './app';

export default context => {
  // 由於有可能會是異步路由鉤子函數或組件,因此咱們將返回一個 Promise,
  // 以便服務器可以等待全部的內容在渲染前,
  // 就已經準備就緒。
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp(context);

    // 設置服務器端 router 的位置
    router.push(context.url);

    // 等到 router 將可能的異步組件和鉤子函數解析完
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents();

      // 匹配不到的路由,執行 reject 函數,並返回 404
      if (!matchedComponents.length) {
        return reject({ code: 404 });
      }

      Promise.all(
        matchedComponents.map(component => {
          if (component.asyncData) {
            return component.asyncData({
              store,
              route: router.currentRoute
            });
          }
        })
      ).then(() => {
        context.state = store.state
        // Promise 應該 resolve 應用程序實例,以便它能夠渲染
        resolve(app);
      });
    });
  });
};

複製代碼

當使用 template 時,context.state 將做爲 window.__INITIAL_STATE__ 狀態,自動嵌入到最終的 HTML 中。而在客戶端,在掛載到應用程序以前,store 就應該獲取到狀態:

// entry-client.js

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

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

複製代碼

客戶端數據預取

在客戶端,處理數據預取有兩種不一樣方式:在路由導航以前解析數據匹配要渲染的視圖後,再獲取數據 ,咱們的 demo 裏用第一種方案:

// entry-client.js
import { createApp } from './app';
const { app, router, store } = createApp();

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

router.onReady(() => {
  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();
    }

    Promise.all(
      activated.map(component => {
        if (component.asyncData) {
          component.asyncData({
            store,
            route: to
          });
        }
      })
    )
      .then(() => {
        next();
      })
      .catch(next);
  });
  app.$mount('#app');
});
複製代碼

經過檢查匹配的組件,並在全局路由鉤子函數中執行 asyncData 函數獲取接口數據。

因爲這個 demo 是兩個頁面,還須要的 router.js 添加一個路由信息、添加一個路由組件 Item.vue ,至此已經完成了一個基本的 VUE SSR 實例。

緩存優化

因爲服務端渲染屬於計算密集型,若是併發較大的話,頗有可能有性能問題。適當的使用緩存策略能夠大幅提升響應速度。

const microCache = LRU({
  max: 100,
  maxAge: 1000 // 重要提示:條目在 1 秒後過時。
})

const isCacheable = req => {
  // 實現邏輯爲,檢查請求是不是用戶特定(user-specific)。
  // 只有非用戶特定(non-user-specific)頁面纔會緩存
}

server.get('*', (req, res) => {
  const cacheable = isCacheable(req)
  if (cacheable) {
    const hit = microCache.get(req.url)
    if (hit) {
      return res.end(hit)
    }
  }

  renderer.renderToString((err, html) => {
    res.end(html)
    if (cacheable) {
      microCache.set(req.url, html)
    }
  })
})
複製代碼

基本上,經過 nginx 和緩存,可能很大程度上解決性能瓶頸問題。

相關文章
相關標籤/搜索