簡單的Vue SSR的範例程式碼,vuessr範例程式碼
前言
最近接手一個老項目,典型的 Vue 組件化前端渲染,後續業務最佳化可能會朝 SSR 方向走,因此,就先做些技術儲備。如果對 Vue SSR 完全不瞭解,請先閱讀官方文檔。
思路
Vue 提供了一個官方 Demo,該 Demo 優點是功能大而全,缺點是對新手不友好,容易讓人看蒙。因此,今天我們來寫一個更加容易上手的 Demo。總共分三步走,循序漸進。
- 寫一個簡單的前端渲染 Demo(不包含 Ajax 資料);
- 將前端渲染改成後端渲染(仍然不包含 Ajax 資料);
- 在後端渲染的基礎上,加上 Ajax 資料的處理;
第一步:前端渲染 Demo
這部分比較簡單,就是一個頁面中包含兩個組件:Foo 和 Bar。
<!-- index.html --><body><div id="app"> <app></app></div><script src="./dist/web.js"></script> <!--這是 app.js 打包出來的 JS 檔案 --></body>// app.js,也是 webpack 打包入口import Vue from 'vue';import App from './App.vue';var app = new Vue({ el: '#app', components: { App }});
// App.vue<template> <div> <foo></foo> <bar></bar> </div></template><script> import Foo from './components/Foo.vue'; import Bar from './components/Bar.vue'; export default { components:{ Foo, Bar } }</script>
// Foo.vue<template> <div class='foo'> <h1>Foo</h1> <p>Component </p> </div></template><style> .foo{ background: yellow; }</style>
// Bar.vue<template> <div class='bar'> <h1>Bar</h1> <p>Component </p> </div></template><style> .bar{ background: blue; }</style>
最終渲染結果如所示,源碼請參考這裡。
第二步:後端渲染(不包含 Ajax 資料)
第一步的 Demo 雖不包含任何 Ajax 資料,但即便如此,要把它改造成後端渲染,亦非易事。該從哪幾個方面著手呢?
- 拆分 JS 入口;
- 拆分 Webpack 打包配置;
- 編寫服務端渲染主體邏輯。
1. 拆分 JS 入口
在前端渲染的時候,只需要一個入口 app.js。現在要做後端渲染,就得有兩個 JS 檔案:entry-client.js 和 entry-server.js 分別作為瀏覽器和伺服器的入口。
先看 entry-client.js,它跟第一步的 app.js 有什麼區別嗎? → 沒有區別,只是換了個名字而已,內容都一樣。
再看 entry-server.js,它只需返回 App.vue 的執行個體。
// entry-server.jsexport default function createApp() { const app = new Vue({ render: h => h(App) }); return app; };
entry-server.js 與 entry-client.js 這兩個入口主要區別如下:
- entry-client.js 在瀏覽器端執行,所以需要指定 el 並且顯式調用 $mount 方法,以啟動瀏覽器的渲染。
- entry-server.js 在服務端被調用,因此需要匯出為一個函數。
2. 拆分 Webpack 打包配置
在第一步中,由於只有 app.js 一個入口,只需要一份 Webpack 設定檔。現在有兩個入口了,自然就需要兩份 Webpack 設定檔:webpack.server.conf.js 和 webpack.client.conf.js,它們的公用部分抽象成 webpack.base.conf.js。
關於 webpack.server.conf.js,有兩個注意點:
- libraryTarget: 'commonjs2' → 因為伺服器是 Node,所以必須按照 commonjs 規範打包才能被伺服器調用。
- target: 'node' → 指定 Node 環境,避免非 Node 環境特定 API 報錯,如 document 等。
3. 編寫服務端渲染主體邏輯
Vue SSR 依賴於包 vue-server-render,它的調用支援兩種入口格式:createRenderer 和 createBundleRenderer,前者以 Vue 組件為入口,後者以打包後的 JS 檔案為入口,本文採取後者。
// server.js 服務端渲染主體邏輯// dist/server.js 就是以 entry-server.js 為入口打包出來的 JS const bundle = fs.readFileSync(path.resolve(__dirname, 'dist/server.js'), 'utf-8'); const renderer = require('vue-server-renderer').createBundleRenderer(bundle, { template: fs.readFileSync(path.resolve(__dirname, 'dist/index.ssr.html'), 'utf-8')});server.get('/index', (req, res) => { renderer.renderToString((err, html) => { if (err) { console.error(err); res.status(500).end('伺服器內部錯誤'); return; } res.end(html); })});server.listen(8002, () => { console.log('後端渲染伺服器啟動,連接埠號碼為:8002');});
這一步的最終渲染效果如所示,我們可以看到,組件已經被後端成功渲染了。源碼請參考這裡。
第三步:後端渲染(預擷取 Ajax 資料)
這是關鍵的一步,也是最難的一步。
假如第二步的組件各自都需要請求 Ajax 資料的話,該怎麼處理呢?官方文檔給我們指出了思路,我簡要概括如下:
- 在開始渲染之前,預先擷取所有需要的 Ajax 資料(然後存在 Vuex 的 Store 中);
- 後端渲染的時候,通過 Vuex 將擷取到的 Ajax 資料分別注入到各個組件中;
- 把全部 Ajax 資料埋在 window.INITIAL_STATE 中,通過 HTML 傳遞到瀏覽器端;
- 瀏覽器端通過 Vuex 將 window.INITIAL_STATE 裡面的 Ajax 資料分別注入到各個組件中。
下面談幾個重點。
我們知道,在常規的 Vue 前端渲染中,組件請求 Ajax 一般是這麼寫的:“在 mounted 中調用 this.fetchData,然後在回調裡面把返回資料寫到執行個體的 data 中,這就 ok 了。”
在 SSR 中,這是不行的,因為伺服器並不會執行 mounted 周期。那麼我們是否可以把 this.fetchData
提前到 created 或者 beforeCreate 這兩個生命週期中執行?同樣不行。原因是:this.fetchData 是非同步請求,請求發出去之後,沒等資料返回呢,後端就已經渲染完了,無法把 Ajax 返回的資料也一併渲染出來。
所以,我們得提前知道都有哪些組件有 Ajax 請求,等把這些 Ajax 請求都返回了資料之後,才開始組件的渲染。
// store.jsfunction fetchBar() { return new Promise(function (resolve, reject) { resolve('bar ajax 返回資料'); });}export default function createStore() { return new Vuex.Store({ state: { bar: '', }, actions: { fetchBar({commit}) { return fetchBar().then(msg => { commit('setBar', {msg}) }) } }, mutations:{ setBar(state, {msg}) { Vue.set(state, 'bar', msg); } } })}
// Bar.uveasyncData({store}) { return store.dispatch('fetchBar');},computed: { bar() { return this.$store.state.bar; }}
組件的 asyncData 方法已經定義好了,但是怎麼索引到這個 asyncData 方法呢?先看我的根組件 App.vue 是怎麼寫的。
// App.vue<template> <div> <h1>App.vue</h1> <p>vue with vue </p> <hr> <foo1 ref="foo_ref"></foo1> <bar1 ref="bar_ref"></bar1> <bar2 ref="bar_ref2"></bar2> </div></template><script> import Foo from './components/Foo.vue'; import Bar from './components/Bar.vue'; export default { components: { foo1: Foo, bar1: Bar, bar2: Bar } }</script>
從根組件 App.vue 我們可以看到,只需要解析其 components 欄位,便能依次找到各個組件的 asyncData 方法了。
// entry-server.js export default function (context) { // context 是 vue-server-render 注入的參數 const store = createStore(); let app = new Vue({ store, render: h => h(App) }); // 找到所有 asyncData 方法 let components = App.components; let prefetchFns = []; for (let key in components) { if (!components.hasOwnProperty(key)) continue; let component = components[key]; if(component.asyncData) { prefetchFns.push(component.asyncData({ store })) } } return Promise.all(prefetchFns).then((res) => { // 在所有組件的 Ajax 都返回之後,才最終返回 app 進行渲染 context.state = store.state; // context.state 賦值成什麼,window.__INITIAL_STATE__ 就是什麼 return app; });};
還有幾個問題比較有意思:
1、是否必須使用 vue-router?→ 不是。雖然官方給出的 Demo 裡面用到了 vue-router,那隻不過是因為官方 Demo 是包含多個頁面的 SPA 罷了。一般情況下,是需要用 vue-router 的,因為不同路由對應不同的組件,並非每次都把所有組件的 asyncData 都執行的。但是有例外,比如我的這個老項目,就只有一個頁面(一個頁面中包含很多的組件),所以根本不需要用到 vue-router,也照樣能做 SSR。主要的區別就是如何找到那些該被執行的 asyncData 方法:官方 Demo 通過 vue-router,而我通過直接解析 components 欄位,僅此而已。
2、是否必須使用 Vuex? → 是,但也不是,請看尤大的回答。為什麼必須要有類似 Vuex 的存在?我們來分析一下。
2.1. 當預先擷取到的 Ajax 資料返回之後,Vue 組件還沒開始渲染。所以,我們得把 Ajax 先存在某個地方。
2.2. 當 Vue 組件開始渲染的時候,還得把 Ajax 資料拿出來,正確地傳遞到各個組件中。
2.3. 在瀏覽器渲染的時候,需要正確解析 window.INITIAL_STATE ,並傳遞給各個組件。
因此,我們得有這麼一個獨立於視圖以外的地方,用來儲存、管理和傳遞資料,這就是 Vuex 存在的理由。
3、後端已經把 Ajax 資料轉化為 HTML 了,為什麼還需要把 Ajax 資料通過 window.INITIAL_STATE 傳遞到前端? → 因為前端渲染的時候仍然需要知道這些資料。舉個例子,你寫了一個組件,給它綁定了一個點擊事件,點擊的時候列印出 this.msg 欄位值。現在後端是把組件 HTML 渲染出來了,但是事件的綁定肯定得由瀏覽器來完成啊,如果瀏覽器拿不到跟伺服器端同樣的資料的話,在觸發組件的點擊事件的時候,又上哪兒去找 msg 欄位呢?
至此,我們已經完成了帶 Ajax 資料的後端渲染了。這一步最為複雜,也最為關鍵,需要反覆思考和嘗試。具體渲染如下所示,源碼請參考這裡。
效果
大功告成了嗎?還沒。人們都說 SSR 能提升首屏渲染速度,下面我們對比一下看看到底是不是真的。(同樣在 Fast 3G 網路條件下)。
官方思路的變形
行文至此,關於 Vue SSR Demo便已經結束了。後面是我結合自身項目特點的一些變形,不感興趣的讀者可以不看。
第三步官方思路有什麼缺點嗎?我認為是有的:對老項目來說,改造成本比較大。需要顯式的引入 vuex,就得走 action、mutations 那一套,無論是代碼改動量還是新人學習成本,都不低。
有什麼辦法能減少對舊有前端渲染項目的改動量的嗎?我是這麼做的。
// store.js// action,mutations 那些都不需要了,只定義一個空 stateexport default function createStore() { return new Vuex.Store({ state: {} })}// Bar.vue// tagName 是組件執行個體的名字,比如 bar1、bar2、foo1 等,由 entry-server.js 注入export default { prefetchData: function (tagName) { return new Promise((resolve, reject) => { resolve({ tagName, data: 'Bar ajax 資料' }); }) }}
// entry-server.jsreturn Promise.all(prefetchFns).then((res) => { // 拿到 Ajax 資料之後,手動將資料寫入 state,不通過 action,mutation 那一套 // state 內部區分的 key 值就是 tagName,比如 bar1、bar2、foo1 等 res.forEach((item, key) => { Vue.set(store.state, `${item.tagName}`, item.data); }); context.state = store.state; return app;});
// ssrmixin.js// 將每個組件都需要的 computed 抽象成一個 mixin,然後注入export default { computed: { prefetchData () { let componentTag = this.$options._componentTag; // bar1、bar2、foo1 return this.$store.state[componentTag]; } }}
至此,我們就便得到了 Vue SSR 的一種變形。對於組件開發人員而言,只需要把原來的 this.fetchData 方法抽象到 prefetchData 方法,然後就可以在 DOM 中使用 {{prefetchData}} 拿到到資料了。這部分的代碼請參考這裡。
總結
Vue SSR 確實是個有趣的東西,關鍵在於靈活運用。此 Demo 還有一個遺留問題沒有解決:當把 Ajax 抽象到 prefetchData,做成 SSR 之後,原先的前端渲染就失效了。能不能同一份代碼同時支援前端渲染和後端渲染呢?這樣當後端渲染出問題的時候,我就可以隨時切回前端渲染,便有了兜底的方案。
以上就是本文的全部內容,希望對大家的學習有所協助,也希望大家多多支援幫客之家。