从零到一:搭建一个Vue3开发框架

4,164 阅读10分钟

说明:

之前因为做一个项目从零到一搭建了Vue3的后台管理系统,写本文的时候本想采用现行版本,结果遇到了很多插件版本兼容问题。想起之前踩得那些坑,望而却步,时间、水平有限,故放弃了这个想法。

本文使用 node 14.16.1 版本,如果您的 node 版本与该版本相近,可以尝试能否兼容,也可以使用nvm安装该版本。

1. Vite创建项目


说到 Vue3 就不得不说一下 Vite,如果你受够了 webpack 启动服务40s+,热更新6s+的话,一定要来看看 Vite,真的很香!

这里,我也是用 Vite 进行项目创建,文档提供了示例,可以使用

yarn create vite my-vue-app --template vue

进行Vite+Vue项目创建,下面还提供了几种模板预设,因为要使用 ts,所以这里我使用的命令是

yarn create vite my-vue-app --template vue-ts

然后进入目录,yarn 安装依赖,yarn dev 启动服务就好了

说明:因为 Vite 一直更新版本问题,所以如果您按上述命令创建项目,与该分支上代码并不一致。《项目地址》

2. 集成JEST单元测试


2-1. 安装jest

yarn add jest@26.6.3 -D

2-2. 创建第一个测试内容

在根目录创建 tests 文件夹,再在内部创建 unit 文件夹存放单元测试文件,unit 文件夹内编写我们的第一个测试文件 index.spec.js

test('1+1=2',() => {
  expect(1+1).toBe(2)
})

package.json 配置命令

"scripts": {
  "test-unit":"jest"
}

运行 yarn test-unit 发现第一个测试用例就通过了

2-3. 添加自动提示插件

上面在编写测试用例的时候,是没有代码提示的,这导致效率很低,这里安装@types/jest实现代码提示

yarn add @types/jest@26.0.24 -D

2-4. 支持import

接下来我们在unit文件夹下编写一个测试用文件 foo.js

export default function (){
  return 'this is foo'
}

修改 index.spec.js

import foo from './foo.js'
test('1+1=2',() => {
  expect(1+1).toBe(2);
})
test('foo',() => {
  expect(foo()).toBe('this is foo')
})

再次执行 yarn test-unit 发现报错了

会发现 jest 无法识别 import 语法,这是因为 jest 是基于 node 环境的,所以要将 import 这种语法转化为 nodejs 可以识别的语法

在根目录创建 jest.config.js 进行 jest 配置

module.exports = {
  // 转换器
  transform: {
    // jest解析js的时候通过babel-jest解析
    "^.+\\.jsx?$": "babel-jest"
  }
};

安装 babel-jest

yarn add babel-jest@26.6.3 -D

因为使用了 babel,所以还需要创建在根目录 babel.config.js 配置babel

module.exports = {
  presets: [
    [
      // 安装官方预设插件
      "@babel/preset-env",
      // 指定解析的目标是本机node版本
      { targets: { node: "current" } }
    ],
  ],
};

安装 @babel/preset-env

yarn add @babel/preset-env@7.14.9 -D

再次运行 yarn test-unit 发现测试通过了

2-5. 支持 .vue文件

在src/compontents下创建 Foo.vue

<template>
  <div>
    Foo
  </div>
</template>

修改 index.spec.js

import foo from './foo.js'
import Foo from '../../src/components/Foo.vue
'test('1+1=2',() => {
  expect(1+1).toBe(2);
})
test('foo',() => {
  expect(foo()).toBe('this is foo')
})
test('Foo',() => {
  console.log('Foo',Foo)
})

执行 yarn test-unit 发现报错无法解析vue的语法

这里需要在 jest.config.js 中新增解析 .vue 文件的规则

module.exports = {
  // 转换器
  transform: {
    // jest解析js的时候通过babel-jest解析
    "^.+\\.jsx?$": "babel-jest",
    // jest解析vue的时候通过vue-jest解析
    "^.+\\.vue$": "vue-jest"
  }
};

安装 vue-jest

yarn add vue-jest@next -D

再次执行 yarn test-unit 发现测试失败,缺少 ts-jest 依赖

安装 ts-jest

yarn add ts-jest@26.5.6 -D

再次执行 yarn test-unit 测试通过

2-6. 安装 Vue Test Utils

Vue Test Utils 是Vue官方推荐的Vue单元测试库,提供对vue文件测试的支持

yarn add @vue/test-utils@next -D

修改 index.spec.js

import foo from './foo.js'
import {mount} from '@vue/test-utils'
import Foo from '../../src/components/Foo.vue
'test('1+1=2',() => {
  expect(1+1).toBe(2);
})
test('foo',() => {
  expect(foo()).toBe('this is foo')
})
test('Foo',() => {
  console.log('Foo',Foo)
  console.log('mount',mount(Foo))
})

执行 yarn test-unit 测试通过

2-7. 支持ts

将之前的 js 测试文件直接改成 .ts 文件再 yarn test-unit 肯定是不通过的,这里和之前一样,需要在jest.config.js 中新增转换器配置

