由於以前用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.js
和 entry-server.js
, 在這裏特殊說下 src
下的 app.js
和 template.html
與咱們平時寫的vue項目中的也有所區別。es6
<!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 標記注入的地方。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)。
所以,咱們不該該直接建立一個應用程序實例,而是應該暴露一個能夠重複執行的工廠函數,爲每一個請求建立新的應用程序實例。
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 (即:客戶端激活)。
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 就應該獲取到狀態。
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 應用程序中,而後對客戶端和服務器複用相同的路由配置。
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() ] }
基礎構建過程
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 的構建過程
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 的構建過程
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
等等
後續讓咱們繼續探究...