Vue SSR初探

由於以前用nuxt開發過應用程序,可是nuxt早就達到了開箱即用的目的,因此一直對vue ssr的具體實現存在好奇。css

完整代碼能夠查看 https://github.com/jinghaoo/vuessr-templatehtml

構建步驟

咱們經過上圖能夠看到,vue ssr 也是離不開 webpack 的打包。vue

利用 webpack的打包將 vue 應用程序生成 Server Bundle 和 Client Bundle。 有了Client manifest (Client Bundle的產物)和 Server Bundle,Bundle Renderer 如今具備了服務器和客戶端的構建信息,所以它能夠自動推斷和注入資源預加載 / 數據預取指令(preload / prefetch directive),以及 css 連接 / script 標籤到所渲染的 HTML。node

項目結構

  • build 文件構建配置webpack

  • public 模板文件nginx

  • src 項目文件git

經過上面能夠看出總體和平時的vue項目區別不是很大,主要集中在 build 中 存在了 webpack.server.config.js 文件 以及 src 文件下的 entry-client.jsentry-server.js, 在這裏特殊說下 src 下的 app.jstemplate.html 與咱們平時寫的vue項目中的也有所區別。es6

template.html

<!DOCTYPE html>
<html lang="en">
  <head><title>Hello</title></head>
  <body>
    <!--vue-ssr-outlet-->
  </body>
</html>

當在渲染 Vue 應用程序時,renderer 只會生成 HTML 標記, 咱們須要用一個額外的 HTML 頁面包裹容器,來包裹生成的 HTML 標記,通常直接在建立 renderer 時提供一個頁面模板。github

  • 注意 <!--vue-ssr-outlet--> 註釋 這裏將是應用程序 HTML 標記注入的地方。

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'

  // 導出一個工廠函數,用於建立新的
  // 應用程序、router 和 store 實例
  export function createApp () {
    // 建立 router 實例
    const router = createRouter()
    // 建立 store 實例
    const store = createStore()

    // 同步路由狀態(route state)到 store
    sync(store, router)

    const app = new Vue({
      // 根實例簡單的渲染應用程序組件。
      router,
      store,
      render: h => h(App)
    })

    return { app, router, store }
  }

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

並且對於客戶端渲染,在掛載 (mount) 到客戶端應用程序以前,客戶端須要獲取到與服務器端應用程序徹底相同的數據。

爲了解決以上問題,獲取的數據須要位於視圖組件以外,即放置在專門的數據預取存儲容器(data store)或"狀態容器(state container))"中。首先,在服務器端,咱們能夠在渲染以前預取數據,並將數據填充到 store 中。此外,咱們將在 HTML 中序列化(serialize)和內聯預置(inline)狀態。這樣,在掛載(mount)到客戶端應用程序以前,能夠直接從 store 獲取到內聯預置(inline)狀態。

當編寫純客戶端 (client-only) 代碼時,咱們習慣於每次在新的上下文中對代碼進行取值。可是,Node.js 服務器是一個長期運行的進程。當咱們的代碼進入該進程時,它將進行一次取值並留存在內存中。這意味着若是建立一個單例對象,它將在每一個傳入的請求之間共享。

咱們爲每一個請求建立一個新的根 Vue 實例。這與每一個用戶在本身的瀏覽器中使用新應用程序的實例相似。若是咱們在多個請求之間使用一個共享的實例,很容易致使交叉請求狀態污染 (cross-request state pollution)。

所以,咱們不該該直接建立一個應用程序實例,而是應該暴露一個能夠重複執行的工廠函數,爲每一個請求建立新的應用程序實例。

entry-client.js

import { createApp } from '@/app'

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

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.$mount('#app')
})

當服務端渲染完畢後,Vue 在瀏覽器端接管由服務端發送的靜態 HTML,使其變爲由 Vue 管理的動態 DOM (即:客戶端激活)。

entry-server.js

import { createApp } from '@/app'

const isDev = process.env.NODE_ENV !== 'production'

// 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)
    console.log(router)


    // wait until router has resolved possible async hooks
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()

      console.log(matchedComponents)
      // 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)
  })
}

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

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

server.js

const fs = require('fs')
const path = require('path')
const LRU = require('lru-cache')
const express = require('express')
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.json').version} ` +
  `vue-server-renderer/${require('vue-server-renderer/package.json').version}`

const app = express()

function createRenderer (bundle, options) {
  // https://github.com/vuejs/vue/blob/dev/packages/vue-server-renderer/README.md#why-use-bundlerenderer
  return createBundleRenderer(bundle, Object.assign(options, {
    // for component caching
    cache: LRU({
      max: 1000,
      maxAge: 1000 * 60 * 15
    }),
    // this is only needed when vue-server-renderer is npm-linked
    basedir: resolve('./dist'),
    // recommended for performance
    runInNewContext: false
  }))
}

let renderer
let readyPromise
const templatePath = resolve('./public/index.template.html')
if (isProd) {
  // In production: create server renderer using template and built server bundle.
  // The server bundle is generated by vue-ssr-webpack-plugin.
  const template = fs.readFileSync(templatePath, 'utf-8')
  const bundle = require('./dist/vue-ssr-server-bundle.json')
  // The client manifests are optional, but it allows the renderer
  // to automatically infer preload/prefetch links and directly add <script>
  // tags for any async chunks used during render, avoiding waterfall requests.
  const clientManifest = require('./dist/vue-ssr-client-manifest.json')
  renderer = createRenderer(bundle, {
    template,
    clientManifest
  })
} else {
  // In development: setup the dev server with watch and hot-reload,
  // and create a new renderer on bundle / index template update.
  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.use(compression({ threshold: 0 }))
app.use('/dist', serve('./dist', true))
app.use('/public', serve('./public', true))
app.use('/manifest.json', serve('./manifest.json', true))
app.use('/service-worker.js', serve('./dist/service-worker.js'))

// since this app has no user-specific content, every page is micro-cacheable.
// if your app involves user-specific content, you need to implement custom
// logic to determine whether a request is cacheable based on its url and
// headers.
// 1-second microcache.
// https://www.nginx.com/blog/benefits-of-microcaching-nginx/
app.use(microcache.cacheSeconds(1, req => useMicroCache && req.originalUrl))

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: 'Vue HN 2.0', // default title
    url: req.url
  }
  renderer.renderToString(context, (err, html) => {
    if (err) {
      return handleError(err)
    }
    res.send(html)
    if (!isProd) {
      console.log(`whole request: ${Date.now() - s}ms`)
    }
  })
}

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

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

經過 vue-server-renderer 將咱們打包出來的 server bundle 渲染成 html 返回響應。

服務器代碼使用了一個 * 處理程序,它接受任意 URL。這容許咱們將訪問的 URL 傳遞到咱們的 Vue 應用程序中,而後對客戶端和服務器複用相同的路由配置。

構建代碼

webpack.base.config.js

