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


推薦語:

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

其中的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中下載到。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM