React如何在同一個Repo用拆分不同裝置的Code

> Desktop 與 Mobile(h5)版本,使用不同的domain,也拆分了不同的專案,要如何整合在同一個專案內?

React如何在同一個Repo用拆分不同裝置的Code

此文章的 Demo

此文章的 Code

此文章適用以下情境

  • 沒有打算製作 RWD 的網頁,是維持兩個版本,電腦版、手機版
  • 電腦版與手機版用到的 API 及商業邏輯大致相同,但是卻拆分了 2 個專案,也各自部署到不同的 Domain
  • 兩個版本的專案由+3個人同時維護,維護人員參差不齊
  • 沒有特別為每個 core function 或 component 寫測試
  • 沒有特別的人員來做 Code review

案例分析

維護問題

A 專案(電腦版、Desktop version)

B 專案(手機版、Mobile version)

專案會拆分成 2 個不同的專案,所以若要加上新功能的話必須兩個專案都要維護,視專案大小有可能會有不只 2 位以上人員同時在維護,並且人員交叉維護(甲偶爾維護 A 專案,主要維護 B 專案、乙偶爾維護 B 專案,主要維護 A 專案、丙兩個專案都維護),若沒有做好代碼管理的話,後續的維護會是一個大災難

基本上會遇到這種專案類型的都是比較舊的專案,因為那時候比較沒有RWD的概念,加上手機端的用戶興起,每個網站都必須要支援手機端的網頁,因此原本只有設計電腦版的公司在不干擾原本程式碼的情形,便利用視窗大小來確認。所以便拆分了兩種版本,而隨著專案越來越大也無法隨意合併在一起,之後越走越遠直至平行。

基本上遇到這種情況加上產品還在線上的,不可能改專案架構。除非遇到公司業績大幅衰退,老闆發覺不妙才有可能進行一些調整。

但在這麼講也不可能直接整個打掉重做,因為做產品要考慮的面向太多了。所以我們可以從新專案開始使用這樣的架構來避免未來面臨的技術債。

要改的東西太多了,那就改天吧

要改?老闆走

不改?你走

使用者體驗問題

如:

可以觀察到以上網址無非就是在 Domain 上加上 m 當作 mobile 的標誌

在一般的情況下這種方式是沒有問題的,但是假如你在電腦上開了手機版的 Domain ,頁面便會變得非常奇怪

PC Home mobile page

在 SEO 權重的分配下有可能 Mobile 的 Domain 上到了搜尋結果的第一頁,用戶點了頁面以為此網站有問題,就直接關掉此網頁

ithelp mobile page

我對 SEO 相關的優化不是太了解 😓 ,之前的工作都是使用 CSR(Client side render)的框架

對於什麼是 CSR 還是 SSR 這些名詞的人,可以看這 英文、繁體中文、掘金

正題開始 Show me the code

此篇並沒有打算使用 monorepo 相關的框架來處理,主要使用的技術為

但若你有考慮使用 monorepo ,可以考慮lerna、Nx、Turborepo,各框架都有各自的 trade off,建議研究各個優缺點再選擇適合專案的框架。

環境設置

此範例用的 library 版本,主要用 Typescript ,用 js 的小夥伴可以自己在做調整

# bash
❯ node -v
v14.19.1
❯ npm -v
7.24.2

Run npm create vite@latest 後的 package.json

// package.json
{
  // ...
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "vite": "^4.3.2"
  }
}

接著安裝 React-router-dom 及 @types/node

React router 自從 6.4 版本後做了許多改變,也許你該看看他們的文檔 , 但現階段不建議使用在 Prod 上面,因為近期他們小版本更新的飛快,如果要穩定一點的版本建議還是使用 v5 ,遇到問題社群上的解答也相對比較多一點。

yarn add react-router-dom && yarn add -D @types/node

安裝完後會變成

// package.json
{
  // ...
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-router-dom": "^6.11.2"
  },
  "devDependencies": {
    "@types/node": "^20.2.3",
    "vite": "^4.3.2"
  }
}

接著調整一下 vite.config.ts 以及 tsconfig.json

// vite.config.ts
/** @type {import('vite').UserConfig} */

import { fileURLToPath, URL } from 'node:url'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: [
      {
        find: '@',
        replacement: fileURLToPath(new URL('./src', import.meta.url)),
      },
    ],
  },
})
// tsconfig.json
{
  "compilerOptions": {
    // ....
    // 加上這兩行,讓 vscode,能夠自動抓到相對應的路徑
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

.eslintrc.cjs 會噴錯,先修正一下

'module' is not defined. eslint (no-undef)

// .eslintrc.cjs
module.exports = {
  env: { browser: true, es2020: true, node: true }, // 加上 node: true
  // ...
}

到這邊專案的初始設定算是好了


主邏輯

主要是利用了React.lazy dynamic import 的功能,盡可能把 bundle size 壓到最小,讓用戶在初始載入的速讀提升。

在搭配react-responsive,判斷用戶視窗大小來載入是 Desktop 還是 Mobile 的檔案

React Suspense & React Lazy 為 16.6 以後的版本才有的功能

以下為資料夾結構

  • ./src/* ,共用的邏輯 Hooks、API、components、store、utils
  • ./src/apps/Desktop , 電腦版的邏輯,也可以有自己的 Hooks、API
  • ./src/apps/Mobile , 手機版的邏輯,也可以有自己的 Hooks、API
├── node_modules
├── public
├── src
│   ├── apps
│   │   │
│   │   ├── index.tsx // 導出 Desktop & Mobile
│   │   ├── Desktop
│   │   │     ├── components  // 電腦版的components
│   │   │     ├── pages // 電腦版的頁面
│   │   │     │     ├── Home.tsx
│   │   │     │     ├── About.tsx
│   │   │     │     └── Prod.tsx
│   │   │     │
│   │   │     ├── App.tsx
│   │   │     ├── index.tsx
│   │   │     └── router.tsx
│   │   │
│   │   └──  Mobile
│   │         ├── components // 手機版的components
│   │         ├── pages // 手機版的頁面
│   │         │     ├── Home.tsx
│   │         │     ├── About.tsx
│   │         │     └── Prod.tsx
│   │         │
│   │         ├── App.tsx
│   │         ├── index.tsx
│   │         └── router.tsx
│   │
│   ├── api  // 共用的 API
│   ├── components  // 共用的 components
│   ├── hooks  // 共用的 hooks
│   ├── store  // 共用的 store
│   ├── utils  // 共用的 utils
│   ├── App.tsx
│   ├── index.css
│   └── main.tsx
│
├── .eslintrc.cjs
├── .gitignore
├── package.json
├── package-lock.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts

已下是 Desktop 的 code,Mobile 的就依樣畫葫蘆,若有問題請看demo repo

路徑: src/apps/Desktop/index.tsx

// src/apps/Desktop/index.tsx
import React, { Suspense } from 'react'

import { LoadingSpinner } from '@/components/Loading'

const App = React.lazy(() => import('./App'))
const AsyncApp = () => {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <App />
    </Suspense>
  )
}
export default AsyncApp

路徑: src/apps/Desktop/router.tsx

// src/apps/Desktop/router.tsx
import { lazy, Suspense } from 'react'
import { Route, Routes } from 'react-router-dom'

const Home = lazy(() => import('./pages/Home'))
const About = lazy(() => import('./pages/About'))
const Product = lazy(() => import('./pages/Product'))

const IRoute = () => {
  return (
    <Suspense>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        <Route path="/product" element={<Product />} />
      </Routes>
    </Suspense>
  )
}

export default IRoute

路徑: src/apps/Desktop/App.tsx

// src/apps/Desktop/App.tsx
import { BrowserRouter, Link } from 'react-router-dom'

import IRoute from './router'

const App = () => {
  return (
    <BrowserRouter>
      <h1>Desktop App component</h1>
      <Link to="/">Home</Link>
      <Link to="/about">About</Link>
      <Link to="/product">Product</Link>
      <IRoute />
    </BrowserRouter>
  )
}

export default App

路徑: src/apps/Desktop/pages/About.tsx

路徑: src/apps/Desktop/pages/Home.tsx

路徑: src/apps/Desktop/pages/Prodcut.tsx

// src/apps/Desktop/pages/About.tsx
function About() {
  return (
    <div>
      <h1>This is the Desktop About page</h1>
    </div>
  );
}
export default About;

// src/apps/Desktop/pages/Home.tsx
function Home() {
  return (
    <div>
      <h1>This is the Desktop Home page</h1>
    </div>
  );
}
export default Home;

// src/apps/Desktop/pages/Prodcut.tsx
function Product() {
  return (
    <div>
      <h1>This is the Desktop Product page</h1>
    </div>
  );
}
export default Product;

以上就能建立起 Desktop 初始的資料結構了,接著 Mobile 也一樣做法 接著我們利用 react-responsive 幫助我們判斷什麼時候要載入哪一個版本的 code

yarn add react-responsive

路徑: src/hoc/responsive.tsx

// src/hoc/responsive.tsx
import MediaQuery from 'react-responsive'

const MOBILE_QUERY = '(max-width: 767px)'

type ScreenProps = {
  Mobile: React.ElementType,
  Desktop: React.ElementType,
}

const Screen =
  ({ Mobile, Desktop }: ScreenProps) =>
  ({ ...rest }) =>
    (
      <MediaQuery query={MOBILE_QUERY}>
        {(matches) => (matches ? <Mobile {...rest} /> : <Desktop {...rest} />)}
      </MediaQuery>
    )

export default Screen

路徑: src/App.tsx

import { Desktop, Mobile } from '@/apps'
import Responsive from '@/hoc/responsive'

function App() {
  const Screen = Responsive({ Desktop, Mobile })
  return (
    <div>
      <div>Root App Component</div>
      <Screen />
    </div>
  )
}

export default App

已上就是全部了,不清楚的可以去看dmoe,開 Network 去觀察

deploy to real domain to check

結論

利用此架構的優點是可以將共用的 商業邏輯、API 路徑、共用元件 寫在一個地方共同維護就好,各自的版本可以專注於畫面上的邏輯就好。

  • 在同一個專案上更好設置 prettier、lint 之類的檢查工具,確保大家的代碼品質一致。
  • 測試邏輯可以專注於共用的元件做測試,E2E test 還是要分版本測試
  • 強制大家共用元件要寫的抽象,才能在雙版本上復用

總結來講此篇的方法只是簡單的 🙌🌰 ,每個專案的情境都不一樣,對你來說這可能不是最好的辦法,但在不想要用 monorepo 的架構下需要這樣維持雙版本的情況下,我認為這是個不錯的選擇了。最後還有些地方可以進行優化, 但這篇主要是給你一個可行的架構,剩下的地方就留給你們自行發揮啦~