vue 基础脚手架(typescript+vite+vue3)

269 阅读3分钟

Vue 3 + TypeScript + Vite

待办

  • pnpm 简单,最开始直接安装就好
  • eslint
  • prettier
  • husky 提交 lint
  • commit 提交规范
  • vue-router
  • vue-router 添加类型(变动较大在另个分支)
  • '@' 别名
  • rollup-plugin-visualizer 打包可视化工具,看到打包大小
  • vite-plugin-compression 优化打包体积
  • env 环境变量
  • 支持 jsx
  • pinia 持久化
  • vue-router 添加守卫
  • mitt 发布订阅添加
  • lodash
  • unocss 对比 tailwindcss 后使用 unocss
  • icon 解决方案
  • css 引入重置
  • 项目设置阿里npm源
  • scss 引入
  • unplugin-auto-import 消除部分通用 import 引入
  • vite-plugin-cdn-import 包使用 cdn 减少构建体积(非必须)
  • axios 封装
  • 请求 hooks 封装
  • element-plus 及基础样式修改,推荐样式覆盖解决方案,看看与unocss有冲突没有
  • i18n 多语言解决方案
  • [] 登录 and todo demo

额外分支

  • [] 兼容性腻子脚本添加
  • yapi 接口生成
  • [] vue 最佳实践学习(长期)

eslint + prettier + stylelint

eslint

pnpm create @eslint/config

eslint.config.js 中随便加个 ignores,根据自己目录规则加

{ ignores: ['build', 'dist', 'node_modules', 'script'] },

package.json添加一个命令测试一下

"lint": "eslint . --fix"

prettier

  • eslint-plugin-prettier 该eslint插件可以将prettier的代码修复功能集成到eslint的检查工作流中,对代 码检查过程中报出的问题进行自动修复。

  • eslint-config-prettier 它可以实现eslint和prettier的相互配合而避免检查规则冲突的情况

  • vue-eslint-parser 解析.vue文件 解决lint中遇到的Parsing error: Unexpected token 问题

pnpm add prettier eslint-plugin-prettier eslint-config-prettier -D

eslint.config.js添加插件eslint-plugin-prettier

import globals from 'globals';
import pluginJs from '@eslint/js';
import tseslint from 'typescript-eslint';
import pluginVue from 'eslint-plugin-vue';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';

export default [
  { files: ['**/*.{js,mjs,cjs,ts,vue}'] },
  {
    languageOptions: {
      globals: { ...globals.browser, ...globals.node }
    }
  },
  pluginJs.configs.recommended,
  ...tseslint.configs.recommended,
  ...pluginVue.configs['flat/essential'],
  eslintPluginPrettierRecommended,
  {
    rules: {
      // 本来eslint的规则应该加在这里,等下要装prettier,需要覆盖,所以这里没有
      // 'vue/no-unused-vars': 'error',
    }
  }
];

git 提交 lint

安装 husky

pnpm add husky -D

初始化

pnpm exec husky init

安装 lint-staged

eslint 是检测是全部,lint-staged 只检测 git 暂存区的代码。

pnpm add lint-staged -D

package.json添加配置


  "lint-staged": {
    "*.{js,ts,vue}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{cjs,json}": [
      "prettier --write"
    ],
    "*.{vue,html}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{scss,css}": [
      "stylelint --fix --allow-empty-input",
      "prettier --write"
    ]
  }

其他

自己能保证你代码没问题,后续务必会把错误修改了这种特殊情况,才能忽略掉lint提交代码

git commit -m "feat: 测试git提交lint包含了lint失败的内容" -n

commit 提交规范

  • 'feat', // 新增功能
  • 'fix', // 修复缺陷
  • 'docs', // 文档变更
  • 'style', // 代码格式(不影响功能,例如空格、分号等格式修正)
  • 'refactor', // 代码重构(不包括 bug 修复、功能新增)
  • 'perf', // 性能优化
  • 'test', // 添加疏漏测试或已有测试改动
  • 'build', // 构建流程、外部依赖变更(如升级 npm 包、修改 webpack 配置等)
  • 'ci', // 修改 CI 配置、脚本
  • 'revert', // 回滚 commit
  • 'chore' // 对构建过程或辅助工具和库的更改(不影响源文件、测试用例)

commitlint

安装 @commitlint/cli 和对应规则 @commitlint/config-conventional

pnpm add @commitlint/config-conventional @commitlint/cli -D

添加.husky\commit-msg执行脚本

npx --no -- commitlint --edit $1

package.json添加配置


  "commitlint": {
    "extends": [
      "@commitlint/config-conventional"
    ]
  }

测试一下,随便提交一个 commit 确实被拦截住了

一个提交小工具 git-cz

vue-router

pnpm add vue-router@4

vue-router 添加类型

改动太大不确定能和权限结合起来不,单独开了个分支,auto-router

详情:uvr.esm.is/

rollup-plugin-visualizer

安装

pnpm add rollup-plugin-visualizer -D

vite.config.ts 添加配置

import { visualizer } from 'rollup-plugin-visualizer';

...
plugins: [
  vue(),
  visualizer({
    gzipSize: true,
    brotliSize: true
  })
]
...

vite-plugin-compression 压缩 并开启加速

当前端资源过大时,服务器请求资源会比较慢。前端可以将资源通过Gzip压缩使文件体积减少大概60%左右,能不 能无脑开启gzip压缩呢,最后解答

安装

pnpm add vite-plugin-compression -D

vite.config.ts 添加配置

import viteCompression  from 'vite-plugin-compression';

...
plugins: [
  vue(),
  viteCompression(),
  visualizer({
    gzipSize: true,
    brotliSize: true
  })
]
...

nginx 配置

光是打包有了gz包还不行,得服务器支持才行比如nginx的配置如下

httpserverlocation块中添加:

gzip on;
gzip_static on;
gzip_buffers 4 16k;
gzip_comp_level 8;
gzip_types application/javascript text/plain application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png; #压缩文件类型
gzip_vary on;

测试压缩是否生效

你可以使用浏览器的开发者工具或者命令行工具(如curl)来检查响应头,确认是否包含Content-Encoding: gzip,以验证gzip压缩是否生效。

解答最开始的问题:虽然gzip压缩可以提高传输效率,但它也会增加服务器的CPU负载。因此,在启用gzip压缩 时,需要权衡传输速度和服务器性能之间的关系。所以才有vite-plugin-compression中配置threshold阈值的 一个配置项,可以设置压缩文件的最小体积,只有大于这个体积的资源才会被压缩。

env 环境变量

vite自带的env功能

新建.env.env.development.env.test.env.production文件,分别对应不同的环境变量

其中.env是始终生效,与其他环境变量merge在src\vite-env.d.ts中添加下面定义,作用 让import.meta.env 具有类型

interface ImportMetaEnv {
  readonly VITE_APP_API_BASE_URL: string;
  readonly VITE_APP_API_HOST: string;
}

修改package.json,添加对应环境的启动命令

"dev": "vite",
"dev:test": "vite --mode test",
"dev:prod": "vite --mode production",
"build": "vue-tsc -b && vite build --mode production",
"build:dev": "vue-tsc -b && vite build --mode development",
"build:test": "vue-tsc -b && vite build --mode test",

添加config文件

存在的意义,不一定所有的config都需要放在环境变量中,这里作为系统的config文件,在项目中使用,可以理解 为环境变量在业务中间的一个防腐层

src\config\index.ts添加

export type AppConfig = {
  apiBaseUrl: string;
};
const { VITE_APP_API_BASE_URL } = import.meta.env;
const config: AppConfig = {
  apiBaseUrl: VITE_APP_API_BASE_URL
};

export default config;

业务中使用应该使用这个config文件

jsx 支持

为什么要用jsx,因为vue是模板语法,注定在写具有大量逻辑代码的时候你的模板语法不会很好看

安装

@vitejs/plugin-vue-jsx

vite.config.ts plugins添加

import vueJsx from '@vitejs/plugin-vue-jsx';

plugins: [
  vue(),
  vueJsx(),
  ...
]

eslint 支持jsx 配置

eslint.config.js 添加eslint.config.js

 {
    languageOptions: {
      ...
      parserOptions: {
      ...
        ecmaFeatures: {
          jsx: true
        }
      }
    }
  },

demo

基本使用

<script lang="jsx">
export default {
  render() {
    return (
      <div class="app">
        <h2>我是标题</h2>
        <p>我是内容, 哈哈哈</p>
      </div>
    );
  }
};
</script>

<style lang="less" scoped></style>

组合式组件

<template>
  <jsx />
</template>

<script lang="jsx" setup>
import { ref } from 'vue';
import About from './About.vue';

const counter = ref(0);

const increment = () => {
  counter.value++;
};
const decrement = () => {
  counter.value--;
};

const jsx = () => (
  <div>
    <h2>当前计数:{counter.value}</h2>
    <button onClick={increment}> +1 </button>
    <button onClick={decrement}> -1 </button>
    <About />
  </div>
);
</script>

