vue create 敲完,项目跑起来了,Home.vue 和 About.vue 也能来回切。看着挺完整,但真接上业务就会发现,路由是全量打进一个 bundle 的,Vuex 只有一个空壳 store.js,请求是散在各个组件里的 axios.get,公共方法找不到地方放。这些坑我在几个项目上都重复踩过一遍。这篇把单页项目开工前该定下来的四件事捋一遍,路由、状态、接口、公共设施,每一处都给出改动前后的代码和改动的理由。读完你能拿到一份可以直接抄进新项目的目录约定。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- CLI 默认生成的
router.js有哪三处必须改 base、history模式、路由懒加载各自解决什么问题,代价是什么require.ensure和动态import()的区别,以及 webpack 魔法注释怎么给 chunk 命名- Vuex 从单文件拆成模块的目录约定,四个核心概念之间的调用顺序
- 接口层怎么封装成一个
Http类,devServer.proxy怎么绕开本地跨域 - 这套 2019 年的配置,放到 Vite + Vue 3 时代对应的写法是什么
# 一、路由配置从默认模板改起
# CLI 生成的那份配置
先看 CLI 给我们生成的 router.js 文件的配置:
/* router.js */
import Vue from 'vue'
import Router from 'vue-router'
import Home from './views/Home.vue' // 引入 Home 组件
import About from './views/About.vue' // 引入 About 组件
Vue.use(Router) // 注册路由
export default new Router({
routes: [{
path: '/',
name: 'home',
component: Home
}, {
path: '/about',
name: 'about',
component: About
}]
})
这份配置能跑,但它是给「跑通 demo」准备的,不是给上线准备的。有三点需要优化:
- 如果路由存在二级目录,需要添加
base属性,否则默认为"/" - 默认路由模式是
hash模式,会携带#标记,与真实 url 不符,可以改为history模式 - 页面组件没有进行按需加载,可以使用
require.ensure()来进行优化
下面是我们优化结束的代码:
/* router.js */
import Vue from 'vue'
import Router from 'vue-router'
// 引入 Home 组件
const Home = resolve => {
require.ensure(['./views/Home.vue'], () => {
resolve(require('./views/Home.vue'))
})
}
// 引入 About 组件
const About = resolve => {
require.ensure(['./views/About.vue'], () => {
resolve(require('./views/About.vue'))
})
}
Vue.use(Router)
let base = `${process.env.BASE_URL}` // 动态获取二级目录
export default new Router({
mode: 'history',
base: base,
routes: [{
path: '/',
name: 'home',
component: Home
}, {
path: '/about',
name: 'about',
component: About
}]
})
三处改动,三个不同的问题。挨个说。
# base 是给二级目录准备的
很多人第一次遇到 base 是在部署之后。本地 yarn serve 一切正常,传到服务器上放在 https://xxx.com/vue/ 这个路径下,页面一片空白,控制台全是 404。
原因是路由默认认为自己挂在域名根路径上。你访问 /vue/about,vue-router 拿到的 path 是 /vue/about,而路由表里注册的是 /about,匹配不上。
base 做的事就是告诉路由「前面这一截不归你管」。配上 base: '/vue/' 之后,vue-router 会先把这一段截掉再去匹配。
这里用 process.env.BASE_URL 而不是写死字符串,是因为 Vue CLI 3 会自动把 vue.config.js 里的 publicPath 注入成这个环境变量。这样部署路径只需要改一个地方,路由和静态资源引用会同步跟着走,不会出现「JS 加载成功但路由匹配不上」这种最难查的半残状态。
顺带说一句,publicPath 这个字段在 Vue CLI 3.3 之前叫 baseUrl,老文章和老项目里两种写法都能见到,看到 baseUrl 别以为是笔误。
# history 模式要后端配合
hash 模式的地址长这样:https://xxx.com/#/about。井号后面的内容不会发给服务器,所以刷新页面永远命中的是同一个 index.html,天然不需要后端做任何事。代价是 URL 里挂着一个多余的 #,做 SEO 和分享的时候都不好看。
history 模式用的是 HTML5 的 pushState,地址是干净的 https://xxx.com/about。但你在这个地址上按刷新,请求是真的会发到服务器的,服务器上并没有 /about 这个文件,返回 404。
所以 history 模式不是改一行配置就完事的。
服务端需要配一条兜底规则,把所有匹配不到静态文件的请求都指回 index.html。nginx 里就是 try_files $uri $uri/ /index.html; 这一行。这块在多页应用里要复杂得多,每个单页都得单独指回自己的 html,我在 Vue多页路由与模板解析 里专门写了怎么配。
# 路由懒加载,两种写法
不做懒加载的项目,打包出来就是一个几百 KB 的 app.js,用户打开首页要把整站所有页面的代码都下载完。页面越多,首屏越慢。
懒加载做的事是把每个路由组件切成单独的 chunk,访问到哪个路由才去下载哪个 chunk。
上面的优化版用的是 require.ensure,这是 webpack 早期提供的代码分割 API。它能用,但语法比较绕,resolve 回调那一层完全是仪式感。
除了使用 require.ensure 来拆分代码,Vue Router 官方文档还推荐使用动态 import 语法来进行代码分块,比如上述 require.ensure 代码可以修改为:
// 引入 Home 组件
const Home = () => import('./views/Home.vue');
// 引入 About 组件
const About = () => import('./views/About.vue');
一行搞定,而且这是 ECMAScript 的标准提案,不绑定 webpack。新项目直接用这个就行,require.ensure 只在维护老代码时才需要认识。
拆出来的 chunk 默认叫 0.js、1.js 这种数字名,线上排查问题的时候完全看不出是哪个页面。如果你想给拆分出的文件命名,可以尝试一下 webpack 提供的 Magic Comments(魔法注释):
const Home = () => import(/* webpackChunkName:'home'*/ './views/Home.vue');
这个注释会被 webpack 读走,打包产物就变成 home.js。多个路由用同一个 webpackChunkName,它们还会被合并进同一个 chunk,适合把几个总是一起访问的页面打包在一起,省几次请求。
# 二、Vuex 从单文件拆成模块
CLI 生成的 store.js 是这样一个空壳:
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
},
mutations: {
},
actions: {
}
})
主要有 4 个核心点:state、mutations、actions 及 getter。
这里我用一句话介绍它们之间的关系就是:我们可以通过 actions 异步提交 mutations 去修改 state 的值并通过 getter 获取。
这个顺序不是 Vuex 故意找麻烦。mutations 必须是同步的,因为 devtools 要靠它记录每一次状态变更的快照,异步操作一进来时间线就乱了。所以异步逻辑统一放 actions,拿到结果再 commit 给 mutations。你在 actions 里直接改 state 也能改动,但 devtools 追踪不到,出问题的时候你会很想抽自己。
项目一大,所有状态堆在一个文件里就没法看了。按模块拆开:
└── store
├── index.js # 我们组装模块并导出 store 的地方
├── actions.js # 根级别的 action
├── mutations.js # 根级别的 mutation
└── modules
├── moduleA.js # A模块
└── moduleB.js # B模块
与单个 store.js 文件不同的是,我们按模块进行了划分,每个模块中都可以包含自己 4 个核心功能。比如模块 A 中:
/* moduleA.js */
const moduleA = {
state: {
text: 'hello'
},
mutations: {
addText (state, txt) {
// 这里的 `state` 对象是模块的局部状态
state.text += txt
}
},
actions: {
setText ({ commit }) {
commit('addText', ' world')
}
},
getters: {
getText (state) {
return state.text + '!'
}
}
}
export default moduleA
注意 mutations 里那个 state 参数,它是模块的局部状态,不是根 state。想在模块里访问根 state,得从 actions 的第一个参数里解构 rootState。这个我踩过,在模块的 getters 里直接写 state.someGlobalFlag 拿到 undefined,查了半天才反应过来作用域不对。
然后在 index.js 里组装: