likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 62

前言

在上篇 工欲善其事必先利其器(前端代码规范) 中,我们了解代码规范的基本配置,本篇则配置一个简单的项目模板,以 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 提供的

工欲善其事必先利其器(配置Vue3 + 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 可以有提示

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

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

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

由于 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({
    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>

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

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

如果有需要还可以使用 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]'
      }
    }
  }
})
注意
  • 按照此时配置,打包后有一个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
评论
请登录