工欲善其事必先利其器(配置Vue3 + ts项目模板)
前言
在上篇 工欲善其事必先利其器(前端代码规范) 中,我们了解代码规范的基本配置,本篇则配置一个简单的项目模板,以 Vue3 + ts
为例
准备
在上篇已经给出规范相关的配置,这里就不再详细说明了
index.html
文件
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Demo</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
目录结构
├─.vscode
| ├─settings.json
├─.eslintignore
├─.eslintrc
├─.prettierignore
├─.prettierrc
├─.stylelintignore
├─.stylelintrc
├─index.html
├─package.json
├─tsconfig.json
├─vite.config.dev.ts
├─vite.config.prod.ts
├─types
├─src
| ├─App.vue
| ├─main.ts
| ├─utils
| ├─store
| ├─router
| ├─pages
| ├─i18n
| ├─components
| ├─assets
| ├─api
App.uve
<template>
<router-view />
</template>
main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import Antd from 'ant-design-vue'
import 'ant-design-vue/dist/antd.css'
import i18n from '@/i18n'
import App from './App.vue'
import router from '@/router'
const app = createApp(App)
app.use(router).use(Antd).use(createPinia()).use(i18n).mount('#app', true)
treer
README.md
的完整目录说明,可以使用 treer 生成目录结构
# 全局安装
npm i -g treer
# 生成目录
treer -d 项目名 -e t.md -i "/node_modules|.git|.husky/"
工具集
pnpm i -S vue axios vue-router vue-i18n pinia vite ant-design-vue @ant-design/icons-vue
.eslintrc
需要补充 vue/setup-compiler-macros
配置,用以解决.vue
文件运行时j解析问题
"env": {
"browser": true,
"es2021": true,
"vue/setup-compiler-macros": true
}
tsconfig-json
tsconfig-json详细配置说明可以参看tsconfig-json、完整compilerOptions选项
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"strict": true,
"forceConsistentCasingInFileNames": true,
"allowSyntheticDefaultImports": true,
"strictFunctionTypes": false,
"jsx": "preserve",
"baseUrl": ".",
"allowJs": true,
"sourceMap": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"experimentalDecorators": true,
"lib": ["dom", "esnext"],
"noImplicitAny": false,
"skipLibCheck": true,
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src", "types"],
"exclude": ["node_modules", "dist"]
}
vite
pnpm i -D @vitejs/plugin-vue unplugin-auto-import
vite.config.dev.ts
文件
import path from 'path'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import autoImport from 'unplugin-auto-import/vite'
export default defineConfig({
base: './',
plugins: [
vue(),
autoImport({
imports: ['vue', 'vue-router'],
dts: 'types/auto-imports.d.ts'
})
],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
},
server: {
host: '0.0.0.0',
port: 8000,
open: true,
https: false,
proxy: {
'/dev': {
target: 'http://xxx-dev.com',
changeOrigin: true,
rewrite: p => p.replace(/^\/dev/, '')
},
}
}
})
package.json
增加启动命令
"scripts": {
"dev": "vite -c ./vite.config.dev.ts --mode development"
}
router
路由根据自身需要跟着vue-router官网配置即可,需要注意的无非是动态加载、路由守卫、权限
import type { RouteLocationNormalized, RouteRecordRaw, NavigationGuardNext } from 'vue-router'
import { createRouter, createWebHistory } from 'vue-router'
const LAYOUT = (): Promise<typeof import('@/components/layouts/main.vue')> => import('@/components/layouts/main.vue')
const routes: RouteRecordRaw[] = [
{
path: '/',
component: LAYOUT
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach((to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => {
// 路由守卫
// 登录信息、路由权限
})
export default router
i18n
import { createI18n } from 'vue-i18n'
import modules from './langs'
const i18n = createI18n({
legacy: false, // 使用Composition API,这里必须设置为false
globalInjection: true,
global: true,
locale: 'zh',
fallbackLocale: 'zh',
messages: modules
})
export default i18n
此时的在vue
文件中使用 $t
没有正确的提示,因为此时$t
的类型声明是 vue-i18n.d.ts
提供的
在 types/global.d.ts
中写入如下内容
import { i18n } from '@/i18n'
declare module 'vue' {
export interface ComponentCustomProperties {
$tt: typeof i18n.global.t
}
}
i18n
模块内容改造如下
import type { App } from 'vue'
import { createI18n } from 'vue-i18n'
import modules from './langs'
export const i18n = createI18n({
legacy: false, // 使用Composition API,这里必须设置为false
globalInjection: true,
global: true,
locale: 'zh',
fallbackLocale: 'zh',
messages: modules
})
export default {
install: (app: App) => {
app.config.globalProperties.$tt = i18n.global.t
}
}
此时在vue
文件中使用 $tt
可以有提示
pinia
这个就简单了,跟着pinia 官网配置即可,使用就按照Hooks
方式使用即可
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
function $reset() {
count.value = 0
}
function $add() {
count.value += 1
}
return { count, $add, $reset }
})
utils
这个模块值得强调的是,因为使用了typescript
进行开发,这些公共的模块需要做好typescript
类型处理,比如
/**
* 类型检测
*
* @param { any } val 检测对象
* @param { string } type 类型
* @returns {boolean} 是否属于 type 类型
*/
export function is(val: unknown, type: string): boolean {
return toString.call(val) === `[object ${type}]`
}
/**
* 对象检测
*
* @example <caption>类型保护示例</caption>
* interface AA { aa: string }
* let a: AA | string = ''
* if (Math.random() > 0.5) {
* a = { aa: 'dd' }
* } else {
* a = 'aa'
* }
* if (isObject(a)) {
* console.log(a.aa);// a 在这个条件分支被保护为a: AA
* } else {
* console.log(a);// a 在这个条件分支被保护为a: string
* }
* @param {any} val 检测对象
* @returns {boolean} val is Object
*/
export function isObject<T = unknown, U = T extends object ? T : object>(val: unknown): val is U {
return val !== null && is(val, 'Object')
}
api
这里也一样,做好类型声明即可,至于具体实现则根据具体情况而定,如果文档工具支持也可以从api
文档导出
/* eslint-disable jsdoc/require-jsdoc */
import type { AxiosPromise } from 'axios'
import request from './request'
const prefix = '/v1'
export interface ServerResponse<T> {
code: number
msg: string
data: T
}
export interface UserLoginResponse {
access_token: string
expires_in: number
}
export interface LoginData {
password: string
userName: string
}
export function login(data: LoginData): AxiosPromise<ServerResponse<UserLoginResponse>> {
return request({ url: `${prefix}/login`, method: 'POST', data })
}
优化
优化永远是进行时,根据不同情况需要做相应的取舍,有兴趣可以看看我的这篇简单分析聊聊前端性能优化
启动优化
通过 optimizeDeps.include
添加需要预编译的包
optimizeDeps: {
include: ['vue', 'vue-i18n', 'vue-router']
}
也可以使用vite-plugin-optimize-persist 和vite-plugin-package-config自动加载需要预编译的包
除了预编译,最好的方案还是减少不必要的编译,需要配合 esbuild.exclude
配置,因为和下文打包有类似,这里不详细介绍了
打包体积优化
vite.config.prod.ts
文件
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import autoImport from 'unplugin-auto-import/vite'
export default defineConfig({
base: '/',
define: {
'process.env': process.env
},
plugins: [
autoImport({
imports: ['vue', 'vue-router'],
dts: 'types/auto-imports.d.ts'
}),
vue(),
],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
},
css: {
preprocessorOptions: {
less: {
javascriptEnabled: true
}
}
},
build: {
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true
}
}
}
})
package.json
增加打包命令
"scripts": {
"build": "vite build -c ./vite.config.prod.ts --mode production"
}
优化之前先通过rollup-plugin-visualizer来分析打包结果得到优化方向
安装 rollup-plugin-visualizer
,vite.config.prod.ts
改造如下
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import autoImport from 'unplugin-auto-import/vite'
import { visualizer } from 'rollup-plugin-visualizer'
const lifecycle = process.env.npm_lifecycle_event
const plugins = [
autoImport({
imports: ['vue', 'vue-router'],
dts: 'types/auto-imports.d.ts'
}),
vue(),
]
if (lifecycle === 'report') {
plugins.push(visualizer({ open: true, brotliSize: true, filename: 'visualizer/report.html' }))
}
export default defineConfig({
base: '/',
define: {
'process.env': process.env
},
plugins,
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
},
build: {
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true
}
}
}
})
package.json
增加打包分析预览命令并执行
"scripts": {
"report": "vite build -c ./vite.config.prod.ts --mode production"
}
由于 ant-design-vue
是全局引入,所以即使未使用也打包进来了
按需引入
利用 rollup
的 Tree shaking
能力可以很容易做到,只要把全局引用改为单个引用即可
手动处理按需引入也可以,不过还是使用自动按需引入插件unplugin-vue-components比较香
安装 unplugin-vue-components
,vite.config.prod.ts
改造如下
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import autoImport from 'unplugin-auto-import/vite'
import { visualizer } from 'rollup-plugin-visualizer'
import Components from 'unplugin-vue-components/vite'
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'
const lifecycle = process.env.npm_lifecycle_event
const plugins = [
autoImport({
imports: ['vue', 'vue-router'],
dts: 'types/auto-imports.d.ts',
resolvers: [AntDesignVueResolver()]
}),
Components({
resolvers: [AntDesignVueResolver()]
}),
vue()
]
if (lifecycle === 'report') {
plugins.push(visualizer({ open: true, brotliSize: true, filename: 'visualizer/report.html' }))
}
export default defineConfig({
base: '/',
define: {
'process.env': process.env
},
plugins,
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
},
build: {
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true
}
}
}
})
然后去掉 main.ts
中的全局注册
import { createApp } from 'vue'
import I18n from '@/i18n'
import App from './App.vue'
import Router from '@/router'
createApp(App).use(Router).use(I18n).mount('#app', true)
App.vue
使用a-layout
组件
<template>
<a-layout>
<router-view />
</a-layout>
</template>
结果如下,体积减少了很多
如果有需要还可以使用 vite-plugin-compression 进行压缩
分包
此时除了路由的懒加载自动分包其他包是打到一起的,分包需更改 rollupOptions
的配置
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import autoImport from 'unplugin-auto-import/vite'
import { visualizer } from 'rollup-plugin-visualizer'
import Components from 'unplugin-vue-components/vite'
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'
const lifecycle = process.env.npm_lifecycle_event
const plugins = [
autoImport({
imports: ['vue', 'vue-router'],
dts: 'types/auto-imports.d.ts',
resolvers: [AntDesignVueResolver()]
}),
Components({
resolvers: [AntDesignVueResolver()]
}),
vue()
]
if (lifecycle === 'report') {
plugins.push(visualizer({ open: true, brotliSize: true, filename: 'visualizer/report.html' }))
}
export default defineConfig({
base: '/',
define: {
'process.env': process.env
},
plugins,
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
},
build: {
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true
}
},
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
return id.toString().split('node_modules/.pnpm/')[1].split('/')[0].toString()
}
},
// 资源也做分类
chunkFileNames: 'static/js/[name]-[hash].js',
entryFileNames: 'static/js/[name]-[hash].js',
assetFileNames: 'static/[ext]/[name]-[hash].[ext]'
}
}
}
})
打包加速 & 网络加速
打包加速实际上有两个大方向
- 减少不必要编译
- 多线程编译
这里我们仅关注 减少不必要编译,这就需要将静态资源提取出来,同时为了能加快网络加载资源的速度,对于这些不会经常改动的资源可以使用 CDN 资源
vite
相关的 CDN 库可以考虑 vite-plugin-cdn-import和 vite-plugin-cdn-import-async
回到编译本身,使用了库肯定还是有一定的"编译"量,这里选择手动配置,以 unpkg 为例
将 index.html
改造如下,类似ant-design-vue
这种库可以根据实际情况判断是需要按需打包还是可以使用 CDN 全引,由于 pinia
使用到 vue-demi
所以需要先引入 vue-demi
,像three
这些资源只有进入某些场景才使用到的可以异步加载
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="https://unpkg.com/ant-design-vue@3.2.15/dist/antd.min.css" rel="stylesheet" />
<title>Demo</title>
<script src="https://unpkg.com/vue@3.2.47/dist/vue.runtime.global.prod.js"></script>
<script src="https://unpkg.com/vue-demi@0.13.11"></script>
<script src="https://unpkg.com/pinia@2.0.30"></script>
<script src="https://unpkg.com/axios@1.3.2/dist/axios.min.js"></script>
<script src="https://unpkg.com/dayjs@1.11.7/dayjs.min.js"></script>
<script src="https://unpkg.com/dayjs@1.11.7/plugin/customParseFormat.js"></script>
<script src="https://unpkg.com/dayjs@1.11.7/plugin/weekday.js"></script>
<script src="https://unpkg.com/dayjs@1.11.7/plugin/localeData.js"></script>
<script src="https://unpkg.com/dayjs@1.11.7/plugin/weekOfYear.js"></script>
<script src="https://unpkg.com/dayjs@1.11.7/plugin/weekYear.js"></script>
<script src="https://unpkg.com/dayjs@1.11.7/plugin/advancedFormat.js"></script>
<script src="https://unpkg.com/ant-design-vue@3.2.15/dist/antd.min.js"></script>
<script src="https://unpkg.com/vue-i18n@9.2.2/dist/vue-i18n.runtime.global.prod.js"></script>
<script src="https://unpkg.com/vue-router@4.1.6/dist/vue-router.global.prod.js"></script>
<script async src="https://unpkg.com/three@0.149.0/build/three.min.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
配置完 html
还需要告诉 rollup
这些资源不需要打包,rollupOptions
,需要配置external
rollupOptions: {
external: ['three', 'dayjs', 'ant-design-vue', 'vue', 'axios', 'vue-i18n', 'vue-router', 'vue-demi', 'pinia']
}
但是光屏蔽打包还不行,还需要告知 rollup
这些被屏蔽的资源改从哪里引,使用 rollup-plugin-external-globals 可以很方便处理
rollupOptions: {
plugins: [
externalGlobals({
vue: 'Vue',
three: 'THREE',
'ant-design-vue': 'antd',
axios: 'axios',
dayjs: 'dayjs',
'vue-router': 'VueRouter',
'vue-i18n': 'VueI18n',
'vue-demi': 'VueDemi'
})
],
external: ['three', 'dayjs', 'ant-design-vue', 'vue', 'axios', 'vue-i18n', 'vue-router', 'vue-demi', 'pinia']
}
这个时候 externalGlobals
和 autoImport
执行顺序冲突了,具体可以查看这个issues,vite.config.prod.ts
改造如下
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import autoImport from 'unplugin-auto-import/vite'
import { visualizer } from 'rollup-plugin-visualizer'
import Components from 'unplugin-vue-components/vite'
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'
const lifecycle = process.env.npm_lifecycle_event
const plugins = [
autoImport({
imports: ['vue', 'vue-router'],
dts: 'types/auto-imports.d.ts',
resolvers: [AntDesignVueResolver()]
}),
Components({
resolvers: [AntDesignVueResolver()]
}),
vue(),
{
...externalGlobals({
vue: 'Vue',
three: 'THREE',
'ant-design-vue': 'antd',
// swiper: 'Swiper',
axios: 'axios',
dayjs: 'dayjs',
'hls.js': 'Hls',
'vue-router': 'VueRouter',
'vue-i18n': 'VueI18n',
'vue-demi': 'VueDemi'
}),
enforce: 'post',
apply: 'build'
}
]
if (lifecycle === 'report') {
plugins.push(visualizer({ open: true, brotliSize: true, filename: 'visualizer/report.html' }))
}
export default defineConfig({
base: '/',
define: {
'process.env': process.env
},
plugins,
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
},
build: {
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true
}
},
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
return id.toString().split('node_modules/.pnpm/')[1].split('/')[0].toString()
}
},
// 资源也做分类
chunkFileNames: 'static/js/[name]-[hash].js',
entryFileNames: 'static/js/[name]-[hash].js',
assetFileNames: 'static/[ext]/[name]-[hash].[ext]'
}
}
}
})
注意
- 按照此时配置,打包后有一个
i8n
问题,如果翻译文件配置了这种动态的形式:total: '共 {total} 条'
,使用方式:$t('total', 10)
。构建后{total}
没有动态替换,需要移除vue-i18n
的 CND 配置 - 有些包的 import 方式需要调整,比如
import * as THREE from 'three'
需要改成import { Mesh } from 'three'
或者THREE
改为其他,因为THREE
这个变量已经全局的了 - 针对浏览器兼容性的 vite 构建生产版本 也有介绍
最后
到这里一个简单能用的项目模板就搭建完成了,更完善的还有测试、监控、 CI/CD 等等,这里就不介绍了
转载自:https://juejin.cn/post/7200930164821000229