使用 Vite 搭建 React 項目

背景

爲支持公司業務發展,方便業務在非工作時間段在手機端處理部分工作,需要新開發一個 移動飛書 H5 工作臺 系統。

很高興能負責此次的項目搭建,讓我有機會從頭到尾地經歷一次項目的搭建過程。

在此,我將記錄並分享從項目設計到最終上線的完整步驟,總結搭建過程中遇見過的坑。希望下次有這樣的機會時,能在本次搭建的基礎上,更快地搭建出質量更高的項目。

需求概覽

技術選型

6Jkjct

除部分需要 Node V16+ 版本及以上的包外,基本使用的都是對應庫的最新版本。這樣有什麼優缺點呢?

爲什麼 Node 沒使用 V16+ 的呢?

爲什麼使用 Vite 來構建初始化項目?

ViteWebpack 等都是很好的構建工具。

Vite 提出,它利用瀏覽器開始支持 ES 模塊的特性,基於原生 ES 模塊進行構建,可以極大地縮短項目啓動以及 HMR 的時間。

閱文千遍,不如親身實踐一遍。所以正好藉此機會,從實際使用上來感受一下 ViteWebpack 在開發體驗上的區別。

爲什麼使用 pnpm?

操作步驟

初始化項目

> pnpm create vite $projectName --template react-ts
> cd $projectName
> pnpm i
> pnpm dev

初始化項目後,tsconfig.json 報錯:

tsconfig.json 報錯 1

解決辦法

修改 moduleResolution 的值爲 node

//tsconfig.json

"moduleResolution""node"

tsconfig.node.json 處也做相同修改。

tsconfig.jsonUnknown compiler option 'allowImportingTsExtensions' 錯誤:

tsconfig.json 報錯 2

解決辦法

allowImportingTsExtensions 移動到和 compilerOptions 同級的地方。

// tsconfig.node.json

{
  "compilerOptions"{
    // ...
  },
  "allowImportingTsExtensions": true,
}

參考鏈接:https://blog.csdn.net/qq_46266305/article/details/131140524

main.tsxThis module is declared with using 'export =', and can only be used with a default import when using the 'allowSyntheticDefaultImports' flag 錯誤

main.tsx 報錯 1

解決辦法

tsconfig.json 中配置

// tsconfig.json

"noFallthroughCasesInSwitch": true

main.tsxAn import path cannot end with a '.tsx' extension. Consider importing './App.js' instead.

main.tsx 報錯 2

解決辦法

去掉 .tsx 結尾。

安裝 less

> pnpm i less -D

如果需要以 styles. 的方式使用 less,需要將 less 的文件命名爲 *.module.less

import styles from '*.module.less';

部分變量需要在很多 less 文件中使用,在 vite.config.ts 中配置全局 less

// vite.config.ts

css: {
    modules: {
      generateScopedName: "[local]__[hash:base64:5]",
      hashPrefix: "prefix",
    },
    preprocessorOptions: {
      less: {
        javascriptEnabled: true,
        //  配置 less 全局變量
        additionalData: `@import "${path.resolve(
          __dirname,
          "src/assets/styles/variable.less"
        )}";`,
      },
    },
    // 開發環境生成 less 的 sourceMap
    devSourcemap: !isOnline,
},

參考鏈接:https://cn.vitejs.dev/guide/features.html#css-modules

安裝 eslint

> pnpm i eslint -D

因爲有使用 TypeScript。所以還需要安裝

package.jsonscripts 中配置

// package.json

"lint-staged:js""eslint --cache --ext .js,.jsx,.ts,.tsx ./src"

執行命令

> pnpm run lint-staged:js

如果還有依賴包沒安裝好,在 terminal 中會有提示,根據提示信息完善依賴包的 install 就行。

配置好 eslint 後,將初始化項目中,不符合 eslint 規範的代碼先修改一下,以免後面不符合 eslint 的越來越多。

husky

Eslint 較爲相關的當然是 husky 了。

它的作用是什麼呢?

