vue 服務(wù)端渲染折騰記錄

為了解決 vue 項(xiàng)目的 seo 問(wèn)題肠鲫,最近研究了下服務(wù)端渲染,所以就有了本文的記錄榜轿。

項(xiàng)目結(jié)構(gòu)

├─.babelrc // babel 配置文件
├─index.template.html // html 模板文件
├─server.js // 提供服務(wù)端渲染及 api 服務(wù)
├─src // 前端代碼
|  ├─app.js // 主要用于創(chuàng)建 vue 實(shí)例
|  ├─App.vue // 根組件
|  ├─entry-client.js // 客戶(hù)端渲染入口文件
|  ├─entry-server.js // 服務(wù)端渲染入口文件
|  ├─stores // vuex 相關(guān)
|  ├─routes // vue-router 相關(guān)
|  ├─components // 組件
├─dist // 代碼編譯目標(biāo)路徑
├─build // webpack 配置文件

項(xiàng)目的主要目錄結(jié)構(gòu)如上所示幽歼,其中 package.json 請(qǐng)查看項(xiàng)目。關(guān)于為什么要使用狀態(tài)管理庫(kù) Vuex谬盐,官網(wǎng)有明確的解釋甸私。后文有例子幫助進(jìn)一步理解。

接下來(lái)我們暫時(shí)不管服務(wù)端渲染的事情飞傀,先搭建一個(gè)簡(jiǎn)單的 vue 的開(kāi)發(fā)環(huán)境皇型。

搭建 vue 開(kāi)發(fā)環(huán)境

利用 webpack 可以非澄芘耄快速的搭建一個(gè)簡(jiǎn)單的 vue 開(kāi)發(fā)環(huán)境,可以直接乘電梯前往弃鸦。

為了高效地進(jìn)行開(kāi)發(fā)绞吁,vue 開(kāi)發(fā)環(huán)境應(yīng)該有代碼熱加載和請(qǐng)求轉(zhuǎn)發(fā)的功能。這些都可以使用 webpack-dev-server 來(lái)輕松實(shí)現(xiàn)唬格,只需配置 webpackdevServer 項(xiàng):

module.exports = merge(baseWebpackConfig, {
  devServer: {
    historyApiFallback: true,
    noInfo: true,
    overlay: true,
    proxy: config.proxy
  },
  devtool: '#eval-source-map',
  plugins: [
    new HtmlWebpackPlugin({
      filename: 'index.html',
      template: 'index.template.html',
      inject: true // 插入css和js
    }),
    new webpack.HotModuleReplacementPlugin(),
    new FriendlyErrors()
  ]
})

然后啟動(dòng)時(shí)添加 --hot 參數(shù)即可:

cross-env NODE_ENV=development webpack-dev-server --config build/webpack.dev.conf.js --open --hot

注意到 routerstore 以及 vue 都采用了工廠函數(shù)來(lái)生成實(shí)例家破,這是為了方便代碼在后面的服務(wù)端渲染中進(jìn)行復(fù)用,因?yàn)?“Node.js 服務(wù)器是一個(gè)長(zhǎng)期運(yùn)行的進(jìn)程购岗。必須為每個(gè)請(qǐng)求創(chuàng)建一個(gè)新的 Vue 實(shí)例” (官網(wǎng))汰聋。

同樣,前端請(qǐng)求使用的是 axios 庫(kù)藕畔,也是為了照顧服務(wù)端马僻。

在項(xiàng)目根目錄下運(yùn)行 npm run server 啟動(dòng)后端 api 服務(wù),然后運(yùn)行 npm run dev 注服,webpack 會(huì)自動(dòng)在默認(rèn)瀏覽器中打開(kāi) http://localhost:8080 地址韭邓,即可看到效果。

服務(wù)端渲染

基于上面搭建好的項(xiàng)目基礎(chǔ)上來(lái)搭建服務(wù)端渲染就比較容易了溶弟,讓我們開(kāi)始吧女淑。或者直接看最后的代碼辜御。

要實(shí)現(xiàn)服務(wù)端渲染鸭你,只需增加如下 webpack 配置:

module.exports = merge(baseWebpackConfig, {
  entry: './src/entry-server.js',
  // 告知 `vue-loader` 輸送面向服務(wù)器代碼(server-oriented code)。
  target: 'node',
  output: {
    filename: 'server-bundle.js',
    libraryTarget: 'commonjs2',
  },
  plugins: [
     new VueSSRServerPlugin()
  ]
})

注意到 entry 的文件路徑跟之前的不太一樣擒权,這里使用的是專(zhuān)門(mén)為服務(wù)端渲染準(zhǔn)備的入口文件:

import { createApp } from './app'
// 這里的 context 是服務(wù)端渲染模板時(shí)傳入的
export default context => {
  // 因?yàn)橛锌赡軙?huì)是異步路由鉤子函數(shù)或組件袱巨,所以我們將返回一個(gè) Promise,
  // 以便服務(wù)器能夠等待所有的內(nèi)容在渲染前碳抄,
  // 就已經(jīng)準(zhǔn)備就緒愉老。
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp()

    const { url } = context
    const { fullPath } = router.resolve(url).route

    if (fullPath !== url) {
      return reject({ url: fullPath })
    }

    router.push(url)

    // 等到 router 將可能的異步組件和鉤子函數(shù)解析完
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()
      // 匹配不到的路由,執(zhí)行 reject 函數(shù)剖效,并返回 404
      if (!matchedComponents.length) {
        return reject({ code: 404 })
      }

      // 執(zhí)行所有組件中的異步數(shù)據(jù)請(qǐng)求
      Promise.all(matchedComponents.map(({ asyncData }) => asyncData && asyncData({
        store,
        route: router.currentRoute
      }))).then(() => {
        context.state = store.state
        resolve(app)
      }).catch(reject)
    }, reject)
  })
}

其中的 asyncData 可能會(huì)讓人疑惑嫉入,稍后我們用一個(gè)例子來(lái)說(shuō)明。現(xiàn)在璧尸,然我們來(lái)編譯一下咒林,運(yùn)行 npm run build:server ,將會(huì)在 dist 目錄下得到 vue-ssr-server-bundle.json 文件爷光〉婢海可以看到,該文件包含了 webpack 打包生成的所有 chunk 并指定了入口瞎颗。后面服務(wù)端會(huì)基于該文件來(lái)做渲染件甥。

現(xiàn)在就讓我們移步服務(wù)端捌议,新增一些代碼:

...
 const { createBundleRenderer } = require('vue-server-renderer')
 const bundle = require('./dist/vue-ssr-server-bundle.json')

 const renderer = createBundleRenderer(bundle, {
   template: fs.readFileSync('./index.template.html', 'utf-8')
 })
...
// 服務(wù)端渲染
server.get('*', (req, res) => {
  const context = { url: req.originalUrl }
  renderer.renderToString(context, (err, html) => {
    if (err) {
      if (err.code === 404) {
        res.status(404).end('Page not found')
      } else {
        res.status(500).end('Internal Server Error')
      }
    } else {
      res.end(html)
    }
  })
})

新增代碼不多,首先使用上面生成的文件創(chuàng)建了一個(gè) renderer 對(duì)象引有,然后調(diào)用其 renderToString 方法并傳入包含請(qǐng)求路徑的對(duì)象作為參數(shù)來(lái)進(jìn)行渲染瓣颅,最后將渲染好的數(shù)據(jù)即 html 返回。

運(yùn)行 npm run server 啟動(dòng)服務(wù)端譬正,打開(kāi) http://localhost:8081 就可以看到效果了:

image.png

關(guān)于 asyncData

前面提到了 asyncData 宫补,現(xiàn)在以該例子來(lái)梳理一下。首先曾我,看看組件中的代碼:

...
<script>
export default {
  asyncData ({ store, route }) {
    // 觸發(fā) action 后粉怕,會(huì)返回 Promise
    return store.dispatch('fetchItems')
  },
  data () {
    return {
      title: "",
      content: ""
    }
  },
  computed: {
    // 從 store 的 state 對(duì)象中的獲取 item。
    itemList () {
      return this.$store.state.items
    }
  },
  methods: {
    submit () {
      const {title, content} = this
      this.$store.dispatch('addItem', {title, content})
    }
  }
}
</script>

這是一個(gè)很簡(jiǎn)單的組件抒巢,包括一個(gè)列表贫贝,該列表的內(nèi)容通過(guò)請(qǐng)求從后端獲取,一個(gè)表單蛉谜,用于提交新的記錄到后端保存稚晚。其中 asyncData 是我們約定的函數(shù)名,表示渲染組件需要預(yù)先執(zhí)行它獲取初始數(shù)據(jù)型诚,它返回一個(gè) Promise客燕,以便我們?cè)诤蠖虽秩镜臅r(shí)候可以知道什么時(shí)候該操作完成。這里狰贯,該函數(shù)觸發(fā)了 fetchItems 以更新 store 中的狀態(tài)也搓。還記得我們的 entry-server.js 文件嗎,里面正是調(diào)用了組件的 asyncData 方法來(lái)進(jìn)行數(shù)據(jù)預(yù)取的涵紊。

在開(kāi)發(fā)階段傍妒,我們同樣需要進(jìn)行數(shù)據(jù)預(yù)取,為了復(fù)用 asyncData 代碼摸柄,我們?cè)诮M件的 beforeMount 中調(diào)用該方法拍顷,我們將這個(gè)處理邏輯通過(guò) Vue.mixin 混入到所有的組件中:

Vue.mixin({
  beforeMount() {
    const { asyncData } = this.$options
    if (asyncData) {
      // 將獲取數(shù)據(jù)操作分配給 promise
      // 以便在組件中,我們可以在數(shù)據(jù)準(zhǔn)備就緒后
      // 通過(guò)運(yùn)行 `this.dataPromise.then(...)` 來(lái)執(zhí)行其他任務(wù)
      this.dataPromise = asyncData({
        store: this.$store,
        route: this.$route
      })
    }
  }
})

還有一個(gè)問(wèn)題就是我們生成的 html 中并沒(méi)有引入任何 js塘幅,用戶(hù)無(wú)法進(jìn)行任何交互,比如上面的列表頁(yè)尿贫,用戶(hù)無(wú)法提交新的內(nèi)容电媳。當(dāng)然,如果這個(gè)頁(yè)面是只給爬蟲(chóng)來(lái)“看”的話這樣就足夠了庆亡,但如果考慮到真實(shí)的用戶(hù)匾乓,我們還需要在 html 中引入前端渲染的 js 文件。

前端渲染

該部分的代碼可以直接查看這里又谋。

前端渲染部分需要先增加一個(gè) webpack 的配置文件用于生成所需的 js, css 等靜態(tài)文件:

module.exports = merge(baseWebpackConfig, {
  plugins: [
    new webpack.optimize.UglifyJsPlugin({
      compress: {
        warnings: false,
        drop_console: true
      }
    }),
    // 重要信息:這將 webpack 運(yùn)行時(shí)分離到一個(gè)引導(dǎo) chunk 中拼缝,
    // 以便可以在之后正確注入異步 chunk娱局。
    // 這也為你的 應(yīng)用程序/vendor 代碼提供了更好的緩存。
    new webpack.optimize.CommonsChunkPlugin({
      name: "manifest",
      minChunks: Infinity
    }),
    // 此插件在輸出目錄中
    // 生成 `vue-ssr-client-manifest.json`咧七。
    new VueSSRClientPlugin()
  ]
})

同時(shí)衰齐,前端渲染還需要有自己的入口文件 entry-client,該文件在講 asyncData 的時(shí)候有所提及:

import Vue from 'vue'
import {
  createApp
} from './app.js'
// 客戶(hù)端特定引導(dǎo)邏輯……
const {
  app,
  router,
  store
} = createApp()
if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}

Vue.mixin({
  beforeMount() {
    const { asyncData } = this.$options
    if (asyncData) {
      // 將獲取數(shù)據(jù)操作分配給 promise
      // 以便在組件中继阻,我們可以在數(shù)據(jù)準(zhǔn)備就緒后
      // 通過(guò)運(yùn)行 `this.dataPromise.then(...)` 來(lái)執(zhí)行其他任務(wù)
      this.dataPromise = asyncData({
        store: this.$store,
        route: this.$route
      })
    }
  }
})
// 這里假定 App.vue 模板中根元素具有 `id="app"`
router.onReady(() => {
  app.$mount('#app')
})

現(xiàn)在我們 npm run build:client 編譯一下耻涛,dist 目錄中可以得到若干文件:

0.js
1.js
2.js
app.js
manifest.js
vue-ssr-client-manifest.json

其中,js 文件都是需要引入的文件瘟檩,json 文件像是一個(gè)說(shuō)明文檔抹缕,這里暫不討論其原理,感興趣的可以查看這里墨辛。

最后卓研,server.js 中,稍微做一點(diǎn)點(diǎn)修改:

 const clientManifest = require('./dist/vue-ssr-client-manifest.json')

 const renderer = createBundleRenderer(bundle, {
   template: fs.readFileSync('./index.template.html', 'utf-8'),
   clientManifest
 })

然后 npm run server 啟動(dòng)服務(wù)睹簇,再打開(kāi) http://localhost:8081奏赘,可以看到渲染后的 html 文件中已經(jīng)引入了 js 資源了。

image.png

列表頁(yè)中也可以提交新記錄了:

image.png

總結(jié)

本文先從搭建一個(gè)簡(jiǎn)單的 vue 開(kāi)發(fā)環(huán)境開(kāi)始带膀,然后基于此實(shí)現(xiàn)了服務(wù)端渲染志珍,并引入了客戶(hù)端渲染所需的資源。通過(guò)這個(gè)過(guò)程跑通了 vue 服務(wù)端渲染的大致流程垛叨,但很多地方還需更進(jìn)一步深入:

  • 樣式的處理
    本文并沒(méi)有對(duì)樣式進(jìn)行處理伦糯,需進(jìn)一步研究

  • 編譯后文件的解釋
    文章中編譯生成的 json 等文件到底是怎么用的呢?

  • 針對(duì)爬蟲(chóng)和真實(shí)用戶(hù)的不同策略
    服務(wù)端渲染其實(shí)主要是用來(lái)解決 seo 的問(wèn)題嗽元,所以可以在服務(wù)端通過(guò)請(qǐng)求頭判斷來(lái)源并做不同處理敛纲,若是爬蟲(chóng)則進(jìn)行服務(wù)端渲染(不需要引入客戶(hù)端渲染所需的資源),若是普通用戶(hù)則還是用原始的客戶(hù)端渲染方式剂癌。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末淤翔,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子佩谷,更是在濱河造成了極大的恐慌旁壮,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,126評(píng)論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件谐檀,死亡現(xiàn)場(chǎng)離奇詭異抡谐,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī)桐猬,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,254評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門(mén)麦撵,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人,你說(shuō)我怎么就攤上這事免胃∫粑澹” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 152,445評(píng)論 0 341
  • 文/不壞的土叔 我叫張陵羔沙,是天一觀的道長(zhǎng)躺涝。 經(jīng)常有香客問(wèn)我,道長(zhǎng)撬碟,這世上最難降的妖魔是什么诞挨? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 55,185評(píng)論 1 278
  • 正文 為了忘掉前任,我火速辦了婚禮呢蛤,結(jié)果婚禮上惶傻,老公的妹妹穿的比我還像新娘。我一直安慰自己其障,他們只是感情好银室,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,178評(píng)論 5 371
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著励翼,像睡著了一般蜈敢。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上汽抚,一...
    開(kāi)封第一講書(shū)人閱讀 48,970評(píng)論 1 284
  • 那天抓狭,我揣著相機(jī)與錄音,去河邊找鬼造烁。 笑死否过,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的惭蟋。 我是一名探鬼主播苗桂,決...
    沈念sama閱讀 38,276評(píng)論 3 399
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼告组!你這毒婦竟也來(lái)了煤伟?” 一聲冷哼從身側(cè)響起,我...
    開(kāi)封第一講書(shū)人閱讀 36,927評(píng)論 0 259
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤木缝,失蹤者是張志新(化名)和其女友劉穎便锨,沒(méi)想到半個(gè)月后,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體我碟,經(jīng)...
    沈念sama閱讀 43,400評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡鸿秆,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,883評(píng)論 2 323
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了怎囚。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 37,997評(píng)論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖恳守,靈堂內(nèi)的尸體忽然破棺而出考婴,到底是詐尸還是另有隱情,我是刑警寧澤催烘,帶...
    沈念sama閱讀 33,646評(píng)論 4 322
  • 正文 年R本政府宣布沥阱,位于F島的核電站,受9級(jí)特大地震影響伊群,放射性物質(zhì)發(fā)生泄漏考杉。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,213評(píng)論 3 307
  • 文/蒙蒙 一舰始、第九天 我趴在偏房一處隱蔽的房頂上張望崇棠。 院中可真熱鬧,春花似錦丸卷、人聲如沸枕稀。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 30,204評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)萎坷。三九已至,卻和暖如春沐兰,著一層夾襖步出監(jiān)牢的瞬間哆档,已是汗流浹背。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 31,423評(píng)論 1 260
  • 我被黑心中介騙來(lái)泰國(guó)打工住闯, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留瓜浸,地道東北人驾讲。 一個(gè)月前我還...
    沈念sama閱讀 45,423評(píng)論 2 352
  • 正文 我出身青樓倔约,卻偏偏與公主長(zhǎng)得像掌栅,于是被迫代替她去往敵國(guó)和親督禽。 傳聞我的和親對(duì)象是個(gè)殘疾皇子庇楞,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,722評(píng)論 2 345

推薦閱讀更多精彩內(nèi)容

  • 在實(shí)現(xiàn) egg + vue 服務(wù)端渲染工程化實(shí)現(xiàn)之前步脓,我們先來(lái)看看前面兩篇關(guān)于Webpack構(gòu)建和Egg的文章: ...
    hubcarl閱讀 6,002評(píng)論 0 19
  • 所有程序員的第一行只有王小波的李銀河我和另一個(gè)人格和平相處的國(guó)界 一塊煤球里的蜂后笑話螻蟻去保衛(wèi)家園手拿菜刀的小販...
    張廢材閱讀 82評(píng)論 3 1
  • 這個(gè)程也是我剛學(xué)完Free PASCAL的語(yǔ)法時(shí)自己編的取视。 不忘初心是美好的甥材,但是我還是忘記了我做這個(gè)是干什么用的...
    闊爺閱讀 224評(píng)論 0 0