Photo by kevin laminto
原文連結:前端中的 IoC 理念 - 知乎專欄
背景
前端應用在不斷壯大的過程中,內部模組間的依賴可能也會隨之越來越複雜,模組間的 低複用性 導致應用 難以維護,不過我們可以藉助計算機領域的一些優秀的程式設計理念來一定程度上解決這些問題,接下來要講述的 IoC
就是其中之一。
什麼是 IoC
IoC
的全稱叫做 Inversion of Control
,可翻譯為為「控制反轉」或「依賴倒置」,它主要包含了三個準則:
- 高層次的模組不應該依賴於低層次的模組,它們都應該依賴於抽象
- 抽象不應該依賴於具體實現,具體實現應該依賴於抽象
- 面向介面程式設計 而不要面向實現程式設計
概念總是抽象的,所以下面將以一個例子來解釋上述的概念:
假設需要構建一款應用叫 App
,它包含一個路由模組 Router
和一個頁面監控模組 Track
,一開始可能會這麼實現:
// app.js
import Router from './modules/Router';
import Track from './modules/Track';
class App {
constructor(options) {
this.options = options;
this.router = new Router();
this.track = new Track();
this.init();
}
init() {
window.addEventListener('DOMContentLoaded', () => {
this.router.to('home');
this.track.tracking();
this.options.onReady();
});
}
}
// index.js
import App from 'path/to/App';
new App({
onReady() {
// do something here...
},
});
複製程式碼
嗯,看起來沒什麼問題,但是實際應用中需求是非常多變的,可能需要給路由新增功能(比如實現 history
模式)或者更新配置(啟用 history
, new Router({ mode: 'history' })
)。這就不得不在 App
內部去修改這兩個模組,這是一個 INNER BREAKING
的操作,而對於之前測試通過了的 App
來說,也必須重新測試。
很明顯,這不是一個好的應用結構,高層次的模組 App
依賴了兩個低層次的模組 Router
和 Track
,對低層次模組的修改都會影響高層次的模組 App
。那麼如何解決這個問題呢,解決方案就是接下來要講述的 依賴注入(Dependency Injection)。
依賴注入
所謂的依賴注入,簡單來說就是把高層模組所依賴的模組通過傳參的方式把依賴「注入」到模組內部,上面的程式碼可以通過依賴注入的方式改造成如下方式:
// app.js
class App {
constructor(options) {
this.options = options;
this.router = options.router;
this.track = options.track;
this.init();
}
init() {
window.addEventListener('DOMContentLoaded', () => {
this.router.to('home');
this.track.tracking();
this.options.onReady();
});
}
}
// index.js
import App from 'path/to/App';
import Router from './modules/Router';
import Track from './modules/Track';
new App({
router: new Router(),
track: new Track(),
onReady() {
// do something here...
},
});
複製程式碼
可以看到,通過依賴注入解決了上面所說的 INNER BREAKING
的問題,可以直接在 App
外部對各個模組進行修改而不影響內部。
是不是就萬事大吉了?理想很豐滿,但現實卻是很骨感的,沒過兩天產品就給你提了一個新需求,給 App
新增一個分享模組 Share
。這樣的話又回到了上面所提到的 INNER BREAKING
的問題上:你不得不對 App
模組進行修改加上一行 this.share = options.share
,這明顯不是我們所期望的。
雖然 App
通過依賴注入的方式在一定程度上解耦了與其他幾個模組的依賴關係,但是還不夠徹底,其中的 this.router
和 this.track
等屬性其實都還是對「具體實現」的依賴,明顯違背了 IoC
思想的準則,那如何進一步抽象 App
模組呢。
Talk is cheap, show you the code:
class App {
static modules = []
constructor(options) {
this.options = options;
this.init();
}
init() {
window.addEventListener('DOMContentLoaded', () => {
this.initModules();
this.options.onReady(this);
});
}
static use(module) {
Array.isArray(module) ? module.map(item => App.use(item)) : App.modules.push(module);
}
initModules() {
App.modules.map(module => module.init && typeof module.init == 'function' && module.init(this));
}
}
複製程式碼
經過改造後 App
內已經沒有「具體實現」了,看不到任何業務程式碼了,那麼如何使用 App
來管理我們的依賴呢:
// modules/Router.js
import Router from 'path/to/Router';
export default {
init(app) {
app.router = new Router(app.options.router);
app.router.to('home');
}
};
// modules/Track.js
import Track from 'path/to/Track';
export default {
init(app) {
app.track = new Track(app.options.track);
app.track.tracking();
}
};
// index.js
import App from 'path/to/App';
import Router from './modules/Router';
import Track from './modules/Track';
App.use([Router, Track]);
new App({
router: {
mode: 'history',
},
track: {
// ...
},
onReady(app) {
// app.options ...
},
});
複製程式碼
可以發現 App
模組在使用上也非常的方便,通過 App.use()
方法來「注入」依賴,在 ./modules/some-module.js
中按照一定的「約定」去初始化相關配置,比如此時需要新增一個 Share
模組的話,無需到 App
內部去修改內容:
// modules/Share.js
import Share from 'path/to/Share';
export default {
init(app) {
app.share = new Share();
app.setShare = data => app.share.setShare(data);
}
};
// index.js
App.use(Share);
new App({
// ...
onReady(app) {
app.setShare({
title: 'Hello IoC.',
description: 'description here...',
// some other data here...
});
}
});
複製程式碼
直接在 App
外部去 use
這個 Share
模組即可,對模組的注入和配置極為方便。
那麼在 App
內部到底做了哪些工作呢,首先從 App.use
方法說起:
class App {
static modules = []
static use(module) {
Array.isArray(module) ? module.map(item => App.use(item)) : App.modules.push(module);
}
}
複製程式碼
可以很清楚的發現,App.use
做了一件非常簡單的事情,就是把依賴儲存在了 App.modules
屬性中,等待後續初始化模組的時候被呼叫。
接下來我們看一下模組初始化方法 this.initModules()
具體做了什麼事情:
class App {
initModules() {
App.modules.map(module => module.init && typeof module.init == 'function' && module.init(this));
}
}
複製程式碼
可以發現該方法同樣做了一件非常簡單的事情,就是遍歷 App.modules
中所有的模組,判斷模組是否包含 init
屬性且該屬性必須是一個函式,如果判斷通過的話,該方法就會去執行模組的 init
方法並把 App
的例項 this
傳入其中,以便在模組中引用它。
從這個方法中可以看出,要實現一個可以被 App.use()
的模組,就必須滿足兩個「約定」:
- 模組必須包含
init
屬性 init
必須是一個函式
這其實就是 IoC
思想中對「面向介面程式設計 而不要面向實現程式設計」這一準則的很好的體現。App
不關心模組具體實現了什麼,只要滿足對 介面 init
的「約定」就可以了。
此時回去看 Router
的模組的實現就可以很容易理解為什麼要怎麼寫了:
// modules/Router.js
import Router from 'path/to/Router';
export default {
init(app) {
app.router = new Router(app.options.router);
app.router.to('home');
}
};
複製程式碼
總結
App
模組此時應該稱之為「容器」比較合適了,跟業務已經沒有任何關係了,它僅僅只是提供了一些方法來輔助管理注入的依賴和控制模組如何執行。
控制反轉(Inversion of Control
)是一種「思想」,依賴注入(Dependency Injection
)則是這一思想的一種具體「實現方式」,而這裡的 App
則是輔助依賴管理的一個「容器」。