vite + vue3 + ts 移动端实践

4,774 阅读3分钟

技术栈选择

公司的技术栈主要是react,然后刚好有一个项目,看到vue3的发布一时手痒,直接开干,总结了一下项目的搭建以及一些坑(没配vuex是因为项目用不到)

下载.jpeg

项目搭建

vite 初始化

$ yarn create vite
# or
$ npm create vite

根据提示选择需要的模板

安装router

$ yarn add vue-router@4
# or
$ npm install vue-router@4

创建router文件 跟router3基本没有改变,hash和history必须引用

import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'

const history = createWebHistory() // 如需hash替换成createWebHashHistory
//路由配置
const routes: RouteRecordRaw[] = [{
   {
    path: '/',
    name: 'Login',
    component: (): Promise<typeof import('*.vue')> =>     import('@pages/home/index.vue'),
    meta: { title: '登录', keepAlive: true }
  },
}]

const router = createRouter({
  history,
  scrollBehavior: () => ({ top: 0 }),
  routes
})


export default router

然后需要在mian.ts中进行全局挂载

import { createApp } from 'vue'
import App from './App.vue'
import router from './route'
const app = createApp(App)
app.use(router)
app.mount('#app')

安装vant

$ yarn add vant@next -S
# or
$ npm install vant@next -S

配置按需加载

# 安装插件 
npm install vite-plugin-style-import -D
# or
$ yarn add vite-plugin-style-import -D

vite.config.ts中配置

import vue from '@vitejs/plugin-vue'; 
import styleImport from 'vite-plugin-style-import'; 
export default { 
plugins: [ 
      vue(), 
      styleImport({
        libs: [
          {
            libraryName: 'vant',
            esModule: true,
            resolveStyle: (name): string => `vant/es/${name}/style`
          }
        ]
   });
 ]

移动端适配

# 安装插件 
npm install postcss-pxtorem lib-flexible 
# or
$ yarn add postcss-pxtorem lib-flexible 

在项目根目录中新建postcss.config.js并配置如下:

module.exports = {
  plugins: {
    'postcss-pxtorem': {
      rootValue({ file }) {
        return file.indexOf('vant') !== -1 ? 37.5 : 75
      },
      propList: ['*']
    }
  }
}

然后需要在mian.ts中引入

import 'lib-flexible'

接下来就可以愉快的使用px

axios

//安装
$ yarn add axios
# or
$ npm install axios

创建utils文件夹 在其中新建request文件

状态码错误提示

import axios, { AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
const codeMessage: Record<number, string> = {
  400: '请求错误',
  401: '用户没有权限。',
  403: '用户得到授权,但是访问是被禁止的。',
  404: '发出的请求是不存在的记录',
  406: '请求的格式不可得。',
  410: '请求的资源被永久删除',
  422: '验证错误',
  500: '服务器发生错误',
  502: '网关错误。',
  503: '服务不可用,服务器暂时过载或维护。',
  504: '网关超时。'
}

创建axios实例

其中 import.meta.env是vite提供的环境变量在项目稍后会将如何配置,withCredentials是否开启cookis共享,这里遇到一个问题开启withCredentials以后会导致阿里oss图片上传会失败

const service = axios.create({
  baseURL: import.meta.env.VITE_APP_BASE_API, // proxy 需要注释
  // withCredentials: true, 
  timeout: 12000
})

请求拦截

service.interceptors.request.use(
  (config: AxiosRequestConfig) => {
    // config
    return config
  },
  (error: AxiosError) => {
    return Promise.resolve(error || '服务器异常')
  }
)

响应拦截

service.interceptors.response.use(
  (response: AxiosResponse) => {
    const { status, message } = response.data
    if (status !== 200) {
      return Promise.reject(new Error(message || 'Error'))
    }
    return response.data
  },
  (error: AxiosError) => {
    const { response } = error
    if (response && response.status) {
      const { status, statusText } = response
      const errorText = codeMessage[status] || statusText
      Toast(errorText)
    } else if (!response) {
      Toast('您的网络发生异常,无法连接服务器')
    }
    return Promise.reject(error)
  }
)

export default service

axios进阶版移除重复请求完整配置

import axios, { AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
import qs from 'qs'
import { Toast } from 'vant'

const codeMessage: Record<number, string> = {
  400: '请求错误',
  401: '用户没有权限。',
  403: '用户得到授权,但是访问是被禁止的。',
  404: '发出的请求是不存在的记录',
  406: '请求的格式不可得。',
  410: '请求的资源被永久删除',
  422: '验证错误',
  500: '服务器发生错误',
  502: '网关错误。',
  503: '服务不可用,服务器暂时过载或维护。',
  504: '网关超时。'
}
const pending = new Map()

/**
 * 添加请求
 * @param {Object} config
 */
const addPending = (config: AxiosRequestConfig): void => {
  const url = [config.method, config.url, qs.stringify(config.params), qs.stringify(config.data)].join('&')
  config.cancelToken =
    config.cancelToken ||
    new axios.CancelToken((cancel) => {
      if (!pending.has(url)) {
        // 如果 pending 中不存在当前请求,则添加进去
        pending.set(url, cancel)
      }
    })
}

/**
 * 移除请求
 * @param {Object} config
 */
const removePending = (config: AxiosRequestConfig): void => {
  const url = [config.method, config.url, qs.stringify(config.params), qs.stringify(config.data)].join('&')
  if (pending.has(url)) {
    // 如果在 pending 中存在当前请求标识,需要取消当前请求,并且移除
    const cancel = pending.get(url)
    cancel(url)
    pending.delete(url)
  }
}

/**
 * 清空 pending 中的请求(在路由跳转时调用)
 */
export const clearPending = (): void => {
  for (const [url, cancel] of pending) {
    cancel(url)
  }
  pending.clear()
}

const service = axios.create({
  baseURL: import.meta.env.VITE_APP_BASE_API, // proxy 需要注释
  // withCredentials: true,
  timeout: 12000
})

// 请求拦截器
service.interceptors.request.use(
  (config: AxiosRequestConfig) => {
    removePending(config) // 在请求开始前,对之前的请求做检查取消操作
    addPending(config) // 将当前请求添加到 pending 中
    // config
    return config
  },
  (error: AxiosError) => {
    return Promise.resolve(error || '服务器异常')
  }
)

// 响应拦截器
service.interceptors.response.use(
  (response: AxiosResponse) => {
    removePending(response) // 如果存在就移除未的到响应的请求
    const { status, message } = response.data
    if (status !== 200) {
      return Promise.reject(new Error(message || 'Error'))
    }

    return response.data
  },
  (error: AxiosError) => {
    const { response } = error
    if (response && response.status) {
      const { status, statusText } = response
      const errorText = codeMessage[status] || statusText
      Toast(errorText)
    } else if (!response) {
      Toast('您的网络发生异常,无法连接服务器')
    }
    return Promise.reject(error)
  }
)

export default service

使用方法

src下文件新建api文件夹

// index.ts 中
export interface HttpResponse {
  status: number
  success?: boolean
  traceId?: string
  data: any
}
export const getData = async (params: dataType): Promise<HttpResponse> => {
  return request('api/yyyy', {
    method: 'post',
    data: params
  })
}

统一代码风格

  1. 团队开发,每个人编辑器不同,编码方式不同,导致代码格式不同,代码难看,难以维护
  2. 保持代码可读性,团队成员之间的代码更加易读

eslint

安装eslint

npm install eslint eslint-plugin-vue eslint-plugin-prettier eslint-config-prettier @typescript-eslint/parser @typescript-eslint --save-dev
# or
yarn add eslint eslint-plugin-vue eslint-plugin-prettier eslint-config-prettier @typescript-eslint/parser @typescript-eslint -D

在根目录中创建.eslintrc.js文件配置如下:

module.exports = {
  parser: 'vue-eslint-parser',
  parserOptions: {
    parser: '@typescript-eslint/parser',
    ecmaVersion: 2020,
    sourceType: 'module',
    ecmaFeatures: {
      jsx: true
    }
  },
  extends: ['plugin:vue/vue3-recommended', 'plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'],
  rules: {
    '@typescript-eslint/ban-ts-ignore': 'off',
    '@typescript-eslint/explicit-function-return-type': 1,
    '@typescript-eslint/no-explicit-any': 1,
    '@typescript-eslint/no-var-requires': 1,
    '@typescript-eslint/no-empty-function': 2,
    'vue/custom-event-name-casing': 2,
    'no-use-before-define': 2,
    '@typescript-eslint/no-use-before-define': 2,
    '@typescript-eslint/ban-ts-comment': 1,
    '@typescript-eslint/ban-types': 1,
    '@typescript-eslint/no-non-null-assertion': 2,
    '@typescript-eslint/explicit-module-boundary-types': 2,
    '@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
    '@typescript-eslint/no-unused-vars': ['error'],
    eqeqeq: 2,
    camelcase: 1,
    'use-isnan': 2,
    'no-cond-assign': 2,
    'no-unused-vars': 0,
    'no-const-assign': 2,
    'no-constant-condition': 2,
    'no-delete-var': 2,
    'no-dupe-keys': 2,
    'no-else-return': 2,
    'no-fallthrough': 1,
    'no-func-assign': 2,
    'no-implicit-coercion': 1,
    'no-inner-declarations': [2, 'functions'],
    'no-invalid-regexp': 2,
    'no-invalid-this': 2,
    'no-multiple-empty-lines': [1, { max: 2 }],
    'no-nested-ternary': 0,
    'no-ternary': 0,
    'no-unneeded-ternary': 2,
    'no-var': 2
  }
}

在 package.json 内,配置 eslint修复命令:

"scripts": {
    "lint:eslint": "eslint src --fix --ext .ts,.tsx,.vue ",
  },

在vscode中下载eslint插件来实现自动保存修复并且在vscode设置中的settings.json配置:

  "editor.formatOnSave": true, //每次保存自动格式化
  "vetur.format.defaultFormatter.js": "vscode-typescript", //让vue中的js按编辑器自带的ts格式进行格式化
  "editor.codeActionsOnSave": {
        "source.fixAll.eslint": true,
        "source.fixAll.stylelint": true
    },

项目根目录中新建.eslintignore文件来忽略对一些文件的检查

*.sh
node_modules
*.md
*.scss
*.woff
*.ttf
/dist/

prettier

安装prettier

npm install prettier lint-staged --save-dev
# or
yarn add prettier lint-staged -D

在根目录中创建prettier.config.js文件配置如下

module.exports = {
  printWidth: 160, // 单行输出(不折行)的(最大)长度
  tabWidth: 2, // 每个缩进级别的空格数
  tabs: false, // 使用制表符 (tab) 缩进行而不是空格 (space)。
  semi: false, // 是否在语句末尾打印分号
  singleQuote: true, // 是否使用单引号
  quoteProps: 'as-needed', // 仅在需要时在对象属性周围添加引号
  bracketSpacing: true, // 是否在对象属性添加空格
  jsxBracketSameLine: true, // 将 > 多行 JSX 元素放在最后一行的末尾,而不是单独放在下一行(不适用于自闭元素),默认false,这里选择>不另起一行
  htmlWhitespaceSensitivity: 'css', // 指定 HTML 文件的全局空白区域敏感度, "ignore" - 空格被认为是不敏感的
  trailingComma: 'none', // 去除对象最末尾元素跟随的逗号
  useTabs: false, // 不使用缩进符,而使用空格
  jsxSingleQuote: false, // jsx 不使用单引号,而使用双引号
  arrowParens: 'always', // 箭头函数,只有一个参数的时候,也需要括号
  rangeStart: 0, // 每个文件格式化的范围是文件的全部内容
  proseWrap: 'always', // 当超出print width(上面有这个参数)时就折行
  endOfLine: 'lf' // 换行符使用 lf
}

在 package.json 内,配置 prettier修复命令:

"scripts": {
    "lint:prettier": "prettier --write  \"src/**/*.{js,json,ts,tsx,css,less,scss,vue,html,md}\"",
  },

在vscode中下载Prettier - Code formatter插件来实现自动保存修复

stylelint

// 安装
npm install stylelint stylelint-config-prettier stylelint-config-standard stylelint-order --save-dev
# or
yarn add stylelint stylelint-config-prettier stylelint-config-standard stylelint-order -D

stylelint是不对scss进行检查的,如果需要对scss进行检查需要下载:

npm install stylelint-scss --save-dev
# or
yarn add stylelint-scss -D

在根目录中创建.stylelintrc.js文件配置如下:

module.exports = {
  root: true,
  plugins: ['stylelint-order'],
  extends: ['stylelint-config-standard', 'stylelint-config-prettier'],
  rules: {
    'selector-pseudo-class-no-unknown': [
      true,
      {
        ignorePseudoClasses: ['global']
      }
    ],
    'selector-pseudo-element-no-unknown': [
      true,
      {
        ignorePseudoElements: ['v-deep']
      }
    ],
    'at-rule-no-unknown': [
      true,
      {
        ignoreAtRules: ['tailwind', 'apply', 'variants', 'responsive', 'screen', 'function', 'if', 'each', 'include', 'mixin']
      }
    ],
    'no-empty-source': null,
    'named-grid-areas-no-invalid': null,
    'unicode-bom': 'never',
    'no-descending-specificity': null,
    'font-family-no-missing-generic-family-keyword': null,
    'declaration-colon-space-after': 'always-single-line',
    'declaration-colon-space-before': 'never',
    // 'declaration-block-trailing-semicolon': 'always',
    'rule-empty-line-before': [
      'always',
      {
        ignore: ['after-comment', 'first-nested']
      }
    ],
    'unit-no-unknown': [true, { ignoreUnits: ['rpx'] }],
    'order/order': [
      [
        'dollar-variables',
        'custom-properties',
        'at-rules',
        'declarations',
        {
          type: 'at-rule',
          name: 'supports'
        },
        {
          type: 'at-rule',
          name: 'media'
        },
        'rules'
      ],
      { severity: 'warning' }
    ]
  },
  ignoreFiles: ['**/*.js', '**/*.jsx', '**/*.tsx', '**/*.ts']
}


在 package.json 内,配置 stylelint修复命令:

"scripts": {
     "lint:stylelint": "stylelint src/styles/**/*.scss src/pages/**/*.scss --fix",
  },

在vscode中下载stylelint插件来实现自动保存修复

项目根目录中新建.stylelintignore文件来忽略对一些文件的检查

/dist/*
/public/*
public/*
node_modules/*

husky

// 安装
npm install husky lint-staged --save-dev
# or
yarn add husky lint-staged -D

执行 npx husky init进行初始化,运行完成之后,我们可以看到项目根目录多了一个 .husky 的文件夹,里面自动创建了 pre-commitcommit-msg 的钩子,如下图:

截屏2021-08-13 13.48.14.png

配置钩子

在 package.json 内,配置好 lint-staged以及prepare

"scripts": {
    "lint:lint-staged": "lint-staged",
    "prepare": "husky install",
  },
 "lint-staged": {
    "*.md": "prettier --write",
    "*.{ts,tsx,js,vue,scss}": "prettier --write",
    "*.{ts,tsx,js,vue}": "eslint --fix"
  }

在 .husky/pre-commit 内配置好要执行的命令:

#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
PATH=$PATH:/usr/local/bin:/usr/local/sbin
[ -n "$CI" ] && exit 0

npm run lint:lint-staged

在 .husky/commit-msg 内配置要执行的命令:

#!/bin/sh

# shellcheck source=./_/husky.sh
. "$(dirname "$0")/_/husky.sh"

npx --no-install commitlint --edit "$1"

环境变量

项目根目录下创建.env.development,.env.test,.env.production文件分别对应本地,测试,正式环境的变量地址:

# just a flag
ENV = 'development' // 本地
ENV = 'staging' // 测试
ENV = 'production' // 线上
# base api 
// 环境变量必须以vite命名否则无法拿到环境变量
VITE_APP_BASE_API = 'www.baidu.com'

如果是使用ts进行开发需要类型校验,请在src目录下新建env.d.ts文件,配置如下:

interface ImportMetaEnv {
  VITE_APP_BASE_API: string
}

配置keep-alive和页面跳转动画

vue3开始router-view外层不能包裹keep-alivetransition必须以slot的形式来使用否则浏览器会有警告信息⚠️

<template>
  <router-view v-slot="{ Component }">
    <transition name="fade" mode="in-out">
      <keep-alive v-if="$route.meta.keepAlive">
        <component :is="Component" />
      </keep-alive>
      <component :is="Component" v-else-if="!$route.meta.keepAlive" />
    </transition>
  </router-view>
</template>

<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({
  name: 'App'
})
</script>

<style lang="scss">
.fade-enter {
  opacity: 0;
}

.fade-leave {
  opacity: 1;
}

.fade-enter-active {
  transition: opacity 0.3s;
}

.fade-leave-active {
  opacity: 0;
  transition: opacity 0.3s;
}
</style>

vite.config配置

vite.config中默认是无法拿到环境变量的,如果需要获取环境变量需要配置以下:

import { loadEnv } from 'vite'
// 通过loadenv
loadEnv(mode, process.cwd()).VITE_APP_PATH

配置路径别名

const resolve = (name: string): string => path.resolve(__dirname, name)
 resolve: {
      alias: [
        { find: '@src', replacement: resolve('src') },
      ]
    },

配置全局scss

 css: {
      preprocessorOptions: {
        scss: {
          additionalData: `@import "@styles/variables.scss";`
        }
      }
    },

配置gzip打包

yarn add vite-plugin-compression --sava-dev
# or
npm install vite-plugin-compression --sava-dev
  import viteCompression from 'vite-plugin-vconsole'
  viteCompression({
        verbose: true,
        disable: false,
        threshold: 10240,
        algorithm: 'gzip',
        ext: '.gz'
      }),

配置移动端调试vconsole

yarn add vite-plugin-vconsole --sava-dev
# or
npm install vite-plugin-vconsole --sava-dev
import { viteVConsole } from 'vite-plugin-vconsole'
viteVConsole({
        entry: resolve('src/main.ts'),
        localEnabled: command === 'serve',
        enabled: command === 'build' && mode === 'test',
        config: {
          maxLogNumber: 1000,
          theme: 'light'
        }
    })

build

如果遇到打包过后在安卓的微信中无法打开页面的兼容性问题请将target改成es2015,rollup在打包的时候会警告包太大可以设置chunkSizeWarningLimit,也可以在output中切割打包生成的js大小

build: {
      target: 'es2020',
      outDir: 'dist',
      assetsDir: 'assets',
      chunkSizeWarningLimit: 1000,
      cssCodeSplit: true,
      sourcemap: false,
      rollupOptions: {
        output: {
          manualChunks(id) {
            if (id.includes('node_modules')) {
              return id.toString().split('node_modules/')[1].split('/')[0].toString()
            }
          }
        }
      },
      terserOptions: {
        compress: {
          drop_console: true, //在打包过程去去除所有的console.log()
          drop_debugger: true
        }
      }
    },

兼容性build

yarn add @vitejs/plugin-legacy --sava-dev
# or
npm install @vitejs/plugin-legacy --sava-dev
legacy({
        targets: ['ie >= 11'],
        additionalLegacyPolyfills: ['regenerator-runtime/runtime']
      }),

完整配置

import type { ConfigEnv, UserConfigExport } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import styleImport from 'vite-plugin-style-import'
import viteCompression from 'vite-plugin-compression'
import path from 'path'
import legacy from '@vitejs/plugin-legacy'
import { viteVConsole } from 'vite-plugin-vconsole'

const resolve = (name: string): string => path.resolve(__dirname, name)

export default ({ command, mode }: ConfigEnv): UserConfigExport => {
  return defineConfig({
    base: loadEnv(mode, process.cwd()).VITE_APP_PATH,
    plugins: [
      vue(),
      styleImport({
        libs: [
          {
            libraryName: 'vant',
            esModule: true,
            resolveStyle: (name): string => `vant/es/${name}/style`
          }
        ]
      }),
      viteCompression({
        verbose: true,
        disable: false,
        threshold: 10240,
        algorithm: 'gzip',
        ext: '.gz'
      }),

      legacy({
        targets: ['ie >= 11'],
        additionalLegacyPolyfills: ['regenerator-runtime/runtime']
      }),
      viteVConsole({
        entry: resolve('src/main.ts'),
        localEnabled: command === 'serve',
        enabled: command === 'build' && mode === 'test',
        config: {
          maxLogNumber: 1000,
          theme: 'light'
        }
      })
    ],
    css: {
      preprocessorOptions: {
        scss: {
          additionalData: `@import "@styles/variables.scss";`
        }
      }
    },
    resolve: {
      alias: [
        { find: '@src', replacement: resolve('src') },
      ]
    },
    build: {
      target: 'es2015',
      outDir: 'dist',
      assetsDir: 'assets',
      chunkSizeWarningLimit: 1000,
      cssCodeSplit: true,
      sourcemap: false,
      rollupOptions: {
        output: {
          manualChunks(id) {
            if (id.includes('node_modules')) {
              return id.toString().split('node_modules/')[1].split('/')[0].toString()
            }
          }
        }
      },
      terserOptions: {
        compress: {
          drop_console: true,
          drop_debugger: true
        }
      }
    },

    server: {
      open: true,
      host: '0.0.0.0',
      port: 7001
    }
  })
}