module.exports = {
  // 转换器
  transform: {
    // jest解析js的时候通过babel-jest解析
    "^.+\\.jsx?$": "babel-jest",
    // jest解析vue的时候通过vue-jest解析
    "^.+\\.vue$": "vue-jest",
    // jest解析ts的时候通过ts-jest解析
    "^.+\\.tsx?$": "ts-jest"
  }
};

ts-jest 之前已经安装过了,这里不需要重复安装

修改 babel.config.js 配置

module.exports = {
  presets: [
    [
      // 安装官方预设插件
      "@babel/preset-env",
      // 指定解析的目标是本机node版本
      { targets: { node: "current" } }
    ],
    "@babel/preset-typescript"
  ]
};

安装 @babel/preset-typescript

yarn add @babel/preset-typescript@7.14.5 -D

执行 yarn test-unit 测试通过 《项目地址》

3. 集成Cypress e2e测试


3-1. 安装Cypress

yarn add cypress@8.2.0 -D

package.json 配置命令 "test-e2e":"cypress open“ 并执行

第一次执行的时候会进行初始化,帮我们在根目录创建一个 cypress 文件夹,里边会有 cypress 相关的文件,初始化完成后会自动打开一个窗口

这里会有一个弹框供我们选择会在什么 CI 环境下使用 cypress,这里我们用不到,直接关闭即可

右上角可以切换运行测试环境的浏览器 相关文档

下面是所有的测试用例,点击会在浏览器中打开根据脚本进行测试

3-2. 调整目录

在我们的项目中,是希望所有测试相关的东西都放在 tests 文件夹下的,这里我们在 tests 下创建 e2e 文件夹,并将根目录 cypress 下的所有文件拷贝过来,这个时候如果我们再次执行

yarn test-e2e,会发现 cypress 每次都会检测根目录是否有 cypress 文件夹,没有会再次创建,所以我们需要在 cypress.json 中修改配置

{
  "pluginsFile":"tests/e2e/plugins/index.js"
}

然后修改 plugins/index.js 中的配置 相关文档

module.exports = (on, config) => {
  // `on` is used to hook into various events Cypress emits
  // `config` is the resolved Cypress config
  return Object.assign({}, config, {
    // fixtures路径
    fixturesFolder: "tests/e2e/fixtures",
    // 测试脚本文件夹
    integrationFolder: "tests/e2e/specs",
    // 从 cy.screenshot() 命令或在 cypress 运行期间测试失败后保存屏幕截图的文件夹路径
    screenshotsFolder: "tests/e2e/screenshots",
    // cypress 运行期间保存视频的文件夹路径
    videosFolder: "tests/e2e/videos",
    // 在加载测试文件之前加载的文件路径。 这个文件被编译和捆绑。 (通过 false 禁用)
    supportFile: "tests/e2e/support/index.js"
  });
}

需要注意的是这里将之前的 integration 文件夹重命名为 specs

执行 yarn test-e2e

出现如下弹窗

并且没有在根目录创建 cypress 文件夹

3-3. 支持 ts

首先把e2e文件夹下所有 .js 文件修改为 .ts

然后修改 e2e/plugins/index.ts 中的配置

module.exports = (on: any, config: Cypress.PluginConfig) => {
  // `on` is used to hook into various events Cypress emits
  // `config` is the resolved Cypress config  
  return Object.assign({}, config, {    
  // fixtures路径    
  fixturesFolder: "tests/e2e/fixtures",    
  // 测试脚本文件夹    
  integrationFolder: "tests/e2e/specs",    
  // 从 cy.screenshot() 命令或在 cypress 运行期间测试失败后保存屏幕截图的文件夹路径    
  screenshotsFolder: "tests/e2e/screenshots",    
  // cypress 运行期间保存视频的文件夹路径    
  videosFolder: "tests/e2e/videos",    
  // 在加载测试文件之前加载的文件路径。 这个文件被编译和捆绑。 (通过 false 禁用)    
  supportFile: "tests/e2e/support/index.ts",  
  });
 };

修改 cypress.json 中的配置

{
  "pluginsFile":"tests/e2e/plugins//index.ts"
}

清除 tests/e2e/specs 下的测试示例文件,创建 index.spec.ts

describe("index", () => {
  it("button click", () => {
      cy.visit("http://localhost:3000/");
      cy.get("button").click();
  });
});

执行 yarn test-e2e,弹窗如下:

点击 index.spec.js,cypress 会帮我们打开配置的浏览器并执行测试脚本

可以发现已经触发了按钮点击,因为上方红色数值由0变为了1,而且左侧提示 index.spec.ts 中的button click 测试用例通过

3-4. 解决 jest 测试覆盖 e2e 问题

此时执行 yarn test-unit 会发现报错了

此时我们发现执行 jest 单元测试把 cyress 的测试文件也执行了,修改 jest.config.js 配置,指定测试脚本匹配规则

module.exports = {
  // 转换器
  transform: {
    // jest解析js的时候通过babel-jest解析
    "^.+\\.jsx?$": "babel-jest",
    // jest解析vue的时候通过vue-jest解析
    "^.+\\.vue$": "vue-jest",
    // jest解析ts的时候通过ts-jest解析
    "^.+\\.tsx?$": "ts-jest"  },
  // 配置测试脚本文件匹配规则
  testMatch: ["**/tests/unit/?(*.)+(spec).[jt]s?(x)"],
};

再依次执行 yarn test-unit yarn test-e2e 发现都可以通过了

3-5. 整合 jest & cypress 测试

package.json 配置命令"test":"npm run test-unit && npm run test-e2e"并执行

可以在终端中看到jest测试通过,并打开了cypress窗口,那此时我们其实希望 cypress 的测试也在终端中进行。

cypress提供了另一种执行测试的命令 cypress run,我们在 package.json 中添加命令

"test-e2e-ci":"cypress run" 并执行,发现此时 cypress 测试可以在终端中进行了,修改 test 命令如下

"test":"npm run test-unit && npm run test-e2e-ci"

执行 yarn test,此时 jest 和 cypress 都可以在终端中完成了

3-6. 关闭 cypress 生成视频行为

当我们在终端中进行 cypress 测试的时候,会发现在 tests/e2e 文件夹下多了一个 video 文件夹,里边有一个 index.sepc.ts.mp4 文件,打开会发现该视频就是对应测试文件测试过程的录屏,这里我们其实是不需要的,每次生成.mp4文件会占用内存和增加测试时间,可以在 cypress.json 中进行配置关闭

{
  "pluginsFile":"tests/e2e/plugins/index.ts",
  "video":false
}

至此,集成 cypress e2e 测试完成。《项目地址》

4. 集成eslint


4-1. 安装eslint及相关依赖

yarn add eslint@7.20.0 eslint-plugin-vue@7.6.0 @vue/eslint-config-typescript@7.0.0 @typescript-eslint/parser@4.15.2 @typescript-eslint/eslint-plugin@4.15.2 -D

eslint

就不多说了,我们的目标,代码检查工具

eslint-plugin-vue

Vue.js 的官方 ESLint 插件,提供了,以及 .js 文件中的 Vue 代码的支持

@vue/eslint-config-typescript

为在 Vue 组件中编写 ts 代码提供支持

@typescript-eslint/parser

针对 eslint 的一个 ts 解析器

@typescript-eslint/eslint-plugin

针对 ts 的 eslint plugin

接下来根目录创建 .eslintrc 文件进行 eslint 配置

{
  // 指定当前目录为根目录
  "root": true,
  // 环境配置项
  "env": {
    // 是否浏览器环境
    "browser": true,
    // 是否node环境
    "node": true,
    // es2021 支持
    "es2021": true
  },
  // 引入配置项
  "extends": [
    // vue3
    "plugin:vue/vue3-recommended",
    // eslint
    "eslint:recommended",
    // vue typescript
    "@vue/typescript/recommended"
  ],
  // 解析器配置
  "parserOptions": {
    // 要使用的 ECMAScript 语法版本
    "ecmaVersion": 2021
  }
}

package.json 中 scripts 配置 eslint 命令

"lint":"eslint --ext .ts,vue src/**"

检测 src 目录下的所有 .ts,.vue 文件

此时执行 yarn lint

会发现有一些警告和报错

这里我们再添加一个命令

"lint:fix":"eslint --ext .ts,vue src/** --fix"

fix 会帮助我们自动修复一些警告

执行 yarn lint:fix 后发现,所有警告消失,只剩下一个报错

针对图片没有配置解析器问题,我们可以在根目录配置 .eslintignore 文件,使 eslint 忽略某些文件或目录

node_modules
dist
src/assets
index.html

再次执行 yarn lint:fix 发现可以通过了

4-2. 集成 lint-staged

这个时候我们想到每次 lint 检查都是检查 src 目录下所有的 .ts,.vue 文件是没有必要的,实际上每次我们只需要检查那些进行了修改的文件,也就是git暂存区的文件即可,这要怎么做呢?

这里需要用到 lint-stagedyorkie,使用

yarn add lint-staged@11.1.2 yorkie@2.0.0 -D

进行安装,接下来需要在 package.json 进行一些配置

"gitHooks": {
  "pre-commit": "lint-staged"},
"lint-staged": {
  "*.{ts,vue}": "eslint --fix"
}

以上配置会在 commit 之前调用 lint-staged,该命令会对所有的 .ts,.vue 文件进行 eslint --fix

这里我们将 App.vue 文件中的 HelloWorld 引入注释,然后提交代码,会发现在报错如下:

说明我们配置的校验生效了。《项目地址》

. 集成Prettier


Prettier 可以帮我们美化及统一代码格式,所以这里我们也集成进来。

安装prettier及相关依赖

yarn add prettier@2.2.1 eslint-plugin-prettier@3.3.1 @vue/eslint-config-prettier@6.0.0 -D

eslint-plugin-prettier

为 prettier 在 eslint 中工作提供支持

@vue/eslint-config-prettier

为 eslint 代码校验规则与 prettier 代码校验规则部分冲突提供支持

.eslintrc 文件中新增配置

{
  // 指定当前目录为根目录
  "root": true,
  // 环境配置项
  "env": {
    // 是否浏览器环境
    "browser": true,
    // 是否node环境
    "node": true,
    // es2021 支持
    "es2021": true
  },
  // 引入配置项
  "extends": [
    // vue3
    "plugin:vue/vue3-recommended",
    // eslint
    "eslint:recommended",
    // vue typescript
    "@vue/typescript/recommended",
    "@vue/prettier",
    "@vue/prettier/@typescript-eslint"
  ],
  // 解析器配置
  "parserOptions": {
    // 要使用的 ECMAScript 语法版本
    "ecmaVersion": 2021
  }
}

终端中执行 npx prettier -w -u .

-w: Edit files in-place. (Beware!) 就地编辑文件。 (谨防!)

-u: Ignore unknown files. 忽略未知文件。

. 指定路径为当前路径

会发现 prettier 帮我们格式化了一些文件,打开 App.vue 的变更

会发现 prettier 自动帮我们根据它的规则进行了一些修改,比如换行,空格,单双引号等

然后可以在根目录创建 .prettierrc 文件对 prettier 进行配置 相关文档

{
  是否结尾分号
  "semi": true,
  是否单引号
  "singleQuote": false
}

这里只是进行示例测试,你可以根据团队规范或者自己需要根据文档进行配置,此时如果把某个文件的结尾分号删除,执行npx prettier -w -u .,会发现prettier会帮我们把行尾分号加上

最后,为了方便使用,我们把 prettier 集成到 lint-staged 中,修改 package.json

"lint-staged": {
  "*.{ts,vue}": "eslint --fix",
  "*": "prettier -w -u"
}

至此,项目集成prettier完成。《项目地址》

. 集成commit message校验


对于团队来说,commit message 的规范也很重要,一个规范的 commit message 会让我们回顾之前的 git 历史时十分清晰明了,这里我们使用尤大在 vue 中的方法

该方法依赖 yorkie 库,我们在集成lint-staged 的时候已经进行了安装,这里不再重复安装。

配置 package.json gitHooks

"gitHooks": {
  "commit-msg":"node scripts/verify-commit-msg.js",
  "pre-commit": "lint-staged"
}

这里的作用是在 commit-msg 钩子中,执行 scripts/verify-commit-msg.js 文件,

verify-commit-msg.js 文件我们直接在尤大的vue项目中拷贝过来即可。

该文件的大致逻辑为通过 fs 模块读取 git 文件中的 commit message,并通过正则进行校验,

校验不通过的话,报错提示并阻断 git commit

这里可能会有一个问题就是拷贝 verify-commit-msg.js 到文件后,eslint会报一个警告

Require statement not part of import statement.

可以通过在 .eslintrc 中配置如下规则取消该校验

"rules":{
  "@typescript-eslint/no-var-requires": 0
}

verify-commit-msg.js文件中依赖 chalk 库,chalk 提供了终端中console设置颜色的功能

安装chalk yarn add chalk -D

然后我们通过 git commit -m "test" 测试发现可以拦截不符合要求的commit mesage了

我本地是win10系统,发现 chalk 并没有生效,这里需要在 verify-commit-msg.js 添加下图第二行代码

再次执行 git commit -m "test" 测试发现 chalk 生效了

至此,集成commit-msg校验完成。《项目地址》

7. gitHooks 添加 test 校验


上面我们添加了 jest 单元测试和 cypress e2e 测试,并进行了整合,但是通常我们不希望每次手动执行测试命令,而是希望在提交代码的时候自动执行测试保证推送到 git上的代码的正确性即可。这一点,我们通过package.json 中添加 git hooks 钩子来实现

"gitHooks": {
  "commit-msg": "node scripts/verify-commit-msg.js",
  "pre-commit": "lint-staged",
  "pre-push": "npm run test"
}

这里设置在git push 之前执行 test 命令,上面我们在该命令下配置了 jest 和 cypress 测试,这样每次git push 之前,就会跑一遍测试脚本了

这一步比较简单,代码合并到了集成commit-msg这一步的分支中。《项目地址》

8. 配置 alias 路径别名


路径别名的作用就是通过制定符号简化到指定路径的操作。

8-1. 配置 vite.config.js

例如:如果在 views/Home.vue 中引入 HelloWorld.vue 的时候是这样的

import HelloWorld from "../components/HelloWorld.vue";

如果层级再深一些,例如在 views/Goods/List/index.vue 中,就需要这样

import HelloWorld from "../../../components/HelloWorld.vue";

可以看到很麻烦,而且如果 HelloWorld.vue 文件的位置发生了变化,所有 HelloWorld.vue 的引入代码修改起来也比较麻烦,为了方便我们解决这样的问题,vite 也提供了配置 alias 别名的方法,修改 vite.config.js 如下:

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { resolve } from "path";
// https://vitejs.dev/config/
export default defineConfig({
  resolve: {
    alias: {
      "@": resolve("./src")
    },
  },  
  plugins: [
    vue()
  ]
})

这里我们设置了将 @ 符号指向到 ./src 目录下,以后无论在哪个文件,统一使用如下代码引入