const path = require('path')
const webpack = require('webpack')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin')
const { VueLoaderPlugin } = require('vue-loader')

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: '[name].[chunkhash].js'
  },
  mode: isProd ? 'production' : 'development',
  resolve: {
    alias: {
      'public': path.resolve(__dirname, '../public'),
      vue$: 'vue/dist/vue.esm.js',
      '@': path.resolve('src')
    },
    extensions: ['.js', '.vue', '.json']
  },
  module: {
    noParse: /es6-promise\.js$/, // avoid webpack shimming process
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: {
          compilerOptions: {
            preserveWhitespace: false
          }
        }
      },
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/
      },
      {
        test: /\.(png|jpg|gif|svg)$/,
        loader: 'url-loader',
        options: {
          limit: 10000,
          name: '[name].[ext]?[hash]'
        }
      },
      {
        test: /\.styl(us)?$/,
        use: isProd
          ? ExtractTextPlugin.extract({
            use: [
              {
                loader: 'css-loader',
                options: { minimize: true }
              },
              'stylus-loader'
            ],
            fallback: 'vue-style-loader'
          })
          : ['vue-style-loader', 'css-loader', 'stylus-loader']
      },
    ]
  },
  performance: {
    hints: false
  },
  plugins: isProd
    ? [
      new VueLoaderPlugin(),
      // new webpack.optimize.UglifyJsPlugin({
      //   compress: { warnings: false }
      // }),
      new webpack.optimize.ModuleConcatenationPlugin(),
      new ExtractTextPlugin({
        filename: 'common.[chunkhash].css'
      })
    ]
    : [
      new VueLoaderPlugin(),
      new FriendlyErrorsPlugin()
    ]
}

基礎構建過程

webpack.client.config.js

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

module.exports = merge(baseConfig, {
  entry: {
    app: './src/entry-client.js'
  },
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
      'process.env.VUE_ENV': '"client"'
    }),
    // 重要信息:這將 webpack 運行時分離到一個引導 chunk 中,
    // 以即可以在以後正確注入異步 chunk。
    // 這也爲你的 應用程序/vendor 代碼提供了更好的緩存。
    // new webpack.optimize.CommonsChunkPlugin({
    //   name: "manifest",
    //   minChunks: Infinity
    // }),
    // 此插件在輸出目錄中
    // 生成 `vue-ssr-client-manifest.json`。
    new VueSSRClientPlugin()
  ],
  optimization: {
    // Automatically split vendor and commons
    splitChunks: {
      chunks: 'all',
      name: 'vendors'
    },
    // Keep the runtime chunk seperated to enable long term caching
    runtimeChunk: true
  }
})

配置 client bundle 的構建過程

webpack.server.config.js

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

module.exports = merge(baseConfig, {
  // 將 entry 指向應用程序的 server entry 文件
  entry: './src/entry-server.js',

  // 這容許 webpack 以 Node 適用方式(Node-appropriate fashion)處理動態導入(dynamic import),
  // 而且還會在編譯 Vue 組件時,
  // 告知 `vue-loader` 輸送面向服務器代碼(server-oriented code)。
  target: 'node',

  // 對 bundle renderer 提供 source map 支持
  devtool: 'source-map',

  // 此處告知 server bundle 使用 Node 風格導出模塊(Node-style exports)
  output: {
    libraryTarget: 'commonjs2'
  },

  // https://webpack.js.org/configuration/externals/#function
  // https://github.com/liady/webpack-node-externals
  // 外置化應用程序依賴模塊。可使服務器構建速度更快,
  // 並生成較小的 bundle 文件。
  externals: nodeExternals({
    // 不要外置化 webpack 須要處理的依賴模塊。
    // 你能夠在這裏添加更多的文件類型。例如,未處理 *.vue 原始文件,
    // 你還應該將修改 `global`(例如 polyfill)的依賴模塊列入白名單
    whitelist: /\.css$/
  }),

  // 這是將服務器的整個輸出
  // 構建爲單個 JSON 文件的插件。
  // 默認文件名爲 `vue-ssr-server-bundle.json`
  plugins: [
    new VueSSRServerPlugin()
  ]
})

配置 server bundle 的構建過程

setup-dev-server.js

const fs = require('fs')
const path = require('path')
const MFS = require('memory-fs')
const webpack = require('webpack')
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
}

用於 dev 狀態下 熱更新

到此,基本上上vue ssr的基本結構以瞭解完畢。可是仍是有不少能夠作的事情,好比相似於 nuxt 的根據文件目錄動態生成 route 等等

後續讓咱們繼續探究...

完整代碼能夠查看 https://github.com/jinghaoo/vuessr-template

相關文章
相關標籤/搜索