SPA与MPA的比较 —— SSR的诞生
什么是 SPA
SPA 即为 single page application 的缩写,意为单页面应用,其作为一种网页应用模型,它的主要特点有:
-
优势
SPA路由跳转是基于特定的实现(如vue-router,react-router等前端路由),而非原生浏览器的文档跳转(navigating across documents)。那么即可实现按需进行页面中的必要的组件级更新,而非无差别式页面级更新。- 基于 1 的特点,相较于
MPA避免了不必要的整个页面重载,那么在页面切换之间的间隙更短,更能体现出web应用的流畅特点,因而更具有接近原生应用的 性能优势 与体验。 - 因为组件级更新的特点,那么页面中的代码复用性高于
MPA。正是基于组件复用的特性,那么SPA更加适应需要快速迭代的产品。 - 基于
SPA的前端路由,使得SPA与应用后端解耦,使得前端不再依赖于后端的路由分配。即前后端分离。
-
弊端
SPA应用在初始时是从无状态空白页面进入到有状态内容页面。而搜索引擎算法的抓取结果仅限初次请求时返回页面,搜索引擎是不会等待当前SPA进行状态填充。那么纯粹的SPA是不利于搜索引擎优化(SEO)。- 父子组件必形成耦合,有
父才有子。在原则上,对比开闭原则,每一次页面迭代,都需要修改组件内部代码,有引入BUG风险。 SPA是整个应用页面,那么在未优化前端路由加载时,应用初始首屏即需要下载整个应用。这其中包含了一些用户根本不会在会话中访问的页面(但这些页面对于应用来说又是不可或缺的。)。这一点,相对于单个MPA组件来说,SPA更重一点。
什么是 MPA
MPA 即为 multiple page application 的缩写,意为多页面应用模型,与 SPA 对比最大的不同即是页面路由切换由原生浏览器文档跳转(navigating across documents)控制。
-
优势:
- 因为
MPA各个页面相互独立,那么可将每一个页面都看作一个单一的微服务。各个页面达到相互独立与 解耦 的目的。 - 因其解耦特性,因而更加适合 前端去中心化 的复杂
web应用。 - 因为页面互相独立的特性,那么有利于应用本地数据的模块化。
- 因为页面相互独立的特性,移除一个单页或增加一个单页不会对其他
MPA单页造成影响。那么降低了我们页面迭代的门槛。不用担心对其他单页组件的蝴蝶效应。 - 单个页面相互独立,且页面在初始时,就具有页面内容而非
无状态,那么相对于SPA,更加有利于SEO。
- 因为
-
弊端:
MPA路由基于原生浏览器的文档跳转(navigating across documents)。因此每一次的页面更新都是一次页面重载,这将带来巨大的重启性能消耗。MPA的前端页面与后端是一一对应,耦合的。在开发时,增加了开发成本,前后端开发进度必须统一协作。
与 SPA 的高性能侧重点不同的是,MPA 更加注重于页面之间的相互解耦。以页面为单位形成多个独立组件。多个页面组件构成一个完成的 web 应用。
什么是 SSR
SSR 即为 Server-Side Rendering 的缩写,意为服务端渲染,他是基于SPA且用来解决他的两大痛点而存在的
- SEO优化(一些新闻资讯类或电商类的网站都需要做一些搜索引擎优化)
- 大型应用的首屏渲染(解决一些大型应用首页加载速度问题)
做法是后端渲染出首屏的 DOM 结构返回,前端拿到内容带上首屏,后续的页面操作,再用单页面路由和渲染,称之为服务端渲染(SSR)
SSR 是处于 MPA 与 SPA 应用之间的一个折中的方案,仅是首屏时候在服务端做出了渲染,其他页面还是需要在客户端渲染的。
- 弊端:
- 涉及构建设置和部署的更多要求。与可以部署在任何静态文件服务器上的完全静态单页面应用程序 (SPA) 不同,服务器渲染应用程序,需要处于
Node.jsserver运行环境。 - 更多的服务器端负载。在 Node.js 中渲染完整的应用程序,显然会比仅仅提供静态文件的 server 更加大量占用 CPU 资源,因此如果你预料在高流量环境 (
high traffic) 下使用,请准备相应的服务器负载,并明智地采用缓存策略。
- 涉及构建设置和部署的更多要求。与可以部署在任何静态文件服务器上的完全静态单页面应用程序 (SPA) 不同,服务器渲染应用程序,需要处于
Nuxt框架目录结构解析
| 名称 | 展开 | 用处 |
|---|---|---|
| .nuxt | 构建目录,是 Nuxt 项目 nuxt build 或者 nuxt dev 后动态生成的隐藏文件,每次运行都会重新生成,git 提交应该自动忽略此目录。 | |
| assets | 与 vue-cli 的 assets 用法一致,用于存放静态资源文件,该目录下可存放全局 style 文件、tdk、font、或静态常量数据等。 | |
| components | 于组织应用的常规 Vue 组件。Nuxt.js 不会扩展增强该目录下的组件,即这些组件不 会像页面组件那样有 asyncData 生命周期方法的特性;默认情况下在 components 下建立的 .vue 文件是会被Nuxt.js自动嵌套目录注册到全局的,所以设计上通常需要在内部区分出业务组件business 与 公共组件common,公共组件通过全局注册而业务组件则是哪用哪注册(具体做法在下文) | |
| layout | 是用于组织应用的布局组件; 通常通过添加 layouts/default.vue 文件来扩展主布局 ;添加layouts/error.vue来作为错误页面布局 | |
| middleware | 路由切换之前也就是进入页面前运行的自定义函数。可以在三个位置设置中间件,执行顺序如下: 1:全局设置 nuxt.config.js2: layouts 文件的 middleware 属性3: pages 文件的 middleware 属性 | |
| pages | 页面组件,Nuxt.js 读取此目录中的所有文件并自动创建路由器配置(启动项目后可在.nuxt\router.js进行查看),其中项目启动默认地址访问会直接定位到 pages 下的 index.vue | |
| plugins | 全局注入自定义插件的主要路径,当我们希望在整个应用程序中使用某个函数,需要注入到 vue 实例(客户端),context(服务端)甚至 store(Vuex),那么我们就会使用上 plugins | |
| static | 该目录下的文件是不会被 webpack 处理的,它们会被直接复制到最终的打包目录下,并可以直接通过项目的根 URL 访问;举个例子: /static/logo.png 映射至 /logo.png 但为了更好的目录可读性,通常我们会在外层多包一层_static,使静态文件与其他文件区别开,上述的映射也就会变成/_static/logo.png来进行访问 | |
| store | Vuex 状态管理器,Nuxt.js 框架集成了 Vuex 状态树 的相关功能配置,在 store 目 录下创建一个 .js 文件可激活这些配置,自动引入到项目中 |
Nuxt关键概念 —— context(上下文)
context 对象在特定的 Nuxt 函数中可用如 asyncData(客户端生命周期函数)、 plugins(全局注入的js插件)、 middleware(访问页面前执行的中间件) 和 nuxtServerInit(服务端生命周期函数)。我们还可以通过注册 plugins 向 context.app 中注入我们常用的方法,如:封装后的 axios 实例等。
| 属性 | 类型 | 描述 |
|---|---|---|
| app | vue根实例 | 包含所有插件的 Vue 根实例。由于在服务端的生命周期中,如 asyncData 钩子,无 this 去获取实例上的方法和属性,便由context.app来访问 |
| isDev | boolean | 是否是开发 dev 模式 |
| isHMR | boolean | 是否是通过模块热替换 |
| route | Vue router | 路由实例 |
| query | object | router.query ,路由url中?后的部分 |
| params | object | router.params,如路由path中 /:city/list,中的city |
| from | 路由from路径 | |
| req | http.Request | 服务端发送http的请求 |
| res | http.Response | 服务端发送http的响应 |
| store | Vuex数据 | Vuex.Store 实例 |
| error | Function | 用这个方法跳转到错误页:error(params) 。params 参数应该包含 statusCode 和 message 字段如 error({ statusCode: 404, message: '标签不存在' }) |
| env | Object | nuxt.config.js 中配置的环境变量 |
| redirect | Function | 用这个方法重定向用户请求到另一个路由。状态码在服务端被使用,默认 302。redirect([status,] path [, query]) |
app
app 是 context 中最重要的属性,就像我们 Vue 中的 this,全局方法和属性都会挂载到它里面。因为服务端渲染的特殊性,很多Nuxt提供的生命周期都是运行在服务端,也就是说它们会先于 Vue 实例的创建。因此在这些生命周期中,我们无法通过 this 去获取实例上的方法和属性。使用 app 可以来弥补这点,一般我们会把全局的方法同时注入到 this 和 app 中,在服务端的生命周期中使用 app 去访问该方法,而在客户端中使用 this,保证方法的共用
举个例子(Nuxt最为关键的意义):
假设我们自己封装好的请求模块 $axios 已被全局注入,一般主要数据通过 asyncData (该生命周期发起请求,将获取到的数据交给服务端拼接成html返回) 去提前请求做服务端渲染,而次要数据通过客户端的 mounted 去请求。
PS:我们通常使用ES6自动解构的方法来获取context中的属性,
如代码中的 async asyncData({ app, store })
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的注册。store 是 Vuex.Store 实例,在运行时 Nuxt.js 会尝试找到是应用根目录下的 store 目录,如果该目录存在,它会将模块文件加到构建配置中。
所以我们只需要在根目录的 store 创建模块js文件,即可使用。
如文件 /store/index.js :
与 vue 项目使用不一样的是,模块不需要创建一个 modules 文件夹,再在 index.js 中一一引入。store 目录下的每一个除了 index.js 外的js文件就是一个命名空间模块,名称与文件名一致。如:
/ store
--| index.js
--| user.js
在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)
}
}
}
自动注入原理
翻阅 .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
}
}
params、query
params 和 query 分别是 route.params 和 route.query 的别名。它们都带有路由参数的对象
export default {
async asyncData({ app, params, query }) {
let list = await app.$axios.getIndexList({
id: params.id,
src: query.src
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')。
举个例子:
假设我们现在有个路由中间件middleware,用于验证登录身份,逻辑是身份没过期则不做任何事情,若身份过期重定向到登录页
export default function ({ redirect }) {
// ...
if (!token) {
redirect({
path: '/login',
query: {
isExpires: 1
}
})
}
}
Nuxt生命周期
完整生命周期流程
通过上面的流程图可以看出,当一个客户端请求进入的时候,服务端有通过
nuxtServerInit 这个命令执行在 store 的 action 中,在这里接收到客户端请求的时候,可以将一些客户端信息在页面加载前提前存储到 store 中,比如项目中的用户信息或token信息。nuxtServerInit 钩子只有 store 目录下的 index.js 有效,其他模块文件是无法调用的。
之后使用了中间件机制,中间件其实就是一个函数,会在每个路由执行之前去执行,可以理解为是路由器的拦截器的作用。设置 middleware 的位置有三个,一是 nuxt.config.js 全局设置,二是 layout 组件引用 middleware 属性,三是 pages 组件引用 middleware 属性。执行顺序从全局到布局再到页面。
然后在 validate 执行的时候对客户端携带的参数进行校验,在 asyncData 与 fetch 进入正式的渲染周期,asyncData 向服务端获取数据,把请求到的数据合并到data 中;然后开始在服务端渲染好html再回传给浏览器
然后进入 Vue 的生命周期 beforeCreate & created & ……。在此 beforeMount之前都是无法获取到 window 对象的。
完整的生命周期调用顺序如下:
要注意的是 beforeCreate 和 created 会在服务端以及客户端各执行一次
常用生命周期函数
asyncData
asyncData 是最常用最重要的生命周期,同时也是服务端渲染的关键点。该生命周期只限于页面组件调用,第一个参数为 context。它调用的时机在组件初始化之前,运作在服务端环境。所以在 asyncData 生命周期中,我们无法通过 this 引用当前的 Vue 实例,也没有 window 对象和 document 对象,这些是我们需要注意的。
一般在 asyncData 会对主要页面数据进行预先请求,获取到的数据会交由服务端拼接成 html 返回前端渲染,以此提高首屏加载速度和进行 seo 优化。
具体用法可看回上文中提及的Nuxt的context.app属性的实际引用
值得一提的是,
asyncData只在首屏被执行,其它时候相当于created或mounted在客户端渲染页面。
什么意思呢?举个例子:
现在有两个页面,分别是首页和详情页,它们都有设置 asyncData。进入首页时,asyncData 运行在服务端。渲染完成后,点击文章进入详情页,此时详情页的 asyncData 并不会运行在服务端,而是在客户端发起请求获取数据渲染,因为详情页已经不是首屏。当我们刷新详情页,这时候详情页的 asyncData 才会运行在服务端。所以,不要走进这个误区。
validate
validate通常用于在动态路由对应的页面组件中配置一个校验方法用于校验动态路由参数的有效性。
与asyncData相同,第一个参数也为 context。但与上面有点不同的是,我们能够访问vue实例上的方法 this.methods.xxx,用于调用函数来判断路由参数是否正确,结果返回一个 Boolean,为真则进入路由,为假则停止渲染当前页面并显示错误页面或做redirect重定向:
export default {
validate({ params, query }) {
return this.methods.validateParam(params.type)
},
methods: {
validateParam(type){
let typeWhiteList = ['backend', 'frontend', 'android']
return typeWhiteList.includes(type)
}
}
}
head
head 常用于设置当前页面的头部标签,该方法里能通过 this 获取组件的数据。除了好看以外,正确的设置 meta 标签,还能有利于页面被搜索引擎查找,进行 seo 优化。一般都会设置 title(标题),description(简介) 和 keyword(关键词) —— tdk;
为了避免子组件中的 meta 标签不能正确覆盖父组件中相同的标签而产生重复的现象,建议利用 hid 键为 meta 标签配一个唯一的标识编号。
import tdk from '@/assets/tdk/new.json'
export default {
head() {
const cityTDK = tdk[this.city]
return {
title: `${cityTDK.title}`,
meta: [
{ name: 'keywords', hid: 'keywords', content: cityTDK.keywords },
{ name: 'description', hid: 'description', content: cityTDK.description }
]
}
}
}
在 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、开源中国的用户,我们相信你也可以在这里有所收获。'}
],
}
}
此处除了设置tdk还可设置全局引入的外部脚本以及dns解析等html常规的头部属性
watchQuery
watchQuery 可设置 Boolean 或 Array (默认: [])。使用 watchQuery 属性可以监听参数字符串的更改。 如果定义的字符串发生变化,将调用所有组件方法(asyncData, fetch, validate, layout, ...)。 为了提高性能,默认情况下禁用。
export default {
async asyncData({ app, query }) {
let res = await app.$api.searchList({
...
},
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 依据 pages 目录结构自动生成 vue-router 模块的路由配置。自动生成的路由配置可在 .nuxt/router.js 中查看。
若存在动态路由,Nuxt.js 中需要创建对应的以下划线_作为前缀的 Vue 文件 或 目录
pages/
--| users/
-----| _id.vue
--| index.vue
那么,Nuxt.js 自动生成的路由配置如下:
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 扩展路由:
router-extras-module自定义页面中的路由参数- 组件
@nuxtjs/router覆盖Nuxt路由器并编写自己的router.js文件 - 在
nuxt.config.js使用router.extendRoutes属性重新配置
router: {
// 扩展路由,除了pages文件夹下自动生成的路由,可添加一些自定义路由
extendRoutes(routes, resolve) {
routes.push(
{
path: '/error',
name: 'error',
component: resolve(__dirname, 'layouts/error.vue'),
},
)
},
},
router: {
// 完全自定义路由,pages下的文件不生成路由
extendRoutes(routes, resolve) {
return [
{
name: 'index',
path: '/',
component: resolve(__dirname, 'pages/index.vue'),
meta: {
title: '首页',
},
}
]
},
},
环境变量
Nuxt.js可安装 cross-env 插件,允许运行跨平台设置和使用环境变量。
yarn add --save-dev cross-env
在 nuxt.config.js 同级目录下新增 env.js 文件包含我们的环境变量:
// 环境变量
module.exports = {
// 开发环境
dev: {
NODE_ENV: 'development',
NUXT_BASE_API: 'https://dev.domain.com', // 服务器地址
NUXT_LOGIN_API: 'https://devlogin.domain.com', // 登录地址
},
// 测试环境
test: {
NODE_ENV: 'test',
NUXT_BASE_API: 'https://test.domain.com',
NUXT_LOGIN_API: 'https://testlogin.domain.com',
},
// ... others
}
在 nuxt.config.js 设置 env
import env from './env' // 环境配置文件
export default {
env: env[process.env.NODE_ENV],
}
配置 pakage.json
"scripts": {
"dev": "cross-env NODE_ENV=dev nuxt",
"test": "cross-env NODE_ENV=test nuxt build",
},
应用时:
data() {
return {
test:{
NODE_ENV: process.env.NODE_ENV,
NUXT_BASE_API: process.env.NUXT_BASE_API,
NUXT_LOGIN_API: process.env.NUXT_LOGIN_API,
}
}
},
css预处理器
使用less或者scss都是相同的操作
npm i node-sass sass-loader scss-loader --save--dev // 使用sass
npm i less less-loader --save--dev // 使用less
两者均是安装好后无需配置,在模板内即可直接使用
全局样式的引入
可在 nuxt.config.js 中配置:
css: [
'@/assets/css/main.less',
],
或在会被全局注入的plugin中直接import
/plugins/vue-global.js :
import Vue from 'vue'
import componentsInstall from '~/components/componentsInstall'
import '@/assets/css/main.less' // 全局样式
Vue.use(myComponentsInstall)
全局样式变量
使用 @nuxtjs/style-resources 来实现
npm i @nuxtjs/style-resources --save--dev
配置 nuxt.config.js
modules: [
'@nuxtjs/style-resources',
],
// styleResources 配置的资源路径不能使用 ~ 和 @,要使用绝对或者相对路径
styleResources: {
less: ['./assets/css/variable.less'],
},
第三方主题样式也是以这方式来实现,如定义 /assets/css/element-variables.scss,然后引入即可
框架实践应用
layouts
页面布局切换
在我们构建网站应用时,大多数页面的布局都会保持一致。但在某些需求中,可能需要更换另一种布局方式,这时页面 layout 配置选项就能够帮助我们完成。而每一个布局文件应放置在 layouts 目录,文件名的名称将成为布局名称,默认布局是 default。下面的例子是更换页面布局的背景色。其实按照使用 Vue 的理解,感觉就像切换 App.vue。
<Nuxt>相当于vue-router中的router-view;<NuxtLink>则相当于router-link。
/layouts/default.vue :
<template>
<div style="background-color: #f4f4f4;min-height: 100vh;">
<top-bar></top-bar>
<main class="main">
<NuxtLink to='/'> back home page </NuxtLink>
<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">
<NuxtLink to='/'> back home page </NuxtLink>
<nuxt />
</main>
<back-top></back-top>
</div>
</template>
然后在pages页面组件中使用指定layout布局:
export default {
layout: 'default-white',
// 或
layout(context) {
return 'default-white'
}
}
自定义错误页 error.vue
错误页面是一个页面组件,却定义在 layouts文件夹中。它在非服务端抛出错误时始终显示。路由不会替换,是直接把error 页面的全部 HTML 替换掉当前页面。也可以使用 error() 方法调起错误页。
定义
layouts/error.vue:
<template>
<div class="error-page">
<div class="error">
<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'
}
]
}
}
}
触发
通常在asyncData或validate生命周期函数中调用,因为她们都能获取context.error属性
调用方式有:
error({ statusCode: 500, message: "服务器错误" })或throw new Error('')
async asyncData({ app, query, error }) {
const tagInfo = await app.$api.getTagDetail({}).then(res => {
if (res.s === 1) {
return res.d
} else {
// 这样我们在 error 对象中又多了 query 属性(均可自定义)
error({
statusCode: 404,
message: '标签不存在',
query
})
....
}
async validate ({ params, store }) {
throw new Error('页面参数不正确') // 该方式默认statusCode为500,`message` 就是 `new Error` 中的内容
throw new Error(JSON.stringify({ // 若想传对象过去,可以使用 `JSON.stringify` 传过去,错误页面再处理解析出来
message: 'validate错误',
params
}))
}
components
业务组件
上文介绍components文件夹时我们就说过需要在内部区分出business业务组件以及common公共组件两个文件夹
而业务组件需要按需引入,首先需要在 nuxt.config.js 设置 components: false, 否则在 components 文件夹下创建的组件会直接挂载在 vue 实例上,直接全局注册
公共组件
公共组件我们需要实现自动全局注册
可以在 components 下添加 js 文件,我们命名为 componentsInstall.js(除了 index 以外的名称) 内容如下:
export default {
install(Vue) {
// 批量注册公用组件
// 如果第二个参数为 true ,程序将会遍历 components/common 目录下的子目录,并引用其中的 .vue 文件
const components = require.context('~/components/common', true, /.vue$/)
components.keys().forEach((path) => {
// 组件实例
const reqCom = components(path)
// 获取组件文件名
const fileName = path.replace(/(.*/)*([^.]+).*/gi, '$2')
// 组件挂载
// reqCom.default.name为组件内部的name属性,当未命名时自动默认用文件名称作为组件名
// nuxt若没有定义组件name属性,会自动根据目录填充的
Vue.component(reqCom.default.name || fileName, reqCom.default || reqCom)
})
},
}
ps:require.context 来自动化的引入组件,该方法是由 webpack 提供的,它能够读取文件夹内所有文件
然后我们需要在 plugins中定义一个插件来调用我们刚刚编写的componentsInstall脚本
新建 /plugins/vue-global.js :
import Vue from 'vue'
import componentsInstall from '~/components/componentsInstall'
Vue.use(myComponentsInstall)
最后在 nuxt.config.js 的 plugin属性上 加上 vue-global.js 便可以全局引用 common 文件夹下的文件了。
plugins: [
'~/plugins/vue-global.js',
],
plugins
功能详解
plugin 函数参数
plugin 一般向外暴露一个函数,该函数接收两个参数分别是 context 和 inject
context: 上下文对象,该对象存储很多有用的属性。比如常用的 app 属性,包含所有插件的 Vue 根实例。例如:在使用 axios 的时候,你想获取 $axios 可以直接通过 context.app.$axios 来获取。
inject: 该方法可以将 plugin 同时注入到 context, Vue 实例, Vuex 中。
例如:
export default function (context, inject) {}
plugin只注入到vue实例
直接赋值到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')
}
}
plugin只注入到context实例
context 注入方式和在其它 vue 应用程序中注入类似,只是把赋值对象从vue的原型变成context.app上而已
定义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!')
}
}
同时注入
plugin希望同时注入到所有地方时(vue,context,Vuex),就需要使用inject方法。它是 plugin 导出函数的第二个参数。系统会默认将 $ 作为方法名的前缀。
定义 plugins/combined-inject.js :
export default ({ app }, inject) => {
inject('myInjectedFunction', string => console.log('That was easy!', string))
}
在 plugins/combined-inject.js 中配置:
export default {
plugins: ['~/plugins/combined-inject.js']
}
现在你就可以在 context.app ,或者 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对象的,前提是 plugin 需要使用 context 注入。
举个例子:现在已存在 request 请求的 plugin ,有另一个 plugin 需要调用 request
定义plugins/request.js 并注入到context或用inject同时注入 :
export default ({ app: { $axios } }, inject) => {
inject('request', {
get (url, params) {
return $axios({
method: 'get',
url,
params
})
}
})
}
另一个plugins/api.js需要调用request 函数:
把$request 从context.app中自动解构出来
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',
]
}
按需引入UI框架
以 element-ui为例,ant-design同理
根据第三方UI框架的官方文档,借助 babel-plugin-component来实现按需引入
npm install element-ui --save-dev
npm install babel-plugin-component --save-dev
在 nuxt.config.js 中配置:
module.exports = {
build: {
plugins: [
[
"component",
{
"libraryName": "element-ui",
"styleLibraryName": "theme-chalk"
}
]
],
}
}
完成以上步骤即可后续在vue组件中想用什么组件再引用什么组件
但像Input,Modal,Button等基本全局都需要使用的组件,我们则模仿上文中全局注册公共组件的方式进行一次全局注册即可
定义 /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全局注入文件中调用eleComponentsInstall即可
import Vue from 'vue'
import eleComponentsInstall from '~/components/eleComponentsInstall'
Vue.use(eleComponentsInstall)
封装axios
我们通过使用plugin全局注入的特性来封装和调用axios
在 /assets/setting/interface.js 中定义接口返回结果相关的配置信息:
// 错误拦截 接口返回非 200 状态
export function checkStatus(status, msg) {
switch (status) {
case 400:
return (`${msg}`)
case 401:
return ('用户没有权限!')
case 403:
return ('用户得到授权,但是访问是被禁止的!')
case 404:
return ('网络请求错误,未找到该资源!')
case 405:
return ('网络请求错误,请求方法未允许!')
case 408:
return ('网络请求超时!')
case 500:
return ('服务器错误,请联系管理员!')
case 501:
return ('网络未实现!')
case 502:
return ('网络错误!')
case 503:
return ('服务不可用,服务器暂时过载或维护!')
case 504:
return ('网络超时!')
case 505:
return ('http版本不支持该请求!')
default:
return ('服务器错误,请联系管理员!')
}
}
// 返回 200 状态码后の拦截
export const ResultEnum = {
SUCCESS: 200, // 调用成功
TOKEN_OVERDUE: 300, // 用户 token 过去
BAD_REQUEST: 400,
INTERNAL_SERVER_ERROR: 500,
FAIL: 600
}
然后开始封装我们的 axios 啦。当我们安装项目默认选了 axios 时,项目会自动安装 @nuxtjs/axios 依赖;如果没有选的,要先装依赖。
接下来定义 /plugins/request.js,并使用inject注入到全局:
import { message } from 'element-ui'
import { whiteList, tokenKey } from '@/assets/setting/auth'
import { checkStatus, ResultEnum } from '@/assets/setting/interface'
export default ({ app, route, redirect, store }, inject) => {
// $axios.defaults.baseURL = process.env.baseUrl
const instance = app.$axios.create()
instance.defaults.timeout = 30 * 1000
// 设置withCredentials属性为true,使请求自动携带cookie
instance.defaults.withCredentials = true
const beforeRequest = (config) => {
// 设置 cookie, 只有服务端才需要且只有服务端可以手动设置
// 头部带上验证信息
// app.$cookies 应用下文实战技巧中会有详细介绍
config.headers['X-Token'] = app.$cookies.get('token') || ''
config.headers['X-Device-Id'] = app.$cookies.get('clientId') || ''
config.headers['X-Uid'] = app.$cookies.get('userId') || ''
return config
}
const requestError = (err) => {
return Promise.reject(err)
}
const resPreHandle = (response) => {
const { data } = response
if (!data) {
handlerError()
}
// 这里 code,data,message为 后台统一的字段
const { code, data: result } = data
// 接口请求成功 code 200,直接返回结果
if (code === ResultEnum.SUCCESS) {
return result
}
// 接口请求错误,统一处理
switch (code) {
case ResultEnum.BAD_REQUEST:
case ResultEnum.INTERNAL_SERVER_ERROR:
case ResultEnum.FAIL:
// 错误异常处理
handlerError(code)
break
case ResultEnum.TOKEN_OVERDUE:
// 用户信息过期处理
hanlerTokenOverdue()
break
default:
handlerError(code, '其他状态')
break
}
return response
}
const responseError = (err) => {
// console.log("responseError", req)
const { code, message } = err || {}
const msg = checkStatus(code, message)
handlerError(code, msg)
}
// 处理报错异常
const handlerError = (code = 500, msg = '系统错误,请联系管理员!') => {
if (process.client) {
// 客户端弹出提示
message.error(`${code}, ${msg}`)
} else {
// 服务端直接跳转错误页面
// 前提是我们拥有 “/error” 路由“/error” 路由layouts下的error.vue组件
// 如果没有 “/error” 路由将会进入死循环 报错堆栈溢出。
redirect("/error")
}
return Promise.reject(msg)
// 首屏的时候 Promise.reject(new Error('something'))
// 是不会跳转到 error 页面的,会直接报程序错误
}
// 处理用户信息过期
const hanlerTokenOverdue = () => {
// 清除 cookie 与 vuex 的用户状态信息
app.$cookies.remove(tokenKey)
store.commit('user/SET_USER_INFO', null)
// 判断是否在白名单,非白名单页面需重定向到登录页
if (whiteList.includes(route.path)) return
redirect('/login')
}
instance.interceptors.request.use(beforeRequest, requestError)
instance.interceptors.response.use(resPreHandle, responseError)
inject('request', instance)
}
然后再定义 /plugins/api.js, 装载好我们所有的请求,在里面引用 request来发送请求即可:
export default ({ app: { $request } }, inject) => {
inject('api', {
/**
* 获取登录用户基本信息
* 无参数
*/
fetchUserInfo() {
return $request.post(`/api/getUserInfo`)
},
/**
* 获取详情信息
* @id 必填,详情id
*/
fetchUserInfo(params) {
return $request.post(`/api/getDetail`, params)
},
// ... other apis
})
}
最后在设置 nuxt.config.js 中的 plugin(记得按序):
plugins: [
'~/plugins/request.js',
'~/plugins/api.js',
],
然后在页面就可通过 context.app.$api或 this.$api调用接口了
async asyncData({ app, params }) {
const res = await app.$api.fetchIsp({ id: params.id })
return {
form: res,
}
},
mounted() {
const res = await this.$api.fetchIsp({ id: this.id })
...
}
middleware
middleware 目录包含应用程序中间件。中间件允许定义可以在呈现页面或一组页面(布局)之前运行的自定义函数。可以在三个位置设置中间件,执行顺序如下:
- 全局设置:
nuxt.config.js layouts文件的middleware属性pages文件的middleware属性
页面权限验证
判断页面是否白名单内,如果非白名单又未登录则跳转登录页,否则给予正常跳转。
新建 /middleware/auth.js:
import { whiteList, tokenKey } from '@/assets/setting/auth'
export default function ({ app, route, redirect }) {
// 当前路由是否匹配白名单
if (whiteList.includes(route.path)) return
const token = app.$cookies.get(tokenKey)
if (!token) {
redirect(`/login`)
}
}
配置 nuxt.config.js :
module.exports = {
router: {
middleware: ['auth']
},
}
实战技巧
cookie控制
请求接口时,我们经常会需要利用到本地缓存来带上一些全局的参数如token,userId之类的信息。但 Nuxt.js 比较特殊,由于服务端渲染的特点,部分请求在服务端发起,我们无法获取 localStorage 或 sessionStorage
此时我们就必须利用上 cookie了,因为他既是存在客户端的,在请求时也能带上发回给服务端的。然后我们就需要用到cookie-universal-nuxt 模块(该模块只是帮助我们注入,主要实现依赖 cookie-universal),我们能够更方便的使用 cookie。不管在服务端还是客户端,cookie-universal-nuxt 都为我们提供一致的 api,它内部会帮我们去适配对应的方法。
安装 cookie-universal-nuxt
npm install cookie-universal-nuxt --save-dev
在nuxt.config.js中配置 :
module.exports = {
modules: [
'cookie-universal-nuxt'
],
}
按照上文配置好后,cookie-universal-nuxt 会同时注入到两端,均访问 $cookies 进行使用
服务端(context):
// 获取
app.$cookies.get('token')
// 设置
app.$cookies.set('token', 'value')
// 删除
app.$cookies.remove('token')
客户端(this):
// 获取
this.$cookies.get('token')
// 设置
this.$cookies.set('token', 'value')
// 删除
this.$cookies.remove('token')
页面缓存
为提高页面加载的性能,以及尽量减少使用服务端渲染后对服务器的压力,我们需实现页面的缓存机制
此时我们会推荐使用 lru-cache 做服务器数据请求缓存
新建 /serverMiddleware/pageCache.js ,服务器运行的中间件
import LRUCache from 'lru-cache'
import { setLog } from '../utils'
const cache = new LRUCache({
maxAge: 1000 * 60 * 2, // 有效期2分钟
max: 1000 // 最大缓存数量
})
export default function (req, res, next) {
if (process.env.NUXT_ENV !== 'dev' && req.url == '/gz/') { // 开发环境不做页面缓存
try {
const cacheKey = req.url
const cacheData = cache.get(cacheKey)
if (cacheData) {
setLog(`${req.url} | Use page cache.`)
res.setHeader('Content-Type', ' text/html; charset=utf-8')
return res.end(cacheData, 'utf-8')
}
const originalEnd = res.end
res.end = function (data) {
cache.set(cacheKey, data)
originalEnd.call(res, ...arguments)
}
setLog(req.url)
} catch (err) {
setLog(`${req.url} | ${err} | Page cache error.`, 'error')
}
}
next()
}
然后在nuxt.config.js中配置 :
serverMiddleware: [
'~/serverMiddleware/pageCache',
],
当然在实际项目中我们必须在请求url的判断中加上白名单,因为并不是所有请求都能做缓存的
页面迁移计划
按照正常开发流程来说,肯定会存在以前的老旧页面SPA项目一点点往SSR项目迁移的情况
所以我们需要设计一套流程图以及nginx配置