HelloWorld.vue 即可,文件位置发生了变化,也可进行统一替换

import HelloWorld from "@/components/HelloWorld.vue";

文件位置发生了变化,这里也不需要进行调整,为我们提供了很大的便利。

但是到了这里还没有结束,在引入代码的时候可以发现当我们写 ./ 的时候,vscode 会有路径提示,但是当我们写 @/ 的时候,并没有路径提示,这里就需要对 ts 进行一些配置。

8-2. 配置 ts 支持 alias

{
  "compilerOptions": {
    "target": "esnext",    
    "useDefineForClassFields": true,    
    "module": "esnext",    
    "moduleResolution": "node",    
    "strict": true,    
    "jsx": "preserve",    
    "sourceMap": true,    
    "resolveJsonModule": true,    
    "esModuleInterop": true,    
    "lib": ["esnext", "dom"],    
    "types": ["vite/client", "jest", "node"],    
    "baseUrl":"./",    
    "paths":{      
      "@/*":["src/*"]    
    }  
  },  
  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}

baseUrl: 指定当前目录

paths 中是我们的别名配置,这里的含义和 vite.config.js 中的配置一样,再次回到 .vue 文件中,使用 @ 别名引入 HelloWorld.vue 就会有路径提示了,如果无效,可以尝试下重启 vscode

8-3. 配置 jest 支持 alias

接下来我们尝试在 jest 单元测试中使用 alias,修改 tests/unit/index.spec.ts

import foo from "./foo";
import { mount } from "@vue/test-utils";
import Foo from "@/components/Foo.vue";
test("1+1=2", () => {
  expect(1 + 1).toBe(2);
});
test("foo", () => {
  expect(foo()).toBe("this is foo");
});
test("Foo", () => {
  console.log("Foo", Foo);  
  console.log("mount", mount(Foo));
});

修改之后 vscode 就给我们报错提示了

然后我们尝试执行 yarn test-unit

发现对于 @ 路径别名,jest 是识别不了的,接下来需要修改 jest.config.js

module.exports = {
  // 转换器  
  transform: {
    // jest解析js的时候通过babel-jest解析    
    "^.+\\.jsx?$": "babel-jest",    
    // jest解析vue的时候通过vue-jest解析    
    "^.+\\.vue$": "vue-jest",    
    // jest解析ts的时候通过ts-jest解析    
    "^.+\\.tsx?$": "ts-jest"
  },  
  // 配置测试脚本文件匹配规则  
  testMatch: [
    "**/tests/unit/?(*.)+(spec).[jt]s?(x)"
  ],  
  // 配置路径别名  
  moduleNameMapper: {    
    "^@/(.*)$": "<rootDir>/src/$1"
  }
};

接下来再次执行 yarn test-unit,发现可以测试通过了。《项目地址》

9. 集成Vue Router


Vue RouterVue.js 的官方路由,这里不必多说,直接安装

yarn add vue-router@next

src 目录下创建 router 文件夹,编写 index.ts

import { createRouter, createWebHashHistory } from "vue-router";
export const routes = [
  {
    path: "/",    
    redirect: "/home"
  },{  
    path: "/home",    
    name: "Home",    
    component: () => import("@/views/Home.vue")
  },{
    path: "/login",    
    name: "Login",    
    component: () => import("@/views/Login.vue")
  },{  
    path: "/404",
    name: "404",    
    hidden: true,    
    meta: { notNeedAuth: true },    
    component: () => import("@/views/404.vue")
  },  
  // 匹配所有路径 vue2使用* vue3使用/:pathMatch(.*)或/:catchAll(.*)  
  {  
    path: "/:catchAll(.*)",    
    redirect: "/404"
  }
];
// 路由实例
const router = createRouter({
  history: createWebHashHistory(),  
  routes
});
export default router;

这里都是基本用法,不做赘述,看文档即可。

修改 App.vue

<template>
  <router-view>
  </router-view>
</template>
<style>
  #app {
    font-family: Avenir, Helvetica, Arial, sans-serif;  
    -webkit-font-smoothing: antialiased;  
    -moz-osx-font-smoothing: grayscale;  
    text-align: center;  
    color: #2c3e50;  
    margin-top: 60px;
 }
 </style>

创建 views/Home.vue

<template>
  <div>  
    <p>This is Home</p>    
    <img alt="Vue logo" src="@/assets/logo.png" />    
    <HelloWorld msg="Hello  Vite + Vue 3 + TypeScript" />  
  </div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import HelloWorld from "@/components/HelloWorld.vue";
export default defineComponent({
  name: "Home",  
  components: { HelloWorld },  
  setup() {  
    return {};
  }
});
</script>

创建 views/Login.vue

<template>
  <div>This is Login</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
  name: "Login",  
  setup() {  
    return {};
  }
});
</script>

main.ts 引入 vue router

import { createApp } from "vue";
import App from "./App.vue";
import router from "./router/index";
createApp(App).use(router).mount("#app");

至此,vue router 集成完成。 《项目地址》

10. 集成 Vuex


Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式 + 库。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化,直接安装。

yarn add vuex@next

src 目录下创建 store 文件夹,这里我们一步到位,直接把模块划分开,创建一个 user 模块作为示例:

src/store/index.ts

import { createStore } from "vuex";
import getters from "./getters";
import user from "./modules/user";
const modules = {  user,};
const store = createStore({  modules,  getters,});
export default store;

src/store/modules/user.ts

export default { 
  namespaced: true,  
  state: {  
    userInfo: {    
      userId:"001",      
      name: "wzy"
    }
  },  
  mutations: { 
 
  },  
  actions: { 
 
  }
};

src/store/getters.ts

type state = {
  user: {  
    userInfo: {    
      name: string;      
      token: string;      
      avatar: string;      
      roles: string[];
    };
  }
};
export default {
  userInfo: (state: state) => state.user.userInfo
};

main.ts 引入vuex

import { createApp } from "vue";
import App from "./App.vue";
import router from "./router/index";
import store from "./store/index";
createApp(App)
.use(store)
.use(router)
.mount("#app");

src/views/Home.vue 使用

<template>
  <div>  
    <p>This is Home</p>    
    <img alt="Vue logo" src="@/assets/logo.png" />    
    <HelloWorld msg="Hello  Vite + Vue 3 + TypeScript" />  
    <p>Name: {{ userInfo.name }}</p>  
  </div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { useStore } from "vuex";
import HelloWorld from "@/components/HelloWorld.vue";
export default defineComponent({
  name: "Home",  
  components: { HelloWorld },  
  setup() {    
    // 获取类型化的 store    
    const store = useStore();    
    // 获取 userInfo    
    const userInfo = store.getters.userInfo;    
    return {
     userInfo
    };
  }
})
</script>

至此,vuex 集成完成。 《项目地址》

11. 集成 Element3


Element3,一套为开发者、设计师和产品经理准备的基于 Vue 3.0 的桌面端组件库。安装

yarn add element3

main.ts 中引入 element3

这里采用按需引入的方式引入部分组件

import { createApp } from "vue";
import App from "./App.vue";
import router from "./router/index";
import store from "./store/index";
// Element3样式文件
import "element3/lib/theme-chalk/index.css";
import {
  ElIcon,  
  ElButton,  
  ElForm,  
  ElFormItem,  
  ElInput
} from "element3";
createApp(App)
.use(store)
.use(router)
.use(ElIcon)
.use(ElButton)
.use(ElForm)
.use(ElFormItem)
.use(ElInput)
.mount("#app");

修改 src/views/Login.vue

<template>
  <div class="login_main" @keyup.enter="login">  
    <!-- 中间盒子 -->    
    <div class="content">    
      <h3 class="title">登录</h3>      
      <el-form ref="form" :model="param" :rules="rules">      
        <el-form-item prop="name">        
          <el-input          
            v-model="param.name"            
            prefix-icon="el-icon-user"            
            placeholder="账号"          
          ></el-input>        
        </el-form-item>        
        <el-form-item prop="password">        
          <el-input            
            v-model="param.password"            
            prefix-icon="el-icon-lock"            
            placeholder="密码"            
            show-password            
            autocomplete          
          ></el-input>        
        </el-form-item>        
        <el-button class="w_100" type="primary" @click="login">登录</el-button>
      </el-form>    
    </div>  
  </div>
</template>
<script lang="ts">
import { defineComponent, ref, reactive } from "vue";
export default defineComponent({
  name: "Login",
    setup() {  
      // 表单    
      const form = ref(null);    
      // 请求参数    
      const param = reactive({    
        name: "",      
        password: "",    
      });    
    // 表单校验规则    
    const rules = reactive({    
      name: [      
        { required: true, message: "请输入账号", trigger: "blur" },        
      ],      
      password: [
        { required: true, message: "请输入密码", trigger: "blur" }
      ],    
    });    
    // 密码表单类型    
    const passInputType = ref("password");    
    // 修改密码表单类型    
    const changeInputType = (val: string) => {    
      passInputType.value = val;    
    };    
    // 登录    
    const login = () => {    
      form.value.validate((valid: boolean) => {      
        if (valid) {        
          console.log("login 校验通过!");        
        } else {        
          return false;        
        }      
      });    
    };    
    return {    
      form,      
      param,      
      rules,      
      passInputType,      
      changeInputType,      
      login,    
    };  
  }
});
</script>
<style scoped>
.login_main {
  display: flex;  
  align-items: center;  
  justify-content: center;
}
.content {
  width: 500px;
}
</style>

修改后的登录页如下:

至此,element3 集成完成。 《项目地址》

12. 集成 axios + mockjs + sass


12-1. 集成axios

项目中涉及到前后端交互就肯定需要一个HTTP库,这里我们集成最常用的 axios

yarn add axios

src 目录下创建 utils 文件夹存放我们的工具方法

编写 request.ts 封装 axios

import axios from "axios";
import { Message } from "element3";
// 创建axios实例
const service = axios.create({
  baseURL: import.meta.env.BASE_URL,
  timeout: 10000
});
// 请求拦截器
service.interceptors.request.use(
  (config) => {  
    return config;
  },
  (error) => {  
    return Promise.reject(error);  
  }
);
// 响应拦截器
service.interceptors.response.use(
  (response:any) => {  
    const res = response.data;    
    if (res.header.code !== 0) {    
      Message.error(res.header.msg || "Error");      
      return Promise.reject(
       new Error(res.header.msg || "Error"));    
      }    
      return res;
    },
    (error) => {  
      Message.error("request error");    
      return Promise.reject(error);  
    }
  );
  /**
   * 封装接口请求方法 
   * @param url 域名后需补齐的接口地址 
   * @param method 接口请求方式 
   * @param data d请求数据体
 */
 type Method =  
   | "get"  
   | "GET"  
   | "delete"  
   | "DELETE"  
   | "head"  
   | "HEAD"  
   | "options"  
   | "OPTIONS"  
   | "post"  
   | "POST"  
   | "put"  
   | "PUT"  
   | "patch"  
   | "PATCH"  
   | "purge"  
   | "PURGE"  
   | "link"  
   | "LINK"  
   | "unlink"  
   | "UNLINK";
 const request = (
   url: string,  
   method: Method,  
   data: Record<string, unknown>
 ) => {
   return service({  
     url,  
     method,    
     data,  
   });
 };
 export default request;

src 目录下创建 api 文件夹存放所有接口请求方法

src/api 下创建 user.ts 编写 user 相关接口请求

import request from "../utils/request";
type Method =
  | "get"  
  | "GET"  
  | "delete"  
  | "DELETE"  
  | "head"  
  | "HEAD"  
  | "options"  
  | "OPTIONS"  
  | "post"  
  | "POST"  
  | "put"  
  | "PUT"  
  | "patch"  
  | "PATCH"  
  | "purge"  
  | "PURGE"  
  | "link"  
  | "LINK"  
  | "unlink"  
  | "UNLINK";
  const curryRequest = (
    url: string,  
    method: Method,  
    data?: Record<string, unknown> | any
  ) => {
    return request(
      `/module/user/${url}`, 
       method, 
       data
    )
  };
  // 登录
  export function apiLogin(data: {
    name: string;  
    password: string;
  }): PromiseLike<any> {
    return curryRequest("login", "post", data);
  }

将登录相关操作放在 store/modules/user.ts actions 中进行,并完善 mutations

import { apiLogin } from "@/api/user";
type userInfo = {
  userId: string;  
  name: string;
};
type state = {
  userInfo: userInfo;
};
type context = {
  state: Record<string, unknown>;  
  mutations: Record<string, unknown>;  
  actions: Record<string, unknown>;  
  dispatch: any;  
  commit: any;
};
type loginData = {
  name: string;  
  password: string;
};
export default {
  namespaced: true,  
  state: {  
    userInfo: {    
      userId: "",      
      name: ""    
    },  
  },  
  mutations: {  
    // 设置用户信息    
    SET_USERINFO(state:state,val:userInfo){    
      state.userInfo = val;    
    }  
  },  
  actions: {  
    // 登录    
    login({ commit }: context, data: loginData) {    
      return new Promise((resolve) => {      
        apiLogin(data).then(async (res) => {          
          // 更新用户信息          
          commit("SET_USERINFO", {          
            userId: res.body.userId,          
            name: res.body.name,          
          });          
          resolve("success");        
        })      
      })    
    } 
  }
}

更新 Login.vue

<template>
  <div class="login_main" @keyup.enter="login">    
    <!-- 中间盒子 -->    
    <div class="content">    
      <h3 class="title">登录</h3>      
      <el-form ref="form" :model="param" :rules="rules">      
        <el-form-item prop="name">          
          <el-input
            v-model="param.name"            
            prefix-icon="el-icon-user"            
            placeholder="账号"
          ></el-input>        
        </el-form-item>        
        <el-form-item prop="password">          
          <el-input            
            v-model="param.password"            
            prefix-icon="el-icon-lock"            
            placeholder="密码"            
            show-password            
            autocomplete          
          ></el-input>        
        </el-form-item>        
        <el-button class="w_100" type="primary" @click="login">登录</el-button>
      </el-form>    
    </div>  
  </div>
</template>
<script lang="ts">
import { defineComponent, ref, reactive } from "vue";
import { useStore } from "vuex";
import { useRouter } from "vue-router";
export default defineComponent({  name: "Login",  setup() {  
  // sotre实例    
  const store = useStore();    
  // 路由实例    
  const router = useRouter();    
  // 表单    
  const form = ref(null);    
  // 请求参数    
  const param = reactive({    
    name: "",      
    password: "",
  });    
  // 表单校验规则    
  const rules = reactive({    
    name: [      
      { required: true, message: "请输入账号", trigger: "blur" },
    ],      
    password: [
      { required: true, message: "请输入密码", trigger: "blur" }
    ],    
  });    
  // 密码表单类型    
  const passInputType = ref("password");    
  // 修改密码表单类型    
  const changeInputType = (val: string) => {    
    passInputType.value = val;    
  };    
  // 登录    
  const login = () => {    
    form.value.validate((valid: boolean) => {      
      if (valid) {        
        store.dispatch("user/login", param).then(() => {            
          router.push({ name: "Home" });          
        });        
      } else {          
        return false;        
      }      
    });    
  };    
  return {    
    form,      
    param,      
    rules,      
    passInputType,      
    changeInputType,      
    login,    
  };  
}
})
</script>
<style scoped>
.login_main {
  display: flex;  
  align-items: center;  
  justify-content: center;
 }
 .content {
   width: 500px;
 }
 </style>

