[译]框架是如何炼成的 —— 揭秘前端顶级框架的底层实现原理

我们每天都使用大量的前端库和框架,这些各种各样的库和框架已经成为我们日常工作的一部分,我们之所以使用他们,是因为我们不想重新造轮子,即使我们不明白它们的底层是怎么回事,在这篇文章中,我将揭示流行框架中发生了哪些神奇的过程,同时我们也会探讨如何自己去实现。

使用字符串生成 DOM

随着单页应用的兴起,我们正在用JavaScript做越来越多的事情,我们的应用程序逻辑的很大一部分已经被移植到浏览器,在页面上动态产生或或替换元素变得非常频繁,比如如下的代码:

    var text = $('
Simple text
'
); $('body').append(text);

这段代码的结果是添加一个div元素到文档的body标签。使用jQuery,这个操作只需一行代码,如果没有jQuery,代码有点复杂,但是也不会很多:

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

      temp.innerHTML = str;
      return temp.childNodes[0];
    }
    var text = stringToDom('
Simple text
'
); document.querySelector('body').appendChild(text);

我们定义了工具方法 stringToDom,它创建1个临时的 

 元素,然后设置它的 innerHTML 属性为传入的参数,并在最后简单地返回其第一个孩子节点。它们的工作方式相同,然而,如果我们用下面的代码,将会观察到不一样的结果:

    var tableRow = $('Simple text');//使用jquery
    $('body').append(tableRow);

    var tableRow = stringToDom('Simple text');//使用我们自己的方法
    document.querySelector('body').appendChild(tableRow);

用肉眼观察页面,看起来没有差异,但是,如果我们使用 Chrome Developer ,我们会得到一个有趣的结果:

[译]框架是如何炼成的 —— 揭秘前端顶级框架的底层实现原理_第1张图片

似乎我们的 stringToDom 函数创建的只是一个文本节点,而不是想要的  标签,但同时 jQuery 的代码却做到了这一点,这是肿么回事?原来含有该元素的 HTML 字符串是通过浏览器解析器运行,而该解析器忽略了没有正确上下文的 HTML,我们得到的只是一个文本节点,一个不在表格中的行显然被浏览器认为是非法的。

jQuery通过创建合适的上下文并提取其中需要的部分成功地解决了这个问题,如果我们查看它的源代码,可以看到这样一个 map:

    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 元素,并依赖于嵌套来获取所需要的层级元素,例如,对于  元素,需要创建一个 

 节点及子节点  ,因此,嵌套层次有两级。

有一个 map 之后,我们要找出最终想要什么样的标签,下面的代码使用正则表达式从

 提取出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, ''],
        legend: [1, '
', '
'
], area: [1, '', ''], param: [1, '', ''], thead: [1, '
Simple text
', '
'], tr: [2, '', '
'
], col: [2, '', '
'
], td: [3, '', '
'
], _default: [1, '
', '
'
] }; 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(/, '').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标签,使浏览器可以创建一个有效的 DOM 树,最终通过一个while循环,不断地递归,直到找到我们想要的标签。

下面是在 CodePen 显示的执行结果:

<tr><td>Simple texttd>tr>

接下来我们来探讨angularjs的依赖注入。

探讨 Angularjs 的依赖注入

当我们开始使用 AngularJS 时,最令人印象深刻的便是双向数据绑定,但我们注意到的第二件事便是其神奇的依赖注入,下面是一个简单的例子:

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

这是一个典型的 AngularJS 控制器,它执行 HTTP 请求,从一个 JSON 文件读取数据,并把它传递给目前的上下文,我们并没有执行 TodoCtrl 函数,甚至没有传递任何参数的机会,框架帮我们做了,这些 $scope 和 $HTTP 变量从哪里来的?这是一个超级酷的功能,像黑魔法一样,让我们来看看它是如何实现的。

现在假设我们的系统有一个显示用户信息的 JavaScript 函数,它需要访问 DOM 元素并放置最终生成的 HTML ,以及一个 Ajax 包装器来获取数据,为了简化这个例子,我们将伪造实体模型数据和 HTTP 请求。

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

我们将使用标签作为内容载体。 ajaxWrapper 是模拟请求的对象,dataMockup 包含了我们的用户数组。下面是我们将使用的函数:

    var displayUsers = function(domEl, ajax) {
      ajax.get('/api/users', function(users) {
        var html = '';
        for(var i=0; i < users.length; i++) {
          html += '

' + users[i] + '

'
; } domEl.innerHTML = html; }); }

显然,如果我们运行 displayUsers(body,ajaxWrapper) 我们会看到页面上显示了3个名字,控制台输出了/API/users requested ,我们可以说,我们的方法有两个依赖 —— body 和 ajaxWrapper 。现在我们的想法是让函数工作时无需传递参数,也就是我们要得到相同的结果仅通过调用displayUsers()即可,如果使用现有的代码去执行,结果将是:

    Uncaught TypeError: Cannot read property 'get' of undefined

这是正常的,因为 AJAX 参数没有定义。

