【轉】【譯】JavaScript魔法揭祕--探索當前流行框架中部分功能的處理機制

葉小釵發表於2014-07-29

推薦語:

今天推薦一篇華為同事的同事翻譯的一篇文章,推薦的主要原因是作為一個華為員工居然晚上還能寫文章,由不得小釵不佩服!!!

其中的jQuery、angular、react皆是十分優秀的框架,各有特點,各位可以看看

編輯:github 原文連結:Revealing the Magic of JavaScript

jnotnull 釋出在 JavaScript譯文

我們每天都在使用大量的工具,不同的庫和框架已經成為我們日常工作的一部分。我們使用他們是因為我們不想重新造輪子,雖然我們可能並不知道這些框架的原理。在這篇文章中,我們將揭開當前流行框架中那些魔法處理機制。

通過字串來建立DOM節點

隨著單頁應用的興起,我們已經可以使用JS來做越來越多的事情了,業務的大部分邏輯都將移到前臺。我們以下面建立頁面元素為例:

var text = $('<div>Simple text</div>');

$('body').append(text);

執行結果是:在當前頁面中新增了一個div元素。使用jquery,這個只需要一行程式碼就搞定了,如果不用jquery,可能會多幾行程式碼:

var stringToDom = function(str) {
  var temp = document.createElement('div');

  temp.innerHTML = str;
  return temp.childNodes[0];
}
var text = stringToDom('<div>Simple text</div>');

document.querySelector('body').appendChild(text);

我們定義了一個自己的工具方法stringToDom,這個方法做了如下事情:首先建立一個臨時div元素,然後設定它的innerTHML屬性,然後返回該DIV元素的第一個節點。同樣的寫法,下面的程式碼會獲得不同的結果:

var tableRow = $('<tr><td>Simple text</td></tr>');
$('body').append(tableRow);

var tableRow = stringToDom('<tr><td>Simple text</td></tr>');
document.querySelector('body').appendChild(tableRow);

從這個頁面的表面上看,沒有什麼不同。但是我們通過chrome的開發工具檢視生成的HTML標記的話,會得到一個有趣的結果,建立了一個文字元素。

貌似我們的stringToDom 只建立了一個文字節點而不是tr標籤。但是jquery卻不知何故可以正常執行。問題的原因是在瀏覽器端是通過解析器來解析含有HTML元素的字串的。解析器會忽略掉那些放錯上下文位置的標記,因此我們只獲得了文字節點。row標籤沒有包含在正確的table標籤中,這對瀏覽器的解析器來說就是不合法的。

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元素,我們要建立一個帶有tbody的table中,需要包裹兩層。

雖然有了map,但是我們還是得先去查詢到字串中的結束標籤是啥。下面的程式碼可以從<tr><td>Simple text</td></tr>抽取出tr標籤。

var match = /<\s*\w.*?>/g.exec(str);
var tag = match[0].replace(/</g, '').replace(/>/g, '');

剩下來要做的就是找到屬性上下文,然後返回DOM元素。下面是stringToDom方法的最終版本:

var stringToDom = 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條件用於判斷string中是否有tag標籤,如果沒有我們只是簡單的返回文字節點。這裡我們傳入了正確的標籤,所以瀏覽器能夠建立一個正常的DOM節點了。在程式碼的最後部分可以看到,通過使用一個while迴圈,我們一直深入到我們想要的那個tag節點後返回給了呼叫者。

下面讓我們窺探下AngularJS中經常的依賴注入。

揭祕AngularJS中的依賴注入

當我們第一次使用AngularJS的時候,我們肯定對它的雙向資料繫結留下了深刻的影響,那第二個值得關注的就是它那魔法般的依賴注入。下面看下簡單的例子:

function TodoCtrl($scope, $http) {
  $http.get('users/users.json').success(function(data) {
    $scope.users = data;
  });
}

這是非常經典的AngularJS控制器。它通過一個http請求來獲取一個json檔案中的資料,然後放把資料放到當前的scope中。我們不只是TodoCtrl 方法-我們也沒有任何機會去傳遞引數。但是框架做到了。那$scope和$http變數時從哪裡來的呢?這真實一個超級酷的特性,簡直就是一個神奇的魔法。讓我們來看下它的工作原理。

假如我們系統中需要一個展示使用者列表的JS函式。我們需要一個可以把生成的HTML設定到DOM節點的方法,一個封裝了獲得資料的Ajax請求的物件。為了簡化例子,我們mock了資料和http請求。

var dataMockup = ['John', 'Steve', 'David'];
var body = document.querySelector('body');
var ajaxWrapper = {
  get: function(path, cb) {
    console.log(path + ' requested');
    cb(dataMockup);
  }
}

我們將使用body標籤來承載內容。ajaxWrapper是一個觸發請求的物件,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)我們應該可以看到3個名字展示在頁面上,同時在控制檯上應該會輸出/api/users這個log。我們可以說我們的方法依賴兩個東東:body和ajaxWrapper。但是現在我們的目標是在不傳遞引數的情況下也能正常工作,我們希望的只通過呼叫displayUsers()也能得到相同的結果。如果我們直接使用如上的方法進行呼叫,會看到如下結果:

Uncaught TypeError: Cannot read property ‘get’ of undefined

這是因為ajax引數沒有被定義。

大多數提供依賴注入機制的框架都會有一個injector。如果使用了那個依賴,那我們需要在injector中註冊下。

讓我們來建立我們自己的injector:

var injector = {
  storage: {},
  register: function(name, resource) {
    this.storage[name] = resource;
  },
  resolve: function(target) {

  }
};

我們只需要兩個方法。第一個就是register,他接收依賴然後儲存起來。第二個方法resolve接收一個有依賴模組的函式target作為引數。這裡的一個關鍵點是我們要控制好不能讓注入器呼叫我們的方法。resolve方法中返回了一個包含target()的閉包。看下程式碼:

resolve: function(target) {
  return function() {
    target();
  };
}

這樣我們就有可以在不改變應用流程的情況下去訪問函式了。injector當前還是一個獨立的而且不包含任何邏輯的方法。

當然,把displayUsers 傳遞給resove函式還是不行

displayUsers = injector.resolve(displayUsers);
displayUsers();

還是報錯。下一步就是找出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方法很像。它轉換傳遞過去的target為字串,刪除掉註釋程式碼,然後抽取其中的引數。讓我們看下它的執行結果:

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();
  }
}

下面是輸出結果

Revealing the AngularJS dependency injection

如果我們去檢視第二個元素argDecl陣列的話,我們會看到它所需要依賴物件。這正是我們需要的,因為通過名字我們就能從storage中查到依賴的資源了。下面的這個版本能夠完成我們的目標:

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轉換成了陣列。接下來我們來校驗依賴是否註冊了,如果註冊的話我們把它傳遞給target函式作為引數。注入器的程式碼應該是這樣的:

injector.register('domEl', body);
injector.register('ajax', ajaxWrapper);

displayUsers = injector.resolve(displayUsers);
displayUsers();

這樣實現的好處是我們能夠可以吧DOM和ajaxWrapper注入到更多的方法中。這不需要把一個物件從一個類傳遞到另一個類,它只有register和resolve方法。

當然我們的injector還不夠完美,還有提升的空間,比如支援scope定義。target函式當前是一旦被呼叫時候就會建立一個新的scope,但是通常我們希望可以傳遞我們自己的scope。我們還可以讓依賴支援使用者自定義的引數。

如果想我們的程式碼在最小化之後也能正常工作的話,那injector會變的更加複雜。我們知道,最小化工具會替換函式、變數甚至方法引數的名字。而我們的邏輯都是依賴這些名字的,所以我們應該考慮下。我們從AngularJS中找到了一個解決方案:

displayUsers = injector.resolve(['domEl', 'ajax', displayUsers]);

我們不只傳遞displayUsers,我們還傳遞依賴物件的名字。

使用Ember的計算屬性

Ember是當前最流行框架之一。它有很多有用的特性。其中計算屬性非常有趣。計算屬性就是用一個函式來充當屬性。讓我們來看下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: "Tony",
  lastName:  "Stark"
});
ironMan.get('fullName') // "Tony Stark"

這裡有一個類定義了firstName和lastName屬性。計算屬性fullName返回一個組裝後的人的全名字串。這裡比較陌生的的地方是我們使用.property方法跟著函式後面賦值給fullName。我個人從沒有在哪裡看到過這種寫法。同樣,我從原始碼中找到了答案:

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);
};

這裡我們看到它在Function的原型物件中增加了一個新的屬性property。這對於定義一個類來說,是一個非常好的執行邏輯的途徑。

Ember使用get、set來操作物件屬性。這簡化了計算屬性的實現,因為在我們操作中間忽略掉了一個封裝層。但是更加有趣的是我們是否可以在JS原生物件上使用計算屬性呢。看下面的例子:

var User = {
  firstName: 'Tony',
  lastName: 'Stark',
  name: function() {
    // getter + setter
  }
};

console.log(User.name); // Tony Stark
User.name = 'John Doe';
console.log(User.firstName); // John
console.log(User.lastName); // Doe

name是一個普通的物件屬性,但是這裡被賦予了一個方法,可以設定或者獲取到firstName和lastName。

JS值有個內建的特性可以幫助我們實現我們的想法。接著看下面的程式碼:

var User = {
  firstName: 'Tony',
  lastName: 'Stark'
};
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方法接受一個上下文、屬性名稱以及get/set方法。我們要做的就是實現裡面的兩個方法,僅此而已。我們將執行上面的程式碼並且能夠獲得到期望的結果:

console.log(User.name); // Tony Stark
User.name = 'John Doe';
console.log(User.firstName); // John
console.log(User.lastName); // Doe

Object.defineProperty確實是我們需要的,但是我們不想強制每個開發者每次都重寫這個方法。我們需要提供一個原生支援的邏輯程式碼,就類似於Ember的介面。我們只需要一個定義類的方法,在這裡,我們會寫一個使用函式Computize用來把物件中的函式中傳遞的名稱轉換成物件中屬性的名稱。

var Computize = function(obj) {
  return obj;
}
var User = Computize({
  firstName: 'Tony',
  lastName: 'Stark',
  name: function() {
    ...
  }
});

我們想使用set來設定名稱,同時使用get來獲取名稱。這和Ember的計算屬性非常類似。

現在就讓我們增加我們的邏輯程式碼到函式的原型中吧:

Function.prototype.computed = function() {
  return { computed: true, func: this };
};

一旦我們增加了上面的程式碼,我們就會為每個函式增加了一個.computed()方法了。

name: function() {
  ...
}.computed()

結果就是name屬性不在是函式了,而是一個擁有computed為true的屬性和一個func屬性的物件。真正的魔法發生在自定義輔助方法的實現上,它貫穿於整個物件的屬性上。我們會在計算屬性上使用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;
}

注意我們刪除了原生的屬性名稱。在一些瀏覽器中Object.defineProperty只執行於還沒有存在的屬性上。

下面是一個使用.computed()方法最終版本的User物件。

var User = Computize({
  firstName: 'Tony',
  lastName: 'Stark',
  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的變化。在這裡判斷是否判斷了引數,如果傳了引數則把他們分設到firstName和lastName中。

我們已經提過期望的介面,但是我們再來看下:

console.log(User.name); // Tony Stark
User.name = 'John Doe';
console.log(User.firstName); // John
console.log(User.lastName); // Doe
console.log(User.name); // John Doe

下面是CodePen中執行的結果:

瘋狂的React模板

你可能聽說過Facebook的框架React。它的構建思想就是一切都是元件。其中感興趣的就是關於元件的定義。讓我們看下如下例子:

<script type="text/jsx">;
  /** @jsx React.DOM */
  var HelloMessage = React.createClass({
    render: function() {
      return <div>Hello {this.props.name}</div>;
    }
  });
</script>;

我看看到這段程式碼的時候我們會想到這是JS,但是不是合法的,這裡的render方法可能會報錯。但是這裡的手段是這段程式碼放在了script標籤中,同時賦值給了定義的變數中。瀏覽器不會處理它意味著我們的程式碼是安全的。React有它自己的解析器,會把定義好的程式碼轉換成合法的JS程式碼。Facebook的開發者稱這種解析器為JSX。JSX解析器大約390k、12000行程式碼。因此它還是比較複雜的。在本節中,我們將建立一個非常簡單,但是功能強大的東東:一個以React風格解析HTML模板的JS類。

Facebook採取的方式是混合使用JS程式碼和HTML標籤。現在假如我們有如下的模板:

<script type="text/template" id="my-content">;
  <div class="content">;
    <h1>;<% title %>;</h1>;
  </div>;
</script>;

增加一個元件:

var Component = {
  title: 'Awesome template',
  render: '#my-content'
}

想法是我們指定template id,然後定義要被應用的資料。剩下的就是我們的實現了:連線兩個元素的引擎。我們稱之為Engine,它應該是這樣的:

var Engine = function(comp) {
  var parse = function(tplHTML) {
    // ... magic
  }
  var tpl = document.querySelector(comp.render);
  if(tpl) {
    var html = parse(tpl.innerHTML);
    return stringToDom(html);
  }
}
var el = Engine(Component);

我們將獲得script標籤裡面的內容,然後解析它後生成HTML字串。在轉換HTML為DOM元素之後,把它作為結果返回結束。注意我們用了stringToDom函式,我們在第一節中已經見過了。

現在讓我們開始寫parse函式。我們首要任務是從表示式中區分出HTML標記。表示式中我們要查詢<%和%>之間的字串。我們使用正規表示式去遍歷查詢他們:

var parse = function(tplHTML) {
  var re = /<%([^%>]+)?%>/g;
  while(match = re.exec(tplHTML)) {
    console.log(match);
  }
}

上述程式碼的之行結果如下:

[
    "<% title %>", 
    "title", 
    index: 55, 
    input: "<div class="content"><h1><% title %></h1></div>"
]

這裡只有一個表示式,裡面的內容是title。比較直觀的方法是我們使用JS的replace函式去替換<% title %>為comp 物件中的資料。但是,這種方式只能執行於簡單的屬性。如果有巢狀物件甚至要使用函式,比如下面的例子:

var Component = {
  data: {
    title: 'Awesome template',
    subtitle: function() {
      return 'Second title';
    }
  },
  render: '#my-content'
}

我們不用複雜的解析器,也不用發明一種新的語言,我們只用原生JS。我們要用的就是隻有new Function語法。

var fn = new Function('arg', 'console.log(arg + 1);');
fn(2); // outputs 3

我們能夠通過它來建立函式體,而且可以在以後去執行。因此我們需要知道表示式的位置以及它前面的元素。那如果我們使用一個臨時的陣列和一個遊標,那程式碼應該是這樣的:

var parse = function(tplHTML) {
  var re = /<%([^%>]+)?%>/g;
  var code = [], cursor = 0;
  while(match = re.exec(tplHTML)) {
    code.push(tplHTML.slice(cursor, match.index));
    code.push({code: match[1]}); // <-- expression
    cursor = match.index + match[0].length;
  }
  code.push(tplHTML.substr(cursor, tplHTML.length - cursor));
  console.log(code);
}

程式碼的輸出結果如下:

[
  "<div class="content"><h1>", 
  { code: "title" },
  "</h1></div>"
]

程式碼資料陣列將會被轉換成字串來作為函式體。舉例:

return "<div class=\"content\"><h1>" + title + "</h1></div>";

輸出這個結果還是非常容易的。我們可以寫一個迴圈來遍歷程式碼資料的元素來判斷它是字串還是物件。但是這隻能覆蓋部分情況。如果我們有如下的模板該咋辦呢:

// component
var Component = {
  title: 'Awesome template',
  colors: ['read', 'green', 'blue'],
  render: '#my-content'
}

// template
<script type="text/template" id="my-content">
    <div class="content">
        <h1><% title %></h1>
        <% while(c = colors.shift()) { %>
            <p><% c %></p>
        <% } %>
    </div>
</script>

我們不能只是連線表示式就能獲得顏色列表。因此,我們不用字串連線字串,我們要把它們手機起來放到陣列中。下面是更新版本的parse函式:

var parse = function(tplHTML) {
  var re = /<%([^%>]+)?%>/g;
  var code = [], cursor = 0;
  while(match = re.exec(tplHTML)) {
    code.push(tplHTML.slice(cursor, match.index));
    code.push({code: match[1]}); // <-- expression
    cursor = match.index + match[0].length;
  }
  code.push(tplHTML.substr(cursor, tplHTML.length - cursor));
  var body = 'var r=[];\n';
  while(line = code.shift()) {
    if(typeof line === 'string') {
      // escaping quotes
      line = line.replace(/"/g, '\\"');
      // removing new lines
      line = line.replace(/[\r\t\n]/g, '');
      body += 'r.push("' + line+ '");\n'
    } else {
      if(line.code.match(/(^( )?(if|for|else|switch|case|break|while|{|}))(.*)?/g)) {
        body += line.code + '\n';
      } else {
        body += 'r.push(' + line.code + ');\n';
      }
    }
  }
  body += 'return r.join("");';
  console.log(body);
}

一旦程式碼陣列被填充玩我們就開始構建函式體了。模板的每行都會被儲存到一個陣列r中。如果這行是字串,我們會引號進行轉義並且去除掉換行符、回車符和tab符,然後增加到陣列中。如果是程式碼,則需要校驗是否是合法的JS操作符,如果是JS語法則不會新增到陣列中。console.log會有如下輸出:

var r=[];
r.push("<div class=\"content\"><h1>");
r.push(title);
r.push("</h1>");

while(c = colors.shift()) { 
  r.push("<p>");
  r.push(c);
  r.push("</p>");
}

r.push("</div>");
return r.join("");

非常好,不是麼?這個JS的屬性格式化工具,將會獲得到我們想要的結果。

剩下要做的事情就是執行我們建立的函式:

body = 'with(component) {' + body + '}';
return new Function('component', body).apply(comp, [comp]);

我們通過使用with語句來把上下文設定為component,如果不適用它我們需要使用this.title和this.colors而不是title和colors。

總結

在一個大的框架和庫函式背後都集中了非常睿智的開發者。他們找到的很多招數都非常的瑣細的,甚至是神奇的。在這篇文章中,我們總結了這些魔法。在JS世界中,我們可以從它們並且使用他們的程式碼是非常棒的事情。

這篇文章的程式碼都可以從GitHub中下載到。

相關文章