(翻译)使用200行代码创建属于你自己的精简版angular
更新日期:
原文:http://blog.mgechev.com/2015/03/09/build-learn-your-own-light-lightweight-angularjs/
第一次翻译外文,就拿这篇作为第一次练习。加上一些自己的理解并且做了些删减。
正文开始:
我的实践经验证明有两种好方法来学习一项新技术。
- 自己重新实现这个项目
- 分析那些你所知道的技术概念是如何运用在这个项目里的
在一些情况下第一种方式很难做到。比如,如果你为了理解kernel(linux内核)的工作原理而去重新实现一次它会很困难很慢。往往更有效的是你去实现一个轻量的版本,去除掉那些你没兴趣的技术细节,只关注核心功能。
第二种方法一般是很有效的,特别是当你具有一些相似的技术经验的时候。最好的证明就是我写的angularjs-in-patterns,对于有经验的工程师来说这是个对angular框架非常好的介绍。
不管怎么说,从头开始实现一些东西并且去理解代码使用的技术细节是非常好的学习方式。整个angularjs框架大概有20k行代码,其中有很多特别难懂的地方。这是很多聪明的程序员夜以继日的工作做出来的伟大的壮举。然而为了理解这个框架还有它主要的设计原则,我们可以仅仅简单的实现一个‘模型’。
我们可以通过下面这些步骤来实现这个模型:
- 简化api
- 去除掉对于理解核心功能无关的组件代码
这就是我在Lightweight AngularJS里面做的事情。
在开始阅读下面的内容之前,建议先了解下angularjs的基本用法,可以看这篇文章
下面是一些demo例子还有代码片段:
让我们开始我们的实现:
主要的组件:
我们不完全实现angularjs的那套技术,我们就仅仅定义一部分的组件并且实现大部分的angularjs里面的时尚特性。可能会接口变得简单点,或者减少些功能特性。
我们会实现的angular的组件包括:
- Controllers
- Directives
- Services
为了达到这些功能我们需要实现$compile
service(我们称之为DOMCompiler
),还有$provider
跟$injector
(在我们的实现里统称为Provider)。为了实现双向绑定我们还要实现scope。
下面是Provider, Scope 跟 DOMCompiler 的依赖关系:
Provider
就像上面提到的,我们的Provider会包括原生angular里面的两个组件的内容:
- $provide
- $injector
他是一个具有如下功能特性的单列:
- 注册组件(directives, services 和 controllers)
- 解决各个组件之间的依赖关系
- 初始化所有组件
DOMCompiler
DOMCompiler也是一个单列,他会遍历dom树去查找对应的directives节点。我们这里仅仅支持那种用在dom元素属性上的directive。当DOMCompiler发现directive的时候会给他提供scope的功能特性(因为对应的directive可能需要一个新的scope)并且调用关联在它上面对应的逻辑代码(也就是link函数里面的逻辑)。所以这个组件的主要职责就是:
编译dom
- 遍历dom树的所有节点
- 找到注册的属性类型的directives指令
- 调用对应的directive对应的link逻辑
- 管理scope
Scope
我们的轻量级angular的最后一个主要的组件就是scope。为了实现双向绑定的功能,我们需要有一个$scope对象来挂载属性。我们可以把这些属性组合成表达式并且监控它们。当我们发现监控的某个表达式的值改变了,我们就调用对应的回调函数。
scope的职责:
- 监控表达式
- 在每次$digest循环的时候执行所有的表达式,直到稳定(译者注:稳定就是说,表达式的值不再改变的时候)
- 在表达式的值发生改变时,调用对应的所有的回调函数
下面本来还有些图论的讲解,但是认为意义不大,这边就略去了。
开始实现
让我们开始实现我们的轻量版angular
Provider
正如我们上面说的,Provide会:
- 注册组件(directives, services 和 controllers)
- 解决各个组件之间的依赖关系
- 初始化所有组件
所以它具有下面这些接口:
- get(name, locals) - 通过名称 还有本地依赖 返回对应的service
- invoke(fn, locals) - 通过service对应的工厂函数还有本地依赖初始化service
- directive(name, fn) - 通过名称还有工厂函数注册一个directive
- controller(name, fn) - 通过名称还有工厂函数注册一个controller。注意angularjs的代码里并没有controllers对应的代码,他们是通过$controller来实现的。
- service(name, fn) - 通过名称还有工厂函数注册一个service
- annotate(fn) - 返回一个数组,数组里是当前service依赖的模块的名称
组件的注册:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | var Provider = { _providers: {}, directive: function (name, fn) { this._register(name + Provider.DIRECTIVES_SUFFIX, fn); }, controller: function (name, fn) { this._register(name + Provider.CONTROLLERS_SUFFIX, function () { return fn; }); }, service: function (name, fn) { this._register(name, fn); }, _register: function (name, factory) { this._providers[name] = factory; } //... }; Provider.DIRECTIVES_SUFFIX = 'Directive'; Provider.CONTROLLERS_SUFFIX = 'Controller'; |
译者注:看到这里容易对controller的包装一层有疑问,先忽略,看完invoke的实现后,下面我再给出解释。
上面的代码提供了一个针对注册组件的简单的实现。我们定义了一个私有属性_provides
用来存储所有的组件的工厂函数。我们还定义了directive,service和controller这些方法。这些方法本质上内部会调用_register来实现。在controller方法里面我们简单的在给的工厂函数外面包装了一层函数,因为我们希望可以多次实例化同一个controller而不去缓存返回的值。在我们看了下面的get和ngl-controller方法实现后会对controller方法有更加清晰的认识。下面还剩下的方法就是:
- invoke
- get
- annotate
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 | var Provider = { // ... get: function (name, locals) { if (this._cache[name]) { return this._cache[name]; } var provider = this._providers[name]; if (!provider || typeof provider !== 'function') { return null; } return (this._cache[name] = this.invoke(provider, locals)); }, annotate: function (fn) { var res = fn.toString() .replace(/((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg, '') .match(/\((.*?)\)/); if (res && res[1]) { return res[1].split(',').map(function (d) { return d.trim(); }); } return []; }, invoke: function (fn, locals) { locals = locals || {}; var deps = this.annotate(fn).map(function (s) { return locals[s] || this.get(s, locals); }, this); return fn.apply(null, deps); }, _cache: { $rootScope: new Scope() } }; |
我们写了更多的逻辑,下面我们看看get的实现。
在get方法中我们先检测下一个组件是不是已经缓存在了私有属性_cache里面。
- 如果缓存了就直接返回(译者注:这边其实就是个单列模式,只会调用注册的工厂函数一次,以后直接调用缓存的生成好的对象)。$rootScope默认就会被缓存,,因为我们需要一个单独的全局的并且唯一的超级scope。一旦整个应用启动了,他就会被实例化。
- 如果不在缓存里,就从私有属性_providers里面拿到它的工厂函数,并且调用invoke去执行工厂函数实例化它。
在invoke函数里,我们做的第一件事就是判断如果没有locals对象就赋值一个空的值。
这些locals对象 叫做局部依赖,什么是局部依赖呢?
在angularjs里面我们可以想到两种依赖:
- 局部依赖
- 全局依赖
全局依赖是我们使用factory,service,filter等等注册的组件。他们可以被所有应用里的其他组件依赖使用。但是$scope呢?对于每一个controller(具有相同执行函数的controller)我们希望拥有不同的scope,$scope对象不像$http,$resource,它不是全局的依赖对象,而是跟$delegate对象一样是局部依赖,针对当前的组件。
让我们呢回到invoke的实现上。通过合理的规避null,undefined这些值,我们可以获取到当前组件的依赖项的名字。注意我们的实现仅仅支持解析那种作为参数属性的依赖写法:
1 2 3 4 5 | function Controller($scope, $http) { // ... } angular.controller('Controller', Controller); |
一旦把controller的定义转换成字符串,我们就可以很简单的通过annotate里面的正则匹配出它的依赖项。但是万一controller的定义里面有注释呢?
1 2 3 4 5 | function Controller($scope /* only local scope, for the component */, $http) { // ... } angular.controller('Controller', Controller); |
这边简单的正则就不起作用了,因为执行Controller.toString()也会返回注释,所以这就是我们为什么最开始要使用下面的正则先去掉注释:
1 | .replace(/((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg, ''). |
当我们拿到依赖项的名称后,我们需要去实例化他们。所以我们使用map来循环遍历,挨个的调用get来获取实例。你注意到这边的问题了吗?
如果我们有个组件A,A依赖B和C。并且假设C依赖A?在这种情况下我们就会发生无止境的循环,也就是循环依赖。在这个实现里面我们不会处理这种问题,但是你应该小心点,尽量避免。
所以上面就是我们的provider的实现,现在我们可以这样注册组件:
1 2 3 4 5 6 7 8 9 10 11 12 | Provider.service('RESTfulService', function () { return function (url) { // make restful call & return promise }; }); Provider.controller('MainCtrl', function (RESTfulService) { RESTfulService(url) .then(function (data) { alert(data); }); }); |
然后我们可以这样执行MainCtrl:
1 2 | var ctrl = Provider.get('MainCtrl' + Provider.CONTROLLERS_SUFFIX); Provider.invoke(ctrl); |
译者注:
这边可以开始解释下上面的Provider里面controller方法里为啥要包装一层了。
首先我们注意到controller的调用方式是特殊的,Provider.get内部已经调用了一次invoke,但是我们还要再调用一次invoke才能执行MainCtrl的真正执行函数。这是因为我们包装了一层,导致_cache里面单列存储的是MainCtrl的执行函数。而不是执行函数的结果。
想想这才是合理的,因为MainCtrl可能会有多个调用,这些调用只有执行函数是一致的,但是执行函数的执行结果根据不同的scope环境是不一样的。换句话说对于controller来说 执行函数才是单列的,执行结果是差异的。如果我们不包装一层,就会导致第一次的执行结果会直接缓存,这样下次再使用MainCtrl的时候得到的值就是上一次的。
当然带来的问题就是我们需要get到执行函数后,再次调用invoke来获取结果。
这边的controller初始化,需要看下面的ngl-controller的实现,可以到时再回过头来看这边会理解的更清楚。
DOMCompiler
DOMCompiler的主要职责是:
编译dom
- 遍历dom树的所有节点
- 找到注册的属性类型的directives指令
- 调用对应的directive对应的link逻辑
- 管理scope
下面的这些接口就够了:
- bootstrap() - 启动整个项目(类似angularjs里面的angular。bootstrap,不过一直使用html根节点作为启动的节点)
- compile(el, scope) - 执行所有依附在当前html节点上的directives的代码,并且递归执行子元素的组件逻辑。我们需要一个scope对象关联当前的html节点,这样才能实现双向绑定。因为每个directive可能都会生成一个不同的scope,所以我们需要在递归调用的时候传入当前的scope对象。
下面是对应的实现:
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 | var DOMCompiler = { bootstrap: function () { this.compile(document.children[0], Provider.get('$rootScope')); }, compile: function (el, scope) { //获取某个元素上的所有指令 var dirs = this._getElDirectives(el); var dir; var scopeCreated; dirs.forEach(function (d) { dir = Provider.get(d.name + Provider.DIRECTIVES_SUFFIX); //dir.scope代表当前 directive是否需要生成新的scope //这边的情况是只要有一个指令需要单独的scope,其他的directive也会变成具有新的scope对象,这边是不是不太好 if (dir.scope && !scopeCreated) { scope = scope.$new(); scopeCreated = true; } dir.link(el, scope, d.value); }); Array.prototype.slice.call(el.children).forEach(function (c) { this.compile(c, scope); }, this); }, // ... }; |
bootstrap的实现很简单。就是调用了一下compile,传递的是html的根节点,以及全局的$rootScope。
在compile里面的代码就很有趣了,最开始我们使用了一个辅助方法来获取某个节点上面的所有指令。我们后面再来看这个_getElDirectives的实现。
当我们获取到当前节点的所有指令后,我们循环遍历下并且使用Provider.get获取到对应的directive的工厂函数的执行返回对象。然后我们检查当前的directive是否需要一个新的scope,如果需要并且我们还没有为当前的节点初始化过新的scope对象,我们就执行scope.$new()来生成一个新的scope对象。这个对象会原型继承当前的scope对象。然后我们执行当前directive的link方法。最后我们递归执行子节点。因为el.children是一个nodelist对象,所以我们使用Array.prototype.slice.call将它转换成数组,之后对它递归调用compile。
再让我们看看_getElDirectives:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | // ... _getElDirectives: function (el) { var attrs = el.attributes; var result = []; for (var i = 0; i < attrs.length; i += 1) { if (Provider.get(attrs[i].name + Provider.DIRECTIVES_SUFFIX)) { result.push({ name: attrs[i].name, value: attrs[i].value }); } } return result; } // ... |
主要就是遍历当前节点el的所有属性,发现一个注册过的指令就把它的名字和值加入到返回的数组里。
好了,到这里我们的DOMCompiler就完成了,下面我们看看最后一个重要的组件:
Scope
为了实现脏检测的功能,于是scope可能是整个实现里面最复杂的部分了。在angularjs里面我们称为$digest循环。笼统的讲双向绑定的最主要原理,就是在$digest循环里面执行监控表达式。一旦这个循环开始调用,就会执行所有监控的表达式并且检测最后的执行结果是不是更当前的执行结果不同,如果angularjs发现他们不同,它就会执行这个表达式对应的回调函数。一个监控者就是一个对象像这样{ expr, fn, last }。expr是对应的监控表达试,fn是对应的回调函数会在值变化后执行,last是上一次的表达式的执行结果。
scope对象有下面这些方法:
- $watch(expr, fn) - 监控表达式 expr。一旦发现expr的值有变化就只行回调函数fn,并且传入新的值
- $destroy() - 销毁当前的scope对象
- $eval(expr) - 根据上下文执行当前的表达式
- $new() - 原型继承当前的scope对象,生成一个新的scope对象,
- $digest() - 运营脏检测
让我们来深入的看看scope的实现:
1 2 3 4 5 6 7 | function Scope(parent, id) { this.$$watchers = []; this.$$children = []; this.$parent = parent; this.$id = id || 0; } Scope.counter = 0; |
我们大幅度的简化了angularjs的scope。我们仅仅有一个监控者的列表,一个子scope对象的列表,一个父scope对象,还有个当前scope的id。我们添加了一个静态属性counter用来跟踪最后一个scope,并且为下一个scope对象提供一个唯一的标识。
我们来实现$watch方法:
1 2 3 4 5 6 7 | Scope.prototype.$watch = function (exp, fn) { this.$$watchers.push({ exp: exp, fn: fn, last: Utils.clone(this.$eval(exp)) }); }; |
在$watch方法中,我们添加了一个新对象到this.$$watchers监控者列表里。这个对象包括一个表达式,一个执行的回调还有最后一次表达式执行的结果last。因为我们使用this.$eval执行表达式得到的结果有可能是个引用,所以我们需要克隆一份新的。
下面我们看看如何新建scope,和销毁scope。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | Scope.prototype.$new = function () { Scope.counter += 1; var obj = new Scope(this, Scope.counter); //设置原型链,把当前的scope对象作为新scope的原型,这样新的scope对象可以访问到父scope的属性方法 Object.setPrototypeOf(obj, this); this.$$children.push(obj); return obj; }; Scope.prototype.$destroy = function () { var pc = this.$parent.$$children; pc.splice(pc.indexOf(this), 1); }; |
$new用来创建一个新的scope对象,并且具有独一无二的标识,原型被设置为当前scope对象。然后我们把新生成的scope对象放到子scope对象列表(this.$$children)里。
在destroy方法里,我们把当前scope对象从父级scope对象里的子scope对象列表(this.$$children)移除掉。
下面我们看看传说中的脏检测$digest的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | Scope.prototype.$digest = function () { var dirty, watcher, current, i; do { dirty = false; for (i = 0; i < this.$$watchers.length; i += 1) { watcher = this.$$watchers[i]; current = this.$eval(watcher.exp); if (!Utils.equals(watcher.last, current)) { watcher.last = Utils.clone(current); dirty = true; watcher.fn(current); } } } while (dirty); for (i = 0; i < this.$$children.length; i += 1) { this.$$children[i].$digest(); } }; |
基本上我们一直循环运行检测一直到没有脏数据,默认情况下就是没有脏数据的。一旦我们发现当前表达式的执行结果跟上一次的结果不一样我们就任务有了脏数据,一旦我们发现一个脏数据我们就要重新执行一次所有的监控表达式。为什么呢?因为我们可能会有一些内部表达式依赖,所以一个表达式的结果可能会影响到另外一个的结果。这就是为什么我们需要一遍一遍的运行脏检测一直到所有的表达式都没有变化也就是稳定了。一旦我们发现数据改变了,我们就立即执行对应的回调并且更新对应的last值,并且标识当前有脏数据,这样就会再次调用脏检测。
然后我们会继续递归调用子scope对象的脏数据检测,一个需要注意的情况就是这边也会发生循环依赖:
1 2 3 4 5 6 7 8 9 10 11 | function Controller($scope) { $scope.i = $scope.j = 0; $scope.$watch('i', function (val) { $scope.j += 1; }); $scope.$watch('j', function (val) { $scope.i += 1; }); $scope.i += 1; $scope.$digest(); } |
这种情况下我们就会看到:
最后一个方法是$eval.最好不要在生产环境里使用这个,这个是一个hack手段用来避免我们还需要自己做个表达式解析引擎。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | // In the complete implementation there're // lexer, parser and interpreter. // Note that this implementation is pretty evil! // It uses two dangerouse features: // - eval // - with // The reason the 'use strict' statement is // omitted is because of `with` Scope.prototype.$eval = function (exp) { var val; if (typeof exp === 'function') { val = exp.call(this); } else { try { with (this) { val = eval(exp); } } catch (e) { val = undefined; } } return val; }; |
我们检测监控的表达式是不是一个函数,如果是的话我们就使用当前的上下文执行它。否则我们就通过with把当前的执行环境改成当前scope的上下文并且使用eval来得到结果。这个可以允许我们执行类似foo + bar * baz()
的表达式,甚至是更复杂的。当然我们不会支持filters,因为他们是angularjs扩展的功能。
Directive
到目前为止使用已有的元素我们做不了什么。为了让它跑起来我们需要添加一些指令(directive)还有服务(service)。让我们来实现ngl-bind (ng-bind ), ngl-model (ng-model), ngl-controller (ng-controller) and ngl-click (ng-click)。括号里代表在angularjs里面的对应directive
ngl-bind
1 2 3 4 5 6 7 8 9 10 11 12 | Provider.directive('ngl-bind', function () { return { scope: false, link: function (el, scope, exp) { el.innerHTML = scope.$eval(exp); scope.$watch(exp, function (val) { el.innerHTML = val; }); } }; }); |
ngl-bind并不需要一个新的scope,它仅仅对当前节点添加了一个监控。当脏检测发现有了改变,回调函数就会把新的值赋值到innerHTML更新dom
ngl-model
我们的ng-model只会支持input框的改变检测,所以它的实现是这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 | Provider.directive('ngl-model', function () { return { link: function (el, scope, exp) { el.onkeyup = function () { scope[exp] = el.value; scope.$digest(); }; scope.$watch(exp, function (val) { el.value = val; }); } }; }); |
我们对当前的input框添加了一个onkeyup的监听,一旦当前input的值变化了,我们就调用当前scope对象的$digest脏检测循环。这样就可以保证这个改变会应用到所有scope的监控表达式。当值改变了我们就改变对应的节点的值。
ngl-controller
1 2 3 4 5 6 7 8 9 | Provider.directive('ngl-controller', function () { return { scope: true, link: function (el, scope, exp) { var ctrl = Provider.get(exp + Provider.CONTROLLERS_SUFFIX); Provider.invoke(ctrl, { $scope: scope }); } }; }); |
我们需要针对每个controller生成一个新的scope对象,所以它的scope的值是true。我们使用Provide.get来获取到需要的controller执行函数,然后使用当前的scope来执行它。在controller里面我们可以给scope对象添加属性,我们可以使用ngl-bind/ngl-model绑定这些属性。一旦我们改变了属性值我们需要确保我们执行$digest脏检测来保证监控这些属性的表达式会执行。
ngl-click
在我们可以做一个有用的todo应用之前,这是我们最后要看的指令。
1 2 3 4 5 6 7 8 9 10 11 | Provider.directive('ngl-click', function () { return { scope: false, link: function (el, scope, exp) { el.onclick = function () { scope.$eval(exp); scope.$digest(); }; } }; }); |
这里我们不需要新建个scope对象。我们需要的就是当用户点击按钮时执行当前ngl-click后面跟着的表达式并且调用脏检测。
一个完整的例子
为了保证我们可以理解双向绑定是怎么工作的,我们来看个下面的例子:
1 2 3 4 5 6 7 8 9 | <!DOCTYPE html> <html lang="en"> <head> </head> <body ngl-controller="MainCtrl"> <span ngl-bind="bar"></span> <button ngl-click="foo()">Increment</button> </body> </html> |
1 2 3 4 5 6 | Provider.controller('MainCtrl', function ($scope) { $scope.bar = 0; $scope.foo = function () { $scope.bar += 1; }; }); |
让我们看看使用这些会发生什么:
首先DOMCompiler会先发现我们的ngl-controller指令。然后会调用这个指令的link函数生成一个新的scope对象传递给controller的执行函数。我们增加了一个值为0的bar属性,还有一个叫做foo的方法,foo方法会不断增加bar。DOMCompiler会发现ngl-bind然后为bar添加监控。并且还发现了ngl-click同时添加click事件到按钮上。
一旦用户点击了按钮,foo函数就会通过$scope.$eval执行。使用的scope对象就是传递给MainCtrl的scope对象。这之后ngl-click会执行脏检测$scope.$digest。脏检测循环会遍历所有的监控表达式,发现bar的值变化了。因为我们添加了对应的回调函数,所以就执行它更新span的内容。
结论
这个框架离实际的生产环境应用还有很大差距,但是它还是实现了不少功能:
- 双向绑定
- 依赖注入
- 作用域分离
跟在angular里面的运行方式差不多。这些可以帮助我们更容易理解angularjs。
但是你还是要记住的是不要把这些代码用在生产环境,最好还是直接使用bower install angular使用最新的anguar。