能在 git 操作過程中,觸發對應的鉤子,執行對應的命令。在這裏與 Eslint 結合,能在提前到 git 倉庫前,對 Eslint 進行一次校驗,以防止不符合 Eslint 規範的代碼提交到無端倉庫。

具體的安裝步驟,可以參考 github 官方文檔來操作:https://github.com/typicode/husky

然後在 huskypre-commit 鉤子方法裏面寫

// .husky/pre-commit

#!/bin/sh"$(dirname "$0")/_/husky.sh"
echo 'precommit'

npm run lint-staged:js
// package.json

"scripts"{
    "lint-staged:js""eslint --cache --ext .js,.jsx,.ts,.tsx ./src"
}

安裝 commitlint

它的作用是什麼?

規範 commit 信息。如果是功能提交,commit message 需要以 feat: 開頭,如果是修復 bug,需要以 fix: 開頭,如果只是樣式修改,需要以 style 開頭等等。

這樣可以從 commit 處,直觀看到本次 commit 的具體作用是什麼。

具體操作步驟參考官方文檔:https://github.com/conventional-changelog/commitlint

在安裝 commitlint 過程中,遇見一個報錯:

SyntaxError: Failed to load plugin '@typescript-eslint' declared in '.eslintrc.js » @vue/eslint-config-typescript/recommended » /home/viktord/Projects/renthome/dashboard/node_modules/@vue/eslint-config-typescript/index.js': Unexpected token '??='
Referenced from: /home/viktord/Projects/renthome/dashboard/node_modules/@vue/eslint-config-typescript/index.js

/home/viktord/Projects/renthome/dashboard/node_modules/@typescript-eslint/typescript-estree/dist/convert.js:176
        result.range ??= (0, node_utils_1.getRange)(node, this.ast);
                     ^^^

SyntaxError: Unexpected token '??='
    at wrapSafe (internal/modules/cjs/loader.js:1001:16)
    at Module._compile (internal/modules/cjs/loader.js:1049:27)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1114:10)
    at Module.load (internal/modules/cjs/loader.js:950:32)
    at Function.Module._load (internal/modules/cjs/loader.js:790:12)
    at Module.require (internal/modules/cjs/loader.js:974:19)
    at require (internal/modules/cjs/helpers.js:101:18)
    at Object.<anonymous> (/home/viktord/Projects/renthome/dashboard/node_modules/@typescript-eslint/typescript-estree/dist/ast-converter.js:4:19)
    at Module._compile (internal/modules/cjs/loader.js:1085:14)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1114:10)
Process finished with exit code -1

原因:最新的 @typescript-eslint/eslint-plugin v6.2.0 有問題,先降級爲 5.62.0。 等 bug 修復後,可以嘗試再次升級爲 v6+

安裝 antd-mobile

有許多移動端的組件,引入一個完整的組件庫,可以減少很多重複造輪子的時間。

> pnpm i antd-mobile

安裝 iconfont

在 https://www.iconfont.cn/ 中上傳自己的圖標。

Symbol 的方式生成圖標

iconfont symbol

將資源下載到項目的 public 文件夾下。

然後在 index.html 處引入對應的 js

// index.html

<body>
    <div id="root"></div>
    <script src="/iconfont.js"></script>
    <script type="module" src="/src/main.tsx"></script>
  </body>

如果使用的是 @ant-design/icons 它提供了一個方法 createFromIconfontCN 可以擴展字體圖標。但我們不需要它的圖標,所以不需要安裝它的包。

這樣,就需要自己實現一個 Iconfont 組件。

// components/Iconfont.tsx

import { FC } from "react";

interface IProps extends IObject {
  /** 圖標類型 icon- */
  type: string;
  /** 圖標大小 */
  size: number;
  /** 圖標顏色 */
  color?: string;
}

const Iconfont: FC<IProps> = (props) ={
  const { type, color, size, style, ...rest } = props;

  return (
    <svg
      class
      aria-hidden="true"
      fill={color}
      style={{ width: size, height: size, ...style }}
      {...rest}
    >
      <use xlinkHref={`#${type}`}></use>
    </svg>
  );
};

export default Iconfont;

這樣就能使用 IconFont 了。

<Iconfont type="icon-image" size={24} class />

安裝 react-intl

國際化語言支持。

> pnpm i react-intl

修改 main.tsx

// main.tsx

const localeInfo = locales();

ReactDOM.createRoot(document.getElementById("root")!).render(
 <IntlProvider locale={localeInfo.locale} messages={localeInfo.localeMessages}>
   <React.StrictMode>
     <App />
   </React.StrictMode>
 </IntlProvider>
);

添加 src/locales/index.ts。優先讀取 localStorage 中是否有保存語言環境,如果沒有,默認讀取瀏覽器語言環境。切換語言後,將語言信息存放在 localStorage 中。

// src/locales/index.ts

function locales() {
 const language = localStorage.getItem('language-locale') || navigator.language;
 switch(language) {
 case 'zh-CN':
  return { locale: navigator.language, localeMessages: zhCN };
 default:
  return { locale: navigator.language, localeMessages: enUS };
 }
}

export default locales;

切換語言組件

// components/SwitchLanguage.tsx

import { Button, Popover } from "antd-mobile";
import { Action } from "antd-mobile/es/components/popover";
import styles from "./index.module.less";

enum languageEnum {
  "zh-CN" = "zh-CN",
  "en-US" = "en-US",
}

const actions: Action[] = [
  { key: languageEnum["zh-CN"], text: "簡體中文(CN)" },
  { key: languageEnum["en-US"], text: "English(EN)" },
];

/** 切換語言 */
const SelectLanguage = () ={
  const currentLanguage =
    localStorage.getItem("language-locale") || navigator.language;

  // 如果找不到,就展示 English。因爲 zh-CN 只有一個,其它的語言很多
  const languageText =
    actions.find((item) => item.key === currentLanguage)?.text ||
    actions[1].text;

  const handleLanguageChange = (node: Action) ={
    // 如果選擇的是和目前一樣的語言,不做任何處理
    if (node.key === currentLanguage) return;
    localStorage.setItem("language-locale", node.key as string);
    window.location.reload();
  };

  return (
    <section className={styles.selectLanguage}>
      <Popover.Menu
        actions={actions}
        onAction={handleLanguageChange}
        placement="bottom-start"
        trigger="click"
      >
        <Button>{languageText}</Button>
      </Popover.Menu>
    </section>
  );
};

export default SelectLanguage;

然後在對應的組件中,就可以使用了

import { useIntl } from "react-intl";

const intl = useIntl();

intl.formatMessage({ id: "global.copySuccess" })

添加 axios

> pnpm i axios

添加了 axios 後,需要封裝 request 請求。請求數據時,給接口添加請求頭,返回數據時,對接口進行統一錯誤處理。

添加 alias

正常情況下,在一個文件中,使用相對路徑加載其他模塊,如果文件層級較深,經常會出現 ../../../../*.tsx 這樣的寫法。在文件目錄發生變化時,很容易出現路徑修改不完整而導致運行錯誤。

配置 alias,可以以絕對路徑的方式引入其他模塊。例如:import Loading from '@/components/Loading'

配置 alias 的方法如下:

vite.config.ts 中配置

// vite.config.ts

resolve: {
    alias: {
      "@": path.resolve(__dirname, "src"),
    },
    extensions: [".ts"".tsx"".js"".jsx"],
},

另外,還需要在 tsconfig.ts 中配置

// tsconfig.ts

"baseUrl""./",
"paths"{
  "@/*"["src/*"],
}

接入 pont

pont 是什麼?

pont 在法語中是 “橋” 的意思,寓意着前後端之間的橋樑。

Pontswaggerrapdip 等多種接口文檔平臺,轉換成 Pont 元數據。Pont 利用接口元數據,可以高度定製化生成前端接口層代碼,接口 mock 平臺和接口測試平臺。

其中 swagger 數據源,Pont 已經完美支持。並在一些大型項目中使用了近兩年,各種高度定製化需求都可以滿足。

https://github.com/alibaba/pont

通俗來說就是

首先,安裝包

> pnpm i pont-engine -D

在項目的根目錄下添加 pont-config.json

// pont-config.json

{
  "outDir""./src/services/auto-gen-api/src",
  "templatePath""./generate-api-template",
  "originType""SwaggerV2 | SwaggerV3",
  "prettierConfig"{
    "singleQuote": true,
    "trailingComma""all",
    "tabWidth": 2,
    "endOfLine""lf",
    "printWidth": 100,
    "proseWrap""never"
  },
  "origins"[
    {
      "originType""SwaggerV2 | SwaggerV3",
      "originUrl""https://**/v2/api-docs?group=api",
      "name""projectNameApi",
      "usingMultipleOrigins": true,
      "usingOperationId": true,
    }
  ]
}