我一般推荐使用 router.push({ name: "Home" }) 进行路由跳转,因为每个路由定义的 name 一般不会发生变化,但是路径可能会有调整,这样就避免了路径变化后还需要修改对应路由操作的问题

至此,请求相关逻辑编写完成,但是当我们搭建框架的时候一般后台也处于初始阶段,接口还没有准备好,这时候就需要 mockjs 帮我们模拟接口请求

12-2. 集成 mockjs

安装 mockjs

yarn add mockjs@1.1.0     

安装 vite-plugin-mock

yarn add vite-plugin-mock@2.9.4 -D 

修改 vite.config.js 引入 mockjs

import { UserConfigExport, ConfigEnv,loadEnv } from "vite";
import vue from "@vitejs/plugin-vue";
import { resolve } from "path";
import { viteMockServe } from "vite-plugin-mock";
export default ({ command, mode }: ConfigEnv): UserConfigExport => {
  const plugins = [vue()];  
  const env = loadEnv(mode, process.cwd());  
  // 如果当前为测试环境,添加mock插件  
  if (mode === "development") {  
    plugins.push(    
      viteMockServe({      
        mockPath: "mock",      
        localEnabled: command === "serve"
      })    
    );  
  }  
  return {  
    resolve: {    
      alias: {      
        "@": resolve("./src")
      },    
    },    
    plugins
  }
};

项目根目录创建 mock 文件夹,新建 user.ts 编写 user 相关 mock 接口

export default [
  {  
    url: `/module/user/login`,    
    method: "post",    
    response: (req) => {    
      return {      
        header: {        
          code: 0,        
            msg: "OK",      
        },        
        body: {        
          userId:"001",          
          name: `${req.body.name}小明`
        }      
      }    
    }  
  }
];

打开登录页面,输入账号1,密码1,点击登录

控制台可见成功发起了请求并收到了响应,页面跳转到了Home并获取到了userInfo

12-3. 集成 sass

相关文档 安装 sass

yarn add sass -D

修改 src/views/Login.vue style 测试

<style lang="scss" scoped>
$bg: #364d81;
.login_main {
  width: 100%;  
  height: 100%;  
  display: flex;  
  align-items: center;  
  justify-content: center;  
  background: $bg;  
  .content {  
    width: 500px;    
    .title {    
      color: #fff;    
    }  
  }}
</style>

至此,集成 sass 完成。

12-4. 解决 jest 单元测试不支持 import.meta.env

本以为到此万事大吉,但是当我们针对 src/utils/request.ts 进行单元测试的时候

tests/unit/request.spec.ts

import request from "@/utils/request";
test("request", () => {
  expect(request).not.toBeNull();  
  expect(request).not.toBeUndefined();
});

发现 jest 报错了

这里我们之前tsconfig.js中是配置了 "module": "esnext" 的,其实该问题是当前的 ts-jest 库没有对 import.meta.env 做支持,这里采用了如下方法

修改 vite.config.js

import { UserConfigExport, ConfigEnv,loadEnv } from "vite";
import vue from "@vitejs/plugin-vue";
import { resolve } from "path";
import { viteMockServe } from "vite-plugin-mock";
export default ({ command, mode }: ConfigEnv): UserConfigExport => {
  const plugins = [vue()];  
  const env = loadEnv(mode, process.cwd());  
  // 如果当前是测试环境,使用添加mock插件  
  if (mode === "development") {  
    plugins.push(    
      viteMockServe({      
        mockPath: "mock",        
        localEnabled: command === "serve"
      })
    )
  }  
  // 处理使用import.meta.env jest 测试报错问题  
  const envWithProcessPrefix = Object.entries(env).reduce(  
    (prev, [key, val]) => {    
      return {      
        ...prev,        
        // 环境变量添加process.env        
        ["process.env." + key]: `"${val}"`      
      };    
    },    
  {}
  );  
  return {  
    base: "./",    
    resolve: {    
      alias: {      
        "@": resolve("./src"),
      },    
    },    
    plugins,    
    define: envWithProcessPrefix,  
  }};

将拿到的环境相关的变量保存到 process.env 中并向外暴露

修改 request.ts

import axios from "axios";
import { Message } from "element3";
// 创建axios实例
const service = axios.create({
  baseURL: process.env.VITE_BASE_URL,
  timeout: 10000
});

baseURL 中 使用 process.env 下的变量

运行 yarn test-unit 测试通过。

至此,集成 axios+mockjs+sass 完成。《项目地址》

End


至此,本文就结束了,后续会在此基础上搭建后台管理系统,感兴趣的点个关注吧!

关于本文有任何问题或建议,欢迎留言讨论!