前言
入职新公司后,发现代码仓库里,项目搭建的比较乱,每个项目使用的都不太一样,不利于统一规范化管理,我结合已有的业务积累和优秀的实践经验,搭建了一套移动端模板项目,在这里和大家做一个分享。
一般来说,大部分前端去公司都是干活,拧螺丝钉的,项目的框架都是架构师或者小组负责人搭建封装好的,我们经过简单学习,直接开发就好,没有自己去搭建封装的机会,没有做过,总是会下意识觉得很简单,其实让自己去做还是有很多困难的。
如果想做到前端负责人,项目搭建封装是必须掌握的,看了我这篇文章
如果能有所收获,或者帮助到大家,那真是太好了!
没有人甘于平庸,我想你也是!
源码地址:https://github.com/Yinzhuo19970516/vue-template文档地址:https://yinzhuo19970516.github.io/
功能点
本项目是基于 vue-cli4.x,webpack5,对 vue-cli 提供的框架做的二次封装,主要封装的功能点主要有以下
[x] - 多入口打包
[x] - 自动化生成项目基本模版
[x] - pinia 状态管理库
[x] - 持久化存储插件封装
[x] - 路由动画的封装
[x] - axios 二次封装
[x] - less sass 变量,函数的处理
[x] - viewport 适配方案
[x] - 配置多环境变量
[x] - vconsole.js
[x] - 链式操作符
[x] - 入口加载动画
多入口打包
适用场景
移动端开发,一般情况,我们项目开发只需要一个配置一个入口,一个模版文件。
但是有时候又明显不够用,移动端,同一个业务下,同类型的项目,就很适合放在一个项目下做多入口打包。
比如以下场景:
优势
如果我们全部使用单入口,也就是 vue 官方提供的框架,可以设想一下,如果是活动页面,我们把所有活动的页面都定义在一个入口里,router,store 全部在一块,项目耦合会越来越严重,路由名也会重合率越来越高!
项目结构
如下图所示,我们在 src 目录下面,新增了 module 目录,用来存放我们的多个项目入口,page1 和 page2 就是我用来演示的两个项目文件。
前置依赖库
node 的 glob 库
glob 其实是 linux shell 默认的通配符,主要是做文件匹配,glob 模式通常用来匹配目录以及文件
这里介绍的是 node 的一个方法库 glob
glob 在一般用于文件系统中定位文件
主要用到了同步搜索文件 API
glob.sync(pattern, [options])
vue-cli 的 pages 配置
vue-cli 接收一个参数 pages,用来控制输入文件,输出文件
详情可参考文档pages
官方例子如下
比较重要的三个参数就是 entry template filename
page 的入口,模板来源, 在 dist/index.html 的输出
下面这端代码是 vue-cli 的官方说明
module.exports = {
pages: {
index: {
// page 的入口
entry: 'src/index/main.js',
// 模板来源
template: 'public/index.html',
// 在 dist/index.html 的输出
filename: 'index.html',
// 当使用 title 选项时,
// template 中的 title 标签需要是 <title><%= htmlWebpackPlugin.options.title %></title>
title: 'Index Page',
// 在这个页面中包含的块,默认情况下会包含
// 提取出来的通用 chunk 和 vendor chunk。
chunks: ['chunk-vendors', 'chunk-common', 'index']
},
// 当使用只有入口的字符串格式时,
// 模板会被推导为 `public/subpage.html`
// 并且如果找不到的话,就回退到 `public/index.html`。
// 输出文件名会被推导为 `subpage.html`。
subpage: 'src/subpage/main.js'
}
}
复制代码
devServer 的 setupMiddlewares
提供执行自定义函数和应用自定义中间件的能力。
我们可以在 devServer 启动项目之前,做一些自定义的处理
我在启动时渲染了一段 html
devServer: {
host: '0.0.0.0',
setupMiddlewares: (middleware, devServer) => {
devServer.app.get('', (_, response) => {
response.write(html)
response.end()
})
return middleware
},
proxy: {}
}
复制代码
核心代码实现
const entryName = ''
const entry1 = glob.sync('src/main.js') // 单入口
const entry2 = glob.sync('src/module/*/main.js') // 多入口
const entryList = [...entry1, ...entry2]
const pages = {} //pages 参数
let html = '' // 要渲染的html
entryList.forEach(filePath => {
const name1 = filePath.match(/src/main.js$/) // 单入口
const name2 = filePath.match(/src/module/(\w+)/main.js$/) // 多入口
const name = name1 ? name1[1] : name2[1]
const filename = name1 ? name + '/index' : name
if (filename === entryName || !entryName) {
const pagePath = glob.sync(`src/module/${name}/index.html`)[0]
// 读文件夹下的index.html文件
pages[name] = {
entry: filePath,
template: pagePath || 'public/index.html',
// 如果没有自动去取public下的通用index.html
filename: filename + '/index.html'
}
// 构造html 结构
html += `<a href="${filename}/">${filename}/</a><br>`
}
})
console.log(pages)
复制代码
pages 结构
hehehhe 项目的 index.html 文件是取自 public,其他的是取自本项目目录下
考虑到部分页面会有在入口文件引用第三方的库的情况,比如swiper,jq。
所以这里设置的比较灵活,可以直接用通用的项目模版,也可以在项目里自定义模版。
{
hehehe: {
entry: 'src/module/hehehe/main.js',
template: 'public/index.html',
filename: 'hehehe/index.html'
},
page1: {
entry: 'src/module/page1/main.js',
template: 'src/module/page1/index.html',
filename: 'page1/index.html'
},
page2: {
entry: 'src/module/page2/main.js',
template: 'src/module/page2/index.html',
filename: 'page2/index.html'
}
}
复制代码
打包目录
自动化生成项目基本模版
当我们在新建项目时,一般是手动新建文件夹,然后定义项目名字,新建入口文件,index.html,.vue 文件,新建 router store 文件等等,这个是每次新建时必不可少的步骤。
其实,初始化项目的时候,新建的内容都差不多,如果我们能用一行指令,帮助我们生成这些模版文件,我们只需要定义个文件名字,可以显著提升开发体验。
新建项目指令
我在 package.json 里新增了一个指令 init
"init": "node ./src/common/initTemplate/index.js"
复制代码
当执行这个命令时,会自动去执行,在本地 common 目录下新建的 js 脚本,在 module 目录下自动生成一个新的文件。
演示
会提示用户“正在创建项目”
返回用户“该项目已经创建”,我们在 src 项目下能看到刚刚新建的目录 hehehe,有内置的 index.vue,App.vue,main.js,router.js
因为考虑到并不是所有开发者都会用到状态管理库,所以基础模版没有引入。
就可以在浏览器里看到我们刚刚生成的 hehehe 项目了!
当新建的项目已有时,会提示用户,“目录已经存在”,要求用户继续输入
代码实现
下图是 initTempate 文件结构
实现比较简单
核心代码在 src/common/initTemplate/index.js
template 文件夹是我们要生成的项目模版
initTemplate
├── index.js
└── template
├── App.vue
├── main.js
├── router.js
└── view
└── index.vue
// 2 directories, 5 files
复制代码
inquirer
这个库用来询问用户输入项目名称,这是一个比较在处理命令行交互比较常见的库,详情可以查阅文档
前端Node命令行交互工具 —— inquirer
用 vue-cli 脚手架新建项目的应该都进行过命令交互,vue 创建的时候会让你选择 vue2 还是 vue3,也有多选要什么配置,也有输入 y 或者 n 选择是否用 history 路由等,这其实用 inquire 这个包都能实现。
fs.access
fs.access()方法可以用于测试文件是否存在
流程图
代码
#!/usr/bin/env node
console.log('您正在创建项目')
const path = require('path')
const fs = require('fs')
const inquirer = require('inquirer')
const stat = fs.stat
const targetDir = path.resolve(__dirname, './template')
//copy文件目录常用函数,都是常见api,
const copyFile = (targetDir, resultDir) => {
// 读取文件、目录
fs.readdir(targetDir, function(err, paths) {
if (err) {
throw err
}
paths.forEach(function(p) {
const target = path.join(targetDir, '/', p)
const res = path.join(resultDir, '/', p)
let read
let write
stat(target, function (err, statsDta) {
if (err) {
throw err
}
if (statsDta.isFile()) {
read = fs.createReadStream(target)
write = fs.createWriteStream(res)
read.pipe(write)
} else if (statsDta.isDirectory()) {
fs.mkdir(res, function() {
copyFile(target, res)
})
}
})
})
})
}
const question = [
{
type: 'input',
name: 'name',
message: '请输入项目名称:'
}
]
const createProject = () => {
// 询问用户问题
inquirer.prompt(question).then(({ name }) => {
// name 为输入的项目名称
name = name.trim()
if (!name) {
console.log('项目目录不能为空')
// 如果输入空,继续询问
createProject()
return false
}
// 目标路径,要放在module目录下
const resultDir = path.resolve(__dirname, '../../module/', name)
// fs.access()方法用于测试文件是否存在
fs.access(resultDir, function(err, data) {
if (err) {
// 创建文件
fs.mkdir(resultDir, function(err, data) {
if (err) {
throw err
}
// 复制模版文件
copyFile(targetDir, resultDir)
})
console.log(`${name} 项目已创建成功`)
} else {
console.log(`${name} 项目目录已存在,请输入其他名称`)
// 不存在,继续询问
createProject()
}
})
}).catch(err => {
console.log(err)
})
}
createProject()
复制代码
Pinia 状态管理
文档地址:https://pinia.web3doc.top/
Pinia /piːnjʌ/ 中文名:皮你啊
Pinia 优势
1.Pinia 是一个全新的 Vue 状态管理库,是 Vuex 的代替者,尤雨溪强势推荐
翻译翻译 => 官方背书
2.Vue2 和 Vue3 都能支持
翻译翻译 => 既支持 options api 又支持 compostions api,维护成本低
3.抛弃传统的 Mutation ,只有 state, getter 和 action ,简化状态管理库
翻译翻译 => 少写代码,降低心智负担,再也不用写 mutation
4.不需要嵌套模块,符合 Vue3 的 Composition api,让代码扁平化
翻译翻译 => 多个 state,不需要再使用 module,现在可以定义多个 store,相互之间调用
5.TypeScript 支持
翻译翻译 => 没有使用 ts,不好解释
6.代码简单,很好的代码自动分割
翻译翻译 => 可以构建多个 store,打包管理会自动拆分模块化的设计,便于拆分状态,能很好支持代码分割;
7.极轻, 仅有 1 KB
翻译翻译 => 体积小,不会成为项目的负担
核心概念
State: 用于存放数据,有点儿类似 data 的概念;
Getters: 用于获取数据,有点儿类似 computed 的概念;
Actions: 用于修改数据,有点儿类似 methods 的概念;
Plugins: Pinia 插件。
Pinia 与 Vuex 代码分割机制
举个例子:某项目有 3 个 store「user、job、pay」,另外有 2 个路由页面「首页、个人中心页」,首页用到 job store,个人中心页用到了 user store,分别用 Pinia 和 Vuex 对其状态管理。
Vuex 的代码分割
打包时,vuex 会把 3 个 store 合并打包,当首页用到 Vuex 时,这个包会引入到首页一起打包,最后输出 1 个 js chunk。这样的问题是,其实首页只需要其中 1 个 store,但其他 2 个无关的 store 也被打包进来,造成资源浪费。
解决方案:
经常在首页优化时,会考虑到这个场景,一般处理方案是去做 vuex 的按需加载,beforeCreate 的时候,可以去判断当前页面需要加载哪些 store,利用这个 api 可以实现 $store.registerModule
详情可参考文章
vuex按需加载,避免首页初始化所有数据
pinia 的代码分割
打包时,Pinia 会检查引用依赖,当首页用到 job store,打包只会把用到的 store 和页面合并输出 1 个 js chunk,其他 2 个 store 不耦合在其中。Pinia 能做到这点,是因为它的设计就是 store 分离的,解决了项目的耦合问题。
基本使用
定义 state,getters 和 vuex 基本一样,具体使用可以去官网学
这里仅仅对比修改数据时,两者的区别
明显 pinia 的代码量更少,逻辑更清晰,心智负担更小
//store.js
import { defineStore } from 'pinia'
export const main = defineStore('main', {
state: () => {
return {
configInfo: {}
}
},
getters: {},
actions: {}
})
import * as piniaStore from '../piniaStore'
piniaStore.main().$patch({
configInfo: data
})
复制代码
//store.js
import { createStore } from 'vuex'
export const store = createStore({
state: {
count: 0
},
getters: {},
mutations: {
increment (state) {
state.count++
}
},
actions: {},
modules: {}
})
export default store
//index.vue
import { useStore } from 'vuex'
const storeVuex = useStore()
storeVuex.commit('increment')
复制代码
总结
总得来说,Pinia 就是 Vuex 的替代版,可以更好的兼容 Vue2,Vue3 以及 TypeScript。在 Vuex 的基础上去掉了 Mutation,只保留了 state, getter 和 action。
Pinia 拥有更简洁的语法, 扁平化的代码编排,符合 Vue3 的 Composition api
Pinia 数据持久化插件
使用场景
把数据缓存下来,可以避免页面刷新时,重复调用接口,提升用户体验
封装 sessionStorage localStorage
代码在 src/common/utils/storage.ts
let hasSessionStorage = true
let hasLocalStorage = true
//判断当前浏览器是否支持sessionStorage
if (sessionStorage) {
try {
sessionStorage.setItem('_sessionStorageTest', 'Hello World!')
sessionStorage.removeItem('_sessionStorageTest')
} catch (e) {
hasSessionStorage = false
}
} else {
hasSessionStorage = false
}
//判断当前浏览器是否支持localStorage
if (localStorage) {
try {
localStorage.setItem('_localStorageTest', 'Hello World!')
localStorage.removeItem('_localStorageTest')
} catch (e) {
hasLocalStorage = false
}
} else {
hasLocalStorage = false
}
/**
* 设置本地缓存
* @param key
* @param val
*/
export function setLocalStorage(key: string, val?: any): void {
if (!hasLocalStorage) {
return
}
localStorage.setItem(key, JSON.stringify(val))
}
/**
* 设置会话级别缓存
* @param key
* @param val
*/
export function setSessionStorage(key: string, val: any): void {
if (!hasSessionStorage) {
return
}
sessionStorage.setItem(key, JSON.stringify(val))
}
复制代码
代码实现
核心就是,Pinia 的监听 API subscribe
state 中的数据变化时,就会触发 subscribe
这样我们就可以判断当前变化的 store 的 id,是否在我们的需要持久化的数组中
如果在,我们就将数据存到本地缓存
const getStorageTypeMap: AnyObj = {
sessionStorage: getSessionStorage,
localStorage: getLocalStorage
}
const setStorageTypeMap: AnyObj = {
sessionStorage: setSessionStorage,
localStorage: setLocalStorage
}
const plugin = (options: Options): any => {
// key 为标识前缀,避免命名空间冲突
const { key, storeList } = options
return (context: PiniaPluginContext) => {
const { store } = context
let storageType:any
let obj: any = {}
for (const item of storeList) {
if (item.storeName.includes(store.$id)) {
// storeName 为哪个store,path 为store下某个字段
const { storeName, path } = item
storageType = item.storageType
// 如果key 不存在默认走 pinia
const data = getStorageTypeMap[storageType](`${key ?? 'pinia'}-${store.$id}`)
if (data) {
// 更新store
store.$patch(data)
}
if (path && path.length > 0) {
// 如果存在path 则需要判断
if (storeName.length === 1) {
path.forEach((item) => {
obj[item] = store.$state[item]
})
} else {
return new Error('配置path 时只允许配置一个storeName')
}
}
obj = path && path.length > 0 ? obj : store.$state
storeName.includes(store.$id) &&
store.$subscribe(() => {
setStorageTypeMap[storageType](`${key ?? 'pinia'}-${store.$id}`, toRaw(obj))
})
}
}
}
}
复制代码
全局引入
这个是我定义的 store 文件,里面定义了三个 store,分别为 main,test,test1
// piniaStore.js
import { defineStore } from 'pinia'
export const main = defineStore('main', {
state: () => {
return {
test: 'hello word',
test1: 'hello word1',
configInfo: {}
}
},
getters: {},
actions: {}
})
export const test = defineStore('test', {
state: () => {
return {
age: 18,
name: 'yz'
}
}
})
export const test1 = defineStore('test1', {
state: () => {
return {
age: 18,
name: 'yz'
}
}
})
复制代码
在入口文件处 引入插件,然后对进行需要存储的 store 和字段进行配置
key 为这个项目使用的一个命名前缀,保证不会污染到其他缓存数据
storeName 为一个数组,可以为空,默认存储所有 store,可以配置自己需要存储的 store 名字
storageType 为字符串,可以配置需要会话级存储还是本地化存储
path 为一个数组,可以为空,默认存储该 store 下所有字段,可以配置自己需要的字段
import { createPinia } from 'pinia'
import piniaPlugin from '@/common/utils/piniaPlugin'
// 创建pinia 实例
const pinia = createPinia()
pinia.use(
piniaPlugin({
key: 'XXX', // 这是给缓存到本地时,加一个特殊的前缀,以免造成污染到其他缓存数据
storeList: [
{
storeName: ['main'], // 对于特定store进行持久化,空或者不传,则对所有的store进行缓存到本地
storageType: 'sessionStorage',
path: ['configInfo']
},
{
storeName: ['test'], // 对于特定store进行持久化,空或者不传,则对所有的store进行缓存到本地
storageType: 'sessionStorage'
},
{
storeName: ['test1'], // 对于特定store进行持久化,空或者不传,则对所有的store进行缓存到本地
storageType: 'localStorage'
}
]
})
)
复制代码
存储之后的结果,可以在浏览器里看到
参考库
https://github.com/Seb-L/pinia-plugin-persist
当时是参考了网路上一个开源库的实现
这个库如果使用,需要每个 store 下,都进行配置。
我们开发时,不可能只定义一个 store,一般是按功能,模块划分代码,保证可读性。
所以我觉得使用这个库,开发成本更大
因此在这个基础上自行写了一套持久化插件,在入口处全局管理
export const useUserStore = defineStore('storeUser', {
state () {
return {
firstName: 'S',
lastName: 'L',
accessToken: 'xxxxxxxxxxxxx',
}
},
// 插件配置
persist: {
enabled: true,
strategies: [
{
key: 'user', //自定义 Key值
storage: localStorage, // 选择存储方式
},
],
},
})
复制代码
总结
我现在开发的持久化插件,一定程度上,增加了使用者的心智负担,需要去了解配置项的规则。
持久化存储的场景非常多,对于小型页面小型项目,直接在修改 store,再手动设置一次 sessionStroage,在页面中使用的时候,再去主动取一下 sessionStorage 即可,没有任何问题。
但是如果是对于多人维护的大型项目,如果这么写,随着迭代,将搞不清楚到底哪些数据存在了 session 中,在什么时机存储的,无法统一管理。
有这样一个插件,就可以在全局统一配置,增删改查在入口文件统一管理。
路由动画的封装
Vue 路由过渡动画是对 Vue 程序一种快速简便的增加个性化效果的的方法。
可以让你在程序的不同页面之间增加平滑的动画和过渡。
如果使用得当,可以使你的程序显得更加专业,从而增强用户体验。
页面切换的动画时间的同时,下一个页面初始化也在进行了,对用户体验来说,可以有效避免下一个页面的加载 dom,初始化页面的时间。
封装思路
使用 transition 方式给根路由设置全局动画
给 router 的路径设置 meta 的 level 层级
在入口页面的 setup 中,在路由守卫中,根据 level 的大小,设置相应的动画
定义相应的动画类样式
代码
css 写出动画效果
根组件 App.vue,监听路由的变化
如果 to 索引大于 from 索引,使用前进的动画,反之使用后退的动画
level 可以使用数字,也可以字母,也可以数字加字母,能体现大小关系即可
export default {
setup() {
const router = useRouter()
//默认值
const state = reactive({
transitionName: 'slide-left'
})
router.beforeEach((to, from) => {
if (to.meta.level > from.meta.level) {
state.transitionName = 'slide-left'// 向左滑动
} else if (to.meta.level < from.meta.level) {
// 由次级到主级
state.transitionName = 'slide-right'// 向右滑动
} else {
state.transitionName = ''// 同级无过渡效果
}
})
return {
...toRefs(state)
}
}
}
复制代码
效果展示
对比不加动画的,效果还是非常好的
修改动画样式
一个项目里使用的肯定是一个动画,目前我在项目中默认使用的左滑,右滑。
可以根据业务需要,和自己的喜好,去使用更多的动画效果。
可参考大佬写的过渡效果,有兴趣可以再研究研究
4 个 Vue 路由过渡动效
axios 二次封装
封装目的
封装效果
下图是封装前后的使用代码对比
我们在业务调用中,省略了 showLoading 这个过程,不关心业务 code 和 msg,可以直接获取 data 进行处理,省略了显示错误信息的过程,所以代码量大大减小。
通用能力
这里说明一下我封装时候的思路和想法
首先列一下我想要这个通用请求能达到什么样的效果
1.正常请求基础的配置,比如超时配置,baseUrl,跨域携带 cookie 等等
2.响应拦截处理
请求成功,业务状态码成功,直接解析接口中的 data,不用一层一层再去取 code,判断,拿结果
请求成功,业务状态码不成功,可以选择自己处理特殊状态码,也可以选择全局 message 提示服务端的报错,业务开发中,大部分都是直接提示服务端报错,但是也有需要前端处理状态码的逻辑
请求失败,全局 messagege 提示报错
统一的特殊请求码处理,或者状态码做特殊逻辑,比如丢失登陆态,请求参数有误等等
3.全局统一的 loading 配置
默认开启,可配置关闭
统一管理,业务中不用再去关心这个逻辑
代码实现
基础类型
config 是传入参数
type TAxiosOption = {
baseURL: string
timeout: number
slientError: boolean
loading: boolean
}
const config:TAxiosOption = {
baseURL: process.env.VUE_APP_ACTIVITY_SERVER_TARGET,
timeout: process.env.VUE_APP_API_TIMEOUT,
slientError: false,
loading: true
}
class Request {
instance:AxiosInstance
constructor(config: TAxiosOption) {
this.instance = axios.create(config)
this.instance.interceptors.request.use(data=>{
return data
})
this.instance.interceptors.response.use(data=>{
return data
})
}
get<T, U>(url: string, data?: U, config = {}): Promise<T> {
return this.instance.get(url, { params: data, ...config })
}
post<T, U>(url: string, data?: U, config = {}): Promise<T> {
return this.instance.post(url, data, config)
}
}
export default new Request(config)
复制代码
api 管理
我们在调用的时候,可以在post/get
的第三个参数中,传入自己需要的配置
如果不传全部走默认值
slientError: true
表示业务中自己处理异常,网路库不 message 异常
需要我们在业务中的 catch 中 就可以拿到 接口返回的所有信息,去做相应的处理
loading: true
表示展示 loading
import request1 from '@/common/axios/request1'
// 查询拉新活动规则
export function queryInviteActivityRule(params) {
return request1.post('/kyf-activity-api/activity/queryInviteActivityRule', params, { slientError: true, loading: true })
}
复制代码
全局统一 loading 处理
在请求拦截器中 执行 addLoading
在响应拦截器中 执行 cacelLoaing
保证全局无论多少接口调用,requestNum 参数 控制都只有一个 loading
目前的 shoowLoading 引用的是 vant 组件,也可以根据自己业务需要去自定义 loading 组件
let requestNum = 0
const addLoading = () => {
// 增加loading 如果pending请求数量等于1,弹出loading, 防止重复弹出
requestNum++
if (requestNum === 1) showLoading()
}
const cancelLoading = () => {
// 取消loading 如果pending请求数量等于0,关闭loading
requestNum--
if (requestNum === 0) hideLoading()
}
复制代码
响应拦截器
拦截器的代码可以直接去看我的源代码
在src/common/axios/request.ts
中
主要就是在响应拦截器中对返回值做了处理,如果成功,直接返回结果,不成功,返回接口全部内容
this.instance.interceptors.response.use(
(responseData: MyAxiosResponse) => {
const status = responseData.status
const res = responseData.data
const { data, code } = res
const { loading = true } = responseData.config
if (loading) cancelLoading()
// 如果调用其他中台,此处需要单独对code做转化
if (status === 200 && code === '000001') {
return Promise.resolve(data || res || {})
} else {
// 此处可以处理其他特殊的业务逻辑
// 比如丢失登陆态,传参数有误等逻辑
if (responseData.config.slientError) {
return Promise.reject(res)
}
showErrorInfo(res.msg)
return Promise.reject(res)
}
},
(error:any) => {
const { loading = true } = error.config
if (loading) cancelLoading()
let errMsg
if (error && error.response) {
if (error.response.status >= 500 && error.response.status <= 599) {
errMsg = '服务器繁忙,请稍后重试'
} else if (error.response.status === 404) {
errMsg = '服务不存在'
} else {
errMsg = '网络繁忙,请稍后重试'
}
} else {
errMsg = '( ⊙ o ⊙ )啊!网络不太顺畅哦~'
}
if (error.config.slientError) {
return Promise.reject(error)
} else {
showErrorInfo(errMsg)
}
return Promise.reject(errMsg)
}
)
复制代码
请求拦截器
请求拦截器中一般注入 token,我的代码中暂时没有做处理
less sass 的优化处理
背景
我们使用 less sass 主要是为了使用他们提供的变量,函数等特性。如果不进行配置,只在入口文件引入,在各个子页面里是无法直接使用,如果使用,还需要再次引用这个对使用者非常不友好。
官方方案
对 sass 文件 可以在 loaderOptions 增加 additionalData 选项
但是对于 less 文件的处理非常不友好,需要一个个去定义变量,因此我了舍弃官方的 less 处理方案
module.exports = {
css: {
loaderOptions: {
// 给 sass-loader 传递选项
sass: {
// @/ 是 src/ 的别名
// 所以这里假设你有 `src/variables.sass` 这个文件
// 注意:在 sass-loader v8 中,这个选项名是 "prependData"
additionalData: `@import "~@/variables.sass"`
},
// 默认情况下 `sass` 选项会同时对 `sass` 和 `scss` 语法同时生效
// 因为 `scss` 语法在内部也是由 sass-loader 处理的
// 但是在配置 `prependData` 选项的时候
// `scss` 语法会要求语句结尾必须有分号,`sass` 则要求必须没有分号
// 在这种情况下,我们可以使用 `scss` 选项,对 `scss` 语法进行单独配置
scss: {
additionalData: `@import "~@/variables.scss";`
},
// 给 less-loader 传递 Less.js 相关选项
less:{
// http://lesscss.org/usage/#less-options-strict-units `Global Variables`
// `primary` is global variables fields name
globalVars: {
primary: '#fff'
}
}
}
}
}
复制代码
单独处理 less
安装style-resources-loader
在vue.config.js
新增配置
对如果使用了 less,预先引入我们基础 less 文件
pluginOptions: {
'style-resources-loader': {
preProcessor: 'less',
patterns: [
path.resolve(__dirname, './src/common/style/base.less')]
}
}
复制代码
总结
三个预处理器的全局引入方案,可以参考这个文章
CSS 预编译语言 变量全局引用方式 vue-cli@3.0 stylus/sass/less 使用方法
一般来说,一个项目使用一个 css 预处理器,less 或者 sass,因为我极其不适应 stylus 的无括号缩进模式,所以就里没有说 stylus
只要在 webpack 引入了就不用再全局再次引入了,但是我始终觉得这种业务代码不应该出现在 webpack 中,这种方式大大增加了开发者的心智负担,也是现在脚手架设计不合理的地方,希望未来能有更好的解决方案!
至于为什么,在入口文件引入了,不配置的话,还不能使用 less,或者 sass 的。这个深层原因,我猜测和 vue 的框架设计有关,有兴趣可以深入研究
viewport 适配方案
postcss-px-to-viewport 是一款 postcss 插件,用于将单位转化为 vw, 现在很多浏览器对 vw 的支持都很好,适配首选方案。
PostCSS 配置
下面提供了一份基本的 postcss 配置,可以在此配置的基础上根据项目需求进行修改
// postcss.config.js
const path = require('path')
module.exports = ({ file }) => {
const designWidth = file.includes(path.join('node_modules', 'vant')) ? 375 : 750
return {
plugins: {
autoprefixer: {},
'postcss-px-to-viewport': {
// 要转化的单位
unitToConvert: 'px',
// 视口的宽度
viewportWidth: designWidth,
// 保留几位小数
unitPrecision: 3,
// 哪些属性需要修改
propList: ['*'],
// 预期单位
viewportUnit: 'vw',
// 字体单位
fontViewportUnit: 'vw',
// 最小转化单位
minPixelValue: 1,
// 媒体查询里面的单位要不要转化
mediaQuery: true,
// 替换包含vw单位的
replace: true,
// 排除转化文件
exclude: [],
// 添加横向转化值
landscape: false,
// 横向采用的单位
landscapeUnit: 'vw',
// 横屏的视口宽度
landscapeWidth: false
}
}
}
}
复制代码
autoprefixer
如果要配置目标浏览器,可使用 package.json 的 browserslist 字段
你只使用无前缀的 CSS 规则即可
对比 rem
下面这个是 rem 适配方案肯定会有的代码
const deviceWidth = document.documentElement.clientWidth || document.body.clientWidth;
document.querySelector('html').style.fontSize = deviceWidth / 7.5 + 'px';
复制代码
弊端:
兼容第三方 UI 库
vant 团队的是根据 375px 的设计稿去做的,理想视口宽度为 375px。
如果读取的是 vant 相关的文件,viewportWidth 就设为 375,如果是其他的文件,我们就按照我们 UI 的宽度来设置 viewportWidth,即 750
目前网上没有找到完全正确的例子,博客错误的 demo 相互抄袭,我在做代码验证时走了很多弯路,现在下面这行代码肯定是可行的。
// 核心代码就这么一行
const designWidth = file.includes(path.join('node_modules', 'vant')) ? 375 : 750
复制代码
下面是没有配置之前引入 vant 的 cell 组件,整个缩小了
配置之后,样式恢复正常
配置多环境变量
命令
package.json 里的 scripts 配置 build
"build:test": "vue-cli-service build --mode test", "build:online": "vue-cli-service build --mode online
可以根据自己业务情况去实际扩展
环境变量
目录下 3 个环境变量文件
分别对应,通用的环境变量,测试环境变量,线上环境变量
获取时,无论什么命令都会优先获取 .env 里的环境变量,再根据不同命令,执行不同的环境变量文件,遇到相同的环境变量会进行覆盖
目前我对主要定义的几个环境变量进行说明
# 是否是线上版本
VUE_APP_ONLINE_ENV=false
# 环境
NODE_ENV=development
# 请求超时时间
VUE_APP_API_TIMEOUT=30000
# public-path
VUE_APP_PUBLIC_PATH=/
# 请求后端地址
VUE_APP_ACTIVITY_SERVER_TARGET = xxx
复制代码
兼容性处理方案
可选链操作符和空值合并操作符的兼容处理
我们判断嵌套对象属性是否存在时,常常会用到链式操作符 ?. ,是一个非常好用的语法糖
高版本 api,需要加 babel 插件,转化成 es5
我们对 babel.config.js 进行了单独配置
同理空值合并操作符也是如此
注释里已经很清晰了
module.exports = {
presets: ['@vue/cli-plugin-babel/preset'],
plugins: [
// 空值合并操作符号 ??
'@babel/plugin-proposal-nullish-coalescing-operator',
// 可选链 ?.
'@babel/plugin-proposal-optional-chaining',
//vant 按需引入
[
'import',
{
libraryName: 'vant',
libraryDirectory: 'es',
style: true
},
'vant'
]
]
}
复制代码
transpileDependencies
默认情况下 babel-loader 会忽略所有 node_modules 中的文件。你可以启用本选项,以避免构建后的代码中出现未转译的第三方依赖。
不过,对所有的依赖都进行转译可能会降低构建速度。如果对构建性能有所顾虑,你可以只转译部分特定的依赖:给本选项传一个数组,列出需要转译的第三方包包名或正则表达式即可。
上面这种方式会影响项目构建速度和部署包大小,不能把 module 中所有包都放进去
明确哪个包有转义问题,可以用这种方式 babel 重新编译
目前已知需要这样的做的库,有一个加解密库 CryptoJS 当前项目并没有使用
transpileDependencies:['crypto-js']
复制代码
vConsole.js 的兼容处理
vConsole 是一个轻量、可拓展、针对手机网页的前端开发者调试面板。
vConsole
当我们直接 inpmort 引入时,在低版本手机上,在安卓 5.0 手机上直接白屏,这个函数库使用了一些高版本语法,没有进行转义
因为 vconsole 存在引用第三包的情况,无法确定范围,因此没有采用 transpileDependencies 方案
推荐引入链接方式引入,不推荐直接 import
可以直接在入口,根据环境变量,测试环境自动加载一个转义后的 js 文件
<script>
var WEB_ENV = '<%= VUE_APP_ONLINE_ENV %>'
// 根据环境变量引入VConsole(判断非生产环境)
if (!JSON.parse(WEB_ENV)) {
var scriptEl = document.createElement('script')
scriptEl.src = '<%= BASE_URL %>vConsole.js'
scriptEl.async = true
document.head.appendChild(scriptEl)
scriptEl.onload = function () {
window.vConsole = new VConsole()
}
}
</script>
复制代码
其他
入口加载动画
没有挂在 dom 时,先加载 loading 动画,也可以加载 svg,JSON 格式动画,或者骨架屏,持续优化用户体验
<div id="app">
<img class="no-app-img-loading" src="data:image/gif;base64,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" alt="">
</div>
复制代码
总结
脚手架这个东西,本质是工具,工具应该是拿来即用,应该助力开发,不应该成为学习的负担。
目前前端打包工具 webpack,vite 等等,框架迭代升级快速,vite 我暂时没有研究,但是 webpack 从 3 到 5,三个版本,能明显感受的是,配置在简单化,学习成本在降低,很多业内大量使用的通用配置,已经成为官方的默认配置。可能官方也意识到配置复杂性的这个问题,在慢慢做改进。
但是 webpack 生态没有跟上,vue-cli 也是基于 webpack5 之上封装一层,我在做这个 vue-template 项目时去查阅 webpack 的文档,资料不是很多,最佳实践不多,也能理解,这种优秀的实践一般都是公司内部使用,不会往外分享传播。
vue3 +vue-cli4 + webpack5 + 多入口打包 + 自动生成项目模版 + pinia + 数据持久化 + 路由动画 + axios 二次封装 + less sass 变量函数处理 + viewport 适配方案等等
这个二次封装的框架,融合了我工作以来的经验和实践,平时学习到的优秀的类库,以及和大家交流中发现的问题和解决方案,还是遗留了很多我暂时找不到解决方案的问题,后续看精力和兴趣,如果愿意的话,都能一一攻克。
目前来说,是能够支持移动端的大多数业务,也希望能够帮助到大家业务开发!
后期规划
路由配置 history,目前使用 history 无法访问二级页面,等待后期研究 ngnix
统一的格式控制管理,能够适用于 webstrom 和 vscode
多入口时选择编译单个入口文件
整个项目 cli 化,可以像 vue-cli 那样,直接一行命令下载下来
增量编译,随着项目变大,每次发布把所有项目都打包一遍是不现实的,能否有一个方案只编译有修改部分,这样编译效率大大提高
common 基础方法库 打包封装
使用 vite 搭建组件库 componet
如果屏幕前的你读完了,相信也读了很久,有问题,有疑问的地方,欢迎留言,欢迎去 git 联系我,我们一起交流!点个赞就更好了。源代码地址:https://github.com/Yinzhuo19970516/vue-template
评论