同級目錄下添加 generate-api-template.ts,裏面的具體內容,根據接口定義以及封裝的 request 進行自定義開發。

// generate-api-template.ts

export default class MyGenerator extends CodeGenerator {
    // ...
}

做完這些,項目運行時,我們可以對生成的文件進行檢驗,看其語法是否正確,執行 node ./config/swagger-to-api/compile.js

// package.json

"scripts"{
    "compile""cross-env NODE_ENV=development pnpm compileApi",
    "diff""npx pont diff",
    "compileApi""node ./config/swagger-to-api/compile.js",
    "updateMod""npx pont updateMod",
    "updateBo""npx pont updateBo",
    "updateInterface""npx pont updateInterface",
  },

compile.js 中,對生成的文件使用 ttsc 進行校驗。

在執行 pnpm diff 時,有些會報 ES Module 錯誤。

解決辦法:

去掉 package.json 中的

// package.json

type: 'module'

接入 react-router-dom

> pnpm install react-router-dom

修改 main.tsx

// main.tsx

ReactDOM.createRoot(document.getElementById("root")!).render(
  <IntlProvider locale={locale} messages={localeMessages}>
    <React.StrictMode>
      <RouterProvider router={router} />
    </React.StrictMode>
  </IntlProvider>
);

後面的所有的頁面都會經過 Authority.tsx 組件。

爲避免 router.ts 文件過大,不好維護。將路由分散在各個主功能下,使用 import 的方式進行引入。

// router.ts

import { createBrowserRouter } from "react-router-dom";
import Authority from "@/layout/Authority";
import ErrorBoundary from "@/layout/ErrorBoundary";
import Home from "@/pages/home";
import Search from "@/pages/search";
import clueRouters from "@/pages/clue/router";

const router = createBrowserRouter([
  {
    path: "/",
    element: <Authority />,
    errorElement: <ErrorBoundary />,
    children: [
      {
        path: "/home",
        element: <Home />,
      },
      {
        path: "/search",
        element: <Search />,
      },
      ...clueRouters,
    ],
  },
]);

export default router;

如果頁面中有由 JS 執行出錯的地方,會進入 errorElement。在這裏可以做一些頁面錯誤的統一處理。

通過 react-router-domuseRouteError 能獲取到錯誤的具體信息。

// ErrorBoundary

import { useRouteError } from "react-router-dom";
import PageError from "./PageError";

// 錯誤處理
const ErrorBoundary = () ={
  const error = useRouteError() as IObject;

  // ...
  return <PageError message={error.message} />;
};

export default ErrorBoundary;

ErrorBoundry

接入 SSO

在入口文件處引入封裝好的 SSO SDK。由於不同環境的 SDK 對應的 CDN 地址不一致。所有的頁面都會經過 layout/Authority.tsx 組件。所以我們根據當前運行環境,在 Authority.tsxuseLayoutEffect 中進行動態加載,並且進行授權校驗。

// Authority.tsx

useLayoutEffect(() ={
    // 動態加載 SSO 文件
    const script = document.createElement("script");
    script.src = `${process.env.SSO_SERVER}/t.js`;
    document.head.appendChild(script);
    script.onload = async () ={
      // 封裝的 SSO 資源加載完成後,實例化 SSO 方法
      window.authentication = new window.$logo(config);
      setLoading(false);
    };
    // ...
}[]);

