Vue 2.0 開始支援服務端渲染的功能,是以本文章也是基于vue 2.0以上版本。網上對于服務端渲染的資料還是比較少,最經典的莫過于Vue作者尤雨溪大神的 vue-hacker-news。本人在公司做Vue項目的時候,一直苦于産品、客戶對首屏加載要求,SEO的訴求,也想過很多解決方案,本次也是針對浏覽器渲染不足之處,采用了服務端渲染,并且做了兩個一樣的Demo作為比較,更能直覺的對比Vue前後端的渲染。
talk is cheap,show us the code!話不多說,我們分别來看兩個Demo:(歡迎star 歡迎pull request)
1.浏覽器端渲染Demo: https://github.com/monkeyWangs/doubanMovie
2.服務端渲染Demo:https://github.com/monkeyWangs/doubanMovie-SSR
兩套代碼運作結果都是為了展示豆瓣電影的,運作效果也都是差不多,下面我們來分别簡單的闡述一下項目的機理:
一、浏覽器端渲染豆瓣電影
首先我們用官網的腳手架搭建起來一個vue項目
npm install -g vue-cli
vue init webpack doubanMovie
cd doubanMovie
npm install
npm run dev
複制
這樣便可以簡單地打起來一個cli架構,下面我們要做的事情就是分别配置 vue-router, vuex,然後配置我們的webpack proxyTable 讓他支援代理通路豆瓣API。
1.配置Vue-router
我們需要三個導航頁:正在上映、即将上映、Top250;一個詳情頁,一個搜尋頁。這裡我給他們分别配置了各自的路由。在 router/index.js 下配置以下資訊:
import Vue from 'vue'
import Router from 'vue-router'
import Moving from '@/components/moving'
import Upcoming from '@/components/upcoming'
import Top250 from '@/components/top250'
import MoviesDetail from '@/components/common/moviesDetail'
import Search from '@/components/searchList'
Vue.use(Router)
/**
* 路由資訊配置
*/
export default new Router({
routes: [
{
path: '/',
name: 'Moving',
component: Moving
},
{
path: '/upcoming',
name: 'upcoming',
component: Upcoming
},
{
path: '/top250',
name: 'Top250',
component: Top250
},
{
path: '/search',
name: 'Search',
component: Search
},
{
path: '/moviesDetail',
name: 'moviesDetail',
component: MoviesDetail
}
]
})
複制
這樣我們的路由資訊配置好了,然後每次切換路由的時候,盡量避免不要重複請求資料,是以我們還需要配置一下元件的keep-alive:在app.vue元件裡面。
<keep-alive exclude="moviesDetail">
<router-view></router-view>
</keep-alive>
複制
這樣一個基本的vue-router就配置好了。
2.引入vuex
Vuex 是一個專為 Vue.js 應用程式開發的狀态管理模式。它采用集中式存儲管理應用的所有元件的狀态,并以相應的規則保證狀态以一種可預測的方式發生變化。Vuex 也內建到 Vue 的官方調試工具 devtools extension,提供了諸如零配置的 time-travel 調試、狀态快照導入導出等進階調試功能。
簡而言之:Vuex 相當于某種意義上設定了讀寫權限的全局變量,将資料儲存儲存到該“全局變量”下,并通過一定的方法去讀寫資料。
Vuex 并不限制你的代碼結構。但是,它規定了一些需要遵守的規則:
- 應用層級的狀态應該集中到單個 store 對象中。
- 送出 mutation 是更改狀态的唯一方法,并且這個過程是同步的。
- 異步邏輯都應該封裝到 action 裡面。
對于大型應用我們會希望把 Vuex 相關代碼分割到子產品中。下面是項目結構示例:
├── index.html
├── main.js
├── api
│ └── ... # 抽取出API請求
├── components
│ ├── App.vue
│ └── ...
└── store
├── index.js # 我們組裝子產品并導出 store 的地方
└── moving # 電影子產品
├── index.js # 子產品内組裝,并導出子產品的地方
├── actions.js # 子產品基本 action
├── getters.js # 子產品級别 getters
├── mutations.js # 子產品級别 mutations
└── types.js # 子產品級别 types
複制
是以我們開始在我們的src目錄下建立一個名為store 的檔案夾 為了後期考慮 我們建立了moving 檔案夾,用來組織電影,考慮到所有的action,getters,mutations,都寫在一起,檔案太混亂,是以我又給他們分别提取出來。
stroe檔案夾建好,我們要開始在main.js裡面引用vuex執行個體:
import store from './store'
new Vue({
el: '#app',
router,
store,
template: '<App/>',
components: { App }
})
複制
這樣,我們便可以在所有的子元件裡通過 this.$store 來使用vuex了。
3.webpack proxyTable 代理跨域
webpack 開發環境可以使用proxyTable 來代理跨域,生産環境的話可以根據各自的伺服器進行配置代理跨域就行了。在我們的項目config/index.js 檔案下可以看到有一個proxyTable的屬性,我們對其簡單的改寫
proxyTable: {
'/api': {
target: 'http://api.douban.com/v2',
changeOrigin: true,
pathRewrite: {
'^/api': ''
}
}
}
複制
這樣當我們通路
localhost:8080/api/movie
複制
的時候 其實我們通路的是
http://api.douban.com/v2/movie
複制
這樣便達到了一種跨域請求的方案。
至此,浏覽器端的主要配置已經介紹完了,下面我們來看看運作的結果:
為了介紹浏覽器渲染是怎麼回事,我們運作一下npm run build 看看我們的釋出版本的檔案,到底是什麼鬼東西....
run build 後會都出一個dist目錄 ,我們可以看到裡面有個index.html,這個便是我們最終頁面将要展示的html,我們打開,可以看到下面:
觀察好的小夥伴可以發現,我們并沒有多餘的dom元素,就隻有一個div,那麼頁面要怎麼呈現呢?答案是js append,對,下面的那些js會負責innerHTML。而js是由浏覽器解釋執行的,是以呢,我們稱之為浏覽器渲染,這有幾個緻命的缺點:
- js放在dom結尾,如果js檔案過大,那麼必然造成頁面阻塞。使用者體驗明顯不好(這也是我我在公司反複被産品逼問的事情)
- 不利于SEO
- 用戶端運作在老的JavaScript引擎上
對于世界上的一些地區人,可能隻能用1998年産的電腦通路網際網路的方式使用計算機。而Vue隻能運作在IE9以上的浏覽器,你可能也想為那些老式浏覽器提供基礎内容 - 或者是在指令行中使用 Lynx的時髦的黑客
基于以上的一些問題,服務端渲染呼之欲出....
二、伺服器端渲染豆瓣電影
先看一張Vue官網的服務端渲染示意圖
從圖上可以看出,ssr 有兩個入口檔案,client.js 和 server.js, 都包含了應用代碼,webpack 通過兩個入口檔案分别打包成給服務端用的 server bundle 和給用戶端用的 client bundle. 當伺服器接收到了來自用戶端的請求之後,會建立一個渲染器 bundleRenderer,這個 bundleRenderer 會讀取上面生成的 server bundle 檔案,并且執行它的代碼, 然後發送一個生成好的 html 到浏覽器,等到用戶端加載了 client bundle 之後,會和服務端生成的DOM 進行 Hydration(判斷這個DOM 和自己即将生成的DOM 是否相同,如果相同就将用戶端的vue執行個體挂載到這個DOM上, 否則會提示警告)。
具體實作:
我們需要vuex,需要router,需要伺服器,需要服務緩存,需要代理跨域....不急我們慢慢來。
1.建立nodejs服務
首先我們需要一個伺服器,那麼對于nodejs,express是很好地選擇。我們來建立一個server.js
const port = process.env.PORT || 8080
app.listen(port, () => {
console.log(`server started at localhost:${port}`)
})
複制
這裡用來啟動服務監聽 8080 端口。
然後我們開始處理所有的get請求,當請求頁面的時候,我們需要渲染頁面
app.get('*', (req, res) => {
if (!renderer) {
return res.end('waiting for compilation... refresh in a moment.')
}
const s = Date.now()
res.setHeader("Content-Type", "text/html")
res.setHeader("Server", serverInfo)
const errorHandler = err => {
if (err && err.code === 404) {
res.status(404).end('404 | Page Not Found')
} else {
// Render Error Page or Redirect
res.status(500).end('500 | Internal Server Error')
console.error(`error during render : ${req.url}`)
console.error(err)
}
}
renderer.renderToStream({ url: req.url })
.on('error', errorHandler)
.on('end', () => console.log(`whole request: ${Date.now() - s}ms`))
.pipe(res)
})
複制
然後我們需要代理請求,這樣才能進行跨域,我們引入http-proxy-middleware子產品:
const proxy = require('http-proxy-middleware');//引入代理中間件
/**
* proxy middleware options
* 代理跨域配置
* @type {{target: string, changeOrigin: boolean, pathRewrite: {^/api: string}}}
*/
var options = {
target: 'http://api.douban.com/v2', // target host
changeOrigin: true, // needed for virtual hosted sites
pathRewrite: {
'^/api': ''
}
};
var exampleProxy = proxy(options);
app.use('/api', exampleProxy);
複制
這樣我們的服務端server.js便配置完成。接下來 我們需要配置服務端入口檔案,還有用戶端入口檔案,首先來配置一下用戶端檔案,建立src/entry-client.js
import 'es6-promise/auto'
import { app, store, router } from './app'
// prime the store with server-initialized state.
// the state is determined during SSR and inlined in the page markup.
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
/**
* 異步元件
*/
router.onReady(() => {
// 開始挂載到dom上
app.$mount('#app')
})
// service worker
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js')
}
複制
用戶端入口檔案很簡單,同步服務端發送過來的資料,然後把 vue 執行個體挂載到服務端渲染的 DOM 上。
再配置一下服務端入口檔案:src/entry-server.js
import { app, router, store } 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 => {
const s = isDev && Date.now()
return new Promise((resolve, reject) => {
// set router's location
router.push(context.url)
// wait until router has resolved possible async hooks
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
// no matched routes
if (!matchedComponents.length) {
reject({ code: 404 })
}
// Call preFetch 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(component => {
return component.preFetch && component.preFetch(store)
})).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)
})
})
}
複制
server.js 傳回一個函數,該函數接受一個從服務端傳遞過來的 context 的參數,将 vue 執行個體通過 promise 傳回。context 一般包含 目前頁面的url,首先我們調用 vue-router 的 router.push(url) 切換到到對應的路由, 然後調用 getMatchedComponents 方法傳回對應要渲染的元件, 這裡會檢查元件是否有 fetchServerData 方法,如果有就會執行它。
下面這行代碼将服務端擷取到的資料挂載到 context 對象上,後面會把這些資料直接發送到浏覽器端與用戶端的vue 執行個體進行資料(狀态)同步。
context.state = store.state
複制
然後我們分别配置用戶端和服務端webpack,這裡可以在我的github上fork下來參考配置,裡面每一步都有注釋,這裡不再贅述。
接着我們需要建立app.js:
import Vue from 'vue'
import App from './App.vue'
import store from './store'
import router from './router'
import { sync } from 'vuex-router-sync'
import Element from 'element-ui'
Vue.use(Element)
// sync the router with the vuex store.
// this registers `store.state.route`
sync(store, router)
/**
* 建立vue執行個體
* 在這裡注入 router store 到所有的子元件
* 這樣就可以在任何地方使用 `this.$router` and `this.$store`
* @type {Vue$2}
*/
const app = new Vue({
router,
store,
render: h => h(App)
})
/**
* 導出 router and store.
* 在這裡不需要挂載到app上。這裡和浏覽器渲染不一樣
*/
export { app, router, store }
複制
這樣 服務端入口檔案和用戶端入口檔案便有了一個公共執行個體Vue, 和我們以前寫的vue執行個體差别不大,但是我們不會在這裡将app mount到DOM上,因為這個執行個體也會在服務端去運作,這裡直接将 app 暴露出去。
接下來建立路由router,建立vuex跟用戶端都差不多。詳細的可以參考我的項目...
到此,服務端渲染配置 就簡單介紹完了,下面我們啟動項目簡單的看下:
這裡跟服務端界面一樣,不一樣的是url已經不是之前的 #/而變成了請求形式 /
這樣每當浏覽器發送一個頁面的請求,會有伺服器渲染出一個dom字元串傳回,直接在浏覽器段顯示,這樣就避免了浏覽器端渲染的很多問題。
說起SSR,其實早在SPA (Single Page Application) 出現之前,網頁就是在服務端渲染的。伺服器接收到用戶端請求後,将資料和模闆拼接成完整的頁面響應到用戶端。 用戶端直接渲染, 此時使用者希望浏覽新的頁面,就必須重複這個過程, 重新整理頁面. 這種體驗在Web技術發展的當下是幾乎不能被接受的,于是越來越多的技術方案湧現,力求 實作無頁面重新整理或者局部重新整理來達到優秀的互動體驗。但是SEO卻是緻命的,是以一切看應用場景,這裡隻為大家提供技術思路,為vue開發提供多一種可能的方案。
為了更清晰的對比兩次渲染的結果,我做了一次實驗,把兩個想的項目build後模拟生産環境,在浏覽器netWork模拟網速3g環境,先來看看服務端渲染的結果:
可以看到整體加載dom一共花了832ms;使用者可能在網絡比較慢的情況下從遠處通路網站 - 或者通過比較差的帶寬。 這些情況下,盡量減少頁面請求數量,來保證使用者盡快看到基本的内容。
接下來我們再看看浏覽器端渲染的結果:
我們可以看到其中有一個vendor.js 達到了563KB,整體的加載時間達到了了8.19s,這是因為單頁面檔案的原因,會把所有的邏輯代碼打包到一個js裡面。可以用分webpack拆分代碼避免強制使用者下載下傳整個單頁面應用,但是,這樣也遠沒有下載下傳個單獨的預先渲染過的HTML檔案性能高。
想要自己運作對比的小夥伴可以通路我的github,我已将源碼放到上面:
1.浏覽器端渲染Demo: https://github.com/monkeyWangs/doubanMovie
2.服務端渲染Demo:https://github.com/monkeyWangs/doubanMovie-SSR