您的位置:首页 > Web前端 > AngularJS

【转】Build Your own Simplified AngularJS in 200 Lines of JavaScript

2016-11-16 11:04 736 查看
原文:http://blog.mgechev.com/2015/03/09/build-learn-your-own-light-lightweight-angularjs/

Build Your own Simplified AngularJS in 200 Lines of JavaScript

Edit

My practice proved that there are two good/easy ways to learn a new technology:

Re-implement it by your own

See how the concepts you already know fit in it

In some cases the first approach is too big overhead. For instance, if you want to understand how the kernelworks it is far too complex and slow to re-implement it. It might work to implement a light version of it (a model), which abstracts components that are not interesting for your learning purposes.

The second approach works pretty good, especially if you have previous experience with similar technologies. A proof for this is the paper I wrote - “AngularJS in Patterns”. It seems that it is a great introduction to the framework for experienced developers.

However, building something from scratch and understanding the core underlying principles is always better. The whole AngularJS framework is above 20k lines of code and parts of it are quite tricky. Very smart developers have worked with months over it and building everything from an empty file is very ambitious task. However, in order to understand the core of the framework and the main design principles we can simplify the things a little bit - we can build a “model”.


Scientific modelling is a scientific activity, the aim of which is to make a particular part or feature of the world easier to understand, define, quantify, visualize, or simulate by referencing it to existing and usually commonly accepted knowledge. It requires selecting and identifying relevant aspects…


We can achieve this simplification by:

Simplifying the API

Removing components, which are not essential for our understanding of the core concepts

This is what I did in my “Lightweight AngularJS” implementation, which is hosted on GitHub. The code is only with educational purpose and should not be used in production otherwise a kitty somewhere will suffer. I used this method of explaining AngularJS in classes I taught at HackBulgaria and Sofia University. You can also find slides from my talk “Lightweight AngularJS” in the bottom of the blog post.

Before reading the rest of the article I strongly recommend you first to get familiar with the basics of AngularJS. A good start could be this short overview of AngularJS.

Here are some links with code snippets/demos for the following article:

Lightweight AngularJS source code

Very simple todo application built with Lightweight AngularJS

So lets begin with our implementation!

Main Components

Since we are not following the AngularJS implementation completely we will define a set of components and make references to their sources from the original implementation. Although we will not have 100% compatible implementation we will implement most of our framework in the same fashion as it is implemented in AngularJS but with simplified interface and a few missing features.

The AngularJS components we are going to be able to use are:

Controllers

Directives

Services

In order to achieve this functionality we will need to implement the
$compile
service, which we will call
DOMCompiler
, the
$provider
and the
$injector
, grouped into our component called
Provider
. In order to have two-way data-binding we will implement the scope hierarchy.

This is how the relation between
Provider
,
Scope
and
DOMCompiler
will look like:





Provider

As mentioned above, our provider will union two components from the original framework:

$provide


$injector


It will be a singleton with the following responsibilities:

Register components (directives, services and controllers)

Resolve components’ dependencies

Initialize components

DOMCompiler

The
DOMCompiler
is a singleton, which will traverse the DOM tree and find directives. We will support only directive, which could be used as attributes. Once the
DOMCompiler
finds given directive it will provide scope management functionality (since given directive may require a new scope) and invoke the logic associated to it (in our case the
link
function). So the main responsibilities of this component will be:

Compile the DOM

Traverse the DOM tree

Finds registered directives, used as attributes

Invoke the logic associated with them

Manages the scope

Scope

And the last major component in our Lightweight AngularJS, will be the scope. In order to implement the data-binding logic we need to have
$scope
to attach properties. We can compose these properties into expressions and watch them. When we discover that the value of given expression has changed we can simply invoke a callback (observer) associated with the expression.

Responsibilities of the scope:

Watches expressions

Evaluates all watched expressions on each
$digest
loop, until stable

Invokes all the observers, which are associated with the watched expression

Theory

In order to have better understanding of the implementation, we need to dig a bit in theory. I’m doing this mostly for completeness, since we will need only basic graph algorithms. If you’re familiar with the basic graph traversal algorithms (Depth-First Search and Breath-First Search) feel free to skip this section.

First of all, what actually graphs are? We can think of given graph as pair of two sets:
G = { V, E }, E ⊆ V x V
. This seems quite abstract, I believe. Lets make it a bit more understandable. We can think of the set
V
as different Tinder users and the set
E
as their matches. For example, if we have the users
V = (A, B, C, D)
and we have matches between
E = ((A, B), (A, C), (A, D), (B, D))
, this means not only that
A
swipes right everyone but also that the edges inside our graph are these matches. Our “social graph” will look like this:





This is an example for undirected graph, since both users like each other. If we have partial match (only one of the users like the other one), we have directed graph. In the case of directed graph, the connections between the nodes will be arrows, to show the direction (i.e. which is the user who is interested in the other one).

Graph theory in AngularJS

But how we can apply graph theory in our AngularJS implementation? In AngularJS instead of users we have components (services, controllers, directives, filters). Each component may depend (use) another component. So the nodes in our AngularJS graph are the different components and the edges are the relations between them. For example, the graph of the dependencies of the
$resource
service, will look something like:





There are two more places we are going to use graphs - the DOM tree and the scope hierarchy. For example, if we turn the following HTML:

<html>
<head>
</head>
<body>
<p></p>
<div></div>
</body>
</html>

into a tree, we will get:





For discovering all directives in the DOM tree, we need to visit each element and check whether there is registered directive associated with its attributes. How we can visit all nodes? Well, we can use the depth-first search algorithm, which is used in AngularJS:

1  procedure DFS(G,v):
2      label v as discovered
3      for all edges from v to w in G.adjacentEdges(v) do
4          if vertex w is not labeled as discovered then
5              recursively call DFS(G,w)

Implementation

Since we are done with theory, we can begin our implementation!

Provider

As we said the
Provider
will:

Register components (directives, services and controllers)

Resolve components’ dependencies

Initialize components

So it will has the following interface:

get(name, locals)
- returns service by its name and local dependencies

invoke(fn, locals)
- initializes service by its factory and local dependencies

directive(name, fn)
- registers a directive by name and factory

controller(name, fn)
- registers a controller by name and factory. Note that controllers are not part of the AngularJS’ core. They are implemented through the
$controller
service.

service(name, fn)
- registers a service by name and factory

annotate(fn)
- returns an array of the names of the dependencies of given service

Registration of components

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';

The code above provides a simple implementation for registration of components. We define the “private” object called
_providers
, which contains all factory methods of the registered directives, controllers and services. We also define the methods
directive
,
service
and
controller
, which delegate their call to
_register
. In
controller
we wrap the passed controller inside a function for simplicity, since we want to be able to invoke the controller multiple times, without caching the value it returns after being invoked. The method
controller
will get more obvious after we review the
get
method and the
ngl-controller
directive. The only methods left are:

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

We have a little bit more logic here so lets start with
get
. In
get
we initially check whether we already have this component cached in the
_cache
object. If it is cached we simply return it (see singleton).
$rootScope
is cached by default since we want only one instance for it and we need it once the application is bootstrapped. If we don’t find the component in the cache we get its provider (factory) and invoke it using the
invoke
method, by passing its provider and local dependencies.

In
invoke
the first thing we do is to assign an empty object to
locals
if there are no local dependencies. What are the local dependencies?

Local Dependencies
In AngularJS we can think of two types of dependencies:

Local dependencies

Global dependencies

The global dependencies are all the components we register using
factory
,
service
,
filter
etc. They are accessible by each other component in the application. But how about the
$scope
? For each controller we want a different scope, the
$scope
object is not a global dependency registered the same way as lets say
$http
or
$resource
. The same for
$delegate
when we create a decorator.
$scope
and
$delegate
are local dependencies, specific for given component.

Lets go back to the
invoke
implementation. After taking care of
null
or
undefined
for
locals
value, we get the names of all dependencies of the current component. Note that our implementation will support resolving of dependencies only declared as parameter names:

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

Once we cast
Controller
into a string we will get the string corresponding to the controllers definition. After that we can simply take all the dependencies’ names using the regular expression in
annotate
. But what if we have comments in the
Controller
’s definition:

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

A simple regular expression will not work here, because invoking
Controller.toString()
will return the comments as well, so that’s why we initially strip them by using
.replace(/((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg, '')
.

Once we get the names of all dependencies we need to instantiate them so that’s why we have the
map
, which loops over all the strings in the array and calls
this.get
. Do you notice a problem here? What if we have component
A
, which depends on
B
and
C
and lets say
C
depends on
A
? In this case we are going to have infinite loop or so called
circular dependency
. In this implementation we don’t handle such problems but you can take care of them by using topological sort or keeping track of the visited “nodes” (dependencies).

And that’s our provider’s implementation! Now we can register components like this:

Provider.service('RESTfulService', function () {
return function (url) {
// make restful call & return promise
};
});

Provider.controller('MainCtrl', function (RESTfulService) {
RESTfulService(url)
.then(function (data) {
alert(data);
});
});

And later we can invoke
MainCtrl
by:

var ctrl = Provider.get('MainCtrl' + Provider.CONTROLLERS_SUFFIX);
Provider.invoke(ctrl);

Pretty cool, ah? And that’s how we have 1/4 of our Lightweight AngularJS implementation!

DOMCompiler

The main responsibility of the
DOMCompiler
is to:

Compile the DOM

Traverse the DOM tree

Finds registered directives, used as attributes

Invoke the logic associated with them

Manages the scope

The following API is enough:

bootstrap()
- bootstraps the application (similar to
angular.bootstrap
but always uses the root HTML element as root of the application).

compile(el, scope)
- invokes the logic of all directives associated with given element (
el
) and calls itself recursively for each child element of
el
. We need to have a scope associated with the current element because that’s how the data-binding is achieved. Since each directive may create different scope, we need to pass the current scope in the recursive call.

And here is the implementation:

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);
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);
},
// ...
};

The implementation of
bootstrap
is trivial. It delegates its call to
compile
with the root HTML element. What happens in
compile
is far more interesting. Initially we use a helper method, which gets all directives associated to the given element. We will take a look at
_getElDirectives
later. Once we have the list of all directives we loop over them and get the provider for each directive. After that we check whether the given directive requires creation of a new scope, if it does and we haven’t already instantiated any other scope for the given element we invoke
scope.$new()
, which creates a new scope, which prototypically inherits from the current
scope
. After that we invoke the link function of the directive, with the appropriate parameters. What follows after that is the recursive call. Since
el.children
is a
NodeList
we cast it to an array by using
Array.prototype.slice.call
, which is followed by recursive call with the child element and the current scope. What does this algorithm reminds you of? Doesn’t it look just like DFS - yes, that’s what it is. So here the graphs came handy as well!

Now lets take a quick look at
_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;
}
// ...

This method iterates over all attributes of
el
, once it finds an attribute, which is already registered as directive it pushes its name and value in the result list.

Alright! We’re done with the
DOMCompiler
. Lets go to our last major component:

Scope

This might be the trickiest part of the implementation because of the dirty checking functionality. In AngularJS we have the so called
$digest
loop. Basically the whole data-binding mechanism happens because of watched expressions, which are getting evaluated in the
$digest
loop. Once this loop is called it runs over all the watched expressions and checks whether the last value we have for the expression differs from the current result of the expression’s evaluation. If AngularJS finds that they are not equal, it invokes the callback associated with the given expression. An example for a watcher is an object
{ expr, fn, last }
, where
expr
is the watched expression,
fn
is the function, which should be called once the expression has changed and
last
is the last known value of the expression. For instance, we can watch the expression
foo
with a callback, which on change is being invoked with the expression’s value and sets the
innerHTML
of given element (a simplified version of what
ng-bind
does).

The scope in our implementation has the following methods:

$watch(expr, fn)
- watches the expression
expr
. Once we detect change in the
expr
value we invoke
fn
(the callback) with the new value

$destroy()
- destroys the current scope

$eval(expr)
- evaluates the expression
expr
in the context of the current scope

$new()
- creates a new scope, which prototypically inherits from the target of the call

$digest()
- runs the dirty checking loop

So lets dig deeper the scope’s implementation:

function Scope(parent, id) {
this.$$watchers = [];
this.$$children = [];
this.$parent = parent;
this.$id = id || 0;
}
Scope.counter = 0;

We simplify the AngularJS’ scope significantly. We will only have a list of watchers, a list of child scopes, a parent scope and an id for the current scope. We add the “static” property counter only in order to keep track of the last created scope and provide a unique identifier of the next scope we create.

Lets add the
$watch
method:

Scope.prototype.$watch = function (exp, fn) {
this.$$watchers.push({
exp: exp,
fn: fn,
last: Utils.clone(this.$eval(exp))
});
};

In the
$watch
method all we do is to append a new element to the
$$watchers
list. The new element contains a watched expression, a callback (observer) and the
last
result of the expression’s evaluation. Since the returned value by
this.$eval
could be a reference to something, we need to clone it.

Now lets see how we create and destroy scopes!

Scope.prototype.$new = function () {
Scope.counter += 1;
var obj = new Scope(this, Scope.counter);
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);
};

What we do in
$new
is to create a new scope, with unique identifier and set its prototype to be the current scope. After that we append the newly created scope to the list of child scopes of the current scope. In destroy, we remove the current scope from the list of its parent’s children.

Now lets take a look at the legendary
$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();
}
};

Basically we run our loop until it is dirty and by default it is clean. The loop “gets dirty” only if we detect that that result of the evaluation of given expression differs from its previously saved value. Once we detect such “a dirty” expression we run a loop over all watched expressions all over again. Why we do that? We may have some inter-expression dependencies, so one expression may change the value of another one. Thats why we need to run the
$digest
loop until everything gets stable. If we detect that the result of the evaluation of given expression differs from its previous value we simply invoke the callback associated to the expression, update the
last
value and mark the loop as
dirty
.

Once we’re done we invoke
$digest
recursively for all children of the current scope. So one more time we apply what we learned (or already knew) about graph theory! One thing to note here is that we may still have circular dependency (a cycle in the graph), so we should be aware of that! Imagine we have:

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

In this case we will see:





at given moment…

And the last (and super hacky) method is
$eval
. Please do not do that in production, this is a hack for preventing the need of creating our custom interpreter of expressions:

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

We check whether the watched expression is a function, if it is we call it in the context of the current scope. Otherwise we change the context of execution, using
with
and later run
eval
for getting the result of the expression. This allows us to evaluate expressions like:
foo + bar * baz()
, or even more complex JavaScript expressions. Of course, we won’t support filters, since they are extension added by AngularJS.

Directives

So far we can’t anything useful with the primitives we have. In order to make it rocks we need to add a few directives and services. Lets implement
ngl-bind
(called
ng-bind
in AngularJS),
ngl-model
(
ng-model
),
ngl-controller
(
ng-controller
) and
ngl-click
(
ng-click
)

ngl-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
doesn’t require a new scope. It only adds a single watcher for the expression used as value of the
ngl-value
attribute. In the callback, when
$digest
detects a change, we set the
innerHTML
of the element.

ngl-model

Our alternative of
ng-model
will work only with text inputs. So here is how it looks like:

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

We add
onkeyup
listener to the input. Once the value of the input is changed we call the
$digest
method of the current scope, in order to make sure that the change in the property will reflect all other watched expressions, which have the given property as dependency. On change of the watched value we set the element’s value.

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

We need a new scope for each controller, so that’s why the value for
scope
in
ngl-controller
is true. This is one of the places where the magic of AngularJS happens. We get the required controller by using
Provider.get
, later we invoke it by passing the current scope. Inside the controller, we can add properties to the scope. We can bind to these properties by using
ngl-bind
/
ngl-model
. Once we change the properties’ values we need to make sure we’ve invoked
$digest
in order the watchers associated with
ngl-bind
and
ngl-model
to be invoked.

ngl-click

This is the last directive we are going to take a look at, before we’re able to implement a “useful” todo application.

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

We don’t need a new scope here. All we need is to evaluate an expression and invoke the
$digest
loop once the user clicks a button.

Wiring Everything Together

In order to make sure we understand how the data-binding works, lets take a look at the following example:

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

Lets follow what is going on in using the following diagram:





Initially the
ngl-controller
directive is found by the
DOMCompiler
. The
link
function of this directive creates a new
scope
and pass it to the controller’s function. We add
bar
property, which is equals to
0
and a method called
foo
, which increments
bar
. The
DOMCompiler
finds
ngl-bind
and adds a watcher for the
bar
property. It also finds
ngl-click
and adds
click
event handler to the button.

Once the user click on the button, the
foo
method is being evaluated by calling
$scope.$eval
. The
$scope
used is the same on, passed as value to
MainCtrl
. Right after that,
ngl-click
invokes
$scope.$digest
.
$digest
loops over all watchers and detects change in the value of the expression
bar
. Since we have associated callback for it (the one added for
ngl-bind
) we invoke it and update the value of the
span
element.

Conclusion

The framework we just built is far from a usable into production one, however some of its features:

Data-binding

Dependency Injection

Separation of Concerns

work in a similar way they do in AngularJS. This helps understanding AngularJS in deep much easier.

But still you should not forget to not use this code in production, much better would be to just
bower install angular
and enjoy!



【】

And here are the slides from my talk “Lightweight AngularJS” as promised:

Build Your own Simplified AngularJS in 200 Lines of JavaScript was published on March 09, 2015.
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: 
相关文章推荐