[MERN 項目實戰] MERN Multi-Vendor 電商平臺開發筆記(v2.0 從 bug 到結構優化的工程記錄)
其實之前沒想著這么快就能把 2.0 的筆記寫出來的,之前的預期是,下一個階段會一直維持到將 MERN 項目寫完,畢竟后期很多東西都是 cv 了。不過沒想到,一個 frontend(2C 端的商城頁面)寫著寫著還是碰到了不少的問題
后端
這里其實就一個 routes 的路徑順序問題,我也是等到 v1 收尾了,又做了一點點 cleanup,在不同頁面來回切換的時候,發現請求的路徑不對,express 的 log 一直在顯示 string 是一個不合法的 ObjectId,然后找不到對應的數據
后面看了下,是 routes 的路徑問題,之前的寫法是:
routes.get("/something/:someId", getSomethingById);
routes.get("/something/sub-resource", getSubResource);
這種寫法,會將 sub-resource
map 到 :someId
里,express 直接運行 getSomethingById
,最終因為 id 不匹配而拋出異常
數據庫
簡單的記錄一下這個用法:
SellerModel.findById(id).populate("shop");
我這里的 shop 和 seller 又 1-to-1 的關系,具體的 schema 關系如下:
import { Schema, model, Types, Document } from "mongoose";export interface IShop extends Document {seller: Types.ObjectId;name: string;country: string;state: string;city: string;image?: string;
}const shopSchema = new Schema<IShop>({seller: {type: Schema.Types.ObjectId,ref: "Seller",required: true,unique: true, // enforce one-to-one},name: { type: String, required: true },country: { type: String, required: true },state: { type: String, required: true },city: { type: String, required: true },image: { type: String, default: "" },},{ timestamps: true }
);export default model<IShop>("Shop", shopSchema);
Seller 內部的實現是差不多的,代碼太長了就不貼了。這種情況下,使用 populate
可以將 shop 的數據 map 到 seller 種的 shop 屬性——原本是一個 ObjectId 的字符串,這種情況就可以減少一個與后端的 API 請求,在真實的使用場景會很好的減少數據庫的壓力
Workspace/MonoRepo
前端的東西就比較多啦,畢竟這次主要折騰的就是 UI,而且還是比較難得,場景比較全面的 2C 端的 UI。這次寫完也確實發現一點問題,尤其是代碼重復的這個問題
鑒于 notion 的結構比較有限——只有 3 級,這里就把前端部分的問題細細拆成 workspace、React 相關、tailwind css 相關,
重復的業務邏輯……微前端是解決方法?
這里主要說的是 hooks,utils 和 componengs 三個組件,如:
雖然 frontend 尚且還沒有開始實現業務相關的邏輯,不過已經能夠看到有一些重復的使用,如:
- cn.js → 一個簡單的 tailwind css 的 util 方法
- Pagination → Pagination 的 UI 渲染
- usePaginnationSearch → 實現了 debounce/search/pagination 的 hooks
- 共通的 packages 等等
包括之后可能會涉及到的 auth 相關的邏輯……也的確是應該研究一下微前端是不是能夠很好的解決這個問題,尤其是兩個項目都是 React based,共通的 modules 太多了
沖突的 React 版本
這個問題的出現,是在嘗試使用一個 dependency 的時候發生的,具體的報錯大概就是 react 中的 useEffect
這個 hook 出現了問題,具體只記得是 Invalid hook call
,但是記不太清細節了……有可能是 useEffect
被調用了兩次……不過最終發現,原因是 React 的版本發生了沖突:
? yarn list reactyarn list v1.22.22
warning Filtering by arguments is deprecated. Please use the pattern option instead.
├─ frontend@0.1.0
│ └─ react@19.1.0
└─ react@19.0.0
? Done in 0.54s.
我之前有簡單搜索一下,這個問題的確是通過 turborepo 進行 monorepo 的管理出現的問題,尤其是我在兩個不同的時間段安裝了 dashboard 和 frontend 模塊,這導致兩個模塊中的 React 版本有了輕微的沖突。在兩個版本都出現在 node_modules 中,就會被識別成兩個不同的 React 實例
問題的關鍵在這里:
兩個 React 實例創建了不同的 context,以至于在某些 edge case 的情況下會拋出異常,即用串了 context,找不到自己原本應該調用的 context,然后觸發該異常。只能說在運行不同的 React 版本,沒有拋異常是運氣,拋了異常,就有可能是 production issue……
最后的解決方案是在 root dependency 中定義 React 的版本,在根目錄下運行 yarn install --force
,重新安裝/管理依賴,解決問題。根目錄的 package.json 如下:
{"resolutions": {"react": "^19.1.0"}
}
運行過程&結果:
? yarn install --force
? yarn list react
yarn list v1.22.22
warning Filtering by arguments is deprecated. Please use the pattern option instead.
└─ react@19.1.0
? Done in 0.57s.
??:在這種情況下,推薦的做法是不寫死 react 版本,而是用 peerDependencies
去更優化的管理版本
無法安裝依賴的根目錄
這算是一個補充吧,因為我自己其實都不知道還有這個限制
事情起因是,在新建 frontend 的時候,不小心在根目錄下運行了 yarn add
指令,然后 yarn/turborepo 拋出了這個異常:
error Running this command will add the dependency to the workspace root rather than the workspace itself, wh…
這里做個簡單的記錄
React
這里放一點只和 React 相關,范圍比較狹窄的內容
使用 env
改變 PORT
其實我不太清楚 .env
文件到底能夠重寫多少 React 的屬性,不過 port 算是蠻重要的一個,這里提一嘴
修改了 port 之后,turborepo 就可以同時運行 3000(dashboard ui) 和 3001(frontend ui) 了
React 項目文件結構如何設計
最初我們開始寫 React 的時候用的結構就不談了,說一下我們現在主要用的兩種,第一種是所有的相關聯的組件在 components
下,并且按照功能關聯,大體如下:
components/
├── features/ # 頁面級組件
│ ├── home/
│ ├── products/
├── ui/ # 原子化 UI 組件(通常無狀態)
│ ├── Button.tsx
│ ├── Input.tsx
│ └── Card.tsx
├── shared/ # 可復用的復合組件(頁面級別也會引用)
│ ├── PageBanner.tsx
│ ├── ProductCard.tsx
│ └── Ratings.tsx
├── layout/ # 頁面布局類組件
│ └── Navbar.tsx
另一種則是所有相關聯的組件在 pages
下,每個頁面不作為單獨的 jsx 文件,而是作為一個文件夾,存儲相關聯的組件,大體結構如下:
pages/
├── Home/
│ ├── index.tsx # Home 頁面入口組件
│ ├── HeroBanner.tsx # 頁面專屬組件
│ ├── useWelcomeData.ts # 頁面專屬 hook
│ └── styles.module.css # 頁面樣式
├── Products/
│ ├── index.tsx
│ ├── ProductList.tsx
│ ├── ProductFilter.tsx
│ ├── useProductQuery.ts
│ └── styles.module.css
├── Checkout/
│ ├── index.tsx
│ ├── AddressForm.tsx
│ ├── PaymentSummary.tsx
│ ├── useCheckout.ts
│ └── styles.module.css
需要注意的是,這種以頁面為中心的存儲方式,依然會保留 components
文件夾,并且在里面集中管理 shared、UI 之類相關組件,只是會將 features 中的內容放到頁面中
具體二者的存儲方式并沒有絕對意義上的優缺點,只能說必須要根據業務情況做分析。以我自己的項目為經驗,我個人感覺是:
- B2C 適合第一種
其主要原因是 B2C 的業務,結構相對而言更加的簡單,業務邏輯復用更多,比如說一個常見的商城項目,首頁會出現各種各樣的商品卡片、促銷商品,商家頁面中會出現商品卡片,商品頁面中會出現更多的 product 相關的組件。這種時候,在components
下放一個商品相關的 feature,集中管理散落在各個頁面的復用組件 - B2B 適合第二種
與之對比的是 B2B 的業務,結構相對會更加的復雜,業務邏輯多與頁面進行綁定,鮮少會出現核心 UI 邏輯散落在不同頁面中。就算偶爾會出現這個情況,大多數也是作為 reference data 的存在,可以以該 UI 的主頁面作為 base 進行導入
React Router DOM
這個應該說在寫這個項目之前,我都沒有意識到會有這個問題,寫法大體如下:
<Linkto="/something"state={{someState: someState,}}
><button>something</button>
</Link>
在我看來這個代碼是沒問題的——或者說一直以來都是這么寫的,一直工作都沒什么問題,除了這個 state
——主要是想嘗試一下新寫法,嘗試在 navigate 的時候將狀態帶到下一個頁面去,而不是使用 zustand/redux 進行全局化的管理,這樣清理狀態也比較方便
搜索了一下之后發現,這是 React Router DOM 在遵從了 HTML 的標準實現規范后出現的問題。本質上的邏輯是這樣的:
Link
在渲染后成為<a href=""></a>
button
嵌套在了 a 標簽中
這就是問題
好吧,這么說還是不夠直白……具體要解釋原因,就得到 WHATWG——也就是現在 HTML 版本規范的組織——的官方文檔里
其中在 **3.2.5.2.7 interactive content 中提到:
3.2.5.2.7?Interactive content
Interactive content?is content that is specifically intended for user interaction.
a
?(if the?href
?attribute is present)audio
?(if the?controls
?attribute is present)button
details
embed
iframe
img
?(if the?usemap
?attribute is present)input
?(if the?type
?attribute is?not?in the?Hidden?state)label
select
textarea
video
?(if the?controls
?attribute is present)
在 4.10.6?The?button
?element 中提到
4.10.6?The?
button
?element
…Content model:Phrasing content, but there must be no?interactive content?descendant and no descendant with the?
tabindex
?attribute specified.
同樣在 stack overflow 上的一個 thread 也有討論過:**HTML Validation: Why is it not valid to put an interactive element inside an interactive element? ,這就能解決問題了:**
互動內容中嵌套互動內容是不合法的 HTML,這種實踐下的行為是不可預測的,有可能 button 的 event listener 捕捉了 a 標簽的重定向,反之亦然。工作那是運氣好,不工作才是默認的行為
這里最終的解決方法其實是用 onClick
綁定了 useNavigate
,但是真正、最好、符合 accessibility 的解法,還是應該用 button+span+手寫樣式的方法去解決這個問題……
Tailwind CSS
之前主要上的是 tailwind css 的課,instructors 不管怎么說對 tailwind 還是比較專業的,因此學到了一些基礎,不過反思比較少。這次的 instructor 代碼寫的真的挺爛的,然后就發現已經學過的 tailwind css——或者說 css,其實還是有不少東西可以深挖的
基礎色的變化
雖然我發現在之前學習的過程中,大部分項目使用的是 hex,不過在做了 tailwind 之后,我發現其實 rgb 相對而言會更加的動態一些。以下面這個 button 為例:
它 至少 有兩種實現方法:
<buttonclassName={cn("w-[200px] h-[36px] px-4 py-1 rounded-md bg-[#059473] text-white","hover:shadow-lg hover:shadow-[#059473B2]")}
>Example
</button>
這里的 hover,其實還是以 base color,即 059473
做的變量,起主要就是修改了不透明度,也就是 hex 后面的兩位數字
對比起來是用 rgb 的實現:
<buttonclassName={cn("w-[200px] h-[36px] px-4 py-1 rounded-md text-white bg-[rgb(5,148,115)]","hover:shadow-lg hover:shadow-[rgba(5,148,115,0.7)]")}
>Example
</button>
可以看到,這種情況下,使用 rgb 是可以更加直觀地看到對于背景色的修改是多少。對于前端開發來說,這樣可以在選擇好 base 這種基礎顏色后,通過調整不透明程度的方法獲取一整套的顏色表——畢竟現在前端開發其實 UI/UX 的差別越來越大了。以我本人來說,根本搞不定 figma/adobe illustrator,更別說能夠拿出同樣的配色表
rgb 和 rgba 的搭配其實只能獲取一個淺色表,如果想要獲取深色的方法,可以:
- 使用 hsl
如下面的代碼:
效果如下:<div class="w-[150px] h-[32px] my-5 bg-[hsl(164,93%,20%)] text-white">Dark Mode</div><div class="w-[150px] h-[32px] my-5 bg-[hsl(164,93%,30%)] text-white">Base Mode</div><div class="w-[150px] h-[32px] my-5 bg-[hsl(164,93%,45%)] text-black">Light Mode</div>
可以看到,換了亮度后就有了不同等級的顏色,其實也可以對標類似于 blue50-800 這樣的配置 - 手動計算 rgb 的值,即每個數值乘以相同的系數
這個只是我覺得理論上可以 work,實際操作可能會覺得比較麻煩沒做過的事情,而且我覺得這個操作對于純色的挑戰會比較大……
同樣的原理其實也可以用在 opacity 上。普通的 opacity 只能加一個透明度,但是如果在 div 上,添加一個大小完全一致的黑色遮照,通過控制遮照的透明度,也能夠完成 hover 后獲取一個更深的背景色這一方法——這時候就要善用 relative
& absolute
& :before
or :after
了
兄弟組件也一起向上移動
這種情況用截圖說明比較容易:
可以看到,Base Mode 移動了的話,Light Mode 也會一起向上走,這是因為移動時用的是 mt
:
<div class="w-[150px] h-[32px] my-5 bg-[hsl(164,93%,20%)] text-white hover:mt-2 transition-all duration-100">Dark Mode</div><div class="w-[150px] h-[32px] my-5 bg-[hsl(164,93%,30%)] text-white hover:mt-2 transition-all duration-100">Base Mode</div><div class="w-[150px] h-[32px] my-5 bg-[hsl(164,93%,45%)] text-black hover:mt-2 transition-all duration-100">Light Mode
而使用 mt
會重新計算文檔流的位置,這種情況下,用 translate
會有更好的效果。translate
本身不會修改原有元素的位置,因此不會計算剩下所有文檔流的位置
eslint
主要是因為 instructor 有 typo,然后我發現 css 不起效,eslint 有蠻多的問題的,首先是 CRA 用的 eslint 還是 v8,但是現在 eslint 的官方已經出到了 v9,我在這個配置,出了很多的報錯,后面才發現是版本沖突的問題,導致 eslint 的配置也不一樣了——eslint 的配置文件名也不一樣
這里就按照 eslint v8 的配置,文件名還是 .eslintrc.js
:
module.exports = {plugins: ["tailwindcss"],extends: ["react-app", "plugin:tailwindcss/recommended"],rules: {"tailwindcss/no-custom-classname": ["warn",{whitelist: ["header-top", "my-swiper", "custom_bullet"],},],"tailwindcss/classnames-order": "off",},
};
只要 VSCode 開啟了 eslint 的附件,那么,出現了 typo 之后,vscode 就會開始自動提示:
cn
util
之前好像在 electron 里面提過這個 util,實現方法如下:
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";export function cn(...inputs) {return twMerge(clsx(inputs));
}
用 cn
可以用這幾種方式加類名:
cn("plain string", true && "plain string", {"plain string": condition1,"plain stringq": condition2,
});
整體來說,使用 cn
動態管理類名會相對而言更加的直觀
gh
還是 github 的功能,研究了下發現還是還挺有意思的
gh template
templates 需要放在 .github/ISSUE_TEMPLATE
下,里面是 md 文檔,放一些描述/heading 即可
批量更新
不過這里用腳本跑的,代碼大體如下:
for i in 42 43 44 45 46 47
dogh issue edit $i --add-label "features,frontend,ui"
done
這樣就能一次更新 42-47,然后添加相同的 labels