Vite + React + Typescript 最佳實踐

使用 Vite + React + Typescript 打造一個前端單頁應用模板

最近前端大火的 Vite 2.0 版本終於出來了,在這裏分享一下如何使用 vite 構建一個前端單頁應用

該文章主要面向對 Vite 感興趣,或者作前端項目架構的同窗javascript

源碼地址,歡迎 star 跟蹤最新變動:fe-project-basecss

經過這篇文章,你能瞭解到如下幾點:html

想快速瞭解 Vite 配置構建的,能夠直接跳到 這裏前端

初始化項目

這裏咱們項目名是 fe-project-base
這裏咱們採用的 vite 2.0 來初始化咱們的項目java

npm init @vitejs/app fe-project-base --template react-ts

這個時候,會出現命令行提示,我們按照本身想要的模板,選擇對應初始化類型就 OK 了node

安裝項目依賴

首先,咱們須要安裝依賴,要打造一個基本的前端單頁應用模板,我們須要安裝如下依賴:react

  1. react & react-dom:基礎核心
  2. react-router:路由配置
  3. @loadable/component:動態路由加載
  4. classnames:更好的 className 寫法
  5. react-router-config:更好的 react-router 路由配置包
  6. mobx-react & mobx-persist:mobx 狀態管理
  7. eslint & lint-staged & husky & prettier:代碼校驗配置
  8. eslint-config-alloy:ESLint 配置插件

dependencies:git

npm install --save react react-dom react-router @loadable/component classnames react-router-config mobx-react mobx-persist

devDependencies:github

npm install --save-dev eslint lint-staged husky@4.3.8 prettier

pre-commit 配置

在安裝完上面的依賴以後,經過 cat .git/hooks/pre-commit 來判斷 husky 是否正常安裝,若是不存在該文件,則說明安裝失敗,須要從新安裝試試typescript

<span style="color:red;font-weight:bold;">
這裏的 husky 使用 4.x 版本,5.x 版本已經不是免費協議了<br/>測試發現 node/14.15.1 版本會致使 husky 自動建立 .git/hooks/pre-commit 配置失敗,升級 node/14.16.0 修復該問題
</span>

在完成了以上安裝配置以後,咱們還須要對 package.json 添加相關配置

{
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  },
  "lint-staged": {
    "src/**/*.{ts,tsx}": [
      "eslint --cache --fix",
      "git add"
    ],
    "src/**/*.{js,jsx}": [
      "eslint --cache --fix",
      "git add"
    ]
  },
}

到這裏,咱們的整個項目就具有了針對提交的文件作 ESLint 校驗並修復格式化的能力了

ESLintError

<span id="editor">編輯器配置</span>

工欲善其事必先利其器,咱們首要解決的是在團隊內部編輯器協做問題,這個時候,就須要開發者的編輯器統一安裝 EditorConfig 插件(這裏以 vscode 插件爲例)

首先,咱們在項目根目錄新建一個配置文件:.editorconfig

參考配置:

root = true

[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

配置自動格式化與代碼校驗

在 vscode 編輯器中,Mac 快捷鍵 command + , 來快速打開配置項,切換到 workspace 模塊,並點擊右上角的 open settings json 按鈕,配置以下信息:

{
  "editor.formatOnSave": true,
  "editor.codeActionsOnSave": {
    "source.fixAll.tslint": true
  },
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "[javascript]": {
    "editor.formatOnSave": true,
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[typescript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "typescript.tsdk": "node_modules/typescript/lib",
  "[typescriptreact]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  }
}

這個時候,我們的編輯器已經具有了保存並自動格式化的功能了

<span id="eslint">ESLint + Prettier</span>

關於 ESLint 與 Prettier 的關係,能夠移步這裏:完全搞懂 ESLint 和 Prettier

  1. .eslintignore:配置 ESLint 忽略文件
  2. .eslintrc:ESLint 編碼規則配置,這裏推薦使用業界統一標準,這裏我推薦 AlloyTeam 的 eslint-config-alloy,按照文檔安裝對應的 ESLint 配置:
  3. npm install --save-dev eslint typescript @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-plugin-react eslint-config-alloy
  4. .prettierignore:配置 Prettier 忽略文件
  5. .prettierrc:格式化自定義配置

    {
      "singleQuote": true,
      "tabWidth": 2,
      "bracketSpacing": true,
      "trailingComma": "none",
      "printWidth": 100,
      "semi": false,
      "overrides": [
        {
          "files": ".prettierrc",
          "options": { "parser": "typescript" }
        }
      ]
    }

    選擇 eslint-config-alloy 的幾大理由以下:

  6. 更清晰的 ESLint 提示:好比特殊字符須要轉義的提示等等

    error `'` can be escaped with `&apos;`, `&lsquo;`, `&#39;`, `&rsquo;`  react/no-unescaped-entities
  7. 更加嚴格的 ESLint 配置提示:好比會提示 ESLint 沒有配置指明 React 的 version 就會告警

    Warning: React version not specified in eslint-plugin-react settings. See https://github.com/yannickcr/eslint-plugin-react#configuration

    這裏咱們補上對 react 版本的配置

    // .eslintrc
    {
      "settings": {
        "react": {
          "version": "detect" // 表示探測當前 node_modules 安裝的 react 版本
        }
      }
    }

<span id="dir">總體目錄規劃</span>

一個基本的前端單頁應用,須要的大體的目錄架構以下:

這裏以 src 下面的目錄劃分爲例

.
├── app.tsx
├── assets // 靜態資源,會被打包優化
│   ├── favicon.svg
│   └── logo.svg
├── common // 公共配置,好比統一請求封裝,session 封裝
│   ├── http-client
│   └── session
├── components // 全局組件,分業務組件或 UI 組件
│   ├── Toast
├── config // 配置文件目錄
│   ├── index.ts
├── hooks // 自定義 hook
│   └── index.ts
├── layouts // 模板,不一樣的路由,能夠配置不一樣的模板
│   └── index.tsx
├── lib // 一般這裏防止第三方庫,好比 jweixin.js、jsBridge.js
│   ├── README.md
│   ├── jsBridge.js
│   └── jweixin.js
├── pages // 頁面存放位置
│   ├── components // 就近原則頁面級別的組件
│   ├── home
├── routes // 路由配置
│   └── index.ts
├── store // 全局狀態管理
│   ├── common.ts
│   ├── index.ts
│   └── session.ts
├── styles // 全局樣式
│   ├── global.less
│   └── reset.less
└── utils // 工具方法
  └── index.ts

OK,到這裏,咱們規劃好了一個大體的前端項目目錄結構,接下來咱們要配置一下別名,來優化代碼中的,好比: import xxx from '@/utils' 路徑體驗

一般這裏還會有一個 public 目錄與 src 目錄同級,該目錄下的文件會直接拷貝到構建目錄

別名配置

別名的配置,咱們須要關注的是兩個地方:vite.config.ts & tsconfig.json

其中 vite.config.ts 用來編譯識別用的;tsconfig.json 是用來給 Typescript 識別用的;

這裏建議採用的是 @/ 開頭,爲何不用 @ 開頭,這是爲了不跟業界某些 npm 包名衝突(例如 @vitejs)

  • vite.config.ts
// vite.config.ts
{
  resolve: {
    alias: {
      '@/': path.resolve(__dirname, './src'),
      '@/config': path.resolve(__dirname, './src/config'),
      '@/components': path.resolve(__dirname, './src/components'),
      '@/styles': path.resolve(__dirname, './src/styles'),
      '@/utils': path.resolve(__dirname, './src/utils'),
      '@/common': path.resolve(__dirname, './src/common'),
      '@/assets': path.resolve(__dirname, './src/assets'),
      '@/pages': path.resolve(__dirname, './src/pages'),
      '@/routes': path.resolve(__dirname, './src/routes'),
      '@/layouts': path.resolve(__dirname, './src/layouts'),
      '@/hooks': path.resolve(__dirname, './src/hooks'),
      '@/store': path.resolve(__dirname, './src/store')
    }
  },
}
  • tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"],
      "@/components/*": ["./src/components/*"],
      "@/styles/*": ["./src/styles/*"],
      "@/config/*": ["./src/config/*"],
      "@/utils/*": ["./src/utils/*"],
      "@/common/*": ["./src/common/*"],
      "@/assets/*": ["./src/assets/*"],
      "@/pages/*": ["./src/pages/*"],
      "@/routes/*": ["./src/routes/*"],
      "@/hooks/*": ["./src/hooks/*"],
      "@/store/*": ["./src/store/*"]
    },
    "typeRoots": ["./typings/"]
  },
  "include": ["./src", "./typings", "./vite.config.ts"],
  "exclude": ["node_modules"]
}

<span id="vite">從 0 到 1 Vite 構建配置</span>

截止做者寫該篇文章時,vite 版本爲 vite/2.1.2,如下全部配置僅針對該版本負責

配置文件

默認的 vite 初始化項目,是不會給咱們建立 .env.env.production.env.devlopment 三個配置文件的,而後官方模板默認提供的 package.json 文件中,三個 script 分別會要用到這幾個文件,因此須要咱們手動先建立,這裏提供官方文檔:.env 配置

# package.json
{
  "scripts": {
    "dev": "vite", // 等於 vite -m development,此時 command='serve',mode='development'
    "build": "tsc && vite build", // 等於 vite -m production,此時 command='build', mode='production'
    "serve": "vite preview",
    "start:qa": "vite -m qa" // 自定義命令,會尋找 .env.qa 的配置文件;此時 command='serve',mode='qa'
  }
}

同時這裏的命令,對應的配置文件:mode 區分

import { ConfigEnv } from 'vite'
export default ({ command, mode }: ConfigEnv) => {
  // 這裏的 command 默認 === 'serve'
  // 當執行 vite build 時,command === 'build'
  // 因此這裏能夠根據 command 與 mode 作條件判斷來導出對應環境的配置
}

具體配置文件參考:fe-project-vite/vite.config.ts

路由規劃

首先,一個項目最重要的部分,就是路由配置;那麼咱們須要一個配置文件做爲入口來配置全部的頁面路由,這裏以 react-router 爲例:

路由配置文件配置

src/routes/index.ts,這裏咱們引入的了 @loadable/component 庫來作路由動態加載,vite 默認支持動態加載特性,以此提升程序打包效率

import loadable from '@loadable/component'
import Layout, { H5Layout } from '@/layouts'
import { RouteConfig } from 'react-router-config'
import Home from '@/pages/home'

const routesConfig: RouteConfig[] = [
  {
    path: '/',
    exact: true,
    component: Home
  },
  // hybird 路由
  {
    path: '/hybird',
    exact: true,
    component: Layout,
    routes: [
      {
        path: '/',
        exact: false,
        component: loadable(() => import('@/pages/hybird'))
      }
    ]
  },
  // H5 相關路由
  {
    path: '/h5',
    exact: false,
    component: H5Layout,
    routes: [
      {
        path: '/',
        exact: false,
        component: loadable(() => import('@/pages/h5'))
      }
    ]
  }
]

export default routesConfig

入口 main.tsx 文件配置路由路口

import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import '@/styles/global.less'
import { renderRoutes } from 'react-router-config'
import routes from './routes'

ReactDOM.render(
  <React.StrictMode>
    <BrowserRouter>{renderRoutes(routes)}</BrowserRouter>
  </React.StrictMode>,
  document.getElementById('root')
)

這裏的面的 renderRoutes 採用的 react-router-config 提供的方法,其實就是我們 react-router 的配置寫法,經過查看 源碼 以下:

import React from "react";
import { Switch, Route } from "react-router";

function renderRoutes(routes, extraProps = {}, switchProps = {}) {
  return routes ? (
    <Switch {...switchProps}>
      {routes.map((route, i) => (
        <Route
          key={route.key || i}
          path={route.path}
          exact={route.exact}
          strict={route.strict}
          render={props =>
            route.render ? (
              route.render({ ...props, ...extraProps, route: route })
            ) : (
              <route.component {...props} {...extraProps} route={route} />
            )
          }
        />
      ))}
    </Switch>
  ) : null;
}

export default renderRoutes;

經過以上兩個配置,我們就基本能把項目跑起來了,同時也具有了路由的懶加載能力;

執行 npm run build,查看文件輸出,就能發現咱們的動態路由加載已經配置成功了

$ tsc && vite build
vite v2.1.2 building for production...
✓ 53 modules transformed.
dist/index.html                  0.41kb
dist/assets/index.c034ae3d.js    0.11kb / brotli: 0.09kb
dist/assets/index.c034ae3d.js.map 0.30kb
dist/assets/index.f0d0ea4f.js    0.10kb / brotli: 0.09kb
dist/assets/index.f0d0ea4f.js.map 0.29kb
dist/assets/index.8105412a.js    2.25kb / brotli: 0.89kb
dist/assets/index.8105412a.js.map 8.52kb
dist/assets/index.7be450e7.css   1.25kb / brotli: 0.57kb
dist/assets/vendor.7573543b.js   151.44kb / brotli: 43.17kb
dist/assets/vendor.7573543b.js.map 422.16kb
✨  Done in 9.34s.