大多数提供依赖注入的框架通常都有一个称为注入器的模块,使用它需要将依赖注册,然后,我们的资源再由这个注入器模块传递给应用程序逻辑。

现在我们来创建自己的注入器:

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

      }
    };

我们只需要两个方法:第一个,register,它接受我们的资源(依赖)并在内部存储;第二个我们接受注入的目标target - 即那些有依赖性,需要接受他们作为参数的函数,这里的关键点是注入器不应调用我们的函数,这是我们的工作,我们应该能够控制,我们的解决方法是在 resolve 方法返回一个包裹了 target 的闭包并调用它。例如:

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

使用该方法,我们将有机会来调用函数以及所需的依赖关系,并且,我们未改变应用程序的工作流程,注入器仍然是独立的东西,并没有带任何逻辑。

然而,当传递 displayUsers 函数作为 Resolve 方法的参数时并没有作用:

    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 是一样的东西,它把目标函数源码转为字符串,删除注释(如果有的话),并提取参数,让我们的 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();
      }
    }

最终结果是:

[译]框架是如何炼成的 —— 揭秘前端顶级框架的底层实现原理_第2张图片

如果我们得到 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 转换为一个数组,之后,我们查询依赖是否在我们的 storage 对象注册了,如果是我们则传递他们到 target 函数,注入器以外的代码看起来像这样:

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

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

这种实现的好处是,我们可以在许多函数注入 DOM 元素和 Ajax warpper ,我们甚至可以像这样分发我们的应用程序配置,不再需要在类与类之间传递各种对象,仅需一个 register 和 resolve 方法即可。

当然,我们的注入器是不完美的,还有一些改进的空间,比如对自定义作用域的支持,target 现在在一个全新的作用域内被调用,但通常我们会想使用我们自定义的,另外我们应该支持传递参数给依赖。

如果我们既想保持代码短小又能正常工作,则注入器将变得更加复杂,正如我们所知道的我们这个缩小版替换了函数名,变量和方法的参数,而且因为我们的逻辑依赖于这些名称我们需要考虑变通,一个可能的解决方案是再次使用AngularJs:

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

与之前仅仅传递 displayUsers 不同,现在还传递了实际依赖的名字。

现在看看这个例子的最终结果:

John
Steve
David

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

Ember为全局Function对象扩充了一个property方法,这是一个很好的方式,在类的定义中运行一些逻辑。

Ember使用gettersetter方法​​对对象的数据进行操作。这简化了动态属性的实现,因为我们在操作实际变量之前添加了一个层,然而,如果我们能够使用动态属性与普通的JavaScript对象这将更有趣,例如:

    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被当成普通的属性使用,但在实现上却是一个函数,其获取或设置firstNamelastName

如下是JavaScript的一个内置的功能,它可以帮助我们实现这个想法,看看下面的代码片段:

    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方法接受一个对象、对象名称以及需要定义的属性的getter,setter方法,所有我们要做的就是写这两个方法的主体,就是这样,我们将能够运行上面的代码中,我们会得到预期的结果:

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

Object.defineProperty正是我们所需要的,但我们不希望强制开发人员每次都这样写,我们可能需要提供一个 polyfill(译者注:类似于插件、扩展),用于执行额外的逻辑,或者类似的东西,在理想的情况下,我们希望提供类似于Ember的一个接口,只有一个函数是类定义的一部分,在本节中,我们将编写一个名为Computize的工具函数来处理我们的对象并以某种方式将name函数转换为同名属性。

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

我们希望使用name方法作为setter和getter,这类似于Ember的动态属性。

现在,让我们添加我们自己的逻辑到Function对象的原型:

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

一旦我们添加了上面的代码,我们就可以添加computed()方法到每个函数定义的结尾:

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

其结果是,name属性不再是一个函数了,而是一个对象,其包含值为true的属性computed以及值为原函数的属性func,真正的魔法发生在Computize帮手的实施,它会遍历对象的所有属性,并使用Object.defineProperty来重定义那些computed属性为true的属性:

    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只适用于那些尚未定义的属性。

下面是一个使用.Computize() 函数的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()
    });

返回全名的函数用于改变firstNamelastName,它检测第一个参数是否存在,若存在,则分割并重新赋值到对应的属性。

我们已经提到过想要的用法,但让我们再来看一个例子:


    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

以下是最终结果:

Tony Stark
Krasimir
Tsonev
Krasimir Tsonev

译者注:其实本节描述的本质,就是如何把对象的方法作为一个属性来使用,类似高级语言中的get和set,那么其底层的关键实现是使用了 Object.defineProperty 来重定义属性的getter和setter方法。

疯狂的 React 模板

你可能听说过Facebook的框架 React,它的设计思想是一切皆为组件,其中最有趣的是组件的定义,让我们来看看下面的例子:

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

我们开始思考的第一件事是,这是一段JavaScript代码,但它是无效的,它有一个 render 函数,但显而易见会抛出一个语法错误,但是这里的诀窍是,这段代码放在一个自定义type的 

你可能感兴趣的:(js框架)