🏃♀️點亮你的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
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
優化。
看下圖,在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
只在首屏被執行,其它時候相當於 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('//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
可設置 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/[email protected]/build/styles/atom-one-light.min.css' },
],
script: [
{ src: '//cdn.jsdelivr.net/gh/highlightjs/[email protected]/build/highlight.min.js' }
]
}
}
組件配置
組件內可在 head
配置,head
可以接受 object
或 function
。官方例子使用的是 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
變數:
- 通過
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 = '//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')
更多使用方法戳這裡 //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
對象,該對象至少包含兩個屬性 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: '//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()
最後介面的調用地址是: //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
代表是否必填。當 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 '//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
參考資料
-
其它常見問題://www.nuxtjs.cn/faq
-
官方github文檔://github.com/nuxt/docs/tree/master/zh(裡面有全面配置和例子使用,部分在 Nuxt.js 文檔中沒有提及,很建議看下)