前言
AngularJS
虽然已经慢慢退出了淡出了历史舞台,但它作为第一代MVVM
框架,为后来其他框架的诞生和发展提供了很多功能设计的参考.比如响应式状态,依赖注入以及模块化开发.
AngularJS
源码非常难以阅读,并非是因为它的代码写的不好,而是内部功能大量使用了函数式编程进行层层抽象和封装,导致大部分功能嵌套的函数层级过深,往往需要埋头调试跟踪一大轮才能真正窥探到底层的做法.
本文将抽离出框架的核心逻辑进行讲解,顺着自上而下的流程从整体上把握框架是如何设计和实现的.从AngularJS
的实现流程里我们也能大概学习到一个前端框架会包含哪些功能以及这些功能又是如何具体去做的.
快速回顾
下面通过一个编写两个简单的文件快速熟悉AngularJS
框架的使用方法.
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<script src="./lib/angular.js"></script>
<script src="./lib/main.js"></script>
</head>
<body ng-app="myapp">
<div ng-controller="contrl">
<div ng-click="clickHanlder()">{
{name}}</div>
</div>
</body>
</html>
ng-app
定义模块名称,ng-controller
定义控制器.另外给div
绑定一个点击事件clickHanlder
,并渲染状态name
.
main.js
angular.module('myapp', []).controller('contrl', function ($scope) {
$scope.name = 'hello world';
$scope.clickHanlder = function () {
$scope.name = $scope.name + '!';
};
});
调用angular.module
定义模块myapp
,这里定义的模块名称要和上面模板中定义的保持一致.紧接着定义控制器contrl
,同理也要和模板上的名称保持一致.在控制器函数里定义一个状态name
和事件clickHanlder
.
通过上面配置过后刷新浏览器,界面上就会渲染出hello world
.如果点击文案,那么就会触发状态更新,界面就会变成hello world!
.
整个执行流程是这样子的,angular
会定义一个模块myapp
,模板index.html
中使用ng-app
定义同名的页面dom
部分就会被myapp
模块所接管.紧接着在该模块下定义一个控制器contrl
,它又会接管myapp
模块下同名控制器的页面dom
.
在控制器中定义了一个状态name
和事件clickHanlder
.状态name
的值hello world
会直接渲染在模板上定义的{
{name}}
.用户单击文案时会触发name
的值的改变,紧接着又会自动触发页面的重新渲染.
加载流程
var angular = window.angular || (window.angular = {});
//1.给angular对象添加各种属性和方法,最后还挂载了一个module函数,有了这个函数就可以创建模块了
publishExternalAPI(angular);
//2.执行前端编写的js文件内容,angular.module(...).contoller(...),将这部分定义的内容会先收集起来
//3.界面加载完毕后执行下面代码,它会搜索dom文档带有ng-app的标签的dom,这个dom就是document
angularInit(document, bootstrap);
//4.让angular定义的module去接管element,这个element就对应第三步的document
bootstrap(element,modules);
//5.创建注入器,启动依赖注入
injector.invoke([
'$rootScope',
'$rootElement',
'$compile',
'$injector',
function bootstrapApply(scope, element, compile, injector) {
...
},
]);
//6.通过上面依赖注入获取到了scope,element和compile服务,angular启动对element的编译和链接
function bootstrapApply(scope, element, compile, injector) {
scope.$apply(function () {
element.data('$injector', injector);
compile(element)(scope);
});
}
//7.启动脏检查渲染页面
$rootScope.$digest();
首先会定义一个angular
对象并暴露给全局使用.然后给该对象添加各种属性和方法,其中会包含一个.module()
方法,通过运行该方法可以创建模块.
此时页面的dom
还没加载完毕,前端编写的main.js
文件会先执行.通过运行其中的angular.module(...).contoller(...)
代码会将定义的代码内容收集起来.
等到页面的dom
加载完毕后,angular
开始寻找定义了ng-app
的那部分页面dom
.接下来要做的任务就是让main.js
定义的模块去管理页面dom
对应的模块.
angular
开始扫描dom
,启动对页面内容的编译和链接.从而达到让模块对象管理和控制页面的目的.
最后启动脏检查触发页面渲染,将控制器定义的状态数据全部渲染到页面上显示.
依赖注入
贯穿angular
框架全局的就是依赖注入和自定义指令.angular
整个框架就是颗粒化的聚合.所有细小的独立功能都被写成一个个小的服务.服务是就是一个对象,它会包含特定功能的属性和方法.案例如下.
angular.module('myapp', []).controller('contrl', function ($scope,$http) {
$scope.name = 'hello world';
$scope.clickHanlder = function () {
$http.get("http://www.xxx.com/api/get_list").success(
function(data){
console.log(data)//获取接口数据
}
)
};
});
上面代码中$http
就是一个服务,它里面包含了很多与后端交互的方法,比如post
或者get
.如果想使用这个服务只需要在函数中添加$http
这个参数名就可以在回调函数里获取到这个服务并使用.
服务就是一个个具有独立功能的小模块单元,依赖注入的机制就是非常方便的能帮助我们注入任意想使用的服务.比如我们把$http
改成$window
,那么在函数中就能使用$window
这个服务里包含的属性和方法.而像$http
以及$window
这些服务是在全局某个单独的区域进行定义和维护的,这样非常有效的实现了功能代码和业务代码的解耦.所有的功能代码全部单独定义单独维护,业务代码中想使用某个服务直接使用依赖注入注入它就可以使用这个服务了.
依赖注入的机制让整个代码结构变的非常灵活,下面以$http
为例探索一遍整个依赖注入实现的方式.
首先在全局定定义一个与后端交互的服务提供者$HttpProvider
,通过服务提供者可以生成$http
服务.
function $HttpProvider() {
//定义一些基本配置
var defaults = {
headers: {
common: {
Accept: 'application/json, text/plain, */*',
},
post: shallowCopy(CONTENT_TYPE_APPLICATION_JSON),
},
xsrfCookieName: 'XSRF-TOKEN'
};
this.$get = [
'$httpBackend',
'$browser',
'$cacheFactory',
'$rootScope',
'$q',
'$injector',
function (
$httpBackend,
$browser,
$cacheFactory,
$rootScope,
$q,
$injector
) {
//给$http添加get或者post等各种属性和方法,代码省略
...
return $http;
}
}
仔细观察上面定义的服务提供者,我们会容易发现this.$get
它是一个数组.这个数组的前面的几个元素也都是服务,最后一个元素是函数.
这个放在数组最后面的函数便是生成$http
服务所要执行的函数.那为什么数组前面还要加上其他的几个服务呢?这里便又体现出来了依赖注入的强大之处.
我们有些时候定义一个带有独立功能的服务对象时,它的某些功能往往会依赖其他的服务.this.$get
前面的几个字符串参数便是$HttpProvider
服务提供者所依赖的服务,只有当这些依赖的服务先获取到才能生成$http
服务.依赖注入可以让服务提供者里面注入其他服务,这样就可以使每个服务的代码尽可能颗粒化方便独立维护.
所有依赖的服务注入完毕后在函数的参数中获取,并执行该函数最后返回的$http
对象便是我们想要的服务,从这里是可以看出每次执行this.$get
函数返回的$http
对象是多例的.上面这段代码只是一段静态的数据结构,接下来要实现刚才所讲述的过程最终返回一个$http
对象.
回到上述加载流程的第5
步创建注入器,有了注入器就可以获取某个服务实例.
//modulesToLoad = ["ng",'$provide', function ($provide) {},"myApp"]
const injector = createInjector(modulesToLoad); // 生成注入器
createInjector
是生成注入器的具体函数,也是实现依赖注入的核心逻辑.createInjector
内部会调用两次createInternalInjecto
方法.
第一次调用会定义一个专门存储全局服务提供者的对象providerCache
,此时angular
会将全局定义的服务提供者比如$HttpProvider
注册到providerCache
里面存储.代码如下.
$provide.provider({
...
$http: $HttpProvider
})
第二次调用会定义专门存储服务的对象instanceCache
,它里面会存储已经实例化好的服务对象.而上面生成的注入器injector
就是这个专门存储服务的对象instanceCache
.现在从controller
运行过程来还原整个依赖注入的过程.
angular.module('myapp', []).controller('contrl', function ($scope,$http) {
$scope.name = 'hello world';
$scope.clickHanlder = function () {
$http.get("http://www.xxx.com/api/get_list").success(
function(data){
console.log(data)//获取接口数据
}
)
};
});
在编译阶段解析到ng-controller
指令时后,链接阶段开始处理controller
的逻辑.controller
的执行会返回一个闭包函数.
controller = function(){
//expression = function ($scope,$http) {...}
$injector.invoke(expression, instance, locals, constructor); //locals里面有scope
}
从这里可以看出controller
底层调用的是注入器的invoke
方法.
invoke
内部最开始会判断expression
的类型,如果是数组,直接分成两组.前面的参数作为依赖注入的参数列表,而最后一个参数作为执行函数.如果像上面只是一个函数,它会首先调用toString
方法将函数转化字符串.通过正则表达式获取函数的参数名即$scope
和$http
.
首先注入$scope
,这个$scope
并不是全局定义的服务,直接从locals
获取.第二个参数$http
是正儿八经的服务了.
在invoke
方法内部,它会先调用注入器的getService("$http")
方法.注入器$injector
会先查下instanceInjector
的instanceCache
有没有缓存这个服务.如果没有缓存,它就会去调用providerInjector
的getService
方法.此时要知道providerInjector
的providerCache
缓存了全局所有的服务提供者的.它是可以得到$HttpProvider
的.
随后要开始实例化服务提供者$HttpProvider
,其实就是执行$HttpProvider.$get
方法.此时$HttpProvider
里面又依赖其他几个服务.于是它又会调用invoke
将上面的依赖注入流程再走几遍,直到这几个服务全部获取到了.它才可以执行$get
最后的那个函数.函数执行完毕后会返回一个对象$http
,里面会包含与后端交互相关的属性和方法.这个对象就是最终生成的$http
服务了.这个服务来之不易不能下次再请求要把上面流程再走一遍,所以为了效率放到instanceCache
缓存起来,下次需要直接从instanceCache
获取.
有了$scope
和$http
就可以执行controller
里面的代码了.回顾一下整个过程,为了执行controller
里面的代码,发现它依赖$scope
和$http
这两个服务.而$http
又依赖其他服务,只有当其他服务全部生成后才能返回开始执行生成$http
服务的逻辑,最后才能返回给controller
执行控制器内的逻辑代码.
编译和链接
编译和链接是为了实现后面响应式变化做铺垫的.在ES5
里面有object.defineproperty
可以对数据进行监听,从而达到修改页面的目的.而AngularJS
没有使用这个API
,它是用自己的方式实现了一套响应式系统.
编译和链接就是为了搭建这套响应式系统的前期配置工作.当响应式系统正常运行起来后,前端程序员修改数据状态比如$scope.name= ...
,那么页面就能自动更新,而不用程序员去编写dom
操作的逻辑.
编译阶段
编译阶段做的最主要的事情就是扫描dom
树解析每一个带有指令的节点.如果发现属性上有与自定义指令相关的代码,例如ng-if
,ng-repeat
,直接将这个元素节点移除并且生成注释代码替换(applyDirectivesToNode
的作用),另外还会生成该节点的链接函数和渲染该节点内容的函数publicLinkFn
.如果发现有{
{name}}
显示状态的文本节点也会将它们封装成自定义指令并生成链接函数.这些处理ng-if
,ng-repeat
以及文本节点的自定义指令函数都是angular
在其他地方定义好的.每个自定义指令都是一个对象,对象中有一个compile
方法,操作dom
的逻辑就放在compile
里面.
上面讲述加载流程的第6
步开始执行编译.
compile(element)(scope);//element对应dom树,scope对应着$rootScope
compile
函数代码如下.
function compile($compileNodes,transcludeFn){
...
//生成链接函数
var compositeLinkFn = compileNodes(
$compileNodes,
transcludeFn
);
...
return function publicLinkFn (scope,cloneConnectFn){
...
}
}
从上可以看出compile(element)
的执行结果返回函数publicLinkFn
,并会生成链接函数compositeLinkFn
.在publicLinkFn
这个闭包函数里是可以获取到链接函数的.编译阶段最重要的工作便是如何生成链接函数.
compileNodes
函数的代码是整个angular
框架写的最复杂的一部分.下面简单阐述一下其工作流程,如果没有结合源码阅读很难理解.
在compileNodes
函数里,它会扫描整个dom
节点,此时的dom
是用户在html
文件中编写的最初的dom
,通过获取节点上的指令列表(比如<div ng-if="..."></div>
).然后将指令ngIf
处理的逻辑和该dom
节点建立链接生成节点的链接函数nodeLinkFn
.
nodeLinkFn
函数是如何生成的呢?它会根据节点的类型判断是文本节点还是元素节点.如果是文本节点,它会检测有没有{
{}}
.如果有封装一个自定义指令{compile:fn}
.如果是元素节点,会调用this.directive
注册指令,给它添加一个$get
方法.封装成一个provider factory
,并返回该指令对应的服务.最终nodeLinkFn
都会指向得到的指令的compile
函数.
如果当前节点存在子节点又递归调用compilNodes
生成链接函数childLinkFn
.将 nodeLinkFn
和 childLinkFn
和当前节点的索引存储到 linkFns
数组中,并返回一个闭包函数compositeLinkFn
.
linkFns
数组的形式形如[0,nodeLinkFn,childLinkFn]
.
如果有兄弟节点,数据结构类似[0,nodeLinkFn,childLinkFn,1,nodeLinkFn1,childLinkFn1]
.
举例说明.假设当前nodeList
(dom
树)开始遍历,它只有一个div
,没有兄弟节点,所以索引i
为0
.通过收集该div
上面的指令和dom
元素生成nodeLinkFn
.因为该div
存在几个子集,它就把所有子集作为参数再一次调用compileNodes
.结果生成 childLinkFn
. 将i
,nodeLinkFn
和childLinkFn
塞入linkFns
数组中.然后返回一个函数 compositeLinkFn
.这个 compositeLinkFn
里面获取到的linkFns
数组就只会存储3
个元素.
上面是最外层的情况,现在我们把视野推到获取 childLinkFn
的递归调用 compileNodes
的过程,一探遍历子集时到底发生了什么事情.
此时nodeList
等于父div
下面的几个子div
元素,transcludeFn
对应着父div
的链接函数.现在开始遍历nodeList
了,按照老规矩还是先收集每个子div
的指令级,再将指令和dom
结合生成子div
的nodeLinkFn
.但由于该div
没有子集所以它的childLinkFn
为null
,nodeList
都循环完毕后,此时的linkFns
就存储了好几个子div
的index
,nodeLinkFn
和childLinkFn
,函数的结尾返回的compositeLinkFn
的函数.记住此时compositeLinkFn
里面引用的linkFns
就不是最上层的那三个元素了,而是那好几个div
一起组合的元素了.
现在我们再回到最上层.最上层的值是 [index,nodeLinkFn,childLinkFn]
.index
是索引这是没有问题的,nodeLinkFn
是当前dom
和指令链接后的函数也没有疑问.第三个参数childLinkFn
是第二层几个子div
遍历计算构建返回的compositeLinkFn
函数,这个compositeLinkFn
函数它里面引用的linkFns
是那几个子div
一起组合而来的数据.
这个函数执行达到最终的目的就是获取dom
树上每一个绑有自定义指令的dom
节点的链接函数nodeLinkFn
.这个链接函数的具体内容到底是什么呢?其实angular
在解析每个节点上的自定义指令比如ng-if
,它会寻找出该条自指令定义的处理逻辑(这些系统级别的自定义指令逻辑已经在框架的其他地方定义好了).最终nodeLinkFn
里面关联代码是自定义指令的compile
方法编写的代码.
链接阶段
上面编译阶段执行完会返回一个函数publicLinkFn
,当传入scope
执行publicLinkFn
时便进入链接阶段了.
function publicLinkFn(scope,cloneConnectF){
if (cloneConnectFn) cloneConnectFn($linkNode, scope);
if (compositeLinkFn)
compositeLinkFn(
scope,
$linkNode,
$linkNode,
parentBoundTranscludeFn
);
return $linkNode;
}
编译阶段会将dom
树每一个带有自定义指令的节点的链接函数nodeLinkFn
都生成好,而链接阶段要做的事情就是执行完所有的链接函数,也就是执行函数compositeLinkFn
.并且返回链接好的dom
节点.换一句话说,通过publicLinkFn
生成的dom
节点是完成与scope
中状态相互绑定映射的,一旦修改scope
的数据,对应的dom
就会刷新.
将所有dom
节点的链接函数nodeLinkFn
执行一遍就完成了该层dom
的链接了,那nodeLinkFn
里面到底是如何实现这种scope
的数据与页面的dom
元素相互映射的呢?
不管是文本节点{
{name+"hello world"}}
还是像ngif
和ngRepeat
等指令,执行nodeLinkFn
最终都会执行相对应指令的compile
函数.
而在compile
函数里会执行$scope.$watch(interplat,操作dom的回调函数)
,在ngRepeat
的compile
函数里执行的是$scope.$watchGroup
.
每一个自定义指令都会对应一个表达式,比如上面文本节点对应的表达式为name+"hello world"
.如果给表达式传入了scope.name
,通过对表达式计算就可以得到最新的状态值(页面要显示的内容).
如果是文本节点,它操作dom
的回调函数大概如node.nodeValue = value
.而ngIf
的回调函数会根据表达式的值为true
或者false
来决定增加dom
节点还是移除dom
节点.ngRepeat
的回调函数同理,根据表达式值渲染dom
列表.
由此可见链接阶段所做的事情无非就是将每个带有指令的dom
节点执行一个$scope.$watch
操作.那$watch
里面到底做了什么事情?它怎么就可以实现状态的监听?
其实$scope.$watch
内部只是应用了一个观察者模式,执行完一次$scope.$watch
后,angular
就会往$scope
的$$watchers
数组中创建一个数据对象.
{
fn:function(){...},
get:function(){...},
last:function(){...}
}
fn
就是指令里定义的操作dom
的回调函数,而get
函数通过传入scope
可以获取到指令对应表达式的值,而last
会返回上一次表达式的值.
链接阶段底层通过调用$scope.$watch
将数据状态的操作函数和值先存储起来,接下来进入$digest
阶段才会触发页面更新.
响应式更新
angular
走完了编译和链接的阶段,就开始要执行digest
函数,触发页面刷新.
$digest:function(){
...
//current对应当前scope
if ((watchers = current.$$watchers)) {
length = watchers.length;
while (length--) {
try {
watch = watchers[length];
if (watch) {
//将$scope传进入,得到最新的value值,原来的值是保存在watch.list中的
if ((value = watch.get(current)) !== (last = watch.last)) {
watch.last = watch.eq ? copy(value, null) : value; //重置watch的last值
watch.fn(
value,
last === initWatchVal ? value : last,
current
);
}
}
}
在上面链接阶段已经讲过,$scope.$watch
将数据状态的操作函数和值先存储到scope.$$watchers
数组里.
执行$digest
方法后,会依次取出$$watchers
数组中的对象watch
.通过传入当前的scope
获取最新表达式的值,如果发现与上一次保留的值last
不相等,说明状态发生了更改需要触发页面更新.
watch.fn
就是具体更新页面的回调函数,更新完了页面还要记得把最新的值赋值给watch.last
.
综上所述,angular
实现状态监听和页面渲染的功能主要是利用scope.watch
.而这个函数会在编译链接阶段将每个带有指令的节点的所有操作包括获取表达式值的函数,上一次的值以及如何更新dom
的函数全都封装到一个数据对象里并存储在scope.$$watchers
数组中.进入digest
阶段就会取出scope.$$watchers
里面每个watch
对象,通过比较每个watch
对应表达式的值是否改变来决定是否渲染页面.