<style lang="less" scoped></style>

setup 中使用

<script lang="jsx">
import { ref } from 'vue';
import About from './About.vue';

export default {
  setup() {
    const counter = ref(0);

    const increment = () => {
      counter.value++;
    };
    const decrement = () => {
      counter.value--;
    };

    return () => (
      <div class="app">
        <h2>当前计数:{counter.value}</h2>
        <button onClick={increment}>+1</button>
        <button onClick={decrement}>-1</button>
        <About />
      </div>
    );
  }
};
</script>
<style lang="less" scoped></style>

具体语法

看官网吧 cn.vuejs.org/guide/extra…

lodash

安装

按需引入使用Tree-shaking

Tree-shaking主要依赖于ES6的模块特性,‌即模块必须是ESM(ES Module)。‌这是因为ES6模块的依赖关系是确定 的、‌静态的,‌与运行时的状态无关,‌从而可以进行静态分析。‌

而lodash本身不是ES6模块,要么需要如下方式使用import get from "lodash/get"才能支持按需引入,多个包 的时候就不好看所以选择lodash-es

pnpm install lodash-es
pnpm install @types/lodash-es -D

pinia 持久化

安装

pnpm add pinia pinia-plugin-persistedstate

src\store\index.ts

import type { App } from 'vue';
import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';

const store = createPinia();
store.use(piniaPluginPersistedstate);

export function setupStore(app: App<Element>) {
  app.use(store);
}

export { store };

main.ts

const app = createApp(App);

// 配置 store
setupStore(app);

配合ts使用

src\store\modules\user.ts

import { defineStore } from 'pinia';
type UserInfo = any;
interface State {
  token: string | null;
  user: UserInfo | null;
}
export const useUserStore = defineStore('user', {
  // 为了完整类型推理,推荐使用箭头函数
  state: (): State => {
    return {
      token: null,
      user: null
    };
  },
  persist: true
});

vue-router 添加守卫

导航守卫

src\router\guard\index.ts

import { useUserStore } from '@/store/modules/user';
import { Router } from 'vue-router';

export function setupRouterGuard(router: Router) {
  createAuthGuard(router);
}

const createAuthGuard = (router: Router) => {
  router.beforeEach(async to => {
    const { isLogin } = useUserStore();
    if (
      // 检查用户是否已登录
      !isLogin &&
      // ❗️ 避免无限重定向
      to.name !== 'Login'
    ) {
      // 将用户重定向到登录页面
      return { name: 'Login' };
    }
  });
};

src\router\index.ts

// 配置路由器
export function setupRouter(app: App<Element>) {
  app.use(router);
  setupRouterGuard(router);
}

mitt

src\utils\mitt\index.ts

import mitt, { Emitter } from 'mitt';
import { onBeforeUnmount, onMounted } from 'vue';
import { MittEvents } from './types';

export const emitter: Emitter<MittEvents> = mitt<MittEvents>();

// emitter.emit('foo', '123');
// emitter.on('foo', val => {
//   console.log(val);
// });

// emitter.off('foo');
// emitter.all.clear()

/**
 * emitter.on hooks的调用方法
 */
export const useSubscribe: typeof emitter.on = (...arg: Parameters<typeof emitter.on>) => {
  const subscribeKey = arg[0];
  const handler = arg[1];
  onMounted(() => {
    emitter.on(subscribeKey, handler);
  });
  onBeforeUnmount(() => {
    emitter.off(subscribeKey as keyof MittEvents);
  });
};

/**
 * emitter.emit hooks的调用方法
 */
export const usePublish: () => typeof emitter.emit = () => {
  const publish: typeof emitter.emit = (...arg: Parameters<typeof emitter.emit>) => {
    emitter.emit(...arg);
  };
  return publish;
};

src\utils\mitt\types.ts

/**
 * 为方便管理 用事件,必须再这里声明
 * 举例:appInit、userLogin,至少“主”,“动”两个单词组成
 */
export type MittEvents = {
  appInit: string;
  userLogin?: number;
};

unocss

安装

pnpm add unocss -D

uno.config.ts

import { defineConfig, presetUno } from 'unocss';

export default defineConfig({
  presets: [presetUno()],
  shortcuts: {
    'wh-full': 'w-full h-full'
  },
  rules: []
});

main.ts

import 'virtual:uno.css';

vite.config.ts

import UnoCSS from 'unocss/vite';


...
plugins: [
  ...
  UnoCSS(),
  ...
]

插件

class 可分组

pnpm add @unocss/transformer-variant-group -D

uno.config.ts 最终

import { defineConfig, presetUno } from 'unocss';

import transformerVariantGroup from '@unocss/transformer-variant-group';
export default defineConfig({
  presets: [presetUno()],
  shortcuts: {
    'wh-full': 'w-full h-full'
  },
  rules: [],
  transformers: [transformerVariantGroup()]
});

icon 解决方案

方案一 unocss icon

安装

pnpm add @iconify/utils -D

uno.config.ts 引入配置

import { defineConfig, presetUno, presetIcons } from 'unocss';
import { FileSystemIconLoader } from '@iconify/utils/lib/loader/node-loaders';
export default defineConfig({
  presets: [
    presetUno(),
    presetIcons({
      warn: true,
      prefix: ['i-'],
      extraProperties: {
        display: 'inline-block'
      },
      collections: {
        base: FileSystemIconLoader('./src/assets/svg')
      }
    })
  ],
  ...
});

添加svg资源

src\assets\svg下引入svg文件,这里的svg文件名就会是后面icon用到的名字

<div class="i-base:close"></div>

未来应该的发展

应该有个icon解决平台的方案,而不是在本地项目维护

缺点

抽象成组件有问题,因为class是拼出来的,没法起作用

方案二 vite-plugin-svg-icons

安装

pnpm add vite-plugin-svg-icons -D

vite.config.ts


import { createSvgIconsPlugin } from 'vite-plugin-svg-icons';
plugins: [
  ...
  createSvgIconsPlugin({
    // Specify the icon folder to be cached
    iconDirs: [path.resolve(process.cwd(), 'src/assets/svg')],
    // Specify symbolId format
    symbolId: 'icon-[dir]-[name]'
  }),
  ...
]

main.ts

import 'virtual:svg-icons-register';

svg组件

src\components\IconSvg\index.vue

<template>
  <svg aria-hidden="true" :style="getStyle">
    <use :href="symbolId" :fill="color" />
  </svg>
</template>

<script>
import { defineComponent, computed } from 'vue';

export default defineComponent({
  name: 'SvgIcon',
  props: {
    prefix: {
      type: String,
      default: 'icon'
    },
    name: {
      type: String,
      required: true
    },
    size: {
      type: [Number, String],
      default: 24
    },
    color: {
      type: String,
      default: '#333'
    }
  },
  setup(props) {
    const symbolId = computed(() => `#${props.prefix}-${props.name}`);
    const getStyle = computed(() => {
      const { size } = props;
      let s = `${size}`;
      s = `${s.replace('px', '')}px`;
      return {
        width: s,
        height: s
      };
    });
    return { symbolId, getStyle };
  }
});
</script>

css 引入重置

安装

pnpm add @unocss/reset

src\main.ts

import '@unocss/reset/tailwind.css';

设置npm 阿里源

根目录添加.npmrc

registry=https://registry.npmmirror.com

vscode配置

在项目目录下添加.vscode配置文件夹

会和本地配置merge,方便统一管理

一个编辑器小插件

Code Spell Checker 可以检测单词拼写

vite-plugin-cdn-import

项目打包大小的优化,因为比如vue,axios,element-plus,等我们长期都是使用一个版本的,每次打包这些内容 都不需要更新的,也不需要打进包里面的,且单独走cdn,还可以加速访问,减少服务器压力

pnpm add vite-plugin-cdn-import -D

默认是使用的是jsdelivr

vite.config.ts

...
importToCDN({
  modules: [
    'vue',
    {
      name: 'vue-demi',
      var: 'VueDemi',
      path: 'lib/index.iife.min.js'
    },
    'vue-router',
    'axios',
    'dayjs',
    // {
    //   name: 'vue-i18n',
    //   var: 'VueI18n',
    //   path: 'dist/vue-i18n.global.min.js'
    // },
    {
      name: 'pinia',
      var: 'Pinia',
      path: 'dist/pinia.iife.min.js'
    }
  ]
}),

最后使用下来,打包速度快了一丢丢,因为现在这个template比较小看不出来,速度的差别,因为没有把第三方包 打进来,所以肯定是快了的

其次加载的数据,也是因为项目比较小,甚至总体加载完资源的速度还变慢了,虽然有资源走cdn了,但是请求数 量多上去了,而影响整体加载的因素除了跟大小还跟http请求数量有关系,所以这个功能看自己用不用,用也可 以,不用也行

unplugin-auto-import 消除部分通用 import 引入

安装

pnpm add unplugin-auto-import -D

vite.config.ts

import AutoImport from 'unplugin-auto-import/vite';

plugins: [
  AutoImport({
    //要想在项目中优雅地使用自动导入,还要解决以下两个编码的问题:
    //TS 类型丢失,会导致 TS 编译报错
    //插件会在项目根目录生成类型文件 auto-imports.d.ts ,确保该文件在 tsconfig 中被 include
    dts: true,
    imports: ['vue', 'vue-router'],

    //插件会在项目根目录生成类型文件 .eslintrc-auto-import.json ,确保该文件在 eslint 配置中被 extends:
    // .eslintrc.js
    // module.exports = {
    //   extends: [
    //     './.eslintrc-auto-import.json',
    //   ],
    // }

    eslintrc: {
      enabled: true
    }
  })
];

预热

先要跑一次项目生成对应的声明文件auto-imports.d.ts,..eslintrc-auto-import.json

解决ts报错

tsconifg

"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "types/**/*.ts", "auto-imports.d.ts"],

解决eslint报错

eslint.config.js

import fs from 'fs';
const loadJSON = path => JSON.parse(fs.readFileSync(new URL(path, import.meta.url)));
const autoImports = loadJSON('./.eslintrc-auto-import.json');


export default [
  ...
  {
    languageOptions: {
      globals: { ...globals.browser, ...globals.node, ...autoImports.globals },
      parser: parserVue,
      parserOptions: {
        parser: tseslint.parser,
        ecmaFeatures: {
          jsx: true
        }
      }
    }
  },
  ...
]

然后你页面上就可以不用引入比如ref了,同时eslint也正常,ts类型也正常跳转

i18n 多语言解决方案

vue-i18n

太多了,略,简单使用没问题,要配合ts,要不报错,还要全局t函数有提示就搞了很久

vscode i18n Ally

请求 hooks 封装

使用vue-hooks-plus

常用hooks:useBoolean、useUrlState、useToggle、useDebounce、useThrottle、useSize、useRequest

axios 封装

src\utils\request\index.ts

import axios, { AxiosRequestConfig } from 'axios';

axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';

export const axiosInstance = axios.create({
  timeout: 10000
});

axiosInstance.interceptors.request.use(
  config => {
    return config;
  },
  error => {
    return Promise.reject(error);
  }
);

axiosInstance.interceptors.response.use(
  response => {
    if (response?.status === 200) {
      return Promise.resolve(response.data);
    } else {
      return Promise.reject(response);
    }
  },
  error => {
    if (error?.message?.includes?.('timeout')) {
      console.log('timeout');
    } else {
      console.log(error);
    }
    Promise.reject(error);
  }
);

export const request = <ResponseType = unknown>(
  url: string,
  options?: AxiosRequestConfig<unknown>
): Promise<ResponseType> => {
  return new Promise((resolve, reject) => {
    axiosInstance({
      url,
      ...options
    })
      .then(res => {
        resolve(res.data);
      })
      .catch(err => reject(err));
  });
};

element-plus 及基础样式修改,推荐样式覆盖解决方案

由于是ui框架,没在主分支,在单独的element-plus分支上

安装官网安装,我这里采取的全局使用,然后走cdn的方式

样式覆盖

添加 src/assets/style/element/index.scss

@use 'sass:map';
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
  $colors: (
    'white': #ffffff,
    'black': #000000,
    'primary': (
      'base': #409eff
    ),
    'success': (
      'base': #67c23a
    ),
    'warning': (
      'base': #e6a23c
    ),
    'danger': (
      'base': #f56c6c
    ),
    'error': (
      'base': #f56c6c
    ),
    'info': (
      'base': #909399
    )
  ),
  // 正确的样式覆盖方式
  $button-border-radius:
    (
      'large': 2px,
      'default': 2px,
      'small': 2px
    )
);

@use 'element-plus/theme-chalk/src/index.scss' as *;

入口main.ts替换原本的引入element的样式

import '@/assets/style/element/index.scss';

vite.config.ts mixin有冲突,additionalData 使用函数的方式来

preprocessorOptions: {
        scss: {
          additionalData: (source: string, filename: string) => {
            if (filename.includes('style/element/index.scss')) {
              return source;
            }

            return '@import "@/assets/style/mixin.scss";' + source;
          }
          // additionalData: '@import "@/assets/style/mixin.scss";'
        }
      }
    }

注意看覆盖文件中的,这是推荐的覆盖基本样式的方式

$button-border-radius: (
  'large': 2px,
  'default': 2px,
  'small': 2px
);

yapi 接口生成

已开发在yapi-2-typescript 分支