細心的同窗可能會發現,上面我們的路由配置裏面,特地拆分了兩個 Layout & H5Layout,這裏這麼作的目的是爲了區分在微信 h5 與 hybird 之間的差別化而設置的模板入口,你們能夠根據本身的業務來決定是否須要 Layout

樣式處理

說到樣式處理,這裏我們的示例採用的是 .less 文件,因此在項目裏面須要安裝對應的解析庫

npm install --save-dev less postcss

若是要支持 css modules 特性,須要在 vite.config.ts 文件中開啓對應的配置項:

//  vite.config.ts
{
  css: {
    preprocessorOptions: {
      less: {
        // 支持內聯 JavaScript
        javascriptEnabled: true
      }
    },
    modules: {
      // 樣式小駝峯轉化, 
      //css: goods-list => tsx: goodsList
      localsConvention: 'camelCase'
    }
  },
}

編譯構建

其實到這裏,基本就講完了 vite 的整個構建,參考前面提到的配置文件:

export default ({ command, mode }: ConfigEnv) => {
  const envFiles = [
    /** mode local file */ `.env.${mode}.local`,
    /** mode file */ `.env.${mode}`,
    /** local file */ `.env.local`,
    /** default file */ `.env`
  ]
  const { plugins = [], build = {} } = config
  const { rollupOptions = {} } = build

  for (const file of envFiles) {
    try {
      fs.accessSync(file, fs.constants.F_OK)
      const envConfig = dotenv.parse(fs.readFileSync(file))
      for (const k in envConfig) {
        if (Object.prototype.hasOwnProperty.call(envConfig, k)) {
          process.env[k] = envConfig[k]
        }
      }
    } catch (error) {
      console.log('配置文件不存在,忽略')
    }
  }

  const isBuild = command === 'build'
  // const base = isBuild ? process.env.VITE_STATIC_CDN : '//localhost:3000/'

  config.base = process.env.VITE_STATIC_CDN

  if (isBuild) {
    // 壓縮 Html 插件
    config.plugins = [...plugins, minifyHtml()]
  }

  if (process.env.VISUALIZER) {
    const { plugins = [] } = rollupOptions
    rollupOptions.plugins = [
      ...plugins,
      visualizer({
        open: true,
        gzipSize: true,
        brotliSize: true
      })
    ]
  }

  // 在這裏沒法使用 import.meta.env 變量
  if (command === 'serve') {
    config.server = {
      // 反向代理
      proxy: {
        api: {
          target: process.env.VITE_API_HOST,
          changeOrigin: true,
          rewrite: (path: any) => path.replace(/^\/api/, '')
        }
      }
    }
  }
  return config
}

在這裏,咱們利用了一個 dotenv 的庫,來幫咱們將配置的內容綁定到 process.env 上面供咱們配置文件使用

詳細配置請參考 demo

構建優化

  1. 爲了更好地、更直觀的知道項目打包以後的依賴問題,咱們,咱們能夠經過 rollup-plugin-visualizer 包來實現可視化打包依賴
  2. 在使用自定義的環境構建配置文件,在 .env.custom 中,配置

    # .env.custom
    NODE_ENV=production

    截止版本 vite@2.1.5,官方存在一個 BUG,上面的 NODE_ENV=production 在自定義配置文件中不生效,能夠經過如下方式兼容

    // vite.config.ts
    const config = {
      ...
      define: {
        'process.env.NODE_ENV': '"production"'
      }
      ...
    }
  3. antd-mobile 按需加載,配置以下:

    import vitePluginImp from 'vite-plugin-imp'
    // vite.config.ts
    const config = {
      plugins: [
        vitePluginImp({
          libList: [
            {
              libName: 'antd-mobile',
              style: (name) => `antd-mobile/es/${name}/style`,
              libDirectory: 'es'
            }
          ]
        })
      ]
    }

    以上配置,在本地開發模式下能保證 antd 正常運行,可是,在執行 build 命令以後,在服務器訪問會報一個錯誤
    antd-error,相似 issue 能夠參考

    解決方案
    手動安裝單獨安裝 indexof npm 包:npm install indexof

<span id="mobx">mobx6.x + react + typescript 實踐</span>

做者在使用 mobx 的時候,版本已是 mobx@6.x,發現這裏相比於舊版本,API 的使用上有了一些差別,特意在這裏分享下踩坑經歷

Store 劃分

