工欲善其事必先利其器(配置Vue3 + ts项目模板)

1,435 阅读12分钟

前言

在上篇 工欲善其事必先利其器(前端代码规范) 中,我们了解代码规范的基本配置,本篇以 Vue3 + typescript 为例结合 vue-router + pinia + ant-design-vue + vue-i18n + vite 配置一个简单的项目模板

准备

初始化一个项目,目录结构大体如下

目录结构

├─.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

初始化

需要初始化的模块在 main.ts 中可以看到大概

main.ts

import VueDOMPurifyHTML from 'vue-dompurify-html'
import components from '@/components'
import directive from '@/directive'
import { createApp } from 'vue'
import router from '@/router'
import App from './App.vue'
import pinia from '@/store'
import i18n from '@/i18n'
// 全局样式
import '@/assets/style/index.less'

/**
 * bootstrap
 */
async function bootstrap(): Promise<void> {
  const app = createApp(App)
  
  app.use(pinia).use(i18n).use(components).use(router).use(VueDOMPurifyHTML).use(directive)
    
  app.mount('#app', true)
}

bootstrap()

通过 main.ts 可以看到核心有以下模块需要处理

  • components,需要全局注册的组件
  • directive,自定义全局指令
  • router,路由
  • pinia,store
  • i18n,国际化

vue-dompurify-html 是安全的 v-html 指令库

模块处理

为了方便管理和维护,最好约定同类模块的内容都统一收拢到对应模块内统一管理

组件

组价统一放到 src/components 管理,非业务型通用组件可以放到 src/components/common 下,然后在 src/components/index.ts 中统一处理组件的全局注册,如

import type { App } from 'vue'
import SwitchLocale from './common/SwitchLocale.vue'

export default {
  install(app: App): void {
    app.component('SwitchLocale', SwitchLocale)
  }
}

自定义指令

自定义指令收拢在 src/directive 文件夹内,在 src/directive/index.ts 中统一处理指令的全局注册,如

import type { App } from 'vue'
import resize from './resize'
import wheel from './whell'

export default {
  install(app: App): void {
    app.directive('resize', resize)
    app.directive('wheel', wheel)
  }
}

国际化

国际化 相关内容收拢在 src/i18n 文件夹内

import { createI18n } from 'vue-i18n'
import messages from './langs'

export const fallbackLocale = 'zh-CN'
const i18n = createI18n({
  legacy: false, // 使用Composition API,这里必须设置为false
  globalInjection: true,
  global: true,
  locale: fallbackLocale,
  fallbackLocale, // 默认语言
  messages
})
// 方便在 Composition API 及 Hooks 写法下使用,如 import { $t } from '@/i18n'
export const $t = i18n.global.t
  • 处理切换语言下拉选项列表
import messages from './langs'
export type LocaleType = keyof typeof messages

const lacaleKeys = Object.keys(messages)
export const lacaleList = lacaleKeys.map((key: LocaleType) => {
  return { name: messages[key].localeName, key }
})
// 暴露 LacaleOption 类型,因为懒得写声明,直接用 PickArrayItem 推导
export type LacaleOption = PickArrayItem<typeof lacaleList>

// types/index.d.ts
declare type PickArrayItem<T> = T extends Array<infer G> ? G : never
  • 封装并导出切换语言接口
import { locale } from 'dayjs'
/**
 * 切换语言
 *
 * @param {string} newlocale  locale
 * @returns {void}
 */
export function changeLocale(newlocale = fallbackLocale): void {
  i18n.global.locale.value = newlocale
  // 为了这里能正常运作,langs里的命名要按照dayjs的命名来
  locale(newlocale.toLocaleLowerCase())
}
  • 语言 provider 注入处理
// src/i18n/langs/zh-CN.ts
import zhCN from 'ant-design-vue/es/locale/zh_CN'
// 把 ant-design-vue 的引入
export default {
  ...zhCN,
  localeName: '简体中文', // 切换语言下拉列表展示的语言名称
}
// src/i18n/index.ts
export type LangsLocale = (typeof messages)['zh-CN']

/**
 * 获取 ant-design-vue 多语言
 *
 * @returns {LangsLocale} 语言配置
 */
export function getLocale(localeName: LocaleType): LangsLocale {
  return messages[localeName]
}

// src/App.vue
<template>
  <a-config-provider :locale="getLocale(i18nStore.locale.key)">
    <router-view />
  </a-config-provider>
</template>
<script lang="ts" setup>
import { getLocale } from '@/i18n'
import { useI18nStore } from '@/store'
const i18nStore = useI18nStore()
</script>

完整内容请查看 src/i18n/index.ts

路由

路由配置,这个参照 vue-router 配置即可

import { $t } from '@/i18n'
import { apiTokenSession } from '@/utils'
import { notification } from 'ant-design-vue'
import { createRouter, createWebHistory } from 'vue-router'
// 路由配置
import { routes } from './routeRecordRaw'

