使用 Vite 搭建 React 項目

背景

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

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

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

需求概覽

技術選型

6Jkjct

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

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

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

Vite 、 Webpack 等都是很好的構建工具。

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

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

爲什麼使用 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.json 報 Unknown compiler option 'allowImportingTsExtensions' 錯誤:

tsconfig.json 報錯 2

解決辦法

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

// tsconfig.node.json

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

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

main.tsx 報 This 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.tsx 報 An 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.json 的 scripts 中配置

// 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

然後在 husky 的 pre-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 在法語中是 “橋” 的意思,寓意着前後端之間的橋樑。

Pont 把 swagger、rap、dip 等多種接口文檔平臺,轉換成 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-dom 的 useRouteError 能獲取到錯誤的具體信息。

// 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.tsx 的 useLayoutEffect 中進行動態加載,並且進行授權校驗。

// 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.ts 中 base 字段的值。

// vite.config.ts

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

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

添加 vconsole

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

> pnpm i vconsole -D

然後在 Authority.tsx 的頂部添加

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

在 qa 及 sim 環境,可以看到 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,只在線上環境才上傳 sourcemap 到 Sentry。

// 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

最後

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

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

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