store 的劃分,主要參考本文的示例
須要注意的是,在 store 初始化的時候,若是須要數據可以響應式綁定,須要在初始化的時候,給默認值,不能設置爲 undefined 或者 null,這樣子的話,數據是沒法實現響應式的

// store.ts
import { makeAutoObservable, observable } from 'mobx'

class CommonStore {
  // 這裏必須給定一個初始化的只,不然響應式數據不生效
  title = ''
  theme = 'default'

  constructor() {
    // 這裏是實現響應式的關鍵
    makeAutoObservable(this)
  }

  setTheme(theme: string) {
    this.theme = theme
  }

  setTitle(title: string) {
    this.title = title
  }
}

export default new CommonStore()

Store 注入

mobx@6x的數據注入,採用的 reactcontext 特性;主要分紅如下三個步驟

根節點變動

經過 Provider 組件,注入全局 store

// 入口文件 app.tsx
import { Provider } from 'mobx-react'
import counterStore from './counter'
import commonStore from './common'

const stores = {
  counterStore,
  commonStore
}

ReactDOM.render(
  <React.StrictMode>
    <Provider stores={stores}>
      <BrowserRouter>{renderRoutes(routes)}</BrowserRouter>
    </Provider>
  </React.StrictMode>,
  document.getElementById('root')
)

這裏的 Provider 是由 mobx-react 提供的
經過查看源碼咱們會發現,Provier內部實現也是 React Context

// mobx-react Provider 源碼實現
import React from "react"
import { shallowEqual } from "./utils/utils"
import { IValueMap } from "./types/IValueMap"

// 建立一個 Context
export const MobXProviderContext = React.createContext<IValueMap>({})

export interface ProviderProps extends IValueMap {
    children: React.ReactNode
}

export function Provider(props: ProviderProps) {
    // 除開 children 屬性,其餘的都做爲 store 值
    const { children, ...stores } = props
    const parentValue = React.useContext(MobXProviderContext)
    // store 引用最新值
    const mutableProviderRef = React.useRef({ ...parentValue, ...stores })
    const value = mutableProviderRef.current

    if (__DEV__) {
        const newValue = { ...value, ...stores } // spread in previous state for the context based stores
        if (!shallowEqual(value, newValue)) {
            throw new Error(
                "MobX Provider: The set of provided stores has changed. See: https://github.com/mobxjs/mobx-react#the-set-of-provided-stores-has-changed-error."
            )
        }
    }

    return <MobXProviderContext.Provider value={value}>{children}</MobXProviderContext.Provider>
}

// 供調試工具顯示 Provider 名稱
Provider.displayName = "MobXProvider"

Store 使用

由於函數組件無法使用註解的方式,因此我們須要使用自定義 Hook 的方式來實現:

// useStore 實現
import { MobXProviderContext } from 'mobx-react'
import counterStore from './counter'
import commonStore from './common'

const _store = {
  counterStore,
  commonStore
}

export type StoreType = typeof _store

// 聲明 store 類型
interface ContextType {
  stores: StoreType
}

// 這兩個是函數聲明,重載
function useStores(): StoreType
function useStores<T extends keyof StoreType>(storeName: T): StoreType[T]

/**
 * 獲取根 store 或者指定 store 名稱數據
 * @param storeName 指定子 store 名稱
 * @returns typeof StoreType[storeName]
 */
function useStores<T extends keyof StoreType>(storeName?: T) {
  // 這裏的 MobXProviderContext 就是上面 mobx-react 提供的
  const rootStore = React.useContext(MobXProviderContext)
  const { stores } = rootStore as ContextType
  return storeName ? stores[storeName] : stores
}

export { useStores }

組件引用經過自定義組件引用 store

import React from 'react'
import { useStores } from '@/hooks'
import { observer } from 'mobx-react'

// 經過 Observer 高階組件來實現
const HybirdHome: React.FC = observer((props) => {
  const commonStore = useStores('commonStore')

  return (
    <>
      <div>Welcome Hybird Home</div>
      <div>current theme: {commonStore.theme}</div>
      <button type="button" onClick={() => commonStore.setTheme('black')}>
        set theme to black
      </button>
      <button type="button" onClick={() => commonStore.setTheme('red')}>
        set theme to red
      </button>
    </>
  )
})

export default HybirdHome

能夠看到前面我們設計的自定義 Hook,經過 Typescript 的特性,可以提供友好的代碼提示

code demo

以上就是整個 mobx + typescript 在函數式組件中的實際應用場景了;
若是有什麼問題,歡迎評論交流 :)

參考資料

相關文章
相關標籤/搜索