const router = createRouter({
  history: createWebHistory(),
  routes
})

router.beforeEach((to, _from, next) => {
  if (['/login'].includes(to.path)) {
    next()
  } else {
    // 判断token是否失效

    if (apiTokenSession()) {
      next()
    } else {
      notification.info({
        message: $t('pleaseLogin'),
        duration: 2
      })
      next({ path: '/login' })
    }
  }
})

export default router

pinia

store 相关内容收拢在 src/store 文件夹内,在 src/store/modules 文件夹拆分各个模块并提供模块 use,在src/store/index.ts 中统一暴露出去,这里推荐使用 Hooks 写法,pinia 文档 也是这种写法

// src/store/modules/i18n.ts
import type { LacaleOption } from '@/i18n'
import { defineStore } from 'pinia'
import { i18nLocal } from '@/utils'
import { changeLocale } from '@/i18n'

export const useI18nStore = defineStore('i18n', {
  state: () => {
    const store = i18nLocal()
    changeLocale(store.key)
    return { locale: store }
  },
  actions: {
    changeLocale(locale: LacaleOption) {
      i18nLocal(locale)
      changeLocale(locale.key)
      this.locale = locale
    }
  }
})


// src/store/index.ts
import { createPinia } from 'pinia'
export * from './modules/user'
export * from './modules/i18n'

const pinia = createPinia()
export default pinia

Storage

在上面的配置中,你肯定遇到了一些需要存储的数据,比如当前选择语言的i18n 配置信息api token等等,因为也是常用的模块,收拢起来比较好管理

Storage统一放在 src/utils/storage 文件夹内管理, 以 sessionStorage 为例

src/utils/storage/session 中统一管理 sessionStorage

先在 src/utils/storage/session/base.ts 封装好基本的 sessionStorage 模块

export type SessionDB = string | null | void

export function session(name: string): string | null
export function session(name: string, value: string): void

/**
 * sessionStorage
 *
 * @param {string} key 数据标识
 * @param {string} value 数据
 * @returns {SessionDB} SessionDB
 */
export function session(key: string, value?: string): SessionDB {
  if (value !== undefined) {
    return sessionStorage.setItem(key, value)
  }
  return sessionStorage.getItem(key)
}

/**
 * 清空sessionStorage
 */
export function clearAllSession(): void {
  sessionStorage.clear()
}

/**
 * 根据key值,删除对应的sessionStorage
 *
 * @param {string} key Session key
 */
export function removeSession(key: string): void {
  sessionStorage.removeItem(key)
}

然后再在 src/utils/storage/session/modules 处理各个 sessionStorage 模块,以 apiToken 为例

import type { TokenResponse } from '@/api'
import type { SessionDB } from '../base'
import { session, removeSession } from '../base'

// -------------api token-------------
const _API_TOKEN_ = '_API_TOKEN_'
export interface TokenSession extends TokenResponse {
  startTime: number
}
/**
 * 清空apiToken缓存
 *
 * @returns {SessionDB} SessionDB
 */
export function removeApiTokenSession(): SessionDB {
  return removeSession(_API_TOKEN_)
}

/**
 * 用户token
 *
 * @param {string} value value
 * @returns {SessionDB} SessionDB
 */
export function apiTokenSession(value?: TokenResponse): SessionDB {
  if (value !== undefined) {
    return session(_API_TOKEN_, JSON.stringify({ ...value, startTime: Date.now() }))
  }

  const db = session(_API_TOKEN_)
  if (db) {
    const token = JSON.parse(db) as TokenSession
    const { startTime, expires_in } = token
    if (Date.now() - startTime > expires_in) {
      removeApiTokenSession()
      return
    }
    return token.access_token
  }

  return
}

这里封装了 token 数据的存取以及过期处理,如果考虑 JSON.parse 报错也可以在这里统一加 try catch 处理,这样外部使用就无需关心这些细枝末节的问题,而且 typescript 类型也可以统一处理,外部引入即可

api

api 模块统一放在 src/api 下管理

首先在 src/api/request.ts 基于axios 二次封装基本的 request,这里就不过多介绍了,详细请参考 axios 文档

其次是各个模块的 api ,以登录为例,主要是做好类型声明

// src/api/login/index.ts
import type { AxiosPromise } from 'axios'
import request from '../request'

const prefix = '/login'

export interface TokenResponse {
  access_token: string
  expires_in: number
}

export interface LoginFormData {
  username: ''
  password: ''
  remember: true
}
/**
 * 登录
 *
 * @param {FormData} data 表单信息
 * @returns {AxiosPromise} TokenData
 */
export function login(data: FormData): AxiosPromise<ServerResponse<TokenResponse>> {
  return request({
    url: `${prefix}/oauth/token`,
    method: 'POST',
    data,
    headers: {
      Authorization: 'Basic d2ViQXB=='
    }
  })
}

