🏃‍♀️點亮你的Vue技術棧,萬字Nuxt.js實踐筆記來了~

前言

作為一位 Vuer(vue開發者),如果還不會這個框架,那麼你的 Vue 技術棧還沒被點亮。

Nuxt.js 是什麼

Nuxt.js 官方介紹:

Nuxt.js 是一個基於 Vue.js 的通用應用框架。
通過對客戶端/服務端基礎架構的抽象組織,Nuxt.js 主要關注的是應用的 UI渲染。
我們的目標是創建一個靈活的應用框架,你可以基於它初始化新項目的基礎結構程式碼,或者在已有 Node.js 項目中使用 Nuxt.js。
Nuxt.js 預設了利用 Vue.js 開發服務端渲染的應用所需要的各種配置。

如果你熟悉 Vue.js 的使用,那你很快就可以上手 Nuxt.js。開發體驗也和 Vue.js 沒太大區別,相當於為 Vue.js 擴展了一些配置。當然你對 Node.js 有基礎,那就再好不過了。

Nuxt.js 解決什麼問題

現在 Vue.js 大多數用於單頁面應用,隨著技術的發展,單頁面應用已不足以滿足需求。並且一些缺點也成為單頁面應用的通病,單頁面應用在訪問時會將所有的文件進行載入,首屏訪問需要等待一段時間,也就是常說的白屏,另外一點是總所周知的 SEO 優化問題。

Nuxt.js 的出現正好來解決這些問題,如果你的網站是偏向社區需要搜索引擎提供流量的項目,那就再合適不過了。

我的第一個 Nuxt.js 項目

我在空閑的時間也用 Nuxt.js 仿掘金 web 網站:

nuxt-juejin-project 是一個使用 Nuxt.js 仿寫掘金的學習項目,主要使用 :nuxt + koa + vuex + axios + element-ui。該項目所有數據與掘金同步,因為介面都是通過 koa 作為中間層轉發。主要頁面數據通過服務端渲染完成。

在項目完成後的幾天,我將記錄的筆記整理一下,並加入一些常用的技術點,最後有了這篇文章,希望能夠幫到正在學習的小夥伴。

項目介紹里有部分截圖,如果jio得可以,請來個 star😜~

項目地址://github.com/ChanWahFung/nuxt-juejin-project

基礎應用與配置

項目的搭建參照官網指引,跑個項目相信難不到你們,這裡不贅述了。

🏃‍♀️跑起來 //www.nuxtjs.cn/guide/installation

關於項目的配置,我選擇的是:

  • 服務端:Koa
  • UI框架:Element UI
  • 測試框架:None
  • Nuxt模式:Universal
  • 使用集成的 Axios
  • 使用 EsLint

context

context 是從 Nuxt 額外提供的對象,在”asyncData”、”plugins”、”middlewares”、”modules” 和 “store/nuxtServerInit” 等特殊的 Nuxt 生命周期區域中都會使用到 context。

所以,想要使用 Nuxt.js,我們必須要熟知該對象的有哪些可用屬性。

context 官方文檔描述戳這裡 //www.nuxtjs.cn/api/context

下面我列舉幾個在實際應用中比較重要且常用的屬性:

app

appcontext 中最重要的屬性,就像我們 Vue 中的 this,全局方法和屬性都會掛載到它裡面。因為服務端渲染的特殊性,很多Nuxt提供的生命周期都是運行在服務端,也就是說它們會先於 Vue 實例的創建。因此在這些生命周期中,我們無法通過 this 去獲取實例上的方法和屬性。使用 app 可以來彌補這點,一般我們會把全局的方法同時注入到 thisapp 中,在服務端的生命周期中使用 app 去訪問該方法,而在客戶端中使用 this,保證方法的共用。

舉個例子:

假設 $axios 已被同時注入,一般主要數據通過 asyncData (該生命周期發起請求,將獲取到的數據交給服務端拼接成html返回) 去提前請求做服務端渲染,而次要數據通過客戶端的 mounted 去請求。

export default {
  async asyncData({ app }) {
    // 列表數據
    let list = await app.$axios.getIndexList({
      pageNum: 1,
      pageSize: 20
    }).then(res => res.s === 1 ? res.d : [])
    return {
      list
    }
  },
  data() {
    return {
      list: [],
      categories: []
    }
  },
  async mounted() {
    // 分類
    let res = await this.$axios.getCategories()
    if (res.s  === 1) {
      this.categories = res.d
    }
  }
}

store

storeVuex.Store 實例,在運行時 Nuxt.js 會嘗試找到是應用根目錄下的 store 目錄,如果該目錄存在,它會將模組文件加到構建配置中。

所以我們只需要在根目錄的 store 創建模組js文件,即可使用。

/store/index.js :

export const state = () => ({
  list: []
})

export const mutations = {
  updateList(state, payload){
    state.list = payload
  }
}

而且 Nuxt.js 會很貼心的幫我們將 store 同時注入,最後我們可以在組件這樣使用::

export default {
  async asyncData({ app, store }) {
    let list = await app.$axios.getIndexList({
      pageNum: 1,
      pageSize: 20
    }).then(res => res.s === 1 ? res.d : [])
    // 服務端使用
    store.commit('updateList', list)
    return {
      list
    }
  },
  methods: {
    updateList(list) {
      // 客戶端使用,當然你也可以使用輔助函數 mapMutations 來完成
      this.$store.commit('updateList', list)
    }
  }
}

為了明白 store 注入的過程,我翻閱 .nuxt/index.js 源碼(.nuxt 目錄是 Nuxt.js 在構建運行時自動生成的),大概知道了流程。首先在 .nuxt/store.js 中,對 store 模組文件做出一系列的處理,並暴露 createStore 方法。然後在 .nuxt/index.js 中,createApp方法會對其同時注入:

import { createStore } from './store.js'

async function createApp (ssrContext) {
  const store = createStore(ssrContext)
  // ...
  // here we inject the router and store to all child components,
  // making them available everywhere as `this.$router` and `this.$store`.
  // 注入到this
  const app = {
    store
    // ...
  }
  // ...
  // Set context to app.context
  // 注入到context
  await setContext(app, {
    store
    // ...
  })
  // ...
  return {
    store,
    app,
    router
  }
}

除此之外,我還發現 Nuxt.js 會通過 inject 方法為其掛載上 pluginplugin 是掛載全局方法的主要途徑,後面會講到,不知道可以先忽略),也就是說在 store 里,我們可以通過 this 訪問到全局方法:

export const mutations = {
  updateList(state, payload){
    console.log(this.$axios)
    state.list = payload
  }
}

params、query

paramsquery 分別是 route.paramsroute.query 的別名。它們都帶有路由參數的對象,使用方法也很簡單。這個沒什麼好說的,用就完事了。

export default {
  async asyncData({ app, params }) {
    let list = await app.$axios.getIndexList({
      id: params.id,
      pageNum: 1,
      pageSize: 20
    }).then(res => res.s === 1 ? res.d : [])
    return {
      list
    }
  }
}

redirect

該方法重定向用戶請求到另一個路由,通常會用在許可權驗證。用法:redirect(params)params 參數包含 status(狀態碼,默認為302)、path(路由路徑)、query(參數),其中 statusquery 是可選的。當然如果你只是單純的重定向路由,可以傳入路徑字元串,就像 redirect('/index')

舉個例子:

假設我們現在有個路由中間件,用於驗證登錄身份,邏輯是身份沒過期則不做任何事情,若身份過期重定向到登錄頁。

export default function ({ redirect }) {
  // ...
  if (!token) {
    redirect({
      path: '/login',
      query: {
        isExpires: 1
      }
    })
  }
}

error

該方法跳轉到錯誤頁。用法:error(params)params 參數應該包含 statusCodemessage 欄位。在實際場景中,總有一些不按常理的操作,頁面因此無法展示真正想要的效果,使用該方法進行錯誤提示還是有必要的。

舉個例子:

標籤詳情頁面請求數據依賴於 query.name,當 query.name 不存在時,請求無法返回可用的數據,此時跳轉到錯誤頁

export default {
  async asyncData({ app, query, error }) {
    const tagInfo = await app.$api.getTagDetail({
      tagName: encodeURIComponent(query.name)
    }).then(res => {
      if (res.s === 1) {
        return res.d
      } else {
        error({
          statusCode: 404,
          message: '標籤不存在'
        })
        return
      }
    })
    return {
      tagInfo
    }
  }
}

Nuxt常用頁面生命周期

asyncData

你可能想要在伺服器端獲取並渲染數據。Nuxt.js添加了asyncData方法使得你能夠在渲染組件之前非同步獲取數據。

asyncData 是最常用最重要的生命周期,同時也是服務端渲染的關鍵點。該生命周期只限於頁面組件調用,第一個參數為 context。它調用的時機在組件初始化之前,運作在服務端環境。所以在 asyncData 生命周期中,我們無法通過 this 引用當前的 Vue 實例,也沒有 window 對象和 document 對象,這些是我們需要注意的。

一般在 asyncData 會對主要頁面數據進行預先請求,獲取到的數據會交由服務端拼接成 html 返回前端渲染,以此提高首屏載入速度和進行 seo 優化。

看下圖,在Google調試工具中,看不到主要數據介面發起請求,只有返回的 html 文檔,證明數據在服務端被渲染。

最後,需要將介面獲取到的數據返回:

export default {
  async asyncData({ app }) {
    let list = await app.$axios.getIndexList({
      pageNum: 1,
      pageSize: 20
    }).then(res => res.s === 1 ? res.d : [])
    // 返回數據
    return {
      list
    }
  },
  data() {
    return {
      list: []
    }
  }
}

值得一提的是,asyncData 只在首屏被執行,其它時候相當於 createdmounted 在客戶端渲染頁面。

什麼意思呢?舉個例子:

現在有兩個頁面,分別是首頁和詳情頁,它們都有設置 asyncData。進入首頁時,asyncData 運行在服務端。渲染完成後,點擊文章進入詳情頁,此時詳情頁的 asyncData 並不會運行在服務端,而是在客戶端發起請求獲取數據渲染,因為詳情頁已經不是首屏。當我們刷新詳情頁,這時候詳情頁的 asyncData 才會運行在服務端。所以,不要走進這個誤區(誒,不是說服務端渲染嗎,怎麼還會發起請求?)。

fetch

fetch 方法用於在渲染頁面前填充應用的狀態樹(store)數據, 與 asyncData 方法類似,不同的是它不會設置組件的數據。

查看官方的說明,可以得知該生命周期用於填充 Vuex 狀態樹,與 asyncData 同樣,它在組件初始化前調用,第一個參數為 context

為了讓獲取過程可以非同步,你需要返回一個 PromiseNuxt.js 會等這個 promise 完成後再渲染組件。

export default {
  fetch ({ store, params }) {
    return axios.get('//my-api/stars')
    .then((res) => {
      store.commit('setStars', res.data)
    })
  }
}

你也可以使用 async 或 await 的模式簡化程式碼如下:

export default {
  async fetch ({ store, params }) {
    let { data } = await axios.get('//my-api/stars')
    store.commit('setStars', data)
  }
}

但這並不是說我們只能在 fetch 中填充狀態樹,在 asyncData 中同樣可以。

validate

Nuxt.js 可以讓你在動態路由對應的頁面組件中配置一個校驗方法用於校驗動態路由參數的有效性。

在驗證路由參數合法性時,它能夠幫助我們,第一個參數為 context。與上面有點不同的是,我們能夠訪問實例上的方法 this.methods.xxx

列印 this 如下:

生命周期可以返回一個 Boolean,為真則進入路由,為假則停止渲染當前頁面並顯示錯誤頁面:

export default {
  validate({ params, query }) {
    return this.methods.validateParam(params.type)
  },
  methods: {
    validateParam(type){
      let typeWhiteList = ['backend', 'frontend', 'android']
      return typeWhiteList.includes(type)
    }
  }
}

或者返回一個Promise:

export default {
  validate({ params, query, store }) {
    return new Promise((resolve) => setTimeout(() => resolve()))
  }
}

還可以在驗證函數執行期間拋出預期或意外錯誤:

export default {
  async validate ({ params, store }) {
    // 使用自定義消息觸發內部伺服器500錯誤
    throw new Error('Under Construction!')
  }
}

watchQuery

監聽參數字元串更改並在更改時執行組件方法 (asyncData, fetch, validate, layout, …)

watchQuery 可設置 BooleanArray (默認: [])。使用 watchQuery 屬性可以監聽參數字元串的更改。 如果定義的字元串發生變化,將調用所有組件方法(asyncData, fetch, validate, layout, …)。 為了提高性能,默認情況下禁用。

nuxt-juejin-project 項目的搜索頁中,我也用到了這個配置:

<template>
  <div class="search-container">
    <div class="list__header">
      <ul class="list__types">
        <li v-for="item in types" :key="item.title" @click="search({type: item.type})">{{ item.title }}</li>
      </ul>
      <ul class="list__periods">
        <li v-for="item in periods" :key="item.title" @click="search({period: item.period})">{{ item.title }}</li>
      </ul>
    </div>
  </div>
</template>
export default {
  async asyncData({ app, query }) {
    let res = await app.$api.searchList({
      after: 0,
      first: 20,
      type: query.type ? query.type.toUpperCase() : 'ALL',
      keyword: query.keyword,
      period: query.period ? query.period.toUpperCase() : 'ALL'
    }).then(res => res.s == 1 ? res.d : {})
    return {
      pageInfo: res.pageInfo || {},
      searchList: res.edges || []
    }
  },
  watchQuery: ['keyword', 'type', 'period'],
  methods: {
    search(item) {
      // 更新路由參數,觸發 watchQuery,執行 asyncData 重新獲取數據
      this.$router.push({
        name: 'search',
        query: {
          type: item.type || this.type,
          keyword: this.keyword,
          period: item.period || this.period
        }
      })
    }
  }
}

使用 watchQuery有點好處就是,當我們使用瀏覽器後退按鈕或前進按鈕時,頁面數據會刷新,因為參數字元串發生了變化。

Nuxt.js 使用了 vue-meta 更新應用的 頭部標籤(Head) 和 html 屬性。

使用 head 方法設置當前頁面的頭部標籤,該方法里能通過 this 獲取組件的數據。除了好看以外,正確的設置 meta 標籤,還能有利於頁面被搜索引擎查找,進行 seo 優化。一般都會設置 description(簡介) 和 keyword(關鍵詞)。

title:

meta:

export default {
  head () {
    return {
      title: this.articInfo.title,
      meta: [
        { hid: 'description', name: 'description', content: this.articInfo.content }
      ]
    }
  }
}

為了避免子組件中的 meta 標籤不能正確覆蓋父組件中相同的標籤而產生重複的現象,建議利用 hid 鍵為 meta 標籤配一個唯一的標識編號。

nuxt.config.js 中,我們還可以設置全局的 head:

module.exports = {
  head: {
    title: '掘金',
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width,initial-scale=1,user-scalable=no,viewport-fit=cover' },
      { name: 'referrer', content: 'never'},
      { hid: 'keywords', name: 'keywords', content: '掘金,稀土,Vue.js,微信小程式,Kotlin,RxJava,React Native,Wireshark,敏捷開發,Bootstrap,OKHttp,正則表達式,WebGL,Webpack,Docker,MVVM'},
      { hid: 'description', name: 'description', content: '掘金是一個幫助開發者成長的社區,是給開發者用的 Hacker News,給設計師用的 Designer News,和給產品經理用的 Medium。掘金的技術文章由稀土上聚集的技術大牛和極客共同編輯為你篩選出最優質的乾貨,其中包括:Android、iOS、前端、後端等方面的內容。用戶每天都可以在這裡找到技術世界的頭條內容。與此同時,掘金內還有沸點、掘金翻譯計劃、線下活動、專欄文章等內容。即使你是 GitHub、StackOverflow、開源中國的用戶,我們相信你也可以在這裡有所收穫。'}
    ],
  }
}

補充

下面是這些生命周期的調用順序,某些時候可能會對我們有幫助。

validate  =>  asyncData  =>  fetch  =>  head

配置啟動埠

以下兩者都可以配置啟動埠,但我個人更喜歡第一種在 nuxt.config.js 配置,這比較符合正常的邏輯。

第一種

nuxt.config.js :

module.exports = {
  server: {
    port: 8000,
    host: '127.0.0.1'
  }
}

第二種

package.json :

"config": {
  "nuxt": {
    "port": "8000",
    "host": "127.0.0.1"
  }
},

載入外部資源

全局配置

nuxt.config.js :

module.exports = {
  head: {
    link: [
      { rel: 'stylesheet', href: '//cdn.jsdelivr.net/gh/highlightjs/[email protected]/build/styles/atom-one-light.min.css' },
    ],
    script: [
      { src: '//cdn.jsdelivr.net/gh/highlightjs/[email protected]/build/highlight.min.js' }
    ]
  }
}

組件配置

組件內可在 head 配置,head 可以接受 objectfunction。官方例子使用的是 object 類型,使用 function 類型同樣生效。

export default {
  head () {
    return {
      link: [
        { rel: 'stylesheet', href: '//cdn.jsdelivr.net/gh/highlightjs/[email protected]/build/styles/atom-one-light.min.css' },
      ],
      script: [
        { src: '//cdn.jsdelivr.net/gh/highlightjs/[email protected]/build/highlight.min.js' }
      ]
    }
  }
}

環境變數

nuxt.config.js提供env選項進行配置環境變數。但此前我嘗試過根目錄創建 .env 文件管理環境變數,發現是無效的。

創建環境變數

nuxt.config.js :

module.exports = {
  env: {
    baseUrl: process.env.NODE_ENV === 'production' ? '//test.com' : '//127.0.0.1:8000'
  },
}

以上配置我們創建了一個 baseUrl 環境變數,通過 process.env.NODE_ENV 判斷環境來匹配對應的地址

使用環境變數

我們可以通過以下兩種方式來使用 baseUrl 變數:

  1. 通過 process.env.baseUrl
  2. 通過 context.env.baseUrl

舉個例子, 我們可以利用它來配置 axios 的自定義實例。

/plugins/axios.js:

export default function (context) {
	$axios.defaults.baseURL = process.env.baseUrl
	// 或者 $axios.defaults.baseURL = context.env.baseUrl
	$axios.defaults.timeout = 30000
	$axios.interceptors.request.use(config => {
		return config
	})
	$axios.interceptors.response.use(response => {
		return response.data
	})
}

plugins

plugins 作為全局注入的主要途徑,關於一些使用的方式是必須要掌握的。有時你希望在整個應用程式中使用某個函數或屬性值,此時,你需要將它們注入到 Vue 實例(客戶端), context (伺服器端)甚至 store(Vuex)

plugin 函數參數

plugin 一般向外暴露一個函數,該函數接收兩個參數分別是 contextinject

context: 上下文對象,該對象存儲很多有用的屬性。比如常用的 app 屬性,包含所有插件的 Vue 根實例。例如:在使用 axios 的時候,你想獲取 $axios 可以直接通過 context.app.$axios 來獲取。

inject: 該方法可以將 plugin 同時注入到 contextVue 實例, Vuex 中。

例如:

export default function (context, inject) {}

注入 Vue 實例

定義

plugins/vue-inject.js :

import Vue from 'vue'

Vue.prototype.$myInjectedFunction = string => console.log('This is an example', string)

使用

nuxt.config.js :

export default {
  plugins: ['~/plugins/vue-inject.js']
}

這樣在所有 Vue 組件中都可以使用該函數

export default {
  mounted() {
      this.$myInjectedFunction('test')
  }
}

注入 context

context 注入方式和在其它 vue 應用程式中注入類似。

定義

plugins/ctx-inject.js :

export default ({ app }) => {
  app.myInjectedFunction = string => console.log('Okay, another function', string)
}

使用

nuxt.config.js :

export default {
  plugins: ['~/plugins/ctx-inject.js']
}

現在,只要你獲得 context ,你就可以使用該函數(例如在 asyncDatafetch 中)

export default {
  asyncData(context) {
    context.app.myInjectedFunction('ctx!')
  }
}

同時注入

如果需要同時在 contextVue 實例,甚至 Vuex 中同時注入,可以使用 inject 方法,它是 plugin 導出函數的第二個參數。系統會默認將 $ 作為方法名的前綴。

定義

plugins/combined-inject.js :

export default ({ app }, inject) => {
  inject('myInjectedFunction', string => console.log('That was easy!', string))
}

使用

nuxt.config.js :

export default {
  plugins: ['~/plugins/combined-inject.js']
}

現在你就可以在 context ,或者 Vue 實例中的 this ,或者 Vuexactions / mutations 方法中的 this 來調用 myInjectedFunction 方法

export default {
  mounted() {
    this.$myInjectedFunction('works in mounted')
  },
  asyncData(context) {
    context.app.$myInjectedFunction('works with context')
  }
}

store/index.js :

export const state = () => ({
  someValue: ''
})

export const mutations = {
  changeSomeValue(state, newValue) {
    this.$myInjectedFunction('accessible in mutations')
    state.someValue = newValue
  }
}

export const actions = {
  setSomeValueToWhatever({ commit }) {
    this.$myInjectedFunction('accessible in actions')
    const newValue = 'whatever'
    commit('changeSomeValue', newValue)
  }
}

plugin相互調用

plugin 依賴於其他的 plugin 調用時,我們可以訪問 context 來獲取,前提是 plugin 需要使用 context 注入。

舉個例子:現在已存在 request 請求的 plugin ,有另一個 plugin 需要調用 request

plugins/request.js :

export default ({ app: { $axios } }, inject) => {
  inject('request', {
    get (url, params) {
      return $axios({
        method: 'get',
        url,
        params
      })
    }
  })
}

plugins/api.js

export default ({ app: { $request } }, inject) => {
  inject('api', {
    getIndexList(params) {
      return $request.get('/list/indexList', params)
    }
  })
}

值得一提的是,在注入 plugin 時要注意順序,就上面的例子來看, request 的注入順序要在 api 之前

module.exports = {
  plugins: [
    './plugins/axios.js',
    './plugins/request.js',
    './plugins/api.js',
  ]
}

路由配置

Nuxt.js中,路由是基於文件結構自動生成,無需配置。自動生成的路由配置可在 .nuxt/router.js 中查看。

動態路由

Vue 中是這樣配置動態路由的

const router = new VueRouter({
  routes: [
    {
      path: '/users/:id',
      name: 'user',
      component: User
    }
  ]
})

Nuxt.js 中需要創建對應的以下劃線作為前綴的 Vue 文件 或 目錄

以下面目錄為例:

pages/
--| users/
-----| _id.vue
--| index.vue

自動生成的路由配置如下:

router:{
  routes: [
    {
      name: 'index',
      path: '/',
      component: 'pages/index.vue'
    },
    {
      name: 'users-id',
      path: '/users/:id?',
      component: 'pages/users/_id.vue'
    }
  ]
}

嵌套路由

以下面目錄為例, 我們需要一級頁面的 vue 文件,以及和該文件同名的文件夾(用於存放子頁面)

pages/
--| users/
-----| _id.vue
-----| index.vue
--| users.vue

自動生成的路由配置如下:

router: {
  routes: [
    {
      path: '/users',
      component: 'pages/users.vue',
      children: [
        {
          path: '',
          component: 'pages/users/index.vue',
          name: 'users'
        },
        {
          path: ':id',
          component: 'pages/users/_id.vue',
          name: 'users-id'
        }
      ]
    }
  ]
}

然後在一級頁面中使用 nuxt-child 來顯示子頁面,就像使用 router-view 一樣

<template>
  <div>
    <nuxt-child></nuxt-child>
  </div>
</template>

自定義配置

除了基於文件結構生成路由外,還可以通過修改 nuxt.config.js 文件的 router 選項來自定義,這些配置會被添加到 Nuxt.js 的路由配置中。

下面例子是對路由添加重定向的配置:

module.exports = {
  router: {
    extendRoutes (routes, resolve) {
      routes.push({
        path: '/',
        redirect: {
          name: 'timeline-title'
        }
      })
    }
  }
}

axios

安裝

Nuxt 已為我們集成好 @nuxtjs/axios,如果你在創建項目時選擇了 axios,這步可以忽略。

npm i @nuxtjs/axios --save

nuxt.config.js :

module.exports = {
  modules: [
    '@nuxtjs/axios'
  ],
}

SSR使用Axios

伺服器端獲取並渲染數據, asyncData 方法可以在渲染組件之前非同步獲取數據,並把獲取的數據返回給當前組件。

export default {
  async asyncData(context) {
    let data = await context.app.$axios.get("/test")
    return {
      list: data
    };
  },
  data() {
    return {
      list: []
    }
  }
}

非SSR使用Axios

這種使用方式就和我們平常一樣,訪問 this 進行調用

export default {
  data() {
    return {
      list: []
    }
  },
  async created() {
    let data = await this.$axios.get("/test")
    this.list = data
  },
}

自定義配置Axios

大多時候,我們都需要對 axios 做自定義配置(baseUrl、攔截器),這時可以通過配置 plugins 來引入。

定義

/plugins/axios.js :

export default function({ app: { $axios } }) {
  $axios.defaults.baseURL = '//127.0.0.1:8000/'
  $axios.interceptors.request.use(config => {
    return config
  })
  $axios.interceptors.response.use(response => {
    if (/^[4|5]/.test(response.status)) {
      return Promise.reject(response.statusText)
    }
    return response.data
  })
}

使用

nuxt.config.js :

module.exports = {
  plugins: [
    './plugins/axios.js'
  ],
}

完成後,使用方式也和上面的一樣。

css預處理器

scss 為例子

安裝

npm i node-sass sass-loader scss-loader --save--dev

使用

無需配置,模板內直接使用

<style lang="scss" scoped>
.box{
    color: $theme;
}
</style>

全局樣式

在編寫布局樣式時,會有很多相同共用的樣式,此時我們可以將這些樣式提取出來,需要用到時只需要添加一個類名即可。

定義

global.scss :

.shadow{
  box-shadow: 0 1px 2px 0 rgba(0,0,0,.05);
}
.ellipsis{
  text-overflow: ellipsis;
  white-space: nowrap;
  overflow: hidden;
}
.main{
  width: 960px;
  margin: 0 auto;
  margin-top: 20px;
}

使用

nuxt.config.js :

module.exports = {
  css: [
    '~/assets/scss/global.scss'
  ],
}

全局變數

為頁面注入 變數 和 mixin 而且不用每次都去導入他們,可以使用 @nuxtjs/style-resources 來實現。

安裝

npm i @nuxtjs/style-resources --save--dev

定義

/assets/scss/variable.scss:

$theme: #007fff;
$success: #6cbd45;
$success-2: #74ca46;

使用

nuxt.config.js :

module.exports = {
  modules: [
    '@nuxtjs/style-resources'
  ],
  styleResources: {
    scss: [
      './assets/scss/variable.scss'
    ]
  },
}

element-ui 自定義主題

定義

/assets/scss/element-variables.scss

/* 改變主題色變數 */
/* $theme 在上面的 scss 文件中定義並使用 */
$--color-primary: $theme;

/* 改變 icon 字體路徑變數,必需 */
$--font-path: '~element-ui/lib/theme-chalk/fonts';

/* 組件樣式按需引入 */
@import "~element-ui/packages/theme-chalk/src/select";
@import "~element-ui/packages/theme-chalk/src/option";
@import "~element-ui/packages/theme-chalk/src/input";
@import "~element-ui/packages/theme-chalk/src/button";
@import "~element-ui/packages/theme-chalk/src/notification";
@import "~element-ui/packages/theme-chalk/src/message";

使用

nuxt.config.js :

module.exports = {
  modules: [
    '@nuxtjs/style-resources'
  ],
  styleResources: {
    scss: [
      /*
      * 這裡需要注意使用的順序,因為 element-variables.scss 里用到 variable.scss 里定義的變數
      * 如果順序反過來,在啟動編譯時會導致變數找不到報錯
      */
      '~/assets/scss/variable.scss',
      '~/assets/scss/element-variables.scss'
    ]
  },
}

還有另一個方法可以使用,那就是 plugin

import Vue from 'vue'
import myComponentsInstall from '~/components/myComponentsInstall'
import eleComponentsInstall from '~/components/eleComponentsInstall'
import '~/assets/scss/element-variables.scss' // elementUI 自定義主題色

Vue.use(myComponentsInstall)
Vue.use(eleComponentsInstall)

前端技術點

asyncData請求並行

看到這裡你應該能感覺到 asyncData 的重要性,對於這種經常會使用到的生命周期,一些細節上的修改就顯得尤為重要。通常, asyncData 中不只發起一個請求,可能是很多個:

export default {
  async asyncData({ app }) {
    // 文章列表
    let indexData = await app.$api.getIndexList({
      first: 20,
      order: 'POPULAR',
      category: 1
    }).then(res => res.s == 1 ? res.d : {})
    // 推薦作者
    let recommendAuthors = await app.$api.getRecommendAuthor({ 
      limit: 5
    }).then(res => res.s == 1 ? res.d : [])
    // 推薦小冊
    let recommendBooks = await app.$api.getRecommendBook().then(res => res.s === 1 ? res.d.data : [])
    return {
      indexData,
      recommendAuthors,
      recommendBooks
    }
  }
}

上面的操作看起來沒什麼問題,但其實有個細節可以優化一下。現在來盤一盤,我們都知道 async/await 會將非同步任務去同步化執行,上一個非同步任務沒結束之前,下一個非同步任務處於等待狀態中。這樣需要等待3個非同步任務,假設這些請求均耗時1秒,也就是說頁面至少要等待3秒後才會出現內容。原本我們想利用服務端渲染來優化首屏,現在卻因為等待請求而拖慢頁面渲染,豈不是得不償失。

最好的方案應該是多個請求同時發送,可能聰明的小夥伴已經想到 Promise.all。沒錯,利用 Promise.all 將這些請求並行發送,就能解決上述的問題。Promise.all 接受一個 Promise 數組作為參數,當全部 Promise 成功時會返回一個結果數組。最終的耗時會以最久的 Promise 為準,所以說原本3秒的耗時可以降低到1秒。需要注意的是,如果其中有一個請求失敗了,會返回最先被 reject 失敗狀態的值,導致獲取不到數據。在項目封裝基礎請求時我已經做了 catch 錯誤的處理,所以確保請求都不會被 reject

export default {
  asyncData() {
    // 數組解構獲得對應請求的數據
    let [indexData, recommendAuthors, recommendBooks] = await Promise.all([
      // 文章列表
      app.$api.getIndexList({
        first: 20,
        order: 'POPULAR',
        category: 1
      }).then(res => res.s == 1 ? res.d : {}),
      // 推薦作者
      app.$api.getRecommendAuthor({ 
        limit: 5
      }).then(res => res.s == 1 ? res.d : []),
      // 推薦小冊
      app.$api.getRecommendBook().then(res => res.s === 1 ? res.d.data : []),
    ])
    return {
      indexData,
      recommendAuthors,
      recommendBooks
    }
  }
}

token的設置與存儲

一個應用必不可少的功能就是 token 驗證,通常我們在登錄後把返回的驗證資訊存儲起來,之後請求帶上 token 供後端驗證狀態。在前後端分離的項目中,一般都會存放到本地存儲中。但 Nuxt.js 不同,由於服務端渲染的特點,部分請求在服務端發起,我們無法獲取 localStoragesessionStorage

這時候,cookie 就派上了用場。cookie 不僅能在客戶端供我們操作,在請求時也會帶上發回給服務端。使用原生操作 cooike 是非常麻煩的,藉助 cookie-universal-nuxt 模組(該模組只是幫助我們注入,主要實現依賴 cookie-universal),我們能夠更方便的使用 cookie。不管在服務端還是客戶端,cookie-universal-nuxt 都為我們提供一致的 api,它內部會幫我們去適配對應的方法。

安裝

安裝 cookie-universal-nuxt

npm run cookie-universal-nuxt --save

nuxt.config.js :

module.exports = {
  modules: [
    'cookie-universal-nuxt'
  ],
}

基礎使用

同樣的, cookie-universal-nuxt 會同時注入,訪問 $cookies 進行使用。

服務端:

// 獲取
app.$cookies.get('name')
// 設置
app.$cookies.set('name', 'value')
// 刪除
app.$cookies.remove('name')

客戶端:

// 獲取
this.$cookies.get('name')
// 設置
this.$cookies.set('name', 'value')
// 刪除
this.$cookies.remove('name')

更多使用方法戳這裡 //www.npmjs.com/package/cookie-universal-nuxt

實際應用流程

像掘金的登錄,我們在登錄後驗證資訊會被長期存儲起來,而不是每次使用都要進行登錄。但 cookie 生命周期只存在於瀏覽器,當瀏覽器關閉後也會隨之銷毀,所以我們需要為其設置一個較長的過期時間。

在項目中我將設置身份資訊封裝成工具方法,在登錄成功後會調用此方法:

/utils/utils.js :

setAuthInfo(ctx, res) {
  let $cookies, $store
  // 客戶端
  if (process.client) {
    $cookies = ctx.$cookies
    $store = ctx.$store
  }
  // 服務端
  if (process.server) {
    $cookies = ctx.app.$cookies
    $store = ctx.store
  }
  if ($cookies && $store) {
    // 過期時長 new Date(Date.now() + 8.64e7 * 365 * 10)
    const expires = $store.state.auth.cookieMaxExpires
    // 設置cookie
    $cookies.set('userId', res.userId, { expires })
    $cookies.set('clientId', res.clientId, { expires })
    $cookies.set('token', res.token, { expires })
    $cookies.set('userInfo', res.user, { expires })
    // 設置vuex
    $store.commit('auth/UPDATE_USERINFO', res.user)
    $store.commit('auth/UPDATE_CLIENTID', res.clientId)
    $store.commit('auth/UPDATE_TOKEN', res.token)
    $store.commit('auth/UPDATE_USERID', res.userId)
  }
}

之後需要改造下 axios,讓它在請求時帶上驗證資訊:

/plugins/axios.js :

export default function ({ app: { $axios, $cookies } }) {
	$axios.defaults.baseURL = process.env.baseUrl
	$axios.defaults.timeout = 30000
	$axios.interceptors.request.use(config => {
    // 頭部帶上驗證資訊
		config.headers['X-Token'] = $cookies.get('token') || ''
		config.headers['X-Device-Id'] = $cookies.get('clientId') || ''
		config.headers['X-Uid'] = $cookies.get('userId') || ''
		return config
	})
	$axios.interceptors.response.use(response => {
		if (/^[4|5]/.test(response.status)) {
			return Promise.reject(response.statusText)
		}
		return response.data
	})
}

許可權驗證中間件

上面提到身份資訊會設置一個長期的時間,接下來當然就需要驗證身份是否過期啦。這裡我會使用路由中間件來完成驗證功能,中間件運行在一個頁面或一組頁面渲染之前,就像路由守衛一樣。而每一個中間件應放置在 middleware 目錄,文件名的名稱將成為中間件名稱。中間件可以非同步執行,只需要返回 Promise 即可。

定義

/middleware/auth.js

export default function (context) {
  const { app, store } = context
  const cookiesToken = app.$cookies.get('token')
  if (cookiesToken) {
    // 每次跳轉路由 驗證登錄狀態是否過期
    app.$api.isAuth().then(res => {
      if (res.s === 1) {
        if (res.d.isExpired) {   // 過期 移除登陸驗證資訊
          app.$utils.removeAuthInfo(context)
        } else {                 // 未過期 重新設置存儲
          const stateToken = store.state.auth.token
          if (cookiesToken && stateToken === '') {
            store.commit('auth/UPDATE_USERINFO', app.$cookies.get('userInfo'))
            store.commit('auth/UPDATE_USERID', app.$cookies.get('userId'))
            store.commit('auth/UPDATE_CLIENTID', app.$cookies.get('clientId'))
            store.commit('auth/UPDATE_TOKEN', app.$cookies.get('token'))
          }
        }
      }
    })
  }
}

上面 if (cookiesToken && stateToken === '') 中的處理,是因為一些頁面會新開標籤頁,導致 vuex 中的資訊丟失,這裡需要判斷一下重新設置狀態樹。

使用

nuxt.config.js :

module.exports = {
  router: {
    middleware: ['auth']
  },
}

這種中間件使用是注入到全局的每個頁面中,如果你希望中間件只運行於某個頁面,可以配置頁面的 middleware 選項:

export default {
  middleware: 'auth'
}

路由中間件文檔戳這裡 //www.nuxtjs.cn/guide/routing#%E4%B8%AD%E9%97%B4%E4%BB%B6

組件註冊管理

先來個最簡單的例子,在 plugins 文件夾下創建 vue-global.js 用於管理全局需要使用的組件或方法:

import Vue from 'vue'
import utils from '~/utils'
import myComponent from '~/components/myComponent.vue'

Vue.prototype.$utils = utils

Vue.use(myComponent)

nuxt.config.js

module.exports = {
  plugins: [
    '~/plugins/vue-global.js'
  ],
}

自定義組件

對於一些自定義全局共用組件,我的做法是將它們放入 /components/common 文件夾統一管理。這樣可以使用 require.context 來自動化的引入組件,該方法是由 webpack 提供的,它能夠讀取文件夾內所有文件。如果你不知道這個方法,真的很強烈你去了解並使用一下,它能大大提高你的編程效率。

定義

/components/myComponentsInstall.js :

export default {
  install(Vue) {
    const components = require.context('~/components/common', false, /\.vue$/)
    // components.keys() 獲取文件名數組
    components.keys().map(path => {
      // 獲取組件文件名
      const fileName = path.replace(/(.*\/)*([^.]+).*/ig, "$2")
      // components(path).default 獲取 ES6 規範暴露的內容,components(path) 獲取 Common.js 規範暴露的內容
      Vue.component(fileName, components(path).default || components(path))
    })
  } 
}

使用

/plugins/vue-global.js :

import Vue from 'vue'
import myComponentsInstall from '~/components/myComponentsInstall'

Vue.use(myComponentsInstall)

經過上面的操作後,組件已在全局被註冊,我們只要按短橫線命名使用即可。而且每新建一個組件都無需再去引入,真的是一勞永逸。同樣在其他實際應用中,如果 api 文件是按功能分模組,也可以使用這個方法去自動化引入介面文件。

第三方組件庫(element-UI)

全部引入

/plugins/vue-global.js

import Vue from 'vue'
import elementUI from 'element-ui'

Vue.use(elementUI)

nuxt.config.js :

module.exports = {
  css: [
    'element-ui/lib/theme-chalk/index.css'
  ]
}

按需引入

藉助 babel-plugin-component,我們可以只引入需要的組件,以達到減小項目體積的目的。

npm install babel-plugin-component -D

nuxt.config.js :

module.exports = {
  build: {
    plugins: [
      [
        "component",
        {
          "libraryName": "element-ui",
          "styleLibraryName": "theme-chalk"
        }
      ]
    ],
  }
}

接下來引入我們需要的部分組件,同樣創建一個 eleComponentsInstall.js 管理 elementUI 的組件:

/components/eleComponentsInstall.js :

import { Input, Button, Select, Option, Notification, Message } from 'element-ui'

export default {
  install(Vue) {
    Vue.use(Input)
    Vue.use(Select)
    Vue.use(Option)
    Vue.use(Button)
    Vue.prototype.$message = Message
    Vue.prototype.$notify  = Notification
  }
}

/plugins/vue-global.js:

import Vue from 'vue'
import eleComponentsInstall from '~/components/eleComponentsInstall'

Vue.use(eleComponentsInstall)

頁面布局切換

在我們構建網站應用時,大多數頁面的布局都會保持一致。但在某些需求中,可能需要更換另一種布局方式,這時頁面 layout 配置選項就能夠幫助我們完成。而每一個布局文件應放置在 layouts 目錄,文件名的名稱將成為布局名稱,默認布局是 default。下面的例子是更換頁面布局的背景色。其實按照使用 Vue 的理解,感覺就像切換 App.vue

定義

/layouts/default.vue :

<template>
  <div style="background-color: #f4f4f4;min-height: 100vh;">
    <top-bar></top-bar>
    <main class="main">
      <nuxt />
    </main>
    <back-top></back-top>
  </div>
</template>

/layouts/default-white.vue :

<template>
  <div style="background-color: #ffffff;min-height: 100vh;">
    <top-bar></top-bar>
    <main class="main">
      <nuxt />
    </main>
    <back-top></back-top>
  </div>
</template>

使用

頁面組件文件:

export default {
  layout: 'default-white',
  // 或
  layout(context) {
    return 'default-white'
  }
}

自定義錯誤頁

自定義的錯誤頁需要放在 layouts 目錄中,且文件名為 error。雖然此文件放在 layouts 目錄中, 但應該將它看作是一個頁面(page)。這個布局文件不需要包含 <nuxt/> 標籤。你可以把這個布局文件當成是顯示應用錯誤(404,500等)的組件。

定義

<template>
  <div class="error-page">
    <div class="error">
      <div class="where-is-panfish">
        <img class="elem bg" src="//b-gold-cdn.xitu.io/v3/static/img/bg.1f516b3.png">
        <img class="elem panfish" src="//b-gold-cdn.xitu.io/v3/static/img/panfish.9be67f5.png">
        <img class="elem sea" src="//b-gold-cdn.xitu.io/v3/static/img/sea.892cf5d.png">
        <img class="elem spray" src="//b-gold-cdn.xitu.io/v3/static/img/spray.bc638d2.png">
      </div>
      <div class="title">{{statusCode}} - {{ message }}</div>
      <nuxt-link class="error-link" to="/">回到首頁</nuxt-link>
    </div>
  </div>
</template>
export default {
  props: {
    error: {
      type: Object,
      default: null
    }
  },
  computed: {
    statusCode () {
      return (this.error && this.error.statusCode) || 500
    },
    message () {
      return this.error.message || 'Error'
    }
  },
  head () {
    return {
      title: `${this.statusCode === 404 ? '找不到頁面' : '呈現頁面出錯'} - 掘金`,
      meta: [
        {
          name: 'viewport',
          content: 'width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no'
        }
      ]
    }
  }
}

error對象

錯誤頁面中 props 接受一個 error 對象,該對象至少包含兩個屬性 statusCodemessage

除了這兩個屬性,我們還可以傳過去其他的屬性,這裡又要說起上面提到的 error 方法:

export default {
  async asyncData({ app, query, error }) {
    const tagInfo = await app.$api.getTagDetail({
      tagName: encodeURIComponent(query.name)
    }).then(res => {
      if (res.s === 1) {
        return res.d
      } else {
        // 這樣我們在 error 對象中又多了 query 屬性
        error({
          statusCode: 404,
          message: '標籤不存在',
          query 
        })
        return
      }
    })
    return {
      tagInfo
    }
  }
}

還有頁面的 validate 生命周期:

export default {
  async validate ({ params, store }) {
    throw new Error('頁面參數不正確')
  }
}

這裡傳過去的 statusCode 為 500,message 就是 new Error 中的內容。如果想傳對象過去的話,message 會轉為字元串 [object Object],你可以使用 JSON.stringify 傳過去,錯誤頁面再處理解析出來。

export default {
  async validate ({ params, store }) {
    throw new Error(JSON.stringify({ 
      message: 'validate錯誤',
      params
    }))
  }
}

封裝觸底事件

項目中基本每個頁面的都會有觸底事件,所以我將這塊邏輯抽離成 mixin,需要的頁面引入使用即可。

/mixins/reachBottom.js :

export default {
  data() {
    return {
      _scrollingElement: null,
      _isReachBottom: false,  // 防止進入執行區域時 重複觸發
      reachBottomDistance: 80 // 距離底部多遠觸發
    }
  },
  mounted() {
    this._scrollingElement = document.scrollingElement
    window.addEventListener('scroll', this._windowScrollHandler)
  },
  beforeDestroy() {
    window.removeEventListener('scroll', this._windowScrollHandler)
  },
  methods: {
    _windowScrollHandler() {
      let scrollHeight = this._scrollingElement.scrollHeight
      let currentHeight = this._scrollingElement.scrollTop + this._scrollingElement.clientHeight + this.reachBottomDistance
      if (currentHeight < scrollHeight && this._isReachBottom) {
        this._isReachBottom = false
      }
      if (this._isReachBottom) {
        return
      }
      // 觸底事件觸發
      if (currentHeight >= scrollHeight) {
        this._isReachBottom = true
        typeof this.reachBottom === 'function' && this.reachBottom()
      }
    }
  },
}

實現的核心當然是觸發時機: scrollTop(頁面滾動距離)+ clientHeight(頁面可視高度)>= scrollHeight(頁面總高度,包括滾動區域)。但這種需要完全觸底才能觸發事件,所以在此基礎上,我添加 reachBottomDistance 用於控制觸發事件的距離。最終,觸發事件會調用頁面 methodsreachBottom 方法。

命令式彈窗組件

命令式組件是什麼?element-UIMessage 組件就是很好的例子,當我們需要彈窗提示時,只需要調用一下 this.message(),而不是通過 v-if 切換組件。這種的好處就是不用引入組件,使用起來便捷,哪裡需要調哪裡。

nuxt-juejin-project 項目中我也封裝了兩個公用的彈窗組件,登錄彈窗和預覽大圖彈窗,技術點是手動掛載組件。實現程式碼並不多,幾行足矣。

定義

/components/common/picturesModal/picturesModal.vue :

export default {
  data() {
    return {
      url: '',  // 當前圖片鏈接
      urls: ''  // 圖片鏈接數組
    }
  },
  methods: {
    show(cb) {
      this.cb = cb
      return new Promise((resolve, reject) => {
        document.body.style.overflow = 'hidden'
        this.resolve = resolve
        this.reject = reject
      })
    },
    // 銷毀彈窗
    hideModal() {
      typeof this.cb === 'function' && this.cb()
      document.body.removeChild(this.$el)
      document.body.style.overflow = ''
      // 銷毀組件實例
      this.$destroy()
    },
    // 關閉彈窗
    cancel() {
      this.reject()
      this.hideModal()
    },
  }
}

/components/common/picturesModal/index.js

import Vue from 'vue'
import picturesModal from './picturesModal'

let componentInstance = null

// 構造子類
let ModalConstructor = Vue.extend(picturesModal)

function createModal(options) {
  // 實例化組件
  componentInstance = new ModalConstructor()
  // 合併選項
  Object.assign(componentInstance, options)
  // $mount可以傳入選擇器字元串,表示掛載到該選擇器
  // 如果不傳入選擇器,將渲染為文檔之外的的元素,你可以想像成 document.createElement()在記憶體中生成dom
  // $el獲取的是dom元素
  document.body.appendChild(componentInstance.$mount().$el)
}

function caller (options) {
  // 單例 全局只存在一個彈窗
  if (!componentInstance) {
    createModal(options)
    // 調用組件內的show方法 傳入的callback在組件銷毀時調用
    return componentInstance.show(() => { componentInstance = null })
  }
}

export default {
  install(Vue) {
    // 註冊調起彈窗方法,方法返回Promise  then為登錄成功  catch為關閉彈窗
    Vue.prototype.$picturesModal = caller
  }
}

使用

/plugins/vue-global.js

import picturesModal from '~/components/common/picturesModal'

Vue.use(picturesModal)

這裡傳入的對象,就是上面 createModal 接收到的 options 參數,最後合併覆蓋到組件的 data

this.$picturesModal({
  url: 'b.jpg'
  urls: ['a.jpg', 'b.jpg', 'c.jpg']
})

中間層技術點

中間層工作的大概流程是在前端發送請求到中間層,中間層在發送請求到後端獲取數據。這樣做的好處是在前端到後端的交互過程中,我們相當於獲得了代理的控制權。利用這一權利,我們能做的事情就更多。比如:

  • 代理:在開發環境下,我們可以利用代理來,解決最常見的跨域問題;在線上環境下,我們可以利用代理,轉發請求到多個服務端。
  • 快取:快取其實是更靠近前端的需求,用戶的動作觸發數據的更新,node中間層可以直接處理一部分快取需求。
  • 日誌:相比其他服務端語言,node中間層的日誌記錄,能更方便快捷的定位問題。
  • 監控:擅長高並發的請求處理,做監控也是合適的選項。
  • 數據處理:返回所需的數據,數據欄位別名,數據聚合。

中間層的存在也讓前後端職責分離的更加徹底,後端只需要管理數據和編寫介面,需要哪些數據都交給中間層去處理。

nuxt-juejin-project 項目中間層使用的是 koa 框架,中間層的 http 請求方法是基於 request 庫簡單封裝一下,程式碼實現在 /server/request/index.js。因為後面需要用到,這裡就提一下。

請求轉發

安裝相關中間件

npm i koa-router koa-bodyparser --save

koa-router: 路由器中間件,能快速的定義路由以及管理路由

koa-bodyparser: 參數解析中間件,支援解析 json、表單類型,常用於解析 POST 請求

相關中間件的使用方法在 npm 上搜索,這裡就贅述怎麼使用了

路由設計

正所謂無規矩不成方圓,路由設計的規範,我參考的是阮一峰老師的 RESTful API 設計指南

路由目錄

路由文件我會存放在 /server/routes 目錄中,按照規範還需要一個規定 api 版本號的文件夾。最終路由文件存放在 /server/routes/v1 中。

路由路徑

在 RESTful 架構中,每個網址代表一種資源(resource),所以網址中不能有動詞,只能有名詞,而且所用的名詞往往與資料庫的表格名對應。一般來說,資料庫中的表都是同種記錄的”集合”(collection),所以 API 中的名詞也應該使用複數。

例如:

  • 文章相關介面文件命名為 articles
  • 標籤相關介面文件命名為 tag
  • 沸點相關介面文件命名為 pins

路由類型

路由操作資源的具體類型,由 HTTP 動詞表示

  • GET(SELECT):從伺服器取出資源(一項或多項)。
  • POST(CREATE):在伺服器新建一個資源。
  • PUT(UPDATE):在伺服器更新資源(客戶端提供改變後的完整資源)。
  • DELETE(DELETE):從伺服器刪除資源。

路由邏輯

下面是用戶專欄列表介面的例子

/server/router/articles.js

const Router = require('koa-router')
const router = new Router()
const request = require('../../request')
const { toObject } = require('../../../utils')

/**
 * 獲取用戶專欄文章
 * @param {string} targetUid - 用戶id
 * @param {string} before - 最後一條的createdAt,下一頁傳入
 * @param {number} limit - 條數
 * @param {string} order - rankIndex:熱門、createdAt:最新
 */
router.get('/userPost', async (ctx, next) => {
  // 頭部資訊
  const headers = ctx.headers
  const options = {
    url: '//timeline-merger-ms.juejin.im/v1/get_entry_by_self',
    method: "GET",
    params: {
      src: "web",
      uid: headers['x-uid'],
      device_id: headers['x-device-id'],
      token: headers['x-token'],
      targetUid: ctx.query.targetUid,
      type: ctx.query.type || 'post',
      limit: ctx.query.limit || 20,
      before: ctx.query.before,
      order: ctx.query.order || 'createdAt'
    }
  };
  // 發起請求
  let { body } = await request(options)
  // 請求後獲取到的數據為 json,需要轉為 object 進行操作
  body = toObject(body)
  ctx.body = {
    s: body.s,
    d: body.d.entrylist || []
  }
})

module.exports = router

註冊路由

/server/index.jsNuxt.js 為我們生成好的服務端的入口文件,我們的中間件使用和路由註冊都需要在這個文件內編寫。下面的應用會忽略部分程式碼,只展示主要的邏輯。

/server/index.js :

const Koa = require('koa')
const Router = require('koa-router')
const bodyParser = require('koa-bodyparser')
const app = new Koa()
const router = new Router()

// 使用中間件
function useMiddleware(){
  app.use(bodyParser())
}

// 註冊路由
function useRouter(){
  let module = require('./routes/articles')
  router.use('/v1/articles', module.routes())
  app.use(router.routes()).use(router.allowedMethods())
}

function start () {
  useMiddleware()
  useRouter()
  app.listen(8000, '127.0.0.1')
}

start()

最後介面的調用地址是: //127.0.0.1:8000/v1/articles/userPost

路由自動化註冊

沒錯,它又來了。自動化就是香,一勞永逸能不香嗎。

const fs = require('fs')
const Koa = require('koa')
const Router = require('koa-router')
const bodyParser = require('koa-bodyparser')
const app = new Koa()
const router = new Router()

// 註冊路由
function useRouter(path){
  path = path || __dirname + '/routes'
  // 獲取 routes 目錄下的所有文件名,urls為文件名數組
  let urls = fs.readdirSync(path)
  urls.forEach((element) => {
    const elementPath = path + '/' + element
    const stat = fs.lstatSync(elementPath);
    // 是否為文件夾
    const isDir = stat.isDirectory();
    // 文件夾遞歸註冊路由
    if (isDir) {
      useRouter(elementPath)
    } else {
      let module = require(elementPath)
      let routeRrefix = path.split('/routes')[1] || ''
      //routes里的文件名作為 路由名
      router.use(routeRrefix + '/' + element.replace('.js', ''), module.routes())
    }
  })
  //使用路由
  app.use(router.routes()).use(router.allowedMethods())
}

function start () {
  useMiddleware()
  useRouter()
  app.listen(8000, '127.0.0.1')
}

start()

上面的程式碼以 routes 作為路由的主目錄,向下尋找 js 文件註冊路由,最終以 js 文件路徑作為路由名。例如,/server/routes/v1/articles.js 中有個搜索介面 /search,那麼該介面的調用地址為 localhost:8000/v1/articles/search

路由參數驗證

參數驗證是介面中一定會有的功能,不正確的參數會導致程式意外錯誤。我們應該提前對參數驗證,中止錯誤的查詢並告知使用者。項目中我基於 async-validator 封裝了一個路由中間件來驗證參數。如果你不知道 koa 中間件的工作流程,那有必要去了解下洋蔥模型。

定義

/server/middleware/validator/js :

const { default: Schema } = require('async-validator')

module.exports = function (descriptor) {
  return async function (ctx, next) {
    let validator = new Schema(descriptor)
    let params = {}
    // 獲取參數
    Object.keys(descriptor).forEach(key => {
      if (ctx.method === 'GET') {
        params[key] = ctx.query[key]
      } else if (
        ctx.method === 'POST' ||
        ctx.method === 'PUT' ||
        ctx.method === 'DELETE'
      ) {
        params[key] = ctx.request.body[key]
      }
    })
    // 驗證參數
    const errors = await validator.validate(params)
      .then(() => null)
      .catch(err => err.errors)
    // 如果驗證不通過 則返回錯誤
    if (errors) {
      ctx.body = {
        s: 0,
        errors
      }
    } else {
      await next()
    }
  }
}

使用

使用方法請參考 async-validator

const Router = require('koa-router')
const router = new Router()
const request = require('../../request')
const validator = require('../../middleware/validator')
const { toObject } = require('../../../utils')

/**
 * 獲取用戶專欄文章
 * @param {string} targetUid - 用戶id
 * @param {string} before - 最後一條的createdAt,下一頁傳入
 * @param {number} limit - 條數
 * @param {string} order - rankIndex:熱門、createdAt:最新
 */
router.get('/userPost', validator({
  targetUid: { type: 'string', required: true },
  before: { type: 'string' },
  limit: { 
    type: 'string', 
    required: true,
    validator: (rule, value) => Number(value) > 0,
    message: 'limit 需傳入正整數'
  },
  order: { type: 'enum', enum: ['rankIndex', 'createdAt'] }
}), async (ctx, next) => {
  const headers = ctx.headers
  const options = {
    url: '//timeline-merger-ms.juejin.im/v1/get_entry_by_self',
    method: "GET",
    params: {
      src: "web",
      uid: headers['x-uid'],
      device_id: headers['x-device-id'],
      token: headers['x-token'],
      targetUid: ctx.query.targetUid,
      type: ctx.query.type || 'post',
      limit: ctx.query.limit || 20,
      before: ctx.query.before,
      order: ctx.query.order || 'createdAt'
    }
  };
  let { body } = await request(options)
  body = toObject(body)
  ctx.body = {
    s: body.s,
    d: body.d.entrylist || []
  }
})

module.exports = router

type代表參數類型,required代表是否必填。當 typeenum(枚舉)類型時,參數值只能為 enum 數組中的某一項。

需要注意的是,number 類型在這裡是無法驗證的,因為參數在傳輸過程中會被轉變為字元串類型。但是我們能通過 validator 方法自定義驗證規則,就像上面的 limit 參數。

以下是當 limit 參數錯誤時介面返回的內容:

網站安全性

cors

設置 cors 來驗證請求的安全合法性,可以讓你的網站提高安全性。藉助 koa2-cors 能夠幫助我們更便捷的做到這些。koa2-cors 的源碼也不多,建議去看看,只要你有點基礎都能看懂,不僅要懂得用也要知道實現過程。

安裝

npm install koa2-cors --save

使用

/server/index.js :

const cors = require('koa2-cors')

function useMiddleware(){
  app.use(helmet())
  app.use(bodyParser())
  //設置全局返回頭
  app.use(cors({
    // 允許跨域的域名
    origin: function(ctx) {
      return '//localhost:8000';
    },
    exposeHeaders: ['WWW-Authenticate', 'Server-Authorization'],
    maxAge: 86400,
    // 允許攜帶頭部驗證資訊
    credentials: true,  
    // 允許的方法
    allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS'],
    // 允許的標頭
    allowHeaders: ['Content-Type', 'Authorization', 'Accept', 'X-Token', 'X-Device-Id', 'X-Uid'],
  }))
}

如果不符合請求的方式,或帶有未允許的標頭。發送請求時會直接失敗,瀏覽器拋出 cors 策略限制的錯誤。下面是帶有未允許標頭錯誤的例子:

koa-helmet

koa-helmet 提供重要的安全標頭,使你的應用程式在默認情況下更加安全。

安裝

npm install koa-helmet --save

使用

const helmet = require('koa-helmet')

function useMiddleware(){
  app.use(helmet())
  // .....
}

默認為我們做了以下安全設置:

  • X-DNS-Prefetch-Control: 禁用瀏覽器的 DNS 預取。
  • X-Frame-Options: 緩解點擊劫持攻擊。
  • X-Powered-By:刪除了 X-Powered-By 標頭,使攻擊者更難於查看使網站受到潛在威脅的技術。
  • Strict-Transport-Security:使您的用戶使用 HTTPS
  • X-Download-Options:防止 Internet Explorer 在您的站點上下文中執行下載。
  • X-Content-Type-Options: 設置為 nosniff,有助於防止瀏覽器試圖猜測(「嗅探」)MIME 類型,這可能會帶來安全隱患。
  • X-XSS-Protection:防止反射的 XSS 攻擊。

更多說明和配置戳這裡 //www.npmjs.com/package/koa-helmet

最後

感覺中間層的相關知識點還是不夠全,能做的還有很多,還是得繼續學習。項目後續還會更新一段時間,更多會靠近服務端這塊,比如快取優化、異常捕獲這類的。

如果你有任何建議或改進,請告訴我~

😄看到這裡還不來個小星星嗎? //github.com/ChanWahFung/nuxt-juejin-project

參考資料