搭建 vite + vue3 + ts + pinia 项目框架

7,288 阅读17分钟

码云地址: https://gitee.com/guorui999/vue3-vite.git

一、创建项目

1. 安装vite

npm i vite -g

2. 创建项目

  • 一步创建
# npm 6.x
npm create vite@latest my-vue-app --template vue-ts
​
# npm 7+, extra double-dash is needed:
npm create vite@latest my-vue-app -- --template vue-ts
​
# yarn
yarn create vite my-vue-app --template vue-ts
​
# pnpm
pnpm create vite my-vue-app --template vue-ts
  • 配置创建
npm init vue@latest

img

如果安装依赖后运行 npm run dev 报以下错误

image-20230128111555707.png

解决方法: 更新node版本

nodejs.org/en

或者安装nvm切换node版本

3. vite常见报错及解决方法

  • 无法找到模块“../views/HomeView.vue”的声明文件。

解决方法

// env.d.ts添加代码
declare module '*.vue' {
  import { DefineComponent } from 'vue';
  const component: DefineComponent<{}, {}, any>;
  export default component;
}

// 或者
declare module '*.vue' {
   import type { DefineComponent } from 'vue'
   const component: ComponentOptions | ComponentOptions['setup']
   export default component
}
  • 无法找到模块“three”的声明文件

解决方法

安装type文件 npm i --save-dev @types/three

// env.d.ts添加代码

declare module 'three';
  • 打包图片路径报错

解决方法

// 如果是背景图片引入的方式(一定要使用相对路径)
background-image: url('../../assets/images/background.png')

// 如果是template里面使用动态图片, 一定要import 或者require进来

import icon from '@/assets/images/logo.png'
const data=[{name:'text',icon}]
// 或者
const data=[{name:'text',icon:new URL('@/assets/images/logo.png',import.meta.url).href}]
  • require is not defind

解决办法

// 1. 安装依赖 npm install @type/node --save-dev
// 2. 使用 new URL('@/assets/images/logo.png',import.meta.url).href 代替 require('')
  • window声明

解决方法

// window.d.ts

declare global {
  interface Window {
    map: WebGIS.Map;
  }
}

export {};

  • unplugin-vue-components自动按需引入组件, 并生成声明文件components.d.ts

babel-plugin-import 可以实现按需引入进行按需加载,加入这个插件后,可以省去 style 的引入, 但需要手动引入

而 unplugin-vue-components 可以不需要手动引入组件,能够让开发者就像全局组件那样进行开发,但实际上又是按需引入,且不限制打包工具,不需要使用 babel 解决方法

// vite.config.ts
export default defineConfig((/* { mode } */) => {
  const plugins: PluginOption[] = [];
  plugins.push(
    // @ts-ignore
    Components({
      dts: true,
      resolvers: [
        AntDesignVueResolver({
          importStyle: false, // css in js
        }),
      ],
    }),
  );
})

// tsconfig.json
{
  "files": ["./components.d.ts"],
}

  • 路径:找不到模块“@com”或其相应的类型声明

解决方法

// vite.config.ts
{
    resolve: {
      alias: {
        '@': fileURLToPath(new URL('./src', import.meta.url)),
        // 图片文件夹
        '@img': fileURLToPath(new URL('./src/assets/images', import.meta.url)),
        // 公共组件
        '@com': fileURLToPath(new URL('./src/components', import.meta.url)),
      }
    }
}

// tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"],
      "@com/*": ["./src/components/*"],
      "@com": ["./src/components/index"], // @com如果想默认引入components下的index, 需要这样配置
      "@img/*": ["./src/assets/images/*"]
    }
   }
 }

二、项目基本配置

1. 项目icon

在 public目录 下,添加一个 favicon.icon 图片

2. 项目标题

在 index.html 文件的 title标签 中配置

3. 配置 tsconfig.json

能让 代码提示 变得更加友好

{
  "compilerOptions": {
    // 启用项目合成模式。在项目合成模式下,TypeScript 编译器可以更快地增量编译大型项目
    "composite": true,
    // 允许从没有设置默认导出的模块中默认导入。这并不影响代码的输出,仅为了类型检查。
    "allowSyntheticDefaultImports": true,
    // 解析非相对模块名的基准目录
    "baseUrl": ".",
    // 模块加载兼容模式,可以使用import from语法导入commonJS模块
    "esModuleInterop": true,
    // 从 tslib 导入辅助工具函数(比如 __extends, __rest等)
    "importHelpers": true,
    // 指定生成哪个模块系统代码
    "module": "esnext",
    // 决定如何处理模块。
    "moduleResolution": "node",
    // 启用所有严格类型检查选项。
    // 启用 --strict相当于启用 --noImplicitAny, --noImplicitThis, --alwaysStrict,
    // --strictNullChecks和 --strictFunctionTypes和--strictPropertyInitialization。
    "strict": true,
    "noImplicitAny": false,                 // 在表达式和声明上有隐含的 any类型时不报错 
    "noUnusedLocals": false,                // 有未使用的变量时,不抛出错误
    "noUnusedParameters": false,            // 有未使用的参数时,不抛出错误
    // 支持jsx语法
    "jsx": "preserve",
    // 生成相应的 .map文件。
    "sourceMap": true,
    // 忽略所有的声明文件( *.d.ts)的类型检查。
    "skipLibCheck": true,
    // 指定ECMAScript目标版本
    "target": "esnext",
    // 要包含的类型声明文件名列表
    "types": [
      "node"
    ],
    "typeRoots": [
      "./node_modules/@types"
    ],
    // isolatedModules 设置为 true 时,如果某个 ts 文件中没有一个import or export 时,ts 则认为这个模块不是一个 ES Module 模块,它被认为是一个全局的脚本,
    "isolatedModules": true,
    // 模块名到基于 baseUrl的路径映射的列表。
    "paths": {
      "@/*": [
        "/src/*"
      ]
    },
    "vueCompilerOptions": {
      "experimentalDisableTemplateSupport": true //去掉volar下el标签红色波浪线问题
    },
    // 编译过程中需要引入的库文件的列表。
    "lib": [
      "ESNext",
      "DOM",
      "DOM.Iterable",
      "ScriptHost"
    ]
  },
  // 解析的文件
  "include": [
    "env.d.ts",
    "src/**/*",
    "src/**/*.ts",
    "src/**/*.d.ts",
    "src/**/*.tsx",
    "src/**/*.vue",
    "src/*.js",
    "src/**/*.jsx"
  ],
  "exclude": [
    "node_modules"
  ],
  // 扩展配置
  "references": [
    {
      "path": "./tsconfig.node.json"
    }
  ]
}

4. 设置 .prettierrc.json 文件

eslint 配置格式化选项说明

  // 1.一行代码的最大字符数,默认是80(printWidth: <int>)
  printWidth: 80,
  // 2.tab宽度为2空格(tabWidth: <int>)
  tabWidth: 2,
  // 3.是否使用tab来缩进,我们使用空格(useTabs: <bool>)
  useTabs: false,
  // 4.结尾是否添加分号,false的情况下只会在一些导致ASI错误的其工况下在开头加分号
  semi: true,
  // 5.使用单引号(singleQuote: <bool>)
  singleQuote: true,
  // 6.object对象中key值是否加引号(quoteProps: "<as-needed|consistent|preserve>")as-needed只有在需求要的情况下加引号,consistent是有一个需要引号就统一加,preserve是保留用户输入的引号
  quoteProps: 'as-needed',
  // 7.在jsx文件中的引号需要单独设置(jsxSingleQuote: <bool>)
  jsxSingleQuote: false,
  // 8.尾部逗号设置,es5是尾部逗号兼容es5,none就是没有尾部逗号,all是指所有可能的情况,需要node8和es2017以上的环境。(trailingComma: "<es5|none|all>")
  trailingComma: 'es5',
  // 9.object对象里面的key和value值和括号间的空格(bracketSpacing: <bool>)
  bracketSpacing: true,
  // 10.jsx标签多行属性写法时,尖括号是否另起一行(jsxBracketSameLine: <bool>)
  jsxBracketSameLine: false,
  // 11.箭头函数单个参数的情况是否省略括号,默认always是总是带括号(arrowParens: "<always|avoid>")
  arrowParens: 'always',
  // 12.range是format执行的范围,可以选执行一个文件的一部分,默认的设置是整个文件(rangeStart: <int>  rangeEnd: <int>)
  rangeStart: 0,
  rangeEnd: Infinity,
  // 13.不需要写文件开头的 @prettier
  requirePragma: false,
  // 14.不需要自动在文件开头插入 @prettier
  insertPragma: false,
  // 15.vue script和style标签中是否缩进,开启可能会破坏编辑器的代码折叠
  vueIndentScriptAndStyle: false,
  // 16.使用默认的折行标准
  proseWrap: 'preserve',
  // 17.根据显示样式决定 html 要不要折行
  htmlWhitespaceSensitivity: 'css',
  // 18.endOfLine: "<lf|crlf|cr|auto>" 行尾换行符,默认是lf,
  endOfLine: 'lf',
  // 19.embeddedLanguageFormatting: "off",默认是auto,控制被引号包裹的代码是否进行格式化
  embeddedLanguageFormatting: 'off',
   
// 常用配置
{
  "singleQuote": true,
  "tabWidth": 2,
  "semi": true,
}

5. .eslintrc.js 文件参考

// ESlint 检查配置
module.exports = {
    root: true,
    parserOptions: {
        ecmaVersion: 'latest',
        parser: '@typescript-eslint/parser',
        sourceType: 'module',
        ecmaFeatures: {
            jsx: true,
        },
    },
    env: {
        browser: true,
        node: true,
        es6: true,
    },
    parser: 'vue-eslint-parser',
    extends: [
        'eslint:recommended', // ESLint 官方推荐的通用规则
        'plugin:vue/vue3-recommended', // Vue.js 官方推荐的 Vue 3 相关规则。
        'plugin:@typescript-eslint/recommended', // 这是与 TypeScript 结合使用的 ESLint 规则
        'plugin:prettier/recommended', // 这个插件将整合 Prettier 格式化工具和 ESLint,确保代码格式化的一致性。它会关闭与 Prettier 冲突的 ESLint 规则。
        'prettier', // 这个配置用于禁用与 Prettier 冲突的 ESLint 规则。
    ],
    // eslint-plugin-vue @typescript-eslint/eslint-plugin eslint-plugin-prettier的缩写
    plugins: ['vue', '@typescript-eslint', 'prettier'],
    rules: {
        '@typescript-eslint/ban-ts-ignore': 'off',
        '@typescript-eslint/no-unused-vars': 'off',
        '@typescript-eslint/explicit-function-return-type': 'off',
        '@typescript-eslint/no-explicit-any': 'off',
        '@typescript-eslint/no-var-requires': 'off',
        '@typescript-eslint/no-empty-function': 'off',
        '@typescript-eslint/no-use-before-define': 'off',
        '@typescript-eslint/no-this-alias': 'off',
        '@typescript-eslint/ban-ts-comment': 'off',
        '@typescript-eslint/ban-types': 'off',
        '@typescript-eslint/no-non-null-assertion': 'off',
        '@typescript-eslint/explicit-module-boundary-types': 'off',
        'no-var': 'error',
        'prettier/prettier': 'error',
        // 禁止出现console
        'no-console': 'off',
        // 禁用debugger
        'no-debugger': 'warn',
        // 禁止出现重复的 case 标签
        'no-duplicate-case': 'warn',
        // 禁止出现空语句块
        'no-empty': 'warn',
        // 禁止不必要的括号
        'no-extra-parens': 'off',
        // 禁止对 function 声明重新赋值
        'no-func-assign': 'warn',
        // 禁止在 return、throw、continue 和 break 语句之后出现不可达代码
        'no-unreachable': 'warn',
        // 强制所有控制语句使用一致的括号风格
        curly: 'warn',
        // 要求 switch 语句中有 default 分支
        'default-case': 'warn',
        // 强制尽可能地使用点号
        'dot-notation': 'warn',
        // 要求使用 === 和 !==
        eqeqeq: 'warn',
        // 禁止 if 语句中 return 语句之后有 else 块
        'no-else-return': 'warn',
        // 禁止出现空函数
        'no-empty-function': 'warn',
        // 禁用不必要的嵌套块
        'no-lone-blocks': 'warn',
        // 禁止使用多个空格
        'no-multi-spaces': 'warn',
        // 禁止多次声明同一变量
        'no-redeclare': 'warn',
        // 禁止在 return 语句中使用赋值语句
        'no-return-assign': 'warn',
        // 禁用不必要的 return await
        'no-return-await': 'warn',
        // 禁止自我赋值
        'no-self-assign': 'warn',
        // 禁止自身比较
        'no-self-compare': 'warn',
        // 禁止不必要的 catch 子句
        'no-useless-catch': 'warn',
        // 禁止多余的 return 语句
        'no-useless-return': 'warn',
        // 禁止变量声明与外层作用域的变量同名
        'no-shadow': 'off',
        // 允许delete变量
        'no-delete-var': 'off',
        // 强制数组方括号中使用一致的空格
        'array-bracket-spacing': 'warn',
        // 强制在代码块中使用一致的大括号风格
        'brace-style': 'warn',
        // 强制使用骆驼拼写法命名约定
        camelcase: 'warn',
        // 强制使用一致的缩进
        indent: 'off',
        // 强制在 JSX 属性中一致地使用双引号或单引号
        // 'jsx-quotes': 'warn',
        // 强制可嵌套的块的最大深度4
        'max-depth': 'warn',
        // 强制最大行数 300
        // "max-lines": ["warn", { "max": 1200 }],
        // 强制函数最大代码行数 50
        // 'max-lines-per-function': ['warn', { max: 70 }],
        // 强制函数块最多允许的的语句数量20
        'max-statements': ['warn', 100],
        // 强制回调函数最大嵌套深度
        'max-nested-callbacks': ['warn', 3],
        // 强制函数定义中最多允许的参数数量
        'max-params': ['warn', 3],
        // 强制每一行中所允许的最大语句数量
        'max-statements-per-line': ['warn', { max: 1 }],
        // 要求方法链中每个调用都有一个换行符
        'newline-per-chained-call': ['warn', { ignoreChainWithDepth: 3 }],
        // 禁止 if 作为唯一的语句出现在 else 语句中
        'no-lonely-if': 'warn',
        // 禁止空格和 tab 的混合缩进
        'no-mixed-spaces-and-tabs': 'warn',
        // 禁止出现多行空行
        'no-multiple-empty-lines': 'warn',
        // 禁止出现;
        semi: 'warn',
        // 强制在块之前使用一致的空格
        'space-before-blocks': 'warn',
        // 强制在 function的左括号之前使用一致的空格
        // 'space-before-function-paren': ['warn', 'never'],
        // 强制在圆括号内使用一致的空格
        'space-in-parens': 'warn',
        // 要求操作符周围有空格
        'space-infix-ops': 'warn',
        // 强制在一元操作符前后使用一致的空格
        'space-unary-ops': 'warn',
        // 强制在注释中 // 或 /* 使用一致的空格
        // "spaced-comment": "warn",
        // 强制在 switch 的冒号左右有空格
        'switch-colon-spacing': 'warn',
        // 强制箭头函数的箭头前后使用一致的空格
        'arrow-spacing': 'warn',
        'prefer-const': 'warn',
        'prefer-rest-params': 'warn',
        'no-useless-escape': 'warn',
        'no-irregular-whitespace': 'warn',
        'no-prototype-builtins': 'warn',
        'no-fallthrough': 'warn',
        'no-extra-boolean-cast': 'warn',
        'no-case-declarations': 'warn',
        'no-async-promise-executor': 'warn',
        'vue/multi-word-component-names': 'off',
    },
    globals: {
        defineProps: 'readonly',
        defineEmits: 'readonly',
        defineExpose: 'readonly',
        withDefaults: 'readonly',
        module: 'readonly',
    },
};

6. 设置 vite.config.ts 文件

安装 gzip 和 mock 依赖

npm i vite-plugin-compression vite-plugin-mock -D
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
​
import path from 'path'
// gzip插件
import viteCompression from 'vite-plugin-compression'
// mock插件
import { viteMockServe } from 'vite-plugin-mock'const resolve = (dir) => path.resolve(__dirname, dir)
​
export default defineConfig({
    base: './', //打包路径
    publicDir: resolve('public'), //静态资源服务的文件夹
    plugins: [
        vue(),
        vueJsx(),
        // gzip压缩 生产环境生成 .gz 文件
        viteCompression({
            verbose: true,
            disable: false,
            threshold: 10240,
            algorithm: 'gzip',
            ext: '.gz',
        }),
        //mock
        viteMockServe({
            mockPath: './mocks', // 解析,路径可根据实际变动
            localEnabled: true, // 此处可以手动设置为true,也可以根据官方文档格式
        }),
    ],
    // 配置别名
    resolve: {
        alias: {
            '@': resolve('src'),
            // 另一种写法: import { fileURLToPath, URL } from 'node:url';
            // '@': fileURLToPath(new URL('./src', import.meta.url)),
        },
        // 导入时想要省略的扩展名列表
        extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json'],
    },
    css: {
        // css预处理器
        preprocessorOptions: {
            scss: {
                additionalData:
                    '@import "@/assets/styles/common.scss";@import "@/assets/styles/reset.scss";', // '@import "${resolve('src/assets/styles/common.scss')}";@import "${resolve('src/assets/styles/reset.scss')}";'
            },
        },
    },
    //启动服务配置
    server: {
        host: '0.0.0.0',
        port: 8000,
        open: true, // 自动在浏览器打开
        proxy: {},
    },
    // 打包配置
    build: {
        //浏览器兼容性  "esnext"|"modules"
        target: 'modules',
        //指定输出路径
        outDir: 'build',
        //生成静态资源的存放路径
        assetsDir: 'assets',
        //启用/禁用 CSS 代码拆分
        cssCodeSplit: true,
        sourcemap: false,
        assetsInlineLimit: 10240,
        // 打包环境移除console.log, debugger
        minify: 'terser',
        terserOptions: {
            compress: {
                drop_console: true,
                drop_debugger: true,
            },
        },
        rollupOptions: {
            input: {
                main: resolve('index.html'),
            },
            output: {
                entryFileNames: `js/[name]-[hash].js`,
                chunkFileNames: `js/[name]-[hash].js`,
                assetFileNames: `[ext]/[name]-[hash].[ext]`,
            },
        },
    },
})
​

三、项目目录结构划分

    1. assets 存放 => 静态资源
    • css => 样式重置
    • img => 图片文件
    • font => 字体文件
    1. components 存放 => 公共组件
    1. hooks 存放 => 公共常用的hook
    1. mock 存放 => 模拟接口数据
    1. router 存放 => 路由管理
    1. service 存放 => 接口请求
    1. stores 存放 => 状态管理
    1. utils 存放 => 插件、第三方插件
    1. views 存放 => 视图、页面

四、css 样式重置

自定义的css公共文件放置在assets中的css文件中即可

1. normalize.css

01 - 安装

npm i normalize.css

02 - 引入

// 在 main.js 中引入
import 'normalize.css';

2. reset.css

01 - 代码

html,
body,
div,
span,
applet,
object,
iframe,
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote,
pre,
a,
abbr,
acronym,
address,
big,
cite,
code,
del,
dfn,
em,
font,
img,
ins,
kbd,
q,
s,
samp,
small,
strike,
strong,
sub,
sup,
tt,
var,
b,
u,
i,
center,
dl,
dt,
dd,
ol,
ul,
li,
fieldset,
form,
label,
legend,
caption {
    margin: 0;
    padding: 0;
    border: 0;
    outline: 0;
    font-size: 100%;
    vertical-align: baseline;
    background: transparent;
}
​
table,
tbody,
tfoot,
thead,
tr,
th,
td {
    margin: 0;
    padding: 0;
    outline: 0;
    font-size: 100%;
    vertical-align: baseline;
    background: transparent;
}
​
button,
input,
textarea {
    margin: 0;
    padding: 0;
}
​
/* form elements 表单元素 */
body,
button,
input,
select,
textarea {
    font: normal 12px/1.5 '\5FAE\8F6F\96C5\9ED1', tahoma, arial;
}
​
/*设置的字体,行高*/
h1,
h2,
h3,
h4,
h5,
h6,
th {
    font-size: 100%;
    font-weight: normal;
}
​
/*重置标题*/
address,
cite,
dfn,
var {
    font-style: normal;
}
​
/* 将斜体扶正 */
code,
kbd,
pre,
samp {
    font-family: 'courier new', courier, monospace;
}
​
/* 统一等宽字体 */
small {
    font-size: 12px;
}
​
/* 小于 12px 的中文很难阅读,让 small 正常化 */
ul,
ol {
    list-style: none;
}
​
/* 重置列表元素 */
button,
input[type="submit"],
input[type="button"] {
    cursor: pointer;
}
​
input[type="radio"],
input[type="checkbox"],
input[type="submit"],
input[type="reset"] {
    vertical-align: middle;
    cursor: pointer;
    border: none;
}
​
/** 重置文本格式元素 **/
a {
    text-decoration: none;
}
a:hover {
    text-decoration: underline;
}
a:focus {
    outline: 0;
}
sup {
    vertical-align: text-top;
}
​
/* 重置,减少对行高的影响 */
sub {
    vertical-align: text-bottom;
}
​
/** 重置表单元素 **/
legend {
    color: #000;
}
​
/* for ie6 */
fieldset,
img {
    border: 0;
}
​
/* img 搭车:让链接里的 img 无边框 */
button,
input,
select,
textarea {
    background: transparent;
    font-size: 100%;
    outline: 0;
}
​
/* 使得表单元素在 ie 下能继承字体大小 */
/* 注:optgroup 无法扶正 */
table {
    border-collapse: collapse;
    border-spacing: 0;
}
​
td,
th {
    vertical-align: middle;
}
​
/** 重置表格元素 **/
/* 重置 HTML5 元素 */
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
menu,
nav,
section,
summary,
time,
mark,
audio,
video {
    display: block;
    margin: 0;
    padding: 0;
}
​
/*回复标签重置*/
blockquote,
q {
    quotes: none;
}
​
blockquote:before,
blockquote:after,
q:before,
q:after {
    content: '';
    display: none;
}

简版样式重置

// 按照网站自己的需求,提供公用的样式
* {
  box-sizing: border-box;
}

html {
  height: 100%;
  font-size: 14px;
}

body {
  height: 100%;
  color: #333;
  min-width: 1240px;
  font: 1em/1.4 'Microsoft Yahei', 'PingFang SC', 'Avenir', 'Segoe UI', 'Hiragino Sans GB', 'STHeiti', 'Microsoft Sans Serif', 'WenQuanYi Micro Hei', sans-serif;
}

ul,
h1,
h3,
h4,
p,
dl,
dd {
  padding: 0;
  margin: 0;
}

a {
  text-decoration: none;
  color: #333;
  outline: none;
}

i {
  font-style: normal;
}

input[type='text'],
input[type='search'],
input[type='password'],
input[type='checkbox'] {
  padding: 0;
  outline: none;
  border: none;
  -webkit-appearance: none;
  &::placeholder {
    color: #ccc;
  }
}

img {
  max-width: 100%;
  max-height: 100%;
  vertical-align: middle;
  //  background: #ebebeb;
}

ul {
  list-style: none;
}

#app {
  // 不能选中文字
  user-select: none;
}


/* 滚动条容器 */
::-webkit-scrollbar {
    width: 6px;
    height: 6px;
}

/* 滚动槽 */
::-webkit-scrollbar-track {
    border-radius: 10px;
}

/* 滚动条滑块 */
::-webkit-scrollbar-thumb {
    background-color: #313340;
    border-radius: 10px;
    -webkit-transition: all .2s ease-in-out;

    &:hover {
        background-color: #4c4e59;
        cursor: pointer;
    }
}

02 - 引入

// 在 main.js 中引入
import './assets/css/reset.css';

3. common.css

01 - 代码

    @use "sass:math";
    // 背景色
    $background-color-white: #ffffff;


    $vw_base: 1920;
    $vh_base: 1080;
    /* 计算vw */
    @function vw($px) {
    @return math.div($px,  $vw_base) * 100vw;
    }

    /* 计算vh */
    @function vh($px) {
    @return math.div($px,  $vh_base) * 100vh;
    }
    
    /* 单行文本省略 */
@mixin single-line($width) {
  width: #{$width}px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* 多行文本省略 */
@mixin multi-line($num) {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: $num;
  overflow: hidden;
}


    /**
     * 全局样式代码块
     */
    @mixin flex($type) {
        display: flex;

        /* 水平居中 */
        @if $type ==1 {
            justify-content: center;
        }

        /* 垂直居中 */
        @else if($type ==2) {
            align-items: center;
        }

        /* 水平垂直居中 */
        @else if($type ==3) {
            justify-content: center;
            align-items: center;
        }

        /* 水平拉伸垂直居中 */
        @else if($type ==4) {
            justify-content: space-between;
            align-items: center;
        }

        /* 水平拉伸换行 */
        @else if($type ==5) {
            justify-content: space-between;
            flex-wrap: wrap;
        }

        /* 换行 */
        @else if($type ==6) {
            flex-wrap: wrap;
        }
    }

    .fl {
      float: left;
    }

    .fr {
      float: right;
    }

    .clearfix:after {
      content: '.';
      display: block;
      visibility: hidden;
      height: 0;
      line-height: 0;
      clear: both;
    }

    // 闪动画
    .shan {
      &::after {
        content: '';
        position: absolute;
        animation: shan 1.5s ease 0s infinite;
        top: 0;
        width: 30%;
        height: 100%;
        background: linear-gradient(to left, rgba(255, 255, 255, 0) 0, rgba(255, 255, 255, 0.3) 50%, rgba(255, 255, 255, 0) 100%);
        transform: skewX(-45deg);
      }
    }
    @keyframes shan {
      0% {
        left: -100%;
      }
      100% {
        left: 120%;
      }
    }

    // 离开淡出动画
    .fade {
      &-leave {
        &-active {
          position: absolute;
          width: 100%;
          transition: opacity 0.5s 0.2s;
          z-index: 1;
        }
        &-to {
          opacity: 0;
        }
      }
    }

    // 1. 离开,透明度  1---->0    位移 0---->30
    // 2. 进入,透明度  0---->1    位移 30---->0
    // 执行顺序,先离开再进入
    .pop {
      &-leave {
        &-from {
          opacity: 1;
          transform: none;
        }
        &-active {
          transition: all 0.5s;
        }
        &-to {
          opacity: 0;
          transform: translateX(20px);
        }
      }
      &-enter {
        &-from {
          opacity: 0;
          transform: translateX(20px);
        }
        &-active {
          transition: all 0.5s;
        }
        &-to {
          opacity: 1;
          transform: none;
        }
      }
    }

02 - 引入

// 在 main.js 中引入
import './assets/css/common.css';

五、vue-router 路由配置

一步创建需要安装依赖、配置路由, 引入mian.ts, 配置创建则已自动生成

1. 安装

npm i vue-router

2. 配置

// 1. 导入
import { createRouter, createWebHashHistory } from 'vue-router';
​
// 2. 创建路由对象
const router = createRouter({
    history: createWebHashHistory(),
    routes: [
        {
            path: '/',
            redirect: '/home'
        },
        {
            path: '/home',
            component: () => import('xxx/home.vue')
        }
    ]
});
​
// 3. 导出
export default router;

3. 引入

// main.js
 
import { createApp } from 'vue';
import App from './App.vue';
// 1. 导入
import router from './router';
 
import 'normalize.css';
import './assets/css/reset.css';
import './assets/css/common.css';
 
// 2. 使用
createApp(App).use(router).mount('#app');

4. 使用

设置路由映射组件的展示区域

<router-view></router-view>

设置超链接

<router-link to="/index">首页</router-link>
<router-link :to="{ name: 'index' }">首页</router-link>
<router-link to="/school/1">这是广州校区</router-link>

编程式跳转

router.push('home')
router.push({path:'/child/${itemId}'})
router.push({ path:"/home"query:{ id:this.id} })
router.push({ name:'user', params:{userId: '123'}})
router.go()
router.go(-1)
router.back()

六、pinia 状态管理

一步创建需要安装依赖、配置路由, 引入mian.ts, 配置创建则已自动生成

1. 安装

 npm i pinia
 npm i pinia-plugin-persist // 持久化

2. 引入

// main.js
import { createApp } from 'vue';
import { createPinia } from "pinia";
​import piniaPersist from 'pinia-plugin-persist'

import App from './App.vue';
// 1. 导入
import router from './router';
​
import 'normalize.css';
import './assets/css/reset.css';
import './assets/css/common.css';
​
// 2. 使用
const pinia = createPinia()
pinia.use(piniaPersist)
createApp(App).use(pinia).use(router).mount('#app');

3. 模块

// 1. 导入
import { defineStore } from 'pinia';
​
// 2. 使用
const useDemoStore = defineStore('demoStore', {
    state: () => ({
        arrList: []
    }),
    actions: {},
    getters: {},
    // 所有数据持久化
    // persist: {
    //     enabled: true,
    // },

    // 持久化存储插件其他配置
    persist: {
        enabled: true,
        strategies: [
            // 修改存储中使用的键名称,默认为当前 Store的 id
            // 修改为 sessionStorage,默认为 localStorage
            // 部分持久化状态的点符号路径数组,[]意味着没有状态被持久化(默认为undefined,持久化整个状态)
            { key: 'storekey', storage: sessionStorage, paths: ['routes', 'userInfo'] }, // routes 和 userInfo字段用sessionStorage存储
            { key: 'storekey', storage: localStorage, paths: ['token'] }, // token字段用 localstorage存储
        ],
    },
});
​
// 3. 导出
export default useDemoStore;

4. 使用

import { useStore } from '@/stores/counter' 
const store = useStore() 
const { counter, doubleCount } = storeToRefs(store) 
store.$reset() // 将状态 重置 到其初始值 
store.counter++ 
store.$patch({ counter: store.counter + 1, }) 
store.$state = { counter: 666 } 
pinia.state.value = {} 

七、集成 Axios HTTP 工具

安装依赖

npm i axios

请求配置

在 utils 目录下创建 request.ts 文件,配置好适合自己业务的请求拦截和响应拦截

// vite环境变量
// .env.development
NODE_ENV=development
VITE_BASE_URL='http://xxx'
// .env.production
NODE_ENV=production
VITE_BASE_URL='http://xxx'
import axios, { AxiosRequestConfig, Method } from 'axios';

// 发布订阅  
class EventEmitter {  
    constructor() {  
        this.event = {}  
    }  
    on(type, cbres, cbrej) {  
        if (!this.event[type]) {  
            this.event[type] = [[cbres, cbrej]]  
        } else {  
            this.event[type].push([cbres, cbrej])  
        }  
    }  
  
    emit(type, res, ansType) {  
        if (!this.event[type]) return  
        else {  
            this.event[type].forEach(cbArr => {  
                if(ansType === 'resolve') {  
                    cbArr[0](res)  
                }else{  
                    cbArr[1](res)  
                }  
            });  
        }  
    }  
}

// 根据请求生成对应的key  
function generateReqKey(config, hash) {  
    const { method, url, params, data } = config;  
    return [method, url, JSON.stringify(params), JSON.stringify(data), hash].join("&");  
}
// 请求体的数据是FormData类型,直接放行
function isFileUploadApi(config) {  
  return Object.prototype.toString.call(config.data) === "[object FormData]"  
}

// 存储已发送但未响应的请求  
const pendingRequest = new Set();  
// 发布订阅容器  
const ev = new EventEmitter()

// 创建请求实例  
const instance = axios.create({
    baseURL: import.meta.env.VITE_BASE_URL,
    // 指定请求超时的毫秒数  
    timeout: 10000,
    // 表示跨域请求时是否需要使用凭证  
    withCredentials: false,
});

// 设置请求头
instance.defaults.headers.post['Content-Type'] = 'application/json;charset=UTF-8';
instance.defaults.headers.put['Content-Type'] = 'application/x-www-form-urlencoded';
// instance.defaults.headers.put['Content-Type'] = 'application/json';


// 添加请求拦截器  
instance.interceptors.request.use(async (config): AxiosRequestConfig<any> => {  
    let hash = location.hash  
    // 生成请求Key  
    let reqKey = generateReqKey(config, hash)  
      
    if(pendingRequest.has(reqKey)&&!isFileUploadApi(config)) {  
        // 如果是相同请求,在这里将请求挂起,通过发布订阅来为该请求返回结果  
        // 这里需注意,拿到结果后,无论成功与否,都需要return Promise.reject()来中断这次请求,否则请求会正常发送至服务器  
        let res = null  
        try {  
            // 接口成功响应  
          res = await new Promise((resolve, reject) => {  
               ev.on(reqKey, resolve, reject)  
           })  
          return Promise.reject({  
               type'limiteResSuccess',  
               val: res  
          })  
        }catch(limitFunErr) {  
            // 接口报错  
            return Promise.reject({  
                 type'limiteResError',  
                 val: limitFunErr  
             })  
        }  
    }else{  
        // 将请求的key保存在config  
        config.pendKey = reqKey  
        pendingRequest.add(reqKey)  
    }  
    /**  
     * 在这里一般会携带前台的参数发送给后台,比如下面这段代码:  
     * const token = sessionStorage.getItem('token')  
     * if (token) {  
     *  config.headers.Authorization = `Basic ${token}`  
     * }  
    */
    
    return config;  
  }, function (error) {  
    return Promise.reject(error);  
});


// 响应拦截器(获取到响应时的拦截)  
instance.interceptors.response.use(
    (response) => {
        // 将拿到的结果发布给其他相同的接口  
        handleSuccessResponse_limit(response)

        return response;
    },
    (error) => {
        return handleErrorResponse_limit(error)
    },
);

// 接口响应成功  
function handleSuccessResponse_limit(response) {  
      const reqKey = response.config.pendKey  
    if(pendingRequest.has(reqKey)) {  
      let x = null  
      try {  
        x = JSON.parse(JSON.stringify(response))  
      }catch(e) {  
        x = response  
      }  
      pendingRequest.delete(reqKey)  
      ev.emit(reqKey, x, 'resolve')  
      delete ev.reqKey  
    }  
}  
  
// 接口走失败响应  
function handleErrorResponse_limit(error) {  
    if (error.type && error.type === 'limiteResSuccess') {  
      return Promise.resolve(error.val)  
    } else if (error.type && error.type === 'limiteResError') {  
      return Promise.reject(error.val);  
    } else {  
      const reqKey = error.config.pendKey  
      if(pendingRequest.has(reqKey)) {  
        let x = null  
        try {  
          x = JSON.parse(JSON.stringify(error))  
        }catch(e) {  
          x = error  
        }  
        pendingRequest.delete(reqKey)  
        ev.emit(reqKey, x, 'reject')  
        delete ev.reqKey  
      }  
    }  
    return Promise.reject(error);  
}

interface ResType<T> {
    code: number;
    data?: T;
    msg?: string;
    message?: string;
    err?: string;
}

interface IOptions {
    isFormUrlencoded?: boolean;
}

interface Http {
    post<T>(url: string, data?: unknown,  options?: IOptions): Promise<ResType<T>>;
    get<T>(url: string, options?: IOptions): Promise<ResType<T>>;
    put<T>(url: string, data?: unknown, options?: IOptions): Promise<ResType<T>>;
    upload<T>(url: string, file?: unknown): Promise<ResType<T>>;
    _delete<T>(url: string, options?: IOptions): Promise<ResType<T>>;
}


 /**
 * 是否是x-www-form-urlencoded格式请求
 * @param {} 
 */
function isFormUrlencoded(url, params, options) {
    if (options?.isFormUrlencoded) {
        const list: any[] = [];
        for (const key in params) {
            if (params[key] !== null) {
                list.push(`${key}=${encodeURIComponent(params[key])}`);
            }
        }
        const newParams = list.join('&');
        const newOptions = {
            ...options,
            headers: {
                ...options.headers,
                'Content-Type': 'application/x-www-form-urlencoded',
            },
        };

        return { newUrl: url + '?' + newParams, newParams: {}, newOptions };
    }
    return { newUrl: url, newParams: params, newOptions: options };
}

// 导出常用函数  
const http: Http = {
    post(url, data, options?) {
        return new Promise((resolve, reject) => {
            const { newUrl, newData, newOptions } = isFormUrlencoded(data, options);
            instance
                .post(newUrl, JSON.stringify(newData), newOptions)
                .then((res) => {
                    resolve(res.data);
                })
                .catch((err) => {
                    reject(err.data);
                });
        });
    },
    get(url, params, options?) {
        return new Promise((resolve, reject) => {
            instance
                .get(url, { params, ...options })
                .then((res) => {
                    resolve(res.data);
                })
                .catch((err) => {
                    reject(err.data);
                });
        });
    },
    put(url, data, options?) {
        return new Promise((resolve, reject) => {
            const { newUrl, newData, newOptions } = isFormUrlencoded(data, options);
            instance
                .put(newUrl, newData, newOptions)
                .then((res) => {
                    resolve(res.data);
                })
                .catch((err) => {
                    reject(err.data);
                });
        });
    },
     upload(url, file) {
        return new Promise((resolve, reject) => {
            instance
                .post(url, file, {
                    headers: { 'Content-Type': 'multipart/form-data' },
                })
                .then((res) => {
                    resolve(res.data);
                })
                .catch((err) => {
                    reject(err.data);
                });
        });
    },
    downFile(url) {
        const iframe = document.createElement('iframe');
        iframe.style.display = 'none';
        iframe.src = url;
        iframe.onload = function () {
            document.body.removeChild(iframe);
        };
        document.body.appendChild(iframe);
    },
    downBlob(url,fileName) {
        fetch(url, {
        method: 'get',
    })
        .then((res) => res.blob())
        .then((blob) => {
            const a = document.createElement('a');
            // 获取 blob 本地文件连接 (blob 为纯二进制对象,不能够直接保存到磁盘上)
            const url = window.URL.createObjectURL(
                new Blob([blob], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }),
            );
            a.href = url;
            //定义导出的文件名
            a.download = `${fileName}.xls`;
            a.click();
            window.URL.revokeObjectURL(url);
        });
    },
    _delete(url, options?) {
        return new Promise((resolve, reject) => {
            instance
                .delete(url, options)
                .then((res) => {
                    resolve(res.data);
                })
                .catch((err) => {
                    reject(err.data);
                });
        });
    }
}

export default http;

之后在 api 文件夹中以业务模型对接口进行拆分,举个例子,将所有跟用户相关接口封装在 DictionaryAPI 对象中。

import request from '@/service/request';
const typeUrl = '/sysDict';

interface IBusinessType {
   dataName: string
   dataType: string
   pid: string
}

interface IBusinessSubType {
   businessSubType: string
   businessType: string
}

interface InDictionaryAPI {
    allOptions: () => Promise<any>;
    businessTypeList: (params: IBusinessType) => Promise<any>;
    businessSubTypeList: (params: IBusinessSubType) => Promise<any>;

}

/**
 * 查询字典列表
 */
const DictionaryAPI: InDictionaryAPI = {
    /**
     * 获取所有下拉选项
     * @param {object} params
     */
    allOptions() {
        return Promise.allSettled([
            this.businessTypeList({ dataName: '', dataType: '', pid: '1' }),
            this.businessSubTypeList({ businessSubType: '', businessType: '' }),
        ]);
    },
    /**
     * 紧急程度/业务类型
     * @param {object} params
     */
    businessTypeList(params = {}) {
        return request.post(typeUrl + '/findList', params);
    },
    /**
     * 获取业务子类
     * @param {object} params
     */
    businessSubTypeList(params = {}) {
        return request.post(typeUrl + '/getBusinessSubTypeGroup', params, { isFormUrlencoded: true, requestBase: 'AdminBashUrl });
    },
};
export default DictionaryAPI;

把每个业务模型独立成一个 js 文件,声明一个类通过其属性和方法来实现这个模型相关的数据获取,这样可以大大提升代码的可读性与可维护性。

模拟演示

在需要使用接口的地方,引入对应的业务模型文件,参考如下

<script setup lang="ts">  
import { DictionaryAPI } from '@/service/api/index';
import { ref } from 'vue'


/**
 * 获取所有下拉选项
 * @param {type} 参数
 * @returns {type} 返回值
 */
async function getAllOptions() {
    try {
        const res: any = await DictionaryAPI.allOptions();
        // ...
    } catch (error) {
        console.log(error);
    }
},
</script>

八、使用scss, 并定义全局scss变量

首先我们先安装sass(不用安装sass-loader):

npm i sass -D

然后我们需要在vite.config.ts中配置css预处理器

export default defineConfig({
    css: {
        preprocessorOptions: {
            scss: {
                additionalData: '@import "@/assets/styles/global.scss";@import "@/assets/styles/reset.scss";',
            },
        }
    }
})

我们这里默认加载global.scss中的样式,那么我们就需要创建一个这样的文件:

// src/assets/style/global.scss
$primary-color: #5878e2; // 主题色

最后在main.ts中引入即可:

import "./assets/style/global.scss";

然后在组件中使用时,就可以直接使用:

<script setup lang="ts">
import {GlobalStore} from '@/store'
const global = GlobalStore();
</script>
<template>
    <div>{{global.token}}</div>
</template>
<style scoped lang="scss">
div {
    color: $primary-color; // 主题色
}
</style>

样式穿透 在 Vue3 中,改变了以往样式穿透的语法,如果继续使用 ::v-deep/deep/>>> 等语法的话,会出现一个警告,下面是新的语法:

/* 深度选择器 */  
:deep(selector) {  
  /* ... */  
}  
  
/* 插槽选择器 */  
:slotted(selector) {  
  /* ... */  
}  
  
/* 全局选择器 */  
:global(selector) {  
  /* ... */  
}

scss常用语法

1. 嵌套
.page {
  .page-header{
    .header-left {}
  }
  .page-content {}
}

2. 变量
$color:red;

/* 使用 */
.scss_content{
   color:$color;
}

3. 计算
@use "sass:math"; // 放在文件第一行
$vw_base: 1920;
$vh_base: 1080;
/* 计算vw */
@function vw($px) {
@return math.div($px,  $vw_base) * 100vw;
}

/* 计算vh */
@function vh($px) {
@return math.div($px,  $vh_base) * 100vh;
}
/* 使用 */
.head{
  font-size:vh(100);
}

4. 混合
/* 单行文本省略 */
@mixin single-line($width) {
  width: #{$width}px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* 多行文本省略 */
@mixin multi-line($num) {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: $num;
  overflow: hidden;
}
@mixin flex($type) {
    display: flex;

    /* 水平居中 */
    @if $type ==1 {
        justify-content: center;
    }

    /* 垂直居中 */
    @else if($type ==2) {
        align-items: center;
    }

    /* 水平垂直居中 */
    @else if($type ==3) {
        justify-content: center;
        align-items: center;
    }

    /* 水平拉伸垂直居中 */
    @else if($type ==4) {
        justify-content: space-between;
        align-items: center;
    }

    /* 水平拉伸换行 */
    @else if($type ==5) {
        justify-content: space-between;
        flex-wrap: wrap;
    }

    /* 换行 */
    @else if($type ==6) {
        flex-wrap: wrap;
    }
}
/* 使用 */
@include flex(1);