/**
 * 注销
 *
 * @returns {AxiosPromise} logout res
 */
export function logout(): AxiosPromise<ServerResponse<null>> {
  return request({ url: `${prefix}/logout`, method: 'GET' })
}

其中 ServerResponse 是后端封装的一层数据,在 api.d.ts

declare interface PageResponse<T> {
  total: number
  rows: T[]
  code?: number
  msg?: string
  pages?: number
  currentPage: number
  size: number
}
declare interface ServerResponse<T> {
  code: number
  msg: string
  data: T
}

至此,基本的模块就处理完成了

配置

处理完各个模块,接下来就是工程配置了,在上篇已经给出规范相关的配置,这里就不再详细说明了

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"],
    "types": ["vite/client"],
    "typeRoots": ["./node_modules/@types/", "./types"],
    "noImplicitAny": false,
    "skipLibCheck": true,
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "types/*.d.ts"],
  "exclude": ["node_modules", "dist", "**/*.js"]
}

因为全局的.d.ts 收集在 types 下需要配置 typeRoots,默认的 ./node_modules/@types/ 也需要包含上来

vite Client 类型支持参考 Client Types 配置

vite

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"
}

可以使用@vitejs/plugin-vue-jsx 插件增加对 jsx 的支持

针对浏览器兼容性的可参考 vite 构建生产版本

如果没有其他特殊要求,打包也可以使用该配置,只需配置打包命令即可

"build": "vite build -c ./vite.config.dev.ts --mode production",

至此,一个项目的基本配置就完成了,接下来看看还有什么是可以优化和完善的

优化

优化永远是进行时,根据不同情况需要做相应的取舍,有兴趣可以看看我的这篇简单分析聊聊前端性能优化

启动优化

通过 optimizeDeps.include 添加需要预编译的包

  optimizeDeps: {
    include: ['vue', 'vue-i18n', 'vue-router']
  }

也可以使用vite-plugin-optimize-persistvite-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-visualizervite.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"
}

screenshot-20230216-103610.png

由于 ant-design-vue 是全局引入,所以即使未使用也打包进来了

按需引入

利用 rollupTree 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({
    dts: 'types/components.d.ts',
    // dirs 指定组件所在位置,默认为 src/components
    // 可以让我们使用自己定义组件的时候免去 import 的麻烦
    // dirs: ['src/components/'],
    // 配置需要将哪些后缀类型的文件进行自动按需引入
    extensions: ['vue', 'md'],
    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>

结果如下,体积减少了很多

screenshot-20230216-103610s.png

如果有需要还可以使用 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-importvite-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']
    }

这个时候 externalGlobalsautoImport执行顺序冲突了,具体可以查看这个issuesvite.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]'
      }
    }
  }
})

细节处理

还有一些细节可以优化

.vue 文件在 template 使用 $t() 没提示处理

因为此时$t 的类型声明是 vue-i18n.d.ts 提供的,不是实例化的,其类型是 string,所以没有具体提示

screenshot-20230216-103610.png

一种解决方案是换一个字段,在 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 可以有提示

screenshot-20230216-103610.png

不过这种方式不太好看就是了,个人认为是添加一个 $t 的重载才是比较好的方式,不过重载未生效,但是写类型推导的过程还是挺有趣的

export type LangType = (typeof messages)['zh-CN']
export type ConnectKey<Prefix, K> = K extends string ? `${Prefix & string}.${K}` : `${Prefix & string}`

export type GetLangKey<T, K> = ConnectKey<K, T>
export type DeepGetLangKeys<T> = {
  [K in keyof T]: GetLangKey<DeepGetLangKeys<T[K]>, K>
}[keyof T]
export type LangKey = DeepGetLangKeys<LangType>

function test(k: LangKey): string {
  return ''
}
// 这里 test 入参有提示
test('')

treer

README.md 的完整目录说明,可以使用 treer 生成目录结构

# 全局安装
npm i -g treer
# 生成目录
treer -d 项目名 -e t.md -i "/node_modules|.git|.husky/"

.eslintrc 需要补充 vue/setup-compiler-macros 配置,用以解决.vue 文件运行时j解析问题

  "env": {
    "browser": true,
    "es2021": true,
    "vue/setup-compiler-macros": true
  }

注意

  • 按照此时配置,打包后有一个i8n问题,如果翻译文件配置了这种动态的形式: total: '共 {total} 条',使用方式:$t('total', 10)。构建后{total}没有动态替换,需要移除 vue-i18n 的 CND 配置,或者不使用 runtime 版本,即把 vue-i18n.runtime.global.prod.js改为 vue-i18n.global.prod.js

最后

本文完整配置可以查阅vue3-template(请注意时效性)

到这里一个简单能用的项目模板就搭建完成了,至于测试、监控、 CI/CD 等等,篇幅原因这里就不详细介绍了

附录

系列文章