在後面的接口請求時,在 Network 中發現,第一次請求的接口,都執行了兩次。原因是 React 使用了 StrictMode 模式,這是官方的預期行爲。

https://react.dev/reference/react/StrictMode

https://juejin.cn/post/7231842222782054461

運維資源申請

由於系統需要國內國外都能訪問,所以服務器資源需要全球加速。

申請 CDN 資源後,修改 vite.config.tsbase 字段的值。

// vite.config.ts

base: process.env.PKM_CDN_PATH || "/",

由於項目最後是由 PKM 發佈的,所以需要在 PKM 系統中申請簽名。將簽名信息在 CI 中配置。

添加 vconsole

項目部署後,在移動設備上使用,遇見問題時,不能像在 瀏覽器 中一樣按 F12 打開控制檯進行調試代碼。所以添加 vconsole 插件,打開時,可以看到 consolenetwork 等信息,方便 debug

> pnpm i vconsole -D

然後在 Authority.tsx 的頂部添加

// 非線上環境,開啓 VConsole,方便在飛書中查看日誌
if (process.env.ENV !== "online") {
  new VConsole();
}

qasim 環境,可以看到 vconsole 信息,線上環境不會出現。

vconsole

添加 Sentry

> pnpm i @sentry/react @sentry/tracing

在不同的環境中分別配置 Sentry 需要的信息。

// env.ts

SENTRY_DSN: '',
SENTRY_AUTH_TOKEN: '',
SENTRY_ORG: '',
SENTRY_PROJECT: '',
SENTRY_URL: '',

初始化 Sentry

// initSentry.ts

import * as Sentry from "@sentry/react";
import { BrowserTracing } from "@sentry/tracing";

// 初始化 Sentry
function initSentry() {
  if (!process.env.SENTRY_DSN) return;

  Sentry.init({
    dsn: process.env.SENTRY_DSN as string,
    tracesSampleRate: 1.0,
    release: process.env.PKM_VERSION,
    integrations: [new BrowserTracing()],
    ignoreErrors: [
      "Request failed with status code 403",
  });
}

export default initSentry;

在剛纔寫的 ErroryBoundry.tsx 組件中添加錯誤數據上報。

// ErrorBoundary

// 錯誤處理
const ErrorBoundary = () ={
  const error = useRouteError();
  // ...
  Sentry.captureException(error, {});
  // ...
};

sourcemap 資源上傳到 Sentry 中,以便能更好地定位錯誤位置。

> pnpm i @sentry/vite-plugin

修改 vite.config.ts,只在線上環境才上傳 sourcemapSentry

// vite.config.ts

import { sentryVitePlugin } from "@sentry/vite-plugin";

export default defineConfig({
  build: {
      sourcemap: isOnline,
  },
  plugins: [
    // ...
    isOnline
      ? sentryVitePlugin({
          url: definedEnv.SENTRY_URL,
          org: definedEnv.SENTRY_ORG,
          project: definedEnv.SENTRY_PROJECT,
          authToken: definedEnv.SENTRY_AUTH_TOKEN,
          sourcemaps: {
            ignore: ["node_modules"],
          },
          release: {
            name: process.env.PKM_VERSION,
            uploadLegacySourcemaps: {
              paths: [path.join(process.cwd()"/dist")],
              urlPrefix: process.env.PKM_CDN_PATH || "~/",
            },
          },
        })
      : null,
  ]

注意如果公司有自己配置 Sentry,一定要配置 url,不然我們寫了 authToken,會報 token invalid 錯誤。

401 錯誤

添加 spark trace

使用的是字節的用戶信息收集。

添加 .gitlab-ci.yml

cache:
  policy: pull
  key: ${CI_COMMIT_REF_NAME}
  paths:
    - .pnpm-store

// ...

配置好對應的 CI 後,等運維將服務資源申請好,域名申請好後,部署到對應的環境中,進行一次測試,看流程是否跑通。

飛書工作臺申請

在飛書工作臺 https://open.feishu.cn/app 申請應用。根據要求填寫好申請信息,給需要看到的人配置權限,提交申請,審覈通過後,有權限的人就可以在飛書的工作臺中看到對應的應用,點擊添加就可以添加到工作臺中。

然後聯繫運維,配置對應環境與飛書的關聯,實現在飛書工作臺的免登錄操作。

其他優化

頁面跳轉時,自動滾動到最頂部

Authority.tsx 中添加

// Authority.tsx

const location = useLocation();

useEffect(() ={
    // 路由變化時,頁面滾動到最頂部
    if (location.pathname) {
      window.scrollTo({ top: 0 });
    }
  }[location.pathname]);

縮靜態圖片資源

將圖片資源進行無損壓縮,減少空間佔用。

組件按需引入

使用 vite-plugin-imp 對部分組件進行按需引入。

> pnpm i vite-plugin-imp -D

修改 vite.config.ts

// vite.config.ts

export default defineConfig({
  plugins: [
    vitePluginImp({
      libList: [
        {
          libName: "lodash",
          libDirectory: "",
          camel2DashComponentName: false,
        },
        {
          libName: "antd-mobile",
          libDirectory: "es/components",
          style() {
            return `antd-mobile/es/global/index.js`;
          },
        },
      ],
    }),
  ]
 })

拆包

默認情況下,打包後的 JS 只有一個,達到了 2M+,在請求時,如果網絡較慢,這個資源可能等待的時間會比較久。

瀏覽器有一個特性,就是併發請求,同一時間可以同時請求多個資源,所以,使用 npx vite-bundle-visualizer -c vite.config.ts 命令對包體積進行分析,對包進行合理拆分。

// vite.config.ts

build: {
    // ...
    chunkSizeWarningLimit: 1024,
    rollupOptions: {
      output: {
        manualChunks: (id) ={
          const bigNodeModules = [
            "html2canvas",
            "cos-js-sdk",
            "crypto-js",
            "jsencrypt",
          ].some((item) => id.includes(item));
          if (bigNodeModules) {
            return "vendorLarge";
          }
          if (id.includes("node_modules")) {
            return "vendor";
          }
        },
      },
    },
  },

增加兼容性

現在較新的瀏覽器都支持較新的語法。但在監控平臺發現,還有好些用在使用類似 iOS 9 等版本較低的系統及瀏覽器。所以,我們還需要對較低版本的系統及瀏覽器增加兼容。

> pnpm i @vitejs/plugin-legacy terser -D
// vite.config.ts

plugins: [
    // ...
    legacy({
      // 需要兼容的目標列表,可以設置多個
      targets: ["defaults""ie >= 11""chrome >= 70"],
      additionalLegacyPolyfills: ["regenerator-runtime/runtime"],
      renderLegacyChunks: true,
      // 下面的數組可以自定義添加低版本轉換的方法
      polyfills: [
        "es.symbol",
        "es.array.filter",
        "es.promise",
        "es.promise.finally",
        "es/map",
        "es/set",
        "es.array.for-each",
        "es.object.define-properties",
        "es.object.define-property",
        "es.object.get-own-property-descriptor",
        "es.object.get-own-property-descriptors",
        "es.object.keys",
        "es.object.to-string",
        "web.dom-collections.for-each",
        "esnext.global-this",
        "esnext.string.match-all",
      ],
}),

注意

這個是在 build 時才生效,本地因爲 vite 使用的是 module 的方式打包構建的,所以本地加了這個,在本地啓動項目,用手機連接本地時,也是無法訪問的。

兼容的版本越低,生成的包的文件會越大。需要根據項目使用的具體情況,合理調整最小兼容版本。

https://github.com/vitejs/vite/tree/main/packages/plugin-legacy

最後

通過這次搭建,我更加清晰地瞭解了從 01 這樣一個完整的開發流程。除了正常業務開發外,對項目結構、公共組件設計、性能優化、兼容性、信息收集以及分析等都有了更進一步的瞭解及思考。以前不太明白的一些點,這次親身實踐後,都有了更清晰的認識。

雖然花了更多的時間,但收穫很多,非常充實。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/QPJltvJ_T-aIaYSE5h6uzA