AngularJS源码研究

前言

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会先查下instanceInjectorinstanceCache有没有缓存这个服务.如果没有缓存,它就会去调用providerInjectorgetService方法.此时要知道providerInjectorproviderCache缓存了全局所有的服务提供者的.它是可以得到$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.将 nodeLinkFnchildLinkFn和当前节点的索引存储到 linkFns 数组中,并返回一个闭包函数compositeLinkFn.

linkFns数组的形式形如[0,nodeLinkFn,childLinkFn].

如果有兄弟节点,数据结构类似[0,nodeLinkFn,childLinkFn,1,nodeLinkFn1,childLinkFn1].

举例说明.假设当前nodeList(dom树)开始遍历,它只有一个div,没有兄弟节点,所以索引i0.通过收集该div上面的指令和dom元素生成nodeLinkFn.因为该div存在几个子集,它就把所有子集作为参数再一次调用compileNodes.结果生成 childLinkFn. 将i,nodeLinkFnchildLinkFn 塞入linkFns数组中.然后返回一个函数 compositeLinkFn.这个 compositeLinkFn 里面获取到的linkFns数组就只会存储3个元素.

上面是最外层的情况,现在我们把视野推到获取 childLinkFn 的递归调用 compileNodes 的过程,一探遍历子集时到底发生了什么事情.

此时nodeList等于父div下面的几个子div元素,transcludeFn对应着父div的链接函数.现在开始遍历nodeList了,按照老规矩还是先收集每个子div的指令级,再将指令和dom结合生成子divnodeLinkFn.但由于该div没有子集所以它的childLinkFnnull,nodeList都循环完毕后,此时的linkFns就存储了好几个子divindex,nodeLinkFnchildLinkFn,函数的结尾返回的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"}}还是像ngifngRepeat等指令,执行nodeLinkFn最终都会执行相对应指令的compile函数.

而在compile函数里会执行$scope.$watch(interplat,操作dom的回调函数),在ngRepeatcompile函数里执行的是$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对应表达式的值是否改变来决定是否渲染页面.

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值