前言
作為一位 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
app
是 context
中最重要的屬性,就像我們 Vue
中的 this
,全域性方法和屬性都會掛載到它裡面。因為服務端渲染的特殊性,很多Nuxt
提供的生命週期都是執行在服務端,也就是說它們會先於 Vue
例項的建立。因此在這些生命週期中,我們無法通過 this
去獲取例項上的方法和屬性。使用 app
可以來彌補這點,一般我們會把全域性的方法同時注入到 this
和 app
中,在服務端的生命週期中使用 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
store
是 Vuex.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
方法為其掛載上 plugin
(plugin
是掛載全域性方法的主要途徑,後面會講到,不知道可以先忽略),也就是說在 store
裡,我們可以通過 this
訪問到全域性方法:
export const mutations = {
updateList(state, payload){
console.log(this.$axios)
state.list = payload
}
}
params、query
params
和 query
分別是 route.params
和 route.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
(引數),其中 status
和 query
是可選的。當然如果你只是單純的重定向路由,可以傳入路徑字串,就像 redirect('/index')
。
舉個例子:
假設我們現在有個路由中介軟體,用於驗證登入身份,邏輯是身份沒過期則不做任何事情,若身份過期重定向到登入頁。
export default function ({ redirect }) {
// ...
if (!token) {
redirect({
path: '/login',
query: {
isExpires: 1
}
})
}
}
error
該方法跳轉到錯誤頁。用法:error(params)
,params
引數應該包含 statusCode
和 message
欄位。在實際場景中,總有一些不按常理的操作,頁面因此無法展示真正想要的效果,使用該方法進行錯誤提示還是有必要的。
舉個例子:
標籤詳情頁面請求資料依賴於 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
只在首屏被執行,其它時候相當於 created
或 mounted
在客戶端渲染頁面。
什麼意思呢?舉個例子:
現在有兩個頁面,分別是首頁和詳情頁,它們都有設定 asyncData
。進入首頁時,asyncData
執行在服務端。渲染完成後,點選文章進入詳情頁,此時詳情頁的 asyncData
並不會執行在服務端,而是在客戶端發起請求獲取資料渲染,因為詳情頁已經不是首屏。當我們重新整理詳情頁,這時候詳情頁的 asyncData
才會執行在服務端。所以,不要走進這個誤區(誒,不是說服務端渲染嗎,怎麼還會發起請求?)。
fetch
fetch 方法用於在渲染頁面前填充應用的狀態樹(store)資料, 與 asyncData 方法類似,不同的是它不會設定元件的資料。
檢視官方的說明,可以得知該生命週期用於填充 Vuex
狀態樹,與 asyncData
同樣,它在元件初始化前呼叫,第一個引數為 context
。
為了讓獲取過程可以非同步,你需要返回一個 Promise
,Nuxt.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
可設定 Boolean
或 Array
(預設: [])。使用 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
有點好處就是,當我們使用瀏覽器後退按鈕或前進按鈕時,頁面資料會重新整理,因為引數字串發生了變化。
head
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
可以接受 object
或 function
。官方例子使用的是 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
變數:
- 通過
process.env.baseUrl
- 通過
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
一般向外暴露一個函式,該函式接收兩個引數分別是 context
和 inject
context: 上下文物件,該物件儲存很多有用的屬性。比如常用的 app
屬性,包含所有外掛的 Vue
根例項。例如:在使用 axios
的時候,你想獲取 $axios
可以直接通過 context.app.$axios
來獲取。
inject: 該方法可以將 plugin
同時注入到 context
, Vue
例項, 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
,你就可以使用該函式(例如在 asyncData
和 fetch
中)
export default {
asyncData(context) {
context.app.myInjectedFunction('ctx!')
}
}
同時注入
如果需要同時在 context
, Vue
例項,甚至 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
,或者 Vuex
的 actions
/ 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
不同,由於服務端渲染的特點,部分請求在服務端發起,我們無法獲取 localStorage
或 sessionStorage
。
這時候,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
物件,該物件至少包含兩個屬性 statusCode
和 message
。
除了這兩個屬性,我們還可以傳過去其他的屬性,這裡又要說起上面提到的 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
用於控制觸發事件的距離。最終,觸發事件會呼叫頁面 methods
的 reachBottom
方法。
命令式彈窗元件
命令式元件是什麼?element-UI
的 Message
元件就是很好的例子,當我們需要彈窗提示時,只需要呼叫一下 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.js
是 Nuxt.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
代表是否必填。當 type
為 enum
(列舉)型別時,引數值只能為 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
參考資料
-
其它常見問題:https://www.nuxtjs.cn/faq
-
官方github文件:https://github.com/nuxt/docs/tree/master/zh(裡面有全面配置和例子使用,部分在 Nuxt.js 文件中沒有提及,很建議看下)