我们每天都在使用各种各样的框架,这些框架伴随着我们每天的工作。通过使用这些框架的目的是为了解放我们,很少人去真正关心这些框架的背后都做了些什么。我也使用了不少的框架,通过这些流行框架也让我学习到了一些知识,就想把这些东西分享出来。

每个标题都是一个独立的主题,完全可以根据需要挑有兴趣的阅读。

字符串转DOM

经常使用jquery的小伙伴对下面的代码应该一点都不陌生:

1
2
3
var text = $('<div>hello, world</div>');

$('body').append(text)

以上代码执行的结果就是在页面增加了一个div节点。抛开jQuery, 代码可能会变得稍稍复杂:

1
2
3
4
5
6
7
8
9
10
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也不过如此嘛。如果你这么想你就错了。下面两种代码运行的有什么区别:

1
2
3
4
5
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 是如何解决这个问题的呢? 通过分析源码,我找到了下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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

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

剩下的就是根据合适的上下文返回DOM元素, 最终我们将strToDom进行最终的修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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时,它的双向数据绑定让人印象深刻。此外另一个神奇特征就是依赖注入。下面是一个简单的例子:

1
2
3
4
5
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请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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。所有的依赖统一在这里注册,并提供对外访问的接口:

1
2
3
4
5
6
7
8
9
var injector = {
storage: {},
register: function(name, resource) {
this.storage[name] = resource;
},
resolve: function(target) {

}
};

其中关键的resolve的实现:它接收一个目标对象,通过返回一个闭包,包装target并调用它。例如:

1
2
3
4
5
resolve: function(target) {
return function() {
target();
};
}

这样我们就可以调用我们需要的依赖的函数了。

下一步就是获取target的参数列表了,这里我引用了AngularJS的实现方式:

1
2
3
4
5
6
7
8
9
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 。它将通过目标函数转换为一个字符串,同时还将注释给去掉了, 最终得到参数信息:

1
2
3
4
5
6
7
8
9
10
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中存储的依赖项了。 下面是具体的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 函数。

调用的代码应该是这样的:

1
2
3
4
5
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 框架也提供了这样一个特性,用于计算属性的属性。有点绕口,看一个官方例子吧:

1
2
3
4
5
6
7
8
9
10
11
12
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 的代码:

1
2
3
4
5
6
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对象一起使用,那就更有趣了。例如:

1
2
3
4
5
6
7
8
9
10
11
12
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有一个内置的特性,可以帮助我们实现这个想法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 。我们要做的就是编写这两个方法的实现逻辑。运行上面的代码,我们就能得到想要的结果:

1
2
3
4
console.log(User.name); // Kobe Bryant
User.name = 'LeBron James';
console.log(User.firstName); // LeBron
console.log(User.lastName); // James

Object.defineProperty 虽然是我们想要的,但显然我们不想每次都这么写。在理想的情况下,我们希望提供一个接口。在本节中,我们将编写一个名为 Computize 的函数,它将处理对象并以某种方式将name函数转换为具有相同名称的属性。

1
2
3
4
5
6
7
8
9
10
var Computize = function(obj) {
return obj;
}
var User = Computize({
firstName: 'Kobe',
lastName: 'Bryant',
name: function() {
...
}
});

我们想使用name方法作为setter,同时作为getter。这类似于Ember的计算属性。

现在,我们将自己的逻辑添加到函数对象的原型中:

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

这样就可以在每个Function定义后直接调用computed函数了。

1
2
3
name: function() {
...
}.computed()

name属性不再是一个函数,而变成一个对象: { computed: true, func: this } 。其中 computed 等于true, func属性指向原本的函数。

真正神奇的事情发生在Computize helper的实现中。它遍历对象的所有属性,对所有的计算属性使用object.defineproperty:

1
2
3
4
5
6
7
8
9
10
11
12
13
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()函数的用户对象的最终版本:

1
2
3
4
5
6
7
8
9
10
11
12
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赋值,最终返回完整的名字。

结束

在大型框架和库的背后包含着许多优秀前辈的经验。通过学习这些框架能够让我们更好理解这些框架背后的原理,能够脱离框架开发,这点很重要。