使用200行代码创建属于你自己的精简版 Angular(翻译)

2016/03/02 angularmvvm

第一次翻译外文,就拿这篇作为第一次练习。加上一些自己的理解并且做了些删减。

正文开始:

我的实践经验证明有两种好方法来学习一项新技术

  • 自己重新实现这个项目

  • 分析那些你所知道的技术概念是如何运用在这个项目里的

在一些情况下第一种方式很难做到。比如,如果你为了理解 kernel(linux内核)的工作原理而去重新实现一次它会很困难很慢。往往更有效的是你去实现一个轻量的版本,去除掉那些你没兴趣的技术细节,只关注核心功能。

第二种方法一般是很有效的,特别是当你具有一些相似的技术经验的时候。最好的证明就是我写的 angularjs-in-patterns (opens new window),对于有经验的工程师来说这是个对 angular 框架非常好的介绍。

不管怎么说,从头开始实现一些东西并且去理解代码使用的技术细节是非常好的学习方式。整个 angularjs 框架大概有 20k 行代码,其中有很多特别难懂的地方。这是很多聪明的程序员夜以继日的工作做出来的伟大的壮举。然而为了理解这个框架还有它主要的设计原则,我们可以仅仅简单的实现一个“模型”。

我们可以通过下面这些步骤来实现这个模型:

  • 简化 api

  • 去除掉对于理解核心功能无关的组件代码

这就是我在 Lightweight AngularJS (opens new window) 里面做的事情。

在开始阅读下面的内容之前,建议先了解下angularjs的基本用法,可以看这篇文章 (opens new window)

下面是一些 demo 例子还有代码片段:

让我们开始我们的实现:

# 主要的组件:

我们不完全实现 angularjs 的那套技术,我们就仅仅定义一部分的组件并且实现大部分的 angularjs 里面的时尚特性。可能会接口变得简单点,或者减少些功能特性。

我们会实现的 angular 的组件包括:

  • Controllers

  • Directives

  • Services

为了达到这些功能我们需要实现 $compile service(我们称之为 DOMCompiler),还有 $provider$injector(在我们的实现里统称为 Provider)。为了实现双向绑定我们还要实现 scope。

下面是 Provider, Scope 跟 DOMCompiler 的依赖关系:

yilai

# 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 依赖的模块的名称

组件的注册:

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

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 这些值,我们可以获取到当前组件的依赖项的名字。注意我们的实现仅仅支持解析那种作为参数属性的依赖写法:

function Controller($scope, $http) {
    // ...
}
angular.controller('Controller', Controller);

一旦把 controller 的定义转换成字符串,我们就可以很简单的通过 annotate 里面的正则匹配出它的依赖项。但是万一 controller 的定义里面有注释呢?

function Controller($scope /* only local scope, for the component */, $http) {
    // ...
}
angular.controller('Controller', Controller);

这边简单的正则就不起作用了,因为执行 Controller.toString() 也会返回注释,所以这就是我们为什么最开始要使用下面的正则先去掉注释:

.replace(/((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg, '').

当我们拿到依赖项的名称后,我们需要去实例化他们。所以我们使用 map 来循环遍历,挨个的调用 get 来获取实例。你注意到这边的问题了吗?

如果我们有个组件 A,A 依赖 B 和 C。并且假设 C 依赖 A?在这种情况下我们就会发生无止境的循环,也就是循环依赖。在这个实现里面我们不会处理这种问题,但是你应该小心点,尽量避免。

所以上面就是我们的 provider 的实现,现在我们可以这样注册组件:

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:

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 对象

下面是对应的实现:

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:

// ...
_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 的实现:

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 方法:

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:

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 的实现:

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 对象的脏数据检测,一个需要注意的情况就是这边也会发生循环依赖:

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

这种情况下我们就会看到:

Aw, Snap!

最后一个方法是 $eval. 最好不要在生产环境里使用这个,这个是一个 hack 手段用来避免我们还需要自己做个表达式解析引擎。

// 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

ng-bind

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 框的改变检测,所以它的实现是这样:

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

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 应用之前,这是我们最后要看的指令

Provider.directive('ngl-click', function() {
    return {
        scope: false,
        link: function(el, scope, exp) {
            el.onclick = function() {
                scope.$eval(exp);
                scope.$digest();
            };
        }
    };
});

这里我们不需要新建个 scope 对象,我们需要的就是当用户点击按钮时执行当前 ngl-click 后面跟着的表达式并且调用脏检测。

# 一个完整的例子

为了保证我们可以理解双向绑定是怎么工作的,我们来看个下面的例子:

<!DOCTYPE html>
<html lang="en">
<head>
</head>
<body ngl-controller="MainCtrl">
    <span ngl-bind="bar"></span>
    <button ngl-click="foo()">Increment</button>
</body>
</html>
Provider.controller('MainCtrl', function ($scope) {
    $scope.bar = 0;
    $scope.foo = function () {
        $scope.bar += 1;
    };
});

让我们看看使用这些会发生什么:

lifecycle-overview

首先 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。

no-production

本文转载自:http://purplebamboo.github.io/2015/05/27/use-200-line-code-to-implementation-a-simple-angular/ (opens new window)

上次更新: 2024/4/15 02:28:03