揭祕js框架中的常用套路

果糖醬發表於2018-08-10

我們每天都在使用各種各樣的框架,這些框架伴隨著我們每天的工作。通過使用這些框架的目的是為了解放我們,很少人去真正關心這些框架的背後都做了些什麼。我也使用了不少的框架,通過這些流行框架也讓我學習到了一些知識,就想把這些東西分享出來。

每個標題都是一個獨立的主題,完全可以根據需要挑有興趣的閱讀。

字串轉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);
複製程式碼

表面上看沒任何的問題,如果用開發者工具看頁面結構的話,會發現:

揭祕js框架中的常用套路

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 元素,我們需要建立兩層巢狀: tabletbody

有了這個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();
  }
}
複製程式碼

開啟控制檯:

揭祕js框架中的常用套路

其中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&lt;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注入到任意想要的函式中。我們甚至可以實現應用的配置化。不再需要將引數傳來傳去,代價僅僅是通過registerresolve

目前為止我們的自動注入並不是完美的,存在兩個缺點:

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 使用 gettersetter 來操作物件的資料。這就簡化了計算屬性的實現,因為我們之前還有一層要處理實際的變數。但是,如果我們能夠將計算屬性與普通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 方法可以接受物件、物件的屬性名、gettersetter 。我們要做的就是編寫這兩個方法的實現邏輯。執行上面的程式碼,我們就能得到想要的結果:

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賦值,最終返回完整的名字。

結束

在大型框架和庫的背後包含著許多優秀前輩的經驗。通過學習這些框架能夠讓我們更好理解這些框架背後的原理,能夠脫離框架開發,這點很重要。

相關文章