當你的專案隨著時間成長,你會越來越需要管理共用模組,這時候你就會需要一套共用管理機制。就像一個辦公室,隨著人擴張和成長,你沒辦法一直沿用原本的結構和安排,你換需要換伴公司,不可能瞬間進行橫向擴張。有的人說,那一開始就把架構開大不就好了?這叫過度設計,因為你無法確認未來是否會需要這些東西,會浪費一堆開發時間在處理系統架構的環節,你只要確保為來能持續拓張規模就好。
這是所有前往大型架構的必經之路,你必須要有一套版本管理機制,確保版本升級不會影響到其他模組。經常有人會跟我說,使用 Monorepo 會有共用版本問題,使用微前端會有版本管理問題,使用共用的 npm package 會有版本管理問題。但這些都是謬誤,因為就算今天不使用這些技術,你在單體架構中依然有版本管理問題,只是你沒有發現而已。
其實你平常寫的模組就是一個個的共用模組,你每一次的修改都是一個讀一無二的版號,你每一個 git commit 都是在進行版本破壞,只是你沒有發現而已。所以往上思考,你去做這些複雜的拆分其實也是一種管理手段,它只是讓你對於版本會更加敏感,讓你更早發現模組管理議題問題,而不是等到專案規模已經大到變成巨獸時才害怕的無法進行改善。
會經常需要變動某部分底層功能的程式碼,導致依賴的高階模組經常要被調整,那是因為你沒有做好抽象化,導致你無法單純的對於底層進行調整,你必須要跟著底層的調整而調整,這樣的架構是無法持續擴張的。你需要做的是,將底層的程式碼進行抽象化,讓底層的調整不會影響到高階模組,這樣你就可以單純的對於底層進行調整,而不需要跟著底層的調整而調整。說起來很繞口令,但這就是抽象化的目的。另外抽象化還可以帶來許多好處,像是降低耦合度、提高模組的可重用性、提高模組的可測試性、避免依賴循環。甚至你不會套件升級個版本需要大範圍進行改動。
我以 axios
為例子,這是前端常用的 HTTP 請求套件,它提供了許多方便的功能,像是攔截器、轉換器、取消請求、錯誤處理等等。但隨著時代演變,套件需要升級,也有可能你會想換掉套件。
import axios from "axios";
const axiosInstance = axios.create({
baseURL: "https://example.com",
});
export const getUsers = () => axiosInstance.get("/apis/v1/users");
如果你想把 axios
換成 fetch
,你會需要進行大量的修改,這樣的架構是無法持續擴張的,也難以抽換。
- import axios from "axios";
- const axiosInstance = axios.create({
- baseURL: "https://example.com",
- });
+ const baseURL = "https://example.com";
+ const fetchInstance = {
+ get: (url) => {
+ return fetch(baseURL + url).then((res) => res.json());
+ }
+ }
- export const getUsers = () => axiosInstance.get("/apis/v1/users");
+ export const getUsers = () => fetchInstance.get("/apis/v1/users");
如果你換一個寫法,把 axiosInstance
抽象命名為 requestInstance
。
import axios from "axios";
export const requestInstance = axios.create({
baseURL: "https://example.com",
});
export const getUsers = () => requestInstance.get("/apis/v1/users");
當你想要換掉 axios
時,你只需要換掉 requestInstance
的實作,業務邏輯的部分一行都不用修改。
- import axios from "axios";
- export const requestInstance = axios.create({
- baseURL: "https://example.com",
- });
+ const baseURL = "https://example.com";
+ const requestInstance = {
+ get: (url) => {
+ return fetch(baseURL + url).then((res) => res.json());
+ }
+ }
export const getUsers = () => requestInstance.get("/apis/v1/users");
大部分前端在寫程式時會很習慣去增加模組的依賴量,這其實對於模組來說會限制它的發展,也很容易因為需要用大量 map 的手段去管理無關模組,不但同時新增功能時會去大幅度改動個部位模組,很難同時進行協同開發。
當你在開發某個業務邏輯時,你會需要用到許多不同的 context,像是 user context、auth context、theme context、language context、notification context 等等。因為 App
需要依賴這些高階的 context,所以你會需要去 import 這些 context,這樣的架構會讓你的模組耦合度提高,也很難進行協同開發。
import { UserContext } from "./UserContext";
import { AuthContext } from "./AuthContext";
import { ThemeContext } from "./ThemeContext";
import { LanguageContext } from "./LanguageContext";
import { NotificationContext } from "./NotificationContext";
class App {
contexts = [
new UserContext(),
new AuthContext(),
new ThemeContext(),
new LanguageContext(),
new NotificationContext(),
];
}
const app = new App();
但如果改以依賴注入的方式,App
就不需要依賴這些 context,而是透過參數的方式傳入,他並不關心未來有哪些 Context 被傳入,App
的依賴性就減低了。他們只需要知道自己是什麼 Context 就好,這樣的架構可以讓各個 Context 更容易進行協同開發,也可以更容易進行單元測試,不會有開發衝突問題。
export interface Context {
/* implement */
}
class App {
contexts: Context[];
useContext(ctx: Context) {
this.contexts.push(ctx);
}
}
export const app = new App();
import { app } from "./app";
import type { Context } from "./app";
// ==============================
class UserContext implements Context {
/* implement */
}
app.useContext(new UserContext());
// ==============================
class AuthContext implements Context {
/* implement */
}
app.useContext(new AuthContext());
// ==============================
class ThemeContext implements Context {
/* implement */
}
app.useContext(new ThemeContext());
// ==============================
class LanguageContext implements Context {
/* implement */
}
app.useContext(new LanguageContext());
// ==============================
class NotificationContext implements Context {
/* implement */
}
app.useContext(new NotificationContext());
在開發這樣的大型架構中需要應用各種複雜的技巧,這些技巧都是為了讓你的架構能夠持續擴張,不會因為規模擴張而導致開發困難,甚至無法進行開發。然而也提高了架構的複雜度,需要有熟悉軟體開發哲學的人來管理這些架構,以免失控或過度設計。