做者:威威(滬江前端開發工程師)
本文原創,轉載請註明做者及出處。javascript
最近, 產品同窗一如往常笑嘻嘻的遞來需求文檔, 縱使心裏萬般拒絕, 身體卻是很誠實。 接過需求,好在需求不復雜, 簡單構思 後決定用Vue, 駕輕就熟。 切好圖, 挽起袖子準備擼代碼的時候, SEO同窗不知什麼時候已經站到了背後。css
"據說你要用Vue?" "恩..." "SEO考慮了嗎?整個SPA出來,網頁的SEO咋辦?" "奧..."
換之前, 估計只能無奈的換個實現方式, 可是Vue 2.0時代的到來, 給你多了一種可能。 你能夠對SEO工程師說:用Vue沒問題!html
想必,不少前端同窗都有相似這樣的經歷, 爲了SEO,只能放棄駕輕就熟的框架。 SEO(Search Engine Optimization)顧名思義就是一系列爲了提升 網站收錄排名,吸引精準用戶的方案。 這麼看來,SEO確實是有舉足輕重的做用。 不過,好消息是,Vue2.0的發佈爲SEO提供了可能, 這就是SSR(serve side render)。前端
提及SSR,其實早在SPA (Single Page Application) 出現以前,網頁就是在服務端渲染的。服務器接收到客戶端請求後,將數據和模板拼接成完整的頁面響應到客戶端。 客戶端直接渲染, 此時用戶但願瀏覽新的頁面,就必須重複這個過程, 刷新頁面. 這種體驗在Web技術發展的當下是幾乎不能被接受的,因而愈來愈多的技術方案涌現,力求 實現無頁面刷新或者局部刷新來達到優秀的交互體驗。 好比Vue:vue
- 在客戶端管理路由,用戶切換路由,無需向服務器從新請求頁面和靜態資源,只須要使用 ajax 獲取數據在客戶端完成渲染,這樣能夠減小了不少沒必要要的網絡傳輸,縮短了響應時間。 - 聲明式渲染(告訴 vue 你要作什麼,讓它幫你作),把咱們從煩人的DOM操做中解放出來,集中處理業務邏輯。 - 組件化視圖,不管是功能組件仍是UI組件均可以進行抽象,寫一次處處用。 - 先後端並行開發,只須要與後端定好數據格式,前期用模擬數據,就能夠與後端並行開發了。 - 對複雜項目的各個組件之間的數據傳遞 vue - Vuex 狀態管理模式
缺點你們天然猜到了, 對,主要的一點就是不利於SEO,或者說對SEO不友好。 來看下面兩張圖;java
SPA頁面的源代碼node
下圖SSR頁面的源代碼webpack
上面兩張圖就是使用了傳統單頁應用和SSR的頁面源代碼, 第一張圖中,很明顯頁面的數據都是經過Ajax異步獲取,然而搜索引擎度孃家的爬蟲看到這樣空曠的源碼並不會絲毫留戀. 相反,經過服務端渲染的頁面,就有不少對於爬蟲來說有效的鏈接. 畢竟度娘一家獨大,看來服務端渲染確實有探究的必要了。ios
先看一張Vue官網的服務端渲染示意圖git
從圖上能夠看出,ssr 有兩個入口文件,client.js 和 server.js, 都包含了應用代碼,webpack 經過兩個入口文件分別打包成給服務端用的 server bundle 和給客戶端用的 client bundle. 當服務器接收到了來自客戶端的請求以後,會建立一個渲染器 bundleRenderer,這個 bundleRenderer 會讀取上面生成的 server bundle 文件,而且執行它的代碼, 而後發送一個生成好的 html 到瀏覽器,等到客戶端加載了 client bundle 以後,會和服務端生成的DOM 進行 Hydration(判斷這個DOM 和本身即將生成的DOM 是否相同,若是相同就將客戶端的vue實例掛載到這個DOM上, 不然會提示警告)。
知道了Vue服務端渲染的大體流程,那怎麼用代碼來實現呢?
1. 建立一個 vue 實例 2. 配置路由,以及相應的視圖組件 3. 使用 vuex 管理數據 4. 建立服務端入口文件 5. 建立客戶端入口文件 6. 配置 webpack,分服務端打包配置和客戶端打包配置 7. 建立服務器端的渲染器,將vue實例渲染成html
// app.js import Vue from 'vue'; import router from './router'; import store from './store'; import App from './components/app'; let app = new Vue({ template: '<app></app>', base: '/c/', components: { App }, router, store }); export { app, router, store }
和咱們之前寫的vue實例差異不大,可是咱們不會在這裏將app mount到DOM上,由於這個實例也會在服務端去運行,這裏直接將 app 暴露出去。
import Vue from 'vue'; import VueRouter from 'vue-router'; import IndexView from '../views/indexView'; import ArticleItems from '../views/articleItems'; Vue.use(VueRouter); const router = new VueRouter({ mode: 'history', base: '/c/', routes: [ { path: '/:alias', component: IndexView }, { path: '/:alias/list', component: ArticleItems } ] });
注意這裏的 base,在服務端傳遞 path 給 vue-router 的時候要注意去掉前面的 '/c/',不然會匹配不到。
<template> <div class="content"> <course-cover :class-data="classData[0]"></course-cover> <article-items :article-items="articleItems"></article-items> </div> </template> <script> import courseCover from '../components/courseCover.vue'; import articleItems from '../components/articleItems'; export default { computed: { classData() { return this.$store.state.courseListItems; }, articleItems() { return this.$store.state.articleItems; } }, components: { courseCover, articleItems }, // 服務端獲取數據 fetchServerData ({ state, dispatch, commit }) { let alias = state.route.params.alias; return Promise.all([ dispatch('FETCH_ZT', { alias }), dispatch('FETCH_COURSE_ITEMS'), dispatch('FETCH_ARTICLE_ITEMS') ]) }, // 客戶端獲取數據 beforeMount() { return this.$store.dispatch('FETCH_COURSE_ITEMS'); } } </script>
這裏咱們暴露一個 fetchServerData 方法用來在服務端渲染時作數據的預加載,具體在哪調用,下面會講到。 beforeMount 是vue的生命週期鉤子函數,當應用在客戶端切換到這個視圖的時候會在特定的時候去執行,用於在客戶端獲取數據。
import Vue from 'vue'; import Vuex from 'vuex'; import axios from 'axios'; Vue.use(Vuex); let apiHost = 'http://localhost:3000'; const store = new Vuex.Store({ state: { alias: '', ztData: {}, courseListItems: [], articleItems: [] }, actions: { FETCH_ZT: ({ commit, dispatch, state }, { alias }) = { commit('SET_ALIAS', { alias }); return axios.get(`${apiHost}/api/zt`) .then(response => { let data = response.data || {}; commit('SET_ZT_DATA', data); }) }, FETCH_COURSE_ITEMS: ({ commit, dispatch, state }) => { return axios.get(`${apiHost}/api/course_items`).then(response => { let data = response.data; commit('SET_COURSE_ITEMS', data); }); }, FETCH_ARTICLE_ITEMS: ({ commit, dispatch, state }) => { return axios.get(`${apiHost}/api/article_items`) .then(response => { let data = response.data; commit('SET_ARTICLE_ITEMS', data); }) } }, mutations: { SET_COURSE_ITEMS: (state, data) => { state.courseListItems = data; }, SET_ALIAS: (state, { alias }) => { state.alias = alias; }, SET_ZT_DATA: (state, { ztData }) => { state.ztData = ztData; }, SET_ARTICLE_ITEMS: (state, items) => { state.articleItems = items; } } }) export default store;
state 使咱們應用層的數據,至關於一個倉庫,整個應用層的數據都存在這裏,與不使用vuex的vue應用有兩點不一樣:
- Vuex 的狀態存儲是響應式的。當 Vue 組件從 store 中讀取狀態的時候,若 store 中的狀態發生變化,那麼相應的組件也會相應地獲得高效更新。 - Vuex 不容許咱們直接對 store 中的數據進行操做。改變 store 中的狀態的惟一途徑就是顯式地提交(commit) mutations。這樣使得咱們能夠方便地跟蹤每個狀態的變化,從而讓咱們可以實現一些工具幫助咱們更好地瞭解咱們的應用。 action 響應在view上的用戶輸入致使的狀態變化,並不直接操做數據,異步的邏輯都封裝在這裏執行,它最終的目的是提交 mutation 來操做數據。 mutation vuex 中修改store 數據的惟一方法,使用 commit 來提交。
// server-entry.js import {app, router, store} from './app'; export default context => { const s = Date.now(); router.push(context.url); const matchedComponents = router.getMatchedComponents(); if(!matchedComponents) { return Promise.reject({ code: '404' }); } return Promise.all( matchedComponents.map(component => { if(component.fetchServerData) { return component.fetchServerData(store); } }) ).then(() => { context.initialState = store.state; return app; }) }
server.js 返回一個函數,該函數接受一個從服務端傳遞過來的 context 的參數,將 vue 實例經過 promise 返回。 context 通常包含 當前頁面的url,首先咱們調用 vue-router 的 router.push(url) 切換到到對應的路由, 而後調用 getMatchedComponents 方法返回對應要渲染的組件, 這裏會檢查組件是否有 fetchServerData 方法,若是有就會執行它。
下面這行代碼將服務端獲取到的數據掛載到 context 對象上,後面會把這些數據直接發送到瀏覽器端與客戶端的vue 實例進行數據(狀態)同步。
context.initialState = store.state
建立客戶端入口文件 client-entry.js
// client-entry.js import { app, store } from './app'; import './main.scss'; store.replaceState(window.__INITIAL_STATE__); app.$mount('#app');
客戶端入口文件很簡單,同步服務端發送過來的數據,而後把 vue 實例掛載到服務端渲染的 DOM 上。
// webpack.server.config.js const base = require('./webpack.base.config'); // webpack 的通用配置 module.exports = Object.assign({}, base, { target: 'node', entry: './src/server-entry.js', output: { filename: 'server-bundle.js', libraryTarget: 'commonjs2' }, externals: Object.keys(require('../package.json').dependencies), plugins: [ new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), 'process.env.VUE_ENV': '"server"' }) ] })
注意這裏添加了 target: 'node' 和 libraryTarget: 'commonjs2',而後入口文件改爲咱們的 server-entry.js, 客戶端的 webpack 和之前同樣,這裏就不貼了。
由於有兩個 webpack 配置文件,執行 webpack 時候就須要指定 --config 參數來編譯不一樣的 bundle。 咱們能夠配置兩個 npm script
"packclient": "webpack --config webpack.client.config.js", "packserver": "webpack --config webpack.server.config.js"
而後在命令行運行
npm run packclient npm run packserver
就會生成兩個文件 client-bundle.js 和 server-bundle.js
// controller.js const serialize = require('serialize-javascript'); // 由於咱們在vue-router 的配置裏面使用了 `base: '/c'`,這裏須要去掉請求path中的 '/c' let url = this.url.replace(/\/c/, ''); let context = { url: this.url }; // 建立渲染器 let bundleRenderer = createRenderer(fs.readFileSync(resolve('./dist/server-bundle.js'), 'utf-8')) let html = yield new Promise((resolve, reject) => { // 將vue實例編譯成一個字符串 bundleRenderer.renderToString( context, // 傳遞context 給 server-bundle.js 使用 (err, html) => { if(err) { console.error('server render error', err); resolve(''); } /** * 還記得在 server-entry.js 裏面 `context.initialState = store.state` 這行代碼麼? * 這裏就直接把數據發送到瀏覽器端啦 **/ html += `<script> // 將服務器獲取到的數據做爲首屏數據發送到瀏覽器 window.__INITIAL_STATE__ = ${serialize(context.initialState, { isJSON: true })} </script>`; resolve(html); } ) }) yield this.render('ssr', html); // 建立渲染器函數 function createRenderer(code) { return require('vue-server-renderer').createBundleRenderer(code); }
在 node 的 views 模板文件中只須要將上面的 html 輸出就能夠了
// ssr.html {% extends 'layout.html' %} {% block body %} {{ html | safe }} {% endblock %} <script src="/public/client.js"></script>
這樣,一個簡單的服務端渲染就結束了。
限於篇幅,詳細的代碼請參考 Github代碼庫:https://github.com/ikcamp/vue...
整個demo包含了:
沒有涉及:
對Vue的服務端渲染有更深一步的認識,實際在生產環境中的應用可能還須要考慮不少因素。
選擇Vue的服務端渲染方案,是情理之中的選擇,不是對新技術的盲目追捧,而是一切爲了須要。 Vue 2.0的SSR方案只是提供了一種可能,多了一種選擇,框架自己在於服務開發者,根據不一樣的場景選擇不一樣的方案,纔會事半功倍。
文章僅表明我的觀點,有不穩當地方煩請你們指出,共同進步!
iKcamp原創新書《移動Web前端高效開發實戰》已在亞馬遜、京東、噹噹開售。