我們每天都在使用各種各樣的框架,這些框架伴隨著我們每天的工作。通過使用這些框架的目的是為了解放我們,很少人去真正關心這些框架的背後都做了些什麼。我也使用了不少的框架,通過這些流行框架也讓我學習到了一些知識,就想把這些東西分享出來。
每個標題都是一個獨立的主題,完全可以根據需要挑有興趣的閱讀。
字串轉DOM
經常使用jquery的小夥伴對下面的程式碼應該一點都不陌生:
var text = $('<div>hello, world</div>');
$('body').append(text)
複製程式碼
以上程式碼執行的結果就是在頁面增加了一個div節點。拋開jQuery, 程式碼可能會變得稍稍複雜:
var strToDom = function(str) {
var temp = document.createElement('div');
temp.innerHTML = str;
return temp.childNodes[0];
}
var text = strToDom('<div>hello, world</div>');
document.querySelector('body').appendChild(text);
複製程式碼
這段程式碼,跟使用jQuery的效果是一模一樣的,哈哈jQuery也不過如此嘛。如果你這麼想你就錯了。下面兩種程式碼執行的有什麼區別:
var tableTr = $('<tr><td>Simple text</td></tr>');
$('body').append(tableTr);
var tableTr = strToDom('<tr><td>Simple text</td></tr>');
document.querySelector('body').appendChild(tableTr);
複製程式碼
表面上看沒任何的問題,如果用開發者工具看頁面結構的話,會發現:
strToDom
僅僅建立了一個文字節點,而不是一個真正的tr
標籤。原因是包含HTML元素的字串通過解析器在瀏覽器中執行,解析器忽略了沒有放置在正確的上下文中的標籤, 因此我們只能得到一個文字節點。
jQuery
是如何解決這個問題的呢? 通過分析原始碼,我找到了下面的程式碼:
var wrapMap = {
option: [1, '<select multiple="multiple">', '</select>'],
legend: [1, '<fieldset>', '</fieldset>'],
area: [1, '<map>', '</map>'],
param: [1, '<object>', '</object>'],
thead: [1, '<table>', '</table>'],
tr: [2, '<table><tbody>', '</tbody></table>'],
col: [2, '<table><tbody></tbody><colgroup>', '</colgroup></table>'],
td: [3, '<table><tbody><tr>', '</tr></tbody></table>'],
_default: [1, '<div>', '</div>']
};
wrapMap.optgroup = wrapMap.option;
wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead;
wrapMap.th = wrapMap.td;
複製程式碼
每一個元素,需要特殊處理陣列分配。這個想法是為了構建正確的DOM元素和依賴的巢狀級別獲取我們所需要的東西。例如, tr
元素,我們需要建立兩層巢狀: table
、tbody
。
有了這個Map對映表後,我們就可以拿到最終需要的標籤。下面程式碼演示瞭如何從<tr><td>hello word</td></tr>
中取到tr
:
var match = /<\s*\w.*?>/g.exec(str);
var tag = match[0].replace(/</g, '').replace(/>/g, '');
複製程式碼
剩下的就是根據合適的上下文返回DOM元素, 最終我們將strToDom
進行最終的修改:
var strToDom = function(str) {
var wrapMap = {
option: [1, '<select multiple="multiple">', '</select>'],
legend: [1, '<fieldset>', '</fieldset>'],
area: [1, '<map>', '</map>'],
param: [1, '<object>', '</object>'],
thead: [1, '<table>', '</table>'],
tr: [2, '<table><tbody>', '</tbody></table>'],
col: [2, '<table><tbody></tbody><colgroup>', '</colgroup></table>'],
td: [3, '<table><tbody><tr>', '</tr></tbody></table>'],
_default: [1, '<div>', '</div>']
};
wrapMap.optgroup = wrapMap.option;
wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead;
wrapMap.th = wrapMap.td;
var element = document.createElement('div');
var match = /<\s*\w.*?>/g.exec(str);
if(match != null) {
var tag = match[0].replace(/</g, '').replace(/>/g, '');
var map = wrapMap[tag] || wrapMap._default, element;
str = map[1] + str + map[2];
element.innerHTML = str;
// Descend through wrappers to the right content
var j = map[0]+1;
while(j--) {
element = element.lastChild;
}
} else {
// if only text is passed
element.innerHTML = str;
element = element.lastChild;
}
return element;
}
複製程式碼
通過 match != null
判斷是建立的是標籤還是文字節點。這一次我們通過瀏覽器可以建立一個有效的DOM樹。最後通過使用while迴圈,直到取到我們想要的標籤,最後返回這個標籤。
AngularJS 依賴注入
當我們開始使用AngularJS時,它的雙向資料繫結讓人印象深刻。此外另一個神奇特徵就是依賴注入。下面是一個簡單的例子:
function TodoCtrl($scope, $http) {
$http.get('users/users.json').success(function(data) {
$scope.users = data;
});
}
複製程式碼
這是一個典型的AngularJS控制器寫法:通過發起一個HTTP請求,從JSON檔案獲取資料,並將資料賦值給 $scope.users
。AngularJS框架會自動將$scope
和$http
注入控制器中。讓我們看看它是如何實現的。
看一個例子,我們想將使用者姓名顯示到頁面上,為了簡單起見,採用的mock假資料模擬http請求:
var dataMockup = ['John', 'Steve', 'David'];
var body = document.querySelector('body');
var ajaxWrapper = {
get: function(path, cb) {
console.log(path + ' requested');
cb(dataMockup);
}
}
var displayUsers = function(domEl, ajax) {
ajax.get('/api/users', function(users) {
var html = '';
for(var i=0; i < users.length; i++) {
html += '<p>' + users[i] + '</p>';
}
domEl.innerHTML = html;
});
}
displayUsers(body, ajaxWrapper)
複製程式碼
displayUsers(body, ajaxWrapper)
執行需要兩個依賴項:body和ajaxWrapper。我們的目標是直接呼叫displayUsers()而沒有傳遞引數,也能按我們期望的執行。
大部分的框架提供了依賴注入機制有一個模組,通常叫injector。所有的依賴統一在這裡註冊,並提供對外訪問的介面:
var injector = {
storage: {},
register: function(name, resource) {
this.storage[name] = resource;
},
resolve: function(target) {
}
};
複製程式碼
其中關鍵的resolve的實現:它接收一個目標物件,通過返回一個閉包,包裝target並呼叫它。例如:
resolve: function(target) {
return function() {
target();
};
}
複製程式碼
這樣我們就可以呼叫我們需要的依賴的函式了。
下一步就是獲取target的引數列表了,這裡我引用了AngularJS的實現方式:
var FN_ARGS = /^function\s*[^\(]*\(\s*([^\)]*)\)/m;
var STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg;
...
function annotate(fn) {
...
fnText = fn.toString().replace(STRIP_COMMENTS, '');
argDecl = fnText.match(FN_ARGS);
...
}
複製程式碼
我遮蔽了其它程式碼細節,只留下對我們有用的部分。 annotate
對應的就是我們自己的 resolve
。它將通過目標函式轉換為一個字串,同時還將註釋給去掉了, 最終得到引數資訊:
resolve: function(target) {
var FN_ARGS = /^function\s*[^\(]*\(\s*([^\)]*)\)/m;
var STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg;
fnText = target.toString().replace(STRIP_COMMENTS, '');
argDecl = fnText.match(FN_ARGS);
console.log(argDecl);
return function() {
target();
}
}
複製程式碼
開啟控制檯:
其中argDecl陣列的第二個元素包含了所有的引數, 通過引數名稱就可以得到injector中儲存的依賴項了。 下面是具體的實現:
resolve: function(target) {
var FN_ARGS = /^function\s*[^\(]*\(\s*([^\)]*)\)/m;
var STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg;
fnText = target.toString().replace(STRIP_COMMENTS, '');
argDecl = fnText.match(FN_ARGS)[1].split(/, ?/g);
var args = [];
for(var i=0; i<argDecl.length; i++) {
if(this.storage[argDecl[i]]) {
args.push(this.storage[argDecl[i]]);
}
}
return function() {
target.apply({}, args);
}
}
複製程式碼
通過 .split(/, ?/g)
將字串 domEl, ajax
轉換成陣列, 通過檢查injector中是否註冊了同名的依賴,如果存在,將依賴項放入一個新的陣列作為引數傳遞給 target
函式。
呼叫的程式碼應該是這樣的:
injector.register('domEl', body);
injector.register('ajax', ajaxWrapper);
displayUsers = injector.resolve(displayUsers);
displayUsers();
複製程式碼
這樣的實現的好處是,我們將domEl和ajax注入到任意想要的函式中。我們甚至可以實現應用的配置化。不再需要將引數傳來傳去,代價僅僅是通過register
和 resolve
。
目前為止我們的自動注入並不是完美的,存在兩個缺點:
1、函式不支援自定義引數。
2、上線程式碼壓縮導致引數名字改變,導致無法獲取正確的依賴項。
這兩個問題AngualrJS
已經全部解決了,有興趣可以看我的另一篇文章: javascript實現依賴注入的思路,裡面詳細介紹了依賴注入的完整解決方案。
Ember Computed屬性
可能現在大多數人一聽到計算屬性,首先想到的是 Vue
中的 Computed
計算屬性。其實在 Ember
框架也提供了這樣一個特性,用於計算屬性的屬性。有點繞口,看一個官方例子吧:
App.Person = Ember.Object.extend({
firstName: null,
lastName: null,
fullName: function() {
return this.get('firstName') + ' ' + this.get('lastName');
}.property('firstName', 'lastName')
});
var ironMan = App.Person.create({
firstName: "Kobe",
lastName: "Bryant"
});
ironMan.get('fullName') // "Kobe Bryant"
複製程式碼
Person 物件具有firstName和lastName屬性。computed屬性fullName返回包含person全名的連線字串。令人奇怪的地方在於fullName的函式使用了
.property
方法。 我們看一下 property
的程式碼:
Function.prototype.property = function() {
var ret = Ember.computed(this);
// ComputedProperty.prototype.property expands properties; no need for us to
// do so here.
return ret.property.apply(ret, arguments);
};
複製程式碼
通過新增新屬性調整全域性函式物件的原型。在類定義期間執行一些邏輯是一種很好的方法。
Ember
使用 getter
和 setter
來操作物件的資料。這就簡化了計算屬性的實現,因為我們之前還有一層要處理實際的變數。但是,如果我們能夠將計算屬性與普通js物件一起使用,那就更有趣了。例如:
var User = {
firstName: 'Kobe',
lastName: 'Bryant',
name: function() {
// getter + setter
}
};
console.log(User.name); // Kobe Bryant
User.name = 'LeBron James';
console.log(User.firstName); // LeBron
console.log(User.lastName); // James
複製程式碼
name作為一個常規屬性,本質上就是一個獲取或設定firstName和lastName的函式。
JavaScript有一個內建的特性,可以幫助我們實現這個想法:
var User = {
firstName: 'Kobe',
lastName: 'Bryant',
};
Object.defineProperty(User, "name", {
get: function() {
return this.firstName + ' ' + this.lastName;
},
set: function(value) {
var parts = value.toString().split(/ /);
this.firstName = parts[0];
this.lastName = parts[1] ? parts[1] : this.lastName;
}
});
複製程式碼
Object.defineProperty
方法可以接受物件、物件的屬性名、getter
和 setter
。我們要做的就是編寫這兩個方法的實現邏輯。執行上面的程式碼,我們就能得到想要的結果:
console.log(User.name); // Kobe Bryant
User.name = 'LeBron James';
console.log(User.firstName); // LeBron
console.log(User.lastName); // James
複製程式碼
Object.defineProperty
雖然是我們想要的,但顯然我們不想每次都這麼寫。在理想的情況下,我們希望提供一個介面。在本節中,我們將編寫一個名為 Computize
的函式,它將處理物件並以某種方式將name函式轉換為具有相同名稱的屬性。
var Computize = function(obj) {
return obj;
}
var User = Computize({
firstName: 'Kobe',
lastName: 'Bryant',
name: function() {
...
}
});
複製程式碼
我們想使用name方法作為setter,同時作為getter。這類似於Ember的計算屬性。
現在,我們將自己的邏輯新增到函式物件的原型中:
Function.prototype.computed = function() {
return { computed: true, func: this };
};
複製程式碼
這樣就可以在每個Function定義後直接呼叫computed函式了。
name: function() {
...
}.computed()
複製程式碼
name屬性不再是一個函式,而變成一個物件: { computed: true, func: this }
。其中 computed
等於true
, func
屬性指向原本的函式。
真正神奇的事情發生在Computize helper的實現中。它遍歷物件的所有屬性,對所有的計算屬性使用object.defineproperty:
var Computize = function(obj) {
for(var prop in obj) {
if(typeof obj[prop] == 'object' && obj[prop].computed === true) {
var func = obj[prop].func;
delete obj[prop];
Object.defineProperty(obj, prop, {
get: func,
set: func
});
}
}
return obj;
}
複製程式碼
注意: 我們將計算屬性name刪除了,原因是Object.defineProperty在某些瀏覽器下僅對未定義的屬性起作用。
下面是使用.computed()函式的使用者物件的最終版本:
var User = Computize({
firstName: 'Kobe',
lastName: 'Bryant',
name: function() {
if(arguments.length > 0) {
var parts = arguments[0].toString().split(/ /);
this.firstName = parts[0];
this.lastName = parts[1] ? parts[1] : this.lastName;
}
return this.firstName + ' ' + this.lastName;
}.computed()
});
複製程式碼
函式的邏輯就是,判斷是否有引數,如果有引數就直接將引數進行分割處理,並分別為firstname和lastname賦值,最終返回完整的名字。
結束
在大型框架和庫的背後包含著許多優秀前輩的經驗。通過學習這些框架能夠讓我們更好理解這些框架背後的原理,能夠脫離框架開發,這點很重要。