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

WahFung發表於2020-05-16

前言

作為一位 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?~

專案地址:https://github.com/ChanWahFung/nuxt-juejin-project

基礎應用與配置

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

?‍♀️跑起來 https://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 官方文件描述戳這裡 https://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 優化。

看下圖,在谷歌除錯工具中,看不到主要資料介面發起請求,只有返回的 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('http://my-api/stars')
    .then((res) => {
      store.commit('setStars', res.data)
    })
  }
}

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

export default {
  async fetch ({ store, params }) {
    let { data } = await axios.get('http://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/cdn-release@9.18.1/build/styles/atom-one-light.min.css' },
    ],
    script: [
      { src: '//cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.18.1/build/highlight.min.js' }
    ]
  }
}

元件配置

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

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

環境變數

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

建立環境變數

nuxt.config.js :

module.exports = {
  env: {
    baseUrl: process.env.NODE_ENV === 'production' ? 'http://test.com' : 'http://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 = 'http://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')

更多使用方法戳這裡 https://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'
}

路由中介軟體文件戳這裡 https://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="https://b-gold-cdn.xitu.io/v3/static/img/bg.1f516b3.png">
        <img class="elem panfish" src="https://b-gold-cdn.xitu.io/v3/static/img/panfish.9be67f5.png">
        <img class="elem sea" src="https://b-gold-cdn.xitu.io/v3/static/img/sea.892cf5d.png">
        <img class="elem spray" src="https://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: 'https://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()

最後介面的呼叫地址是: http://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: 'https://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 'http://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 攻擊。

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

最後

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

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

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

參考資料

相關文章