眾所周知,Vue SPA單頁(yè)面應(yīng)用對(duì)SEO不友好,當(dāng)然也有相應(yīng)的解決方案。 服務(wù)端渲染 (SSR) 就是常用的一種。 SSR 有利于 搜索引擎優(yōu)化(SEO, Search Engine Optimization) ,并且 內(nèi)容到達(dá)時(shí)間(time-to-content) (或稱之為首屏渲染時(shí)長(zhǎng))也有很大的優(yōu)化空間。
Nuxt.js 是一個(gè)基于 Vue.js 的輕量級(jí)應(yīng)用框架,可用來創(chuàng)建 服務(wù)端渲染 (SSR) 應(yīng)用,也可充當(dāng)靜態(tài)站點(diǎn)引擎生成靜態(tài)站點(diǎn)應(yīng)用,具有優(yōu)雅的代碼結(jié)構(gòu)分層和熱加載等特性。
項(xiàng)目地址:明么的博客
初始化項(xiàng)目
運(yùn)行 create-nuxt-app
通過 Nuxt 官方提供的腳手架工具 create-nuxt-app 初始化項(xiàng)目:
$ npx create-nuxt-app <項(xiàng)目名>
// 或者
$ yarn create nuxt-app <項(xiàng)目名>
項(xiàng)目配置
項(xiàng)目創(chuàng)建的時(shí)候會(huì)讓你進(jìn)行一些配置的選擇,可根據(jù)自己需要進(jìn)行選擇。

項(xiàng)目運(yùn)行
運(yùn)行完后,它將安裝所有依賴項(xiàng),下一步是啟動(dòng)項(xiàng)目:
$ cd <project-name>
$ yarn dev
在瀏覽器中,打開 http://localhost:3000

目錄結(jié)構(gòu)
.
├── assets // 用于組織未編譯的靜態(tài)資源
├── components // 用于組織應(yīng)用的 Vue.js 組件
├── layouts // 用于組織應(yīng)用的布局組件
├── middleware // 用于存放應(yīng)用的中間件
├── node_modules
├── pages // 用于組織應(yīng)用的路由及視圖
├── plugins // 組織插件。
├── static // 用于存放應(yīng)用的靜態(tài)文件
├── store // 狀態(tài)管理
├── nuxt.config.js // 配置文件
├── package.json
├── jsconfig.json
├── stylelint.config.js
├── README.md
└── yarn.lock
項(xiàng)目開發(fā)
項(xiàng)目啟動(dòng)之后,我們就可以進(jìn)行開發(fā)階段了。
創(chuàng)建頁(yè)面
在pages創(chuàng)建頁(yè)面文件:
pages/
└── article/
├── index.vue
├── _category/
│ └── index.vue
└── detail/
└── _articleId.vue
Nuxt.js 預(yù)設(shè)了利用 Vue.js 開發(fā)服務(wù)端渲染的應(yīng)用所需要的各種配置。所以不需要再安裝 vue-router 了,他會(huì)依據(jù) pages 目錄結(jié)構(gòu)自動(dòng)生成 vue-router 模塊的路由配置。頁(yè)面之間使用路由,官方推薦使用 <nuxt-link> 標(biāo)簽,與 <router-link> 的使用方式是一樣的。上面創(chuàng)建的目錄結(jié)構(gòu)將會(huì)生成對(duì)應(yīng)的路由配置表:
router: {
routes: [
{
name: 'article',
path: '/article',
component: 'pages/article/index.vue'
},
{
name: "article-category"
path: "/article/:category",
component: 'pages/article/_category/index.vue',
},
{
name: "article-detail-articleId"
path: "/article/detail/:articleId",
component: 'pages/article/detail/_articleId.vue'
}
]
}
組件部分
組件這一塊劃分為base、framework、page三個(gè)目錄:
components/
├── base 基本組件
├── framework 布局相關(guān)組件
└── page/ 各個(gè)頁(yè)面下的組件
├── home
└── ...
這里需要注意在開發(fā) VUE SPA 應(yīng)用時(shí)我們有時(shí)候會(huì)把頁(yè)面組件放在 pages 下,我將頁(yè)面下的組件全部放到了components下,因?yàn)?Nuxt.js 框架會(huì)讀取 pages 目錄下所有的 .vue 文件并自動(dòng)生成對(duì)應(yīng)的路由配置。
資源的存放
官方介紹的很詳細(xì),資源的存放有兩個(gè)目錄:static、assets
static : 用于存放應(yīng)用的靜態(tài)文件,此類文件不會(huì)調(diào)用 Webpack 進(jìn)行構(gòu)建編譯處理。服務(wù)器啟動(dòng)的時(shí)候,該目錄下的文件會(huì)映射至應(yīng)用的根路徑 / 下。
舉個(gè)例子: /static/banner.png 映射至 /banner.png
assets : 用于組織未編譯的靜態(tài)資源如 LESS、SASS 或 JS。
別名
| 別名 | 目錄 |
|---|---|
| ~ 或 @ | srcDir |
| ~~ 或 @@ | rootDir |
為了方便引用,nuxt 提供了兩個(gè)別名,如果你需要引入 assets 或者 static 目錄, 使用 ~/assets/your_image.png 和 ~/static/your_image.png 方式。
全局樣式
這里我選用 LESS 預(yù)處理語(yǔ)言,安裝:
$ yarn add less less-loader -D
在 assets/css/ 創(chuàng)建 .less 文件, 通過一個(gè)文件引入:
// assets/css/index.less
@import './normalize.less';
@import './reset.less';
@import './variables.less';
@import './common.less'
在 nuxt.config.js 中引入
export default {
...
css: ['~/assets/css/index.less'],
...
}
LESS 全局變量
在使用預(yù)處理語(yǔ)言的時(shí)候,我們肯定會(huì)使用到變量,以方便統(tǒng)一管理顏色、字體大小等。
首先定義好變量文件 variables.less
/* ===== 主題色配置 ===== */
@colorPrimary: #6bc30d;
@colorAssist: #2db7f5;
@colorSuccess: #67c23a;
@colorWarning: #e6a23c;
@colorError: #f56c6c;
@colorInfo: #909399;
安裝:
$ yarn add @nuxtjs/style-resources -D
在 nuxt.config.js 中增加配置:
export default {
...
modules: [
// https://go.nuxtjs.dev/axios
'@nuxtjs/axios',
'@nuxtjs/style-resources',
],
styleResources: {
// your settings here
// sass: [],
// scss: [],
// stylus: [],
less: ['~/assets/css/variables.less'],
},
...
}
布局layouts
我的博客大概分為這幾種布局方式:

在這里我創(chuàng)建了三種布局組件:
layouts/
├── admin.vue // 上圖第四個(gè)
├── default.vue // 上圖第一個(gè)和第三個(gè)只包含nav和footer
└── user.vue //上圖第二個(gè)
admin.vue: 后臺(tái)管理模塊的布局
user.vue: 個(gè)人中心模塊的布局
default.vue: 默認(rèn)的布局
拿 default.vue 舉例,我把 導(dǎo)航 和 頁(yè)腳 放到了一個(gè)組件 AppLayout 中:
<!-- layouts/default.vue -->
<template>
<app-layout>
<nuxt />
</app-layout>
</template>
<script>
import AppLayout from '@/components/framework/AppLayout/AppLayout'
export default {
name: 'AppLayoutDefault',
components: {
AppLayout
}
}
</script>
然后在頁(yè)面中使用:
<!-- pages/index.vue -->
<template>
<!-- Your template -->
</template>
<script>
export default {
layout: 'default'
// 指定布局,不指定的話將會(huì)使用默認(rèn)布局: layouts/default.vue
// 其實(shí)我這里指不指定都可以哈哈。
}
</script>
關(guān)于頁(yè)面上路由的跳轉(zhuǎn),官方推薦使用 <nuxt-link>,這里 <nuxt-link> 和 <a> 還是有區(qū)別的,nuxt-link 走的是 vue-router 的路由,即頁(yè)面為單頁(yè)面,瀏覽器不會(huì)重定向。而 <a>標(biāo)簽走的是 window.location.href ,每次點(diǎn)擊a標(biāo)簽后頁(yè)面,都會(huì)進(jìn)行一次服務(wù)端渲染。
全局過濾器
在 plugins/ 目錄下,新建 filters.js,比如說我們要對(duì)時(shí)間進(jìn)行一個(gè)格式化處理 :
Day.js :一個(gè)輕量的處理時(shí)間和日期的 JavaScript 庫(kù)
$ yarn add dayjs
import Vue from 'vue'
import dayjs from 'dayjs'
// 時(shí)間格式化
export function dateFormatFilter(date, fmt) {
if (!date) {
return ''
} else {
return dayjs(date).format(fmt)
}
}
const filters = {
dateFormatFilter
}
Object.keys(filters).forEach((key) => {
Vue.filter(key, filters[key])
})
export default filters
然后,在 nuxt.config.js 中配置,
export default {
...
plugins: ['~/plugins/filters.js']
...
}
自定義指令
在 plugins/directive/focus 目錄下,添加 index.js :
import Vue from 'vue';
const focus = Vue.directive('focus', {
inserted(el) {
el.focus();
},
});
export default focus;
自定義指令和全局過濾器一樣,都需要在 nuxt.config.js 添加配置:
export default {
...
plugins: [
'~/plugins/filters.js',
{ src: '~/plugins/directive/focus/index.js', ssr: false },
],
...
}
head 配置SEO
通過使用 head 方法設(shè)置當(dāng)前頁(yè)面的頭部標(biāo)簽。
<template>
<h1>{{ title }}</h1></template>
<script>
export default {
...
head() {
return {
title: '明么的博客',
meta: [
{
hid: 'description',
name: 'description',
content: 'My custom description'
}
]
}
}
}
</script>
注意:為了避免子組件中的 meta 標(biāo)簽不能正確覆蓋父組件中相同的標(biāo)簽而產(chǎn)生重復(fù)的現(xiàn)象,建議利用 hid 鍵為 meta 標(biāo)簽配一個(gè)唯一的標(biāo)識(shí)編號(hào)。
如果頁(yè)面比較多的話,每個(gè)頁(yè)面都需要寫 head 對(duì)象,就會(huì)有些的繁瑣??梢越柚?nuxt 的 plugin 機(jī)制,將其封裝成一個(gè)函數(shù),并注入到每一個(gè)頁(yè)面當(dāng)中:
// plugins/head.js
import Vue from 'vue'
Vue.mixin({
methods: {
$seo(title, content) {
return {
title,
meta: [{
hid: 'description',
name: 'description',
content
}]
}
}
}
})
在 nuxt.config.js 中增加配置:
export default {
...
plugins: [
'~/plugins/filters.js',
{ src: '~/plugins/directive/focus/index.js', ssr: false },
'~/plugins/head.js'
],
...
}
在頁(yè)面中使用:
head() {
return this.$seo(this.detail.title, this.detail.summary)
}
axios 請(qǐng)求數(shù)據(jù)
請(qǐng)求數(shù)據(jù),在初始化項(xiàng)目的時(shí)候已經(jīng)選擇了Axios,就不需要再另行安裝了,可以查看 nuxt.config.js 中已經(jīng)配置好了:
export default {
...
modules: [
// https://go.nuxtjs.dev/axios
'@nuxtjs/axios',
...
],
...
}
頁(yè)面中通過 this.$axios.$get 來獲取數(shù)據(jù),不需要在每個(gè)頁(yè)面都單獨(dú)引入 axios.
但是一般來說我們會(huì)對(duì) axios 做一下封裝,集中處理一些數(shù)據(jù)或者是錯(cuò)誤信息。
在 plugins 目錄下新建 axios.js 和 api-repositories.js,下面是我的一些簡(jiǎn)單的配置:
// plugins/axios.js
import qs from 'qs'
export default function(ctx) {
const { $axios, store, app } = ctx
// $axios.defaults.timeout = 0;
$axios.transformRequest = [
(data, header) => {
if (header['Content-Type'] && header['Content-Type'].includes('json')) {
return JSON.stringify(data)
}
return qs.stringify(data, { arrayFormat: 'repeat' })
}
]
$axios.onRequest((config) => {
const token = store.getters.token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// 如果是 get 請(qǐng)求,參數(shù)序列化
if (config.method === 'get') {
config.paramsSerializer = function(params) {
return qs.stringify(params, { arrayFormat: 'repeat' }) // params是數(shù)組類型如arr=[1,2],則轉(zhuǎn)換成arr=1&arr=2
}
}
return config
})
$axios.onRequestError((error) => {
console.log('onRequestError', error)
})
$axios.onResponse((res) => {
// ["data", "status", "statusText", "headers", "config", "request"]
// 如果 后端返回的碼正常 則 將 res.data 返回
if (res && res.data) {
if (res.headers['content-type'] === 'text/html') {
return res
}
if (res.data.code === 'success') {
return res
} else {
return Promise.reject(res.data)
}
}
})
$axios.onResponseError((error) => {
console.log('onResponseError', error)
})
$axios.onError((error) => {
console.log('onError', error)
if (error && error.message.indexOf('401') > 1) {
app.$toast.error('登錄過期了,請(qǐng)重新登錄!')
sessionStorage.clear()
store.dispatch('changeUserInfo', null)
store.dispatch('changeToken', '')
} else {
app.$toast.show(error.message)
}
})
}
// plugins/api-repositories.js
export default ({ $axios }, inject) => {
const repositories = {
GetCategory: (params, options) => $axios.get('/categories', params, options),
PostCategory: (params, options) => $axios.post('/categories', params, options),
PutCategory: (params, options) => $axios.put(`/categories/${params.categoryId}`, params, options),
DeleteCategory: (params, options) => $axios.delete(`/categories/${params.categoryId}`, params, options)
...
}
inject('myApi', repositories)
}
然后在 nuxt.config.js 中增加配置:
export default {
...
plugins: [
...
{ src: '~/plugins/axios.js', ssr: true },
{ src: '~/plugins/api-repositories.js', ssr: true },
],
/*
** Axios module configuration
** See https://axios.nuxtjs.org/options
*/
axios: {
baseURL: 'http://localhost:5000/'
},
}
這樣就可以直接在頁(yè)面中使用了:
this.$myApi.GetCategory()
proxy 代理
使用 proxy 解決跨域問題:
$ yarn add @nuxtjs/proxy
在 nuxt.config.js 中增加配置,下面是我的配置:
export default {
...
modules: [
...
'@nuxtjs/proxy',
...
],
axios: {
proxy: true,
headers: {
'Access-Control-Allow-Origin': '*',
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/json; charset=UTF-8'
},
prefix: '/api',
credentials: true
},
/*
** 配置代理
*/
proxy: {
'/api': {
target: process.env.NODE_ENV === 'development' ? 'http://localhost:5000/' : 'http://localhost:5000/',
changeOrigin: true,
pathRewrite: {
'^/api': ''
}
},
'/douban/': {
target: 'http://api.douban.com/v2',
changeOrigin: true,
pathRewrite: {
'^/douban': ''
}
},
...
},
}
在單頁(yè)面開發(fā)中,打包發(fā)布上線還需要 nginx 代理才能實(shí)現(xiàn)跨域,在 nuxt 中,打包發(fā)布上線之后,請(qǐng)求是在服務(wù)端發(fā)起的,不存在跨域問題,所以不需要在另外再做 nginx 代理。
asyncData
該方法是 Nuxt 一大賣點(diǎn), asyncData 方法會(huì)在組件(限于頁(yè)面組件)每次加載之前被調(diào)用。它可以在服務(wù)端或路由更新之前被調(diào)用,服務(wù)端渲染的能力就在這里。
注意:由于
asyncData方法是在組件 初始化 前被調(diào)用的,所以在方法內(nèi)是沒有辦法通過this來引用組件的實(shí)例對(duì)象。
另外提及一點(diǎn),當(dāng) asyncData 在服務(wù)端執(zhí)行時(shí),是沒有 document 和 window 對(duì)象的。
asyncData 第一個(gè)參數(shù)被設(shè)定為當(dāng)前頁(yè)面的上下文對(duì)象,可以利用 asyncData 方法來獲取數(shù)據(jù),Nuxt.js 會(huì)將 asyncData 返回的數(shù)據(jù)融合組件 data 方法返回的數(shù)據(jù)一并返回給當(dāng)前組件。
export default {
asyncData (ctx) {
ctx.app // 根實(shí)例
ctx.route // 路由實(shí)例
ctx.params //路由參數(shù)
ctx.query // 路由問號(hào)后面的參數(shù)
ctx.error // 錯(cuò)誤處理方法
}
}
服務(wù)端渲染:
export default {
data () {
return { categoryList: [] };
},
async asyncData({ app }) {
const res = await app.$myApi.GetCategory();
return {
categoryList: res.result.list
};
},
}
asyncData渲染出錯(cuò)
在使用 asyncData 時(shí)可能由于服務(wù)器錯(cuò)誤或api錯(cuò)誤導(dǎo)致頁(yè)面無(wú)法渲染,針對(duì)這種情況的出現(xiàn),我們還需要做一下處理。nuxt 提供了 context.error 方法用于錯(cuò)誤處理,在 asyncData 中調(diào)用該方法即可跳轉(zhuǎn)到錯(cuò)誤頁(yè)面。
export default {
async asyncData({ app, error}) {
app.$myApi.GetCategory()
.then(res => {
return { categoryList: res.result.list }
})
.catch(e => {
error({ statusCode: 500, message: '服務(wù)器出錯(cuò)了啦~' })
})
},
}
當(dāng)出現(xiàn)異常時(shí)會(huì)跳轉(zhuǎn)到默認(rèn)的錯(cuò)誤頁(yè),錯(cuò)誤頁(yè)面可以通過 /layout/error.vue 自定義。
context.error的參數(shù)必須是類似{ statusCode: 500, message: '服務(wù)器開了個(gè)小差~' },statusCode必須是http狀態(tài)碼
為了方便,全局統(tǒng)一處理錯(cuò)誤方法,在 plugins 目錄下創(chuàng)建 ctx-inject.js :
// plugins/ctx-inject.js
export default (ctx, inject) => {
ctx.$errorHandler = (err) => {
try {
const res = err.data
if (res) {
// 由于nuxt的錯(cuò)誤頁(yè)面只能識(shí)別http的狀態(tài)碼,因此statusCode統(tǒng)一傳500,表示服務(wù)器異常。
ctx.error({ statusCode: 500, message: res.resultInfo })
} else {
ctx.error({ statusCode: 500, message: '服務(wù)器出錯(cuò)了啦~' })
}
} catch {
ctx.error({ statusCode: 500, message: '服務(wù)器出錯(cuò)了啦~' })
}
}
}
然后,在 nuxt.config.js 中增加配置:
export default {
...
plugins: [
...
'~/plugins/ctx-inject.js',
...
],
...
}
在頁(yè)面中使用:
export default {
data() {
return { categoryList: [] }
},
async asyncData(ctx) {
const { app } = ctx
// 盡量使用try catch的寫法,將所有異常都捕捉到
try {
const res = await app.$myApi.GetCategory()
return {
categoryList: res.result.list,
}
} catch (err) {
ctx.$errorHandler(err)
}
},
}
fetch
fetch 方法用于在渲染頁(yè)面前填充應(yīng)用的狀態(tài)樹(store)數(shù)據(jù), 與 asyncData 方法類似,不同的是它不會(huì)設(shè)置組件的數(shù)據(jù)。它會(huì)在組件每次加載前被調(diào)用(在服務(wù)端或切換至目標(biāo)路由之前)。和 asyncData 一樣,第一個(gè)參數(shù)也是頁(yè)面的上下文對(duì)象,同樣無(wú)法在內(nèi)部使用 this 來獲取組件實(shí)例。
<template>
...
</template>
<script>
export default {
async fetch({ app, store, params }) {
let res = await app.$myApi.GetCategory()
store.commit('setCategory', res.result.list)
}
}
</script>
store
在 nuxt 中使用狀態(tài)管理,只需要在 store/ 目錄下創(chuàng)建文件即可。
store/
├── actions.js
├── getters.js
├── index.js
├── mutations.js
└── state.js
// store/actions.js
const actions = {
changeToken({ commit }, token) {
commit('setToken', token)
},
...
}
export default actions
// store/getters.js
export const token = (state) => state.token
export const userInfo = (state) => state.userInfo
...
// store/mutations.js
const mutations = {
setToken(state, token) {
state.token = token
},
...
}
export default mutations
// store/state.js
const state = () => ({
token: '',
userInfo: null,
...
})
export default state
// store/index.js
import state from './state'
import * as getters from './getters'
import actions from './actions'
import mutations from './mutations'
export default {
state,
getters,
actions,
mutations
}
無(wú)論使用那種模式,您的
state的值應(yīng)該始終是function,為了避免返回引用類型,會(huì)導(dǎo)致多個(gè)實(shí)例相互影響。
構(gòu)建部署
開發(fā)完畢后,就可以進(jìn)行打包部署了,一般來說先在本地測(cè)試一下:
$ yarn build
$ yarn start
然后,云服務(wù)器安裝 node 環(huán)境 和 pm2。
增加pm2配置,在 server/ 目錄下,新建 pm2.config.json 文件:
{
"apps": [
{
"name": "my-blog",
"script": "./server/index.js",
"instances": 0,
"watch": false,
"exec_mode": "cluster_mode"
}
]
}
然后,在 package.json 中 scripts 配置命令:
{
"scripts": {
...
"pm2": "cross-env NODE_ENV=production pm2 start ./server/pm2.config.json",
}
}
把我們項(xiàng)目中 .nuxt , static , package.json , nuxt.config.js , yarn.lock 或者是 package.lock 上傳到服務(wù)器。進(jìn)入上傳的服務(wù)器目錄,安裝依賴:
$ yarn install
然后,運(yùn)行:
$ npm run pm2
在設(shè)置服務(wù)器開放 3000 端口后,就可以通過端口訪問了。后面加個(gè)端口號(hào)總歸是不合適,還需要使用 nginx 代理到默認(rèn)端口 80(http) 或 433(https)。
記錄一個(gè)小問題:3000 端口沒問題,項(xiàng)目啟動(dòng)也正常,通過
http://60.***.***.110:3000就是訪問不了。在nuxt.config.js增加:
{
...
server: {
port: 3000,
host: '0.0.0.0'
}
}
重新啟動(dòng)項(xiàng)目即可。