基于 Vue3 + Vue-Cli4 + Vant-ui + sass + axios 封装的移动端模版
vue-h5-template
├─ public --------------- 静态资源
│ ├─ favicon.ico --------- 图标
│ └─ index.html ---------- 首页
├─ src ------------------ 源码目录
│ ├─ api ----------------- 接口目录,与后端对接
│ ├─ assets -------------- UI资源
│ ├─ components ---------- 公共组件
│ ├─ hooks --------------- vue3-hooks
│ ├─ router -------------- vue-router 路由,分模块加目录引入到index.js
│ ├─ index.js ----------- 路由注册
│ └─ permission.js ------ 路由全局守卫
│ ├─ store --------------- vuex
│ ├─ styles -------------- 项目样式
│ ├─ components --------- 组件样式
│ ├─ views -------------- 页面样式
│ ├─ iconfont.css ------- 字体图标CSS
│ ├─ index.css ---------- 全局通用样式
│ ├─ mixins.css --------- 全局mixins样式
│ └─ variables.css ------ css变量
│ ├─ utils --------------- 工具类
│ ├─ debug.js ----------- 调试方法
│ ├─ enums.js ----------- 枚举值
│ ├─ environment.js ----- 环境变量及方法
│ ├─ httpEnums.js ------- http状态码枚举
│ ├─ index.js ----------- 常用方法
│ ├─ regExp.js --------- 正则表达式
│ ├─ request.js --------- axios 封装
│ └─ storage.js --------- 本地存储封装
│ ├─ views --------------- 页面
│ ├─ App.vue ------------- 入口vue
│ ├─ main.js ------------- 入口main.js
│ ├─ .env.development ---- 开发环境
│ ├─ .env.test ----------- 测试环境
│ ├─ .env.stage ---------- 预发布环境
│ ├─ .env.production ----- 发布环境
│ ├─ .eslintrc.js -------- eslint 配置
│ ├─ .gitignore ---------- git 忽略配置
│ ├─ .babel.config.js ---- babel 配置
│ └─ package.json -------- 依赖管理
git clone git@github.com:ZYCHOOO/vue3-h5-template.git
cd vue-h5-template
npm install
npm run serve
- 环境变量配置
- eruda 移动端调试
- 去除 console.log
- rem 适配
- 全局 sass 样式
- BEM 命名规范
- 样式穿透
- iconfont 图标管理
- 适配苹果底部安全距离
- proxy 跨域配置
- 使用 mock 数据
- axios 封装及接口拦截
- vue-router
- 本地存储 storage 封装
- 哀悼模式
- dayjs 处理时间
- 虚拟列表组建
package.json
里的 scripts
配置 serve
stage
build
,通过 --mode xxx
来执行不同环境
- 通过
npm run test
执行vue-cli-service serve --mode test
- 通过
npm run build
执行vue-cli-service build
- 通过
npm run build:test
打包测试,执行vue-cli-service build --mode test
- 通过
npm run build:stage
打包预发布,执行vue-cli-service build --mode stage
- 通过
npm run build:prod
打包发布,执行vue-cli-service build --mode production
"scripts": {
"serve": "vue-cli-service serve",
"test": "vue-cli-service serve --mode test",
"build": "vue-cli-service build",
"build:test": "vue-cli-service build --mode test",
"build:stage": "vue-cli-service build --mode stage",
"build:prod": "vue-cli-service build --mode production",
"lint": "vue-cli-service lint"
},
- 在
.env.xxx
文件中,变量命名必须要以VUE_APP_
开头 - 在
environment.js
文件中,
- 获取变量名的两种方式
import { getEnvVariables, getEnvValue } from '@/utils/environment'
setup () {
console.log(getEnvValue('VUE_APP_ENV'))
console.log(getEnvVariables('APP_ID'))
}
- 关于 getEnvVariables 方法
在
environment.js
文件中集中管理了环境变量,这样的好处是可以一目了然各个环境下的变量,而且改动后可以立即生效,不用重新运行项目才获取到
在开发环境和测试环境中显示 eruda 调试工具
npm install eruda
eruda.init()
若要在其他自定义环境中显示 eruda 调试工具,在enums.js
的DEBUG_WHITE_LIST
白名单中修改配置即可
npm i -D babel-plugin-transform-remove-console
在开发环境和测试环境中保留 console.log 输出,babel.config.js
中配置如下
const plugins = []
const DEBUG_WHITE_LIST = ['development', 'test']
const IS_DEBUG = DEBUG_WHITE_LIST.includes(process.env.VUE_APP_ENV)
// 非调试环境,去除代码中的所有console.log(development-env, test-env)
if (!IS_DEBUG) {
plugins.push('transform-remove-console')
}
module.exports = {
plugins
}
rem(font size of the root element)是 css3 新增的一个相对单位,是指相对于根元素的字体大小的单位
- 手动 rem 适配
<script>
function setRem() {
var width =
document.documentElement.clientWidth || document.body.clientWidth
var ratio = width / 375
var fontSize = 100 * ratio
document.getElementsByTagName('html')[0].style['font-size'] =
fontSize + 'px'
}
setRem()
window.addEventListener('resize', setRem)
</script>
- 使用 postcss 插件 rem 适配
postcss-pxtorem 是一款 postcss 插件,用于将 px 单位转化为 rem 单位 amfe-flexible 用于设置 rem 基准值
npm i amfe-flexible -S
npm i postcss-pxtorem -D
在项目根目录中,新建postcss.config.js
module.exports = {
plugins: {
autoprefixer: {
browsers: 'last 5 version',
overrideBrowserslist: [
'Android 4.1',
'iOS 7.1',
'Chrome > 31',
'ff > 31',
'ie >= 8'
]
},
'postcss-pxtorem': {
rootValue: 37.5,
propList: ['*']
}
}
}
在main.js
中引入amfe-flexible
import 'amfe-flexible/index.js'
vue 的思想就是组件化,在每个.vue
页面的样式要想独立开来,可以添加 scoped 属性,使当前样式只能在当前 vue 文件中生效,使各个组件的样式互不污染。
<style lang="scss">
/** global style */
</style>
<style lang="scss" scoped>
/** local style */
</style>
vue3-h5-template 所有全局样式都在@/styles
目录下设置
│ ├─ styles ---------------- 项目样式
│ ├─ components --------- 组件样式
│ ├─ views -------------- 页面样式
│ ├─ iconfont.css ------- 字体图标CSS
│ ├─ index.css ---------- 全局通用样式
│ ├─ mixins.css --------- 全局mixins样式
│ └─ variables.css ------ css变量
vue.config.js
添加全局样式配置
css: {
loaderOptions: {
// provide global variables
sass: {
prependData: `
@import "~@/style/mixins.scss";
@import "~@/style/variables.scss";
`
}
}
},
在mixins.scss
中写好了常用的样式,如 flex 布局的上下左右居中,超出宽度省略等
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
@mixin flex-align-center {
display: flex;
align-items: center;
}
@mixin flex-justify-center {
display: flex;
justify-content: center;
}
@mixin ellipsis {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
@mixin multiline-ellipsis($lineNum) {
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: $lineNum;
-webkit-box-orient: vertical;
}
该项目使用 BEM 命名方法,由块(Block)元素(Element)修饰符(Modifier)组成,具有可读性且方便维护。
<!-- good -->
<div class="header-btn--success" />
<!-- bad -->
<div class="header-btn-success" />
当你子组件使用了 scoped
但在父组件又想修改子组件的样式可以 通过 :deep
来实现
:deep .btn {
background: lightskyblue;
}
在index.html
的 meta 中添加viewport-fit=cover
Vant 中部分组件提供了 safe-area-inset-top 或 safe-area-inset-bottom 属性,设置该属性后,即可在对应的机型上开启适配
<!-- 在 head 标签中添加 meta 标签,并设置 viewport-fit=cover 值 -->
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover"
/>
<!-- 开启顶部安全区适配 -->
<van-nav-bar safe-area-inset-top />
<!-- 开启底部安全区适配 -->
<van-number-keyboard safe-area-inset-bottom />
在mixins.scss
中有写好的样式,可直接用
@mixin bottom-safe-area {
padding-bottom: constant(safe-area-inset-bottom); /*兼容 IOS<11.2*/
padding-bottom: env(safe-area-inset-bottom); /*兼容 IOS>11.2*/
}
在vue.config.js
的 proxy 中配置相应参数
proxy: {
'/api': {
target: 'https://test.xxx.com', // 测试环境URL
// ws: true, // 是否启用websocket
changeOrigin: true, // 开启代理,在本地创建一个虚拟服务端
secure: false
}
},
mock 请求的封装采用的是vue-element-admin 的 mock 请求封装
- mock-server.js
import Mock from 'mockjs'
import { getEnvValue } from '@/utils/environment'
import user from './user'
const mocks = [...user]
function mockXHR() {
Mock.XHR.prototype.proxy_send = Mock.XHR.prototype.send
Mock.XHR.prototype.send = function () {
if (this.custom.xhr) {
this.custom.xhr.withCredentials = this.withCredentials || false
if (this.responseType) {
this.custom.xhr.responseType = this.responseType
}
}
this.proxy_send(...arguments)
}
function XHR2ExpressReqWrap(respond) {
return function (options) {
let result = null
if (respond instanceof Function) {
const { body, type, url } = options
result = respond({
method: type,
body: JSON.parse(body),
query: url
})
} else {
result = respond
}
return Mock.mock(result)
}
}
for (const i of mocks) {
Mock.mock(
new RegExp(i.url),
i.type || 'get',
XHR2ExpressReqWrap(i.response)
)
}
}
export function initMockServer() {
const NEED_MOCK = getEnvValue('VUE_APP_NEED_MOCK')
if (NEED_MOCK) {
mockXHR()
}
}
- user.js
import { getQueryString } from '@/utils/index'
const tokens = {
admin: { token: 'admin-token' },
editor: { token: 'editor-token' }
}
const users = {
'admin-token': {
roles: ['admin'],
introduction: 'I am a super administrator',
avatar:
'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif',
name: 'Super Admin'
},
'editor-token': {
roles: ['editor'],
introduction: 'I am an editor',
avatar:
'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif',
name: 'Normal Editor'
}
}
export default [
// user login
{
url: '/vue3-h5-template/user/login',
type: 'post',
response: (config) => {
const { username } = config.body
const token = tokens[username]
// mock error
if (!token) {
return {
code: 60204,
message: 'Account and password are incorrect.'
}
}
return {
code: 200,
data: token
}
}
},
// get user info
{
// eslint-disable-next-line no-useless-escape
url: '/vue3-h5-template/user/info.*',
type: 'get',
response: (config) => {
console.log(config, 'config')
const token = getQueryString(config.query, 'token')
const info = users[token]
// mock error
if (!info) {
return {
code: 50008,
message: 'Login failed, unable to get user details.'
}
}
return {
code: 200,
data: info
}
}
},
// user logout
{
url: '/vue3-h5-template/user/logout',
type: 'post',
response: (_) => {
return {
code: 200,
data: 'success'
}
}
}
]
在main.js
中引入 initMockServer,根据.env.*
文件中的VUE_APP_NEED_MOCK
参数来判断是否用 mock 数据
import { initMockServer } from '@/mock/mock-server'
initMockServer()
-
httpEnums.js
utils/httpEnums.js
内含有 http 相应状态码、状态码、白名单的枚举
export default {
// http响应状态码
HTTP_STATUS: {
// 1xx,临时响应
TEMP_RESPOND: {
Continue: 100, // 服务器通知浏览器之前一切正常,请客户端继续请求,如果请求结束,可忽略
SwitchingProtocal: 101 // 针对请求头的Upgrade返回的信息。表明服务器正在切换到指定的协议
},
// 2xx,成功
SUCCESS: {
Ok: 200, // 请求成功
Created: 201, // 常用于POST,PUT 请求,表明请求已经成功,并新建了一个资源。并在响应体中返回路径
Accepted: 202, // 请求已经接收到,但没有响应,稍后也不会返回一个异步请求结果。 该状态码适用于等待其他进程处理或者批处理的场景
NoAuthoritativeInformation: 203, // 表明响应返回的元信息(meta-infomation)和最初的服务器不同,而是从本地或者第三方获取的
NoContent: 204, // 请求没有数据返回,但是头信息有用。用户代理(浏览器)会更新缓存的头信息
ResetContent: 205, // 告诉用户代理(浏览器)重置发送该请求的文档
ParticalContent: 206 // 客户端使用Range请求头时,返回该状态码
},
// 3xx,重定向
REDIRECT: {
MultipleChoice: 300, // 返回多个响应,需要浏览器或者用户选择
MovedPermanently: 301, // 请求资源的URL被永久的改变,新的URL会在响应的Location中给出。浏览器到新的URL重新请求资源,因为有些客户端会把请求方式method改成GET。所以该状态码建议GET和HEAD方法中使用。搜索引擎会更新地址到资源的链接(SEO中‘link-judge’被发送到新的URL)
Found: 302, // 请求资源的URL被暂时修改到Location提供的URL。未来可能还会有新的修改。览器会根据新的URL重新请求资源。有些客户端会把方法method改为GET,建议在GET和HEAD方法中使用。搜索引擎不会更改URL到资源的。
SeeOther: 303, // 服务通过返回的响应数据指导客户端通过GET方法去另一个URL获取资源。通常用于POST或者PUT的请求返回结果,重定向到信息提示页面或者进度展示页面。重定向页面的方法是GET方法。
NotModified: 304, // 资源未变更。服务器根据请求头判断,需要资源未修改,只返回响应头;否则将资源一起返回。
TemporaryRedirect: 307, // 临时重定向。基本和302相同。唯一的区别是这个状态码严格禁止浏览器到新URL请求资源时修改原来的请求方式和请求体。
PermanentRedirect: 308 // 永久重定向。基本和301相同。但是严格禁止修改请求方式和请求体。
},
// 4xx,请求错误
REQUEST_ERROR: {
BadRequest: 400, // 请求语法有问题,服务器无法识别。
UnAuthorized: 401, // 客户端未授权该请求。缺乏有效的身份认证凭证,一般可能是未登陆。登陆后一般都解决问题。
Forbidden: 403, // 服务器拒绝响应。权限不足。
NotFound: 404, // URL无效或者URL有效但是没有资源。
MethodNotAllowed: 405, // 请求方式Method不允许。但是GET和HEAD属于强制方式,不能返回这个状态码。
NotAccepted: 406, // 资源类型不符合服务器要求。
ProxyAuthorizationRequired: 407, // 需要代理授权。
RequestTimeout: 408, // 服务器将不再使用的连接关闭。响应头会有Connection: close。
UpgradeRequired: 426 // 告诉客户端需要升级通信协议。
},
// 5xx,服务器错误
SERVER_ERROR: {
InternalServerError: 500, // 服务器内部错误,未捕获。
BadGateway: 502, // 服务器作为网关使用时,收到上游服务器返回的无效响应。
ServiceUnavailable: 503, // 无法服务。一般发生在因维护而停机或者服务过载。一般还会伴随着返回一个响应头Retry-After: 说明恢复服务的估计时间。
GateTimeout: 504, // 网关超时。服务器作为网关或者代理,不能及时从上游服务器获取响应返回给客户端。
HttpVersionNotSupported: 505 // 发出的请求http版本服务器不支持。如果请求通过http2发送,服务器不支持http2.0,就会返回该状态码。
}
},
// http状态码
CODES: {
Success: 200,
UnAuthorized: 401
},
// http状态码白名单,在具体业务中处理
ERRCODE_WHITE_LIST: []
}
-
request.js
utils/request.js
封装了 axios,开发者需要根据后台接口做修改
import axios from 'axios'
import store from '@/store'
import { Notify } from 'vant'
import httpEnums from '@/constant/httpEnums'
import { getStorage } from '@/utils/storage'
import { getEnvValue } from '@/utils/environment'
// create an axios instance
const request = axios.create({
baseURL: getEnvValue('VUE_APP_BASE_API'),
timeout: 10000
})
const requestArr = [request]
// 一段时间内的提示显示的防抖,防抖时间设置为5秒
let errMsgDebounceTimer = null
const errMsgDebounceWait = 5000
requestArr.forEach((service) => {
// request interceptor
service.interceptors.request.use(
async (config) => {
if (store.getters.token) {
config.headers.Authorization = `Bearer ${getStorage('token')}`
}
return config
},
(error) => {
// do something with request error
console.log(error) // for debug
return Promise.reject(error)
}
)
// response interceptor
service.interceptors.response.use(
async (response) => {
const res = response.data
// blob文件流
if (res instanceof Blob) {
return res
}
// 成功
if (res.code === httpEnums.CODES.Success) {
return res
}
// token失效
if (res.code === httpEnums.CODES.UnAuthorized) {
return Promise.reject(new Error('token过期!'))
}
const errMsg = res.message || res.msg
Notify({
message: errMsg || 'Error',
type: 'danger',
duration: 5 * 1000
})
return Promise.reject(new Error(errMsg || 'Error'))
},
async (error) => {
console.log('err' + error) // for debug
// 定时器提示报错的防抖
const errorDebounceHandler = (errorMessage) => {
if (errMsgDebounceTimer) clearTimeout(errMsgDebounceTimer)
const callNow = !errMsgDebounceTimer
errMsgDebounceTimer = setTimeout(() => {
errMsgDebounceTimer = null
}, errMsgDebounceWait)
if (callNow) {
Notify({
message: errorMessage,
type: 'danger',
duration: errMsgDebounceWait
})
}
}
const errorData = error.response.data
const errorStatus = error.response.status
if (errorStatus === httpEnums.HTTP_STATUS.REQUEST_ERROR.UnAuthorized) {
errorDebounceHandler(error.message)
} else if (
errorData.code ===
httpEnums.HTTP_STATUS.SERVER_ERROR.InternalServerError
) {
// 500特殊处理
return Promise.reject(error)
} else {
errorDebounceHandler(error.message)
return Promise.reject(error)
}
}
)
})
export { request }
在api/modules
下管理各个模块接口,以test.js
为例
- url 接口地址
- method 请求方法
- data/params 请求参数
import { request } from '@/utils/request'
export const getUserInfo = (params) => {
return request({
url: '/vue3-h5-template/user/info',
method: 'GET',
params
})
}
以login.vue
为例
import { reactive, toRefs } from 'vue'
import { login } from '@/api/modules/test'
setup () {
const loginForm = reactive({ username: '', password: '' })
const { username, password } = toRefs(loginForm)
const result = await login({ username: username.value, password: password.value })
const { token } = result
}
在router/modules
下管理各个路由。以demo.js
为例
const routes = [
{
path: '/userInfo',
name: 'UserInfo',
component: () => import(/* webpackChunkName: 'user-info' */ '@/views/demo'),
meta: { title: '用户信息', showNavbar: true }
}
]
export default routes
路由守卫分为全局守卫、单个路由守卫、组件内部守卫,在模版中用到了全局和单个路由守卫,用于登录鉴权
- 全局守卫
import store from '@/store'
import router from '@/router'
import { ROUTE_WHITE_LIST } from '@/constant/enums'
router.beforeEach((to, from, next) => {
const { name } = to
const { token } = store.state.userModule
console.log('token::', token)
const isInWhiteList = ROUTE_WHITE_LIST.includes(name)
token || isInWhiteList ? next() : next({ name: 'Login' })
})
- 单个路由守卫
{
path: '/login',
name: 'Login',
component: () => import(/* webpackChunkName: "login" */ '@/views/login/Login'),
meta: { title: '登录' },
beforeEnter: (to, from, next) => {
const { token } = store.state.userModule
token ? next('/') : next()
}
}
已做好 storage 封装,可直接使用
import { getStorage, setStorage, removeStorage } from '@/utils/storage'
setup () {
const token = getStorage('token')
const userInfo = { name: 'zychooo' }
setStorage('user', userInfo)
removeStorage('token')
}
通过 filter.grayscale() 实现页面哀悼模式
2022/11/30 祝长者一路走好 👓🕯️
@mixin mourning-mode {
filter: grayscale(100%);
-webkit-filter: grayscale(100%);
-moz-filter: grayscale(100%);
-ms-filter: grayscale(100%);
-o-filter: grayscale(100%);
-webkit-filter: grayscale(1);
}
开启哀悼模式:
- 在路由拼接 mourn=true, 项目全页面会进入哀悼模式
- 通过
utils/index
中isMournMode
和mixins.scss
中的样式,对特定页面开启哀悼模式,如首页home/index.vue
代码模版在 v1.0.5 版本更新了首屏哀悼模式,在路由拼接 mourn=true 即可;通过 backdrop-filter 来实现该功能
html[theme='mourn'] {
#app::before {
content: '';
position: absolute;
backdrop-filter: grayscale(100%);
width: 100%;
height: calc(100vh - 50px);
}
}
本项目引入 dayjs 并已在全局挂载,可直接使用
<template>
<div class="wrapper">
{{ proxy.$dayjs(new Date()).format('YY-MM-DD HH:mm:ss') }}
</div>
</template>
<script setup>
import { computed, getCurrentInstance } from 'vue'
const { proxy } = getCurrentInstance()
const currentTime = computed(() => {
return proxy.$dayjs(new Date()).format('YY-MM-DD HH:mm:ss')
})
</script>
为了防止数据量过大,DOM 渲染过多导致卡顿,该模版在 v1.0.5 版本提供了虚拟列表组件。
参数如下:
参数 | 说明 | 类型 | 可选值 | 默认值 |
---|---|---|---|---|
itemKey | key 值 | String | - | id |
listData | 列表数据 | Array | - | - |
itemHeight | 单个子项的高度 | Number | - | 0 |
showNum | 需要给用户展示的数量 | Number | - | 0 |
startIndex | 起始下标 | Number | - | 0 |
endIndex | 结束下标 | Number | - | 0 |
vue3-h5-template vue-element-admin