[譯]javascript中的依賴注入

瀟湘待雨發表於2018-10-25

前言

上文介紹過控制反轉之後,本來打算寫篇文章介紹下控制反轉的常見模式-依賴注入。在翻看資料的時候,發現了一篇好文Dependency injection in JavaScript,就不自己折騰了,結合自己理解翻譯一下,好文共賞。

我喜歡引用這樣一句話‘程式設計是對複雜性的管理’。可能你也聽過計算機世界是一個巨大的抽象結構。
我們簡單的包裝東西並重復的生產新的工具。思考那麼一下下,我們使用的程式語言都包括內建的功能,這些功能可能是基於其他低階操作的抽象方法,包括我們是用的javascript。

遲早,我們都會需要使用別的開發者開發的抽象功能,也就是我們要依賴其他人的程式碼。
我希望使用沒有依賴的模組,顯然這是很難實現的。
即使你建立了很好的像黑盒一樣的元件,但總有個將所有部分合並起來的地方。
這就是依賴注入起作用的地方,當前來看,高效管理依賴的能力是迫切需要的,本文總結了原作者對這個問題的看法。

目標

假設我們有兩個模組,一個是發出ajax請求的服務,一個是路由:

var service = function() {
    return { name: 'Service' };
}
var router = function() {
    return { name: 'Router' };
}
複製程式碼

下面是另一個依賴了上述模組的函式:

var doSomething = function(other) {
    var s = service();
    var r = router();
};
複製程式碼

為了更有趣一點,該函式需要接受一個引數。當然我們可以使用上面的程式碼,但是這不太靈活。

如果我們想使用ServiceXML、ServiceJSON,或者我們想要mock一些測試模組,這樣我們不能每次都是編輯函式體。
為了解決這個現狀,首先我們提出將依賴當做引數傳給函式,如下:

var doSomething = function(service, router, other) {
    var s = service();
    var r = router();
};
複製程式碼

這樣,我們把需要的模組的具體例項傳遞過來。
然而這樣有個新的問題:想一下如果dosomething函式在很多地方被呼叫,如果有第三個依賴條件,我們不能改變所有的呼叫doSomething的地方。
舉個小栗子:
假如我們有很多地方用到了doSomething:

//a.js
var a = doSomething(service,router,1)
//b.js 
var b = doSomething(service,router,2)
// 假如依賴條件更改了,即doSomething需要第三個依賴,才能正常工作
// 這時候就需要在上面不同檔案中修改了,如果檔案數量夠多,就不合適了。
var doSomething = function(service, router, third,thother) {
    var s = service();
    var r = router();
    //***
};

複製程式碼

因此,我們需要一個幫助我們來管理依賴的工具。
這就是依賴注入器想要解決的問題,先看一下我們想要達到的目標:

  • 可以註冊依賴
  • 注入器應該接受一個函式並且返回一個已經獲得需要資源的函式
  • 我們不應該寫複雜的程式碼,需要簡短優雅的語法
  • 注入器應該保持傳入函式的作用域
  • 被傳入的函式應該可以接受自定義引數,不僅僅是被描述的依賴。

看起來比較完美的列表就如上了,讓我們來嘗試實現它。

requirejs/AMD的方式

大家都可能聽說過requirejs,它是很不錯的依賴管理方案。

define(['service', 'router'], function(service, router) {       
    // ...
});
複製程式碼

這種思路是首先宣告需要的依賴,然後開始編寫函式。這裡引數的順序是很重要的。
我們來試試寫一個名為injector的模組,可以接受相同語法。

var doSomething = injector.resolve(['service', 'router'], function(service, router, other) {
    expect(service().name).to.be('Service');
    expect(router().name).to.be('Router');
    expect(other).to.be('Other');
});
doSomething("Other");
複製程式碼

這裡稍微停頓一下,解釋一下doSomething的函式體,使用expect.js來作為斷言庫來確保我的程式碼能像期望那樣正常工作。
體現了一點點TDD(測試驅動開發)的開發模式。

下面是我們injector模組的開始,一個單例模式是很好的選擇,因此可以在我們應用的不同部分執行的很不錯。

var injector = {
    dependencies: {},
    register: function(key, value) {
        this.dependencies[key] = value;
    },
    resolve: function(deps, func, scope) {

    }
}
複製程式碼

從程式碼來看,確實是一個很簡單的物件。
有兩個函式和一個作為儲存佇列的變數。
我們需要做的是檢查deps依賴陣列,並且從dependencies佇列中查詢答案。
剩下的就是呼叫.apply方法來拼接被傳遞過來函式的引數。

 //處理之後將依賴項當做引數傳入給func
resolve: function(deps, func, scope) {
    var args = [];
    //處理依賴,如果依賴佇列中不存在對應的依賴模組,顯然該依賴不能被呼叫那麼報錯,
    for(var i=0; i<deps.length, d=deps[i]; i++) {
        if(this.dependencies[d]) {
            args.push(this.dependencies[d]);
        } else {
            throw new Error('Can\'t resolve ' + d);
        }
    }
    //處理引數,將引數拼接在依賴後面,以便和函式中引數位置對應
    return function() {
        func.apply(scope || {}, args.concat(Array.prototype.slice.call(arguments, 0)));
    }        
}
複製程式碼

如果scope存在,是可以被有效傳遞的。Array.prototype.slice.call(arguments, 0)將arguments(類陣列)轉換成真正的陣列。
目前來看很不錯的,可以通過測試。
當前的問題是,我們必須寫兩次需要的依賴,並且順序不可變動,額外的引數只能在最後面。

反射實現

從維基百科來說,反射是程式在執行時可以檢查和修改物件結構和行為的一種能力。
簡而言之,在js的上下文中,是指讀取並且分析物件或者函式的原始碼。
看下開頭的doSomething,如果使用doSomething.toString() 可以得到下面的結果。

"function (service, router, other) {
    var s = service();
    var r = router();
}"
複製程式碼

這種將函式轉成字串的方式賦予我們獲取預期引數的能力。並且更重要的是,他們的name。
下面是Angular依賴注入的實現方式,我從Angular那拿了點可以獲取arguments的正規表示式:

/^function\s*[^\(]*\(\s*([^\)]*)\)/m
複製程式碼

這樣我們可以修改resolve方法了:

tip

這裡,我將測試例子拿上來應該更好理解一點。

var doSomething = injector.resolve(function(service, other, router) {
    expect(service().name).to.be('Service');
    expect(router().name).to.be('Router');
    expect(other).to.be('Other');
});
doSomething("Other");
複製程式碼

繼續來看我們的實現。

resolve: function() {
    // agrs 傳給func的引數陣列,包括依賴模組及自定義引數
    var func, deps, scope, args = [], self = this;
    // 獲取傳入的func,主要是為了下面來拆分字串
    func = arguments[0];
    // 正則拆分,獲取依賴模組的陣列
    deps = func.toString().match(/^functions*[^(]*(s*([^)]*))/m)[1].replace(/ /g, '').split(',');
    //待繫結作用域,不存在則不指定
    scope = arguments[1] || {};
    return function() {
        // 將arguments轉為陣列
        // 即後面再次呼叫的時候,doSomething("Other");   
        // 這裡的Other就是a,用來補充缺失的模組。
        var a = Array.prototype.slice.call(arguments, 0);
        //迴圈依賴模組陣列
        for(var i=0; i<deps.length; i++) {
            var d = deps[i];
            // 依賴佇列中模組存在且不為空的話,push進引數陣列中。
            // 依賴佇列中不存在對應模組的話從a中取第一個元素push進去(shift之後,陣列在改變)
            args.push(self.dependencies[d] && d != '' ? self.dependencies[d] : a.shift());
        }
        //依賴當做引數傳入
        func.apply(scope || {}, args);
    }        
}
複製程式碼

使用這個正則來處理函式時,可以得到下面結果:

["function (service, router, other)", "service, router, other"]
複製程式碼

我們需要的只是第二項,一旦我們清除陣列並拆分字串,我們將會得到依賴陣列。
主要變化在下面:

var a = Array.prototype.slice.call(arguments, 0);
...
args.push(self.dependencies[d] && d != '' ? self.dependencies[d] : a.shift());
複製程式碼

這樣我們就迴圈遍歷依賴項,如果缺少某些東西,我們可以嘗試從arguments物件中獲取。幸好,當陣列為空的時候shift方法也只是返回undefined而非拋錯。所以新版的用法如下:

//不用在前面宣告依賴模組了
var doSomething = injector.resolve(function(service, other, router) {
    expect(service().name).to.be('Service');
    expect(router().name).to.be('Router');
    expect(other).to.be('Other');
});
doSomething("Other");
複製程式碼

這樣就不用重複宣告瞭,順序也可變。我們複製了Angular的魔力。
然而,這並不完美,壓縮會破壞我們的邏輯,這是反射注入的一大問題。
因為壓縮改變了引數的名稱所以我們沒有能力去解決這些依賴。例如:

// 顯然根據key來匹配就是有問題的了
var doSomething=function(e,t,n){var r=e();var i=t()}
複製程式碼

Angular團隊的解決方案如下:

var doSomething = injector.resolve(['service', 'router', function(service, router) {

}]);
複製程式碼

看起來就和開始的require.js的方式一樣了。
作者個人不能找到更優的解決方案,為了適應這兩種方式。最終方案看起來如下:

var injector = {
    dependencies: {},
    register: function(key, value) {
        this.dependencies[key] = value;
    },
    resolve: function() {
        var func, deps, scope, args = [], self = this;
        // 該種情況是相容形式,先宣告
        if(typeof arguments[0] === 'string') {
            func = arguments[1];
            deps = arguments[0].replace(/ /g, '').split(',');
            scope = arguments[2] || {};
        } else {
            // 反射的第一種方式
            func = arguments[0];
            deps = func.toString().match(/^function\s*[^\(]*\(\s*([^\)]*)\)/m)[1].replace(/ /g, '').split(',');
            scope = arguments[1] || {};
        }
        return function() {
            var a = Array.prototype.slice.call(arguments, 0);
            for(var i=0; i<deps.length; i++) {
                var d = deps[i];
                args.push(self.dependencies[d] && d != '' ? self.dependencies[d] : a.shift());
            }
            func.apply(scope || {}, args);
        }        
    }
}
複製程式碼

現在resolve接受兩或者三個引數,如果是兩個就是我們寫的第一種了,如果是三個,會將第一個引數解析並填充到deps。
下面就是測試例子(我一直認為將這段例子放在前面可能大家更好閱讀一些。):

// 缺失了一項模組other
var doSomething = injector.resolve('router,,service', function(a, b, c) {
    expect(a().name).to.be('Router');
    expect(b).to.be('Other');
    expect(c().name).to.be('Service');
});
// 這裡傳的Other將會用來拼湊
doSomething("Other");
複製程式碼

可能會注意到argumets[0]中確實了一項,就是為了測試填充功能的。

直接注入作用域

有時候,我們使用第三種的注入方式,它涉及到函式作用域的操作(或者其他名字,this物件),並不經常使用

var injector = {
    dependencies: {},
    register: function(key, value) {
        this.dependencies[key] = value;
    },
    resolve: function(deps, func, scope) {
        var args = [];
        scope = scope || {};
        for(var i=0; i<deps.length, d=deps[i]; i++) {
            if(this.dependencies[d]) {
                //區別就在這裡了,直接將依賴加到scope上
                //這樣就可以直接在函式作用域中呼叫了
                scope[d] = this.dependencies[d];
            } else {
                throw new Error('Can\'t resolve ' + d);
            }
        }
        return function() {
            func.apply(scope || {}, Array.prototype.slice.call(arguments, 0));
        }        
    }
}
複製程式碼

我們做的就是將依賴加到作用域上,這樣的好處是不用再引數里加依賴了,已經是函式作用域的一部分了。

var doSomething = injector.resolve(['service', 'router'], function(other) {
    expect(this.service().name).to.be('Service');
    expect(this.router().name).to.be('Router');
    expect(other).to.be('Other');
});
doSomething("Other");
複製程式碼

結束語

依賴注入是我們所有人都做過的事情中的一種,可能沒有意識到罷了。即使沒有聽過,你也可能用過很多次了。
通過這篇文章對於這個熟悉而又陌生的概念的瞭解加深了不少,希望能幫助到有需要的同學。最後個人能力有限,翻譯有誤的地方歡迎大家指出,共同進步。
再次感謝原文作者原文地址
更多文章請移步我的部落格

相關文章