
AngularJS 这名字现在提起来很多人第一反应是“古董”。但你要是去翻那些 2015 到 2018 年间上线的后台管理系统、企业内部工具、老牌电商站点AngularJS特指 AngularJS 1.x的身影依然随处可见。这两年我还接过好几个维护老项目的私活代码里跑着几千个$watch页面一打开就是满满的回忆。这篇文章不打算讲“要不要用 AngularJS”这种口水话题而是把我在实际开发里和 AngularJS 反复拉扯出来的经验整理出来重点放在两个词上双向绑定和ng-include标题里的“包含”。一个是 AngularJS 的灵魂一个是做模块化页面时绕不开的指令。把这俩搞透你维护起老项目来会轻松很多。1. 还在维护 AngularJS先理清它的核心价值1.1 一个老框架为何至今还有存在感AngularJS 是 Google 在 2010 年推出的前端 MVVM 框架它比 Vue 和 React 都早了一代。在那个 jQuery 一统天下的年代AngularJS 带来的声明式编程体验是颠覆性的——你不用再手动操作 DOM不用自己拼 HTML 字符串只要把数据和模板绑定好剩下的交给框架去处理。哪怕是现在AngularJS 的生命周期早已进入维护模式但它依然大量存在于生产环境中。原因无非是几个老项目重构成本太高、业务系统稳定运行没有重构动力、团队已经习惯这套技术栈。所以“AngularJS 已死”的说法听听就好真正的问题是你手上这套跑了好几年的 AngularJS 代码能不能稳定运行、能不能快速迭代如果你正在看这篇文章大概率是两种情况要么你接手了一个老项目正在被各种$scope的诡异问题折磨要么你在学习过程中想弄明白 AngularJS 和现代框架到底差在哪。无论哪种把双向绑定机制和 ng-include 的用法吃透都是性价比最高的投入。1.2 双向绑定AngularJS 最核心的“卖点”双向绑定是 AngularJS 区别于 jQuery 时代的最显著特征。用一句话概括视图会变数据数据也会变视图而且是自动的。传统 jQuery 方式下你想让页面上一个输入框的值同步到 JavaScript 变量里得监听input事件然后手动取值、手动更新。反过来数据变了要刷新页面又得手动定位到 DOM 节点改值。这种“手动同步”在简单页面上没什么问题但一旦页面复杂起来状态一变多代码就会被 DOM 操作淹没。AngularJS 的双向绑定把这件事简化成一行指令。你只需要定义好变量用ng-model或者{{ }}把数据和视图关联起来剩下的同步工作由框架完成。input typetext ng-modeluser.name / p你好{{ user.name }}/p这段代码里输入框的内容一变下面的p标签里的文字立刻跟着变。你不需要写任何事件监听也不需要手动更新 DOM。这种感觉在第一次接触时是很愉快的也是 AngularJS 当年能快速流行起来的重要原因。不过这背后牵扯出一整套机制——$scope、$watch、$digest、$apply。如果你只停留在“会用指令”的层面那遇到复杂场景时会非常被动。下一节我就把这套机制掰开揉碎讲清楚。2. 双向数据绑定的底层逻辑Scope、脏检查与 Digest 循环2.1 $scope 与 $rootScope数据流动的容器在 AngularJS 的世界里$scope是一个 JavaScript 对象负责承载数据和业务逻辑。视图通过ng-model、{{ }}等指令与$scope上的属性建立关联。你可以把$scope理解成一个“数据仓库”所有需要展示在页面上的数据都存在这里。每个 AngularJS 应用都有一个根作用域$rootScope它是所有$scope的祖先。Controller 通过注入$scope来拿到当前作用域对象并往上面挂数据app.controller(UserController, function($scope) { $scope.user { name: 张三, age: 28 }; });这里有个容易被忽视的点$scope具有继承特性。子作用域会通过原型链继承父作用域的属性。这意味着在子作用域里可以直接访问父作用域上定义的数据但也因此埋下了一个经典的双向绑定陷阱——在子作用域里修改父作用域的基本类型变量时实际是在创建新属性而不是修改父级属性。关于这个坑后面第 3 节讲 ng-include 时我会专门展开因为 ng-include 恰好就是作用域继承问题的高发场景。2.2 脏检查每一次“比较”都在烧性能AngularJS 的双向绑定不是靠实时监听事件实现的而是靠一套叫做脏检查dirty checking的机制。逻辑很简单当某个事件触发后AngularJS 会遍历所有注册过的$watch把每个被监听的属性的当前值和上一次的值做对比。如果值不同说明“脏”了就更新视图如果值相同就跳过。然后再来一轮直到所有$watch都检查完、没有变化为止。你可以打断点看到这个机制的踪迹。在页面里输入一个字符然后看调用栈你会看到Scope.$digest方法被调用了很多次每次都在做大量的属性比较。这种机制的好处是实现简单、兼容性好不需要浏览器提供新的 API坏处也显而易见——性能开销和监听数量成正比。我在一个老项目里遇到过最夸张的情况一个页面挂了 3000 多个$watch每次输入一个字母页面要卡几百毫秒。那时候才真正理解“脏检查”为什么叫脏检查——它是真的在拼命做比较每一帧都不停地比。2.3 digest 循环的触发时机与性能红线脏检查的完整过程叫digest 循环。AngularJS 会在特定事件发生时自动触发它这些事件包括用户交互如ng-click、ng-model的 input 事件定时器如$timeout和$intervalHTTP 请求如$http成功或失败回调手动调用$scope.$apply()或$digest()理解触发时机非常关键。因为你如果用原生 JavaScript 事件比如document.getElementById(btn).addEventListener(click, ...)去修改$scope上的数据AngularJS 根本不知道发生了这件事视图不会自动更新。这种情况下你必须手动调用$scope.$apply()告诉框架“数据变了你跑一轮 digest 吧”。性能方面有一条经验性的红线一个页面的$watch数量尽量控制在 2000 以内。超过这个数每次 digest 的耗时就会到肉眼可见的程度。这个数字不是官方规范而是我结合多个项目实测下来的一条参考线。具体的减负方法我在第 4 节会详细讲。还有一个容易忽略的点$digest有最大循环次数限制默认是 10 次。如果一个循环内属性一直在变化比如两个$watch互相修改对方的监听属性框架就会抛异常终止循环。这种“死循环”的现象在数据联动场景里偶尔会出现排查时要重视。3. ng-include 深度解析模板包含的正确姿势3.1 基本用法别忘了一对单引号ng-include是用来在页面中嵌入模板文件的指令语法非常简洁div ng-includepartials/user-info.html/div注意这里的写法双引号里面还套了一对单引号。ng-include接收的是一个表达式所以如果你不写内层的单引号AngularJS 就会去$scope上找一个叫做partials/user-info.html的变量而这个变量通常是不存在的页面就会报错或者什么都不渲染。这个细节是新手最容易踩的第一个坑。我在给团队做培训时经常说ng-include的值是表达式不是路径字符串。你希望它按字面路径读取模板就必须加引号让它变成一个字符串表达式。你也可以通过变量来动态指定模板路径div ng-includecurrentTemplate/div$scope.currentTemplate partials/user-info.html;这样只要改变currentTemplate的值页面上包含的内容就会动态切换。这种写法在做 Tab 切页、步骤向导这类交互时非常实用。3.2 作用域继承为什么子页面改不了父页面的变量这是 ng-include 使用中最深的一个坑也是 AngularJS 作用域继承机制最典型的展示场景。ng-include会创建一个新的子作用域并且这个子作用域通过原型链继承父作用域。听起来很美好——子模板可以直接使用父页面的变量省去传参的麻烦。但问题恰恰出在这个“继承”上。直接看个实际例子。你有一个父页面定义了$scope.message hello;然后在父页面里 ng-include 一个子模板模板里有这两行input typetext ng-modelmessage / p{{ message }}/p表面上看输入框绑定的是父页面的message你修改输入框的值父页面的message应该跟着变。但实际情况不是这样。因为子模板在修改message时由于message是基本类型字符串JavaScript 的原型继承机制会先在子作用域上创建一个新的message属性然后赋值。结果就是子作用域的message被创建出来并覆盖了继承关系父作用域里的message纹丝不动。说得直白一点在子作用域里给基本类型变量赋值永远改不掉父作用域的对应变量。那怎么解决有三个常见方案第一种把数据改成对象。因为对象是引用类型子作用域修改对象的属性时不会创建新对象只是改原对象的属性$scope.data { message: hello };input typetext ng-modeldata.message /这种写法最推荐代码清晰也没有奇奇怪怪的原型链问题。第二种使用$parent显式访问父作用域input typetext ng-model$parent.message /效果能达到但只适合层级简单的情况。如果嵌套层级多了$parent的链会越来越长可读性极差。第三种用ng-change配合事件广播通知父控制器。这个方案最灵活适合跨多层作用域通信的场景但写起来要绕一段路。3.3 模板缓存、动画与跨域容易踩的细节ng-include 虽然用起来简单但背后有几个细节值得记录。第一是模板缓存。ng-include 拉取模板后会默认缓存到$templateCache中。同一模板第二次使用时不会重新发请求直接走缓存。这在大部分场景下是好事能省带宽、加快渲染。但如果你在开发或迭代中改了模板文件而刷新页面后还是旧内容就要考虑是不是缓存的问题。强制刷新的方法是在模板路径加版本参数div ng-includepartials/user-info.html?ver20250201/div或者在运行时手动清除$templateCache中对应的 keyapp.run(function($templateCache) { $templateCache.remove(partials/user-info.html); });第二种是动画。ng-include 配合ng-animate做页面切换动画时需要在包含的元素上添加指令div ng-includepartials/page.html classfade-animation/div配合 CSS 里定义的.fade-animation.ng-enter、.fade-animation.ng-leave样式。不生效的情况下大多数原因是忘掉了引入ngAnimate模块。第三种是跨域。如果 ng-include 的模板地址指向其他域名下的静态文件默认会存在跨域限制。比较安全的做法是把模板内容转为内联脚本script typetext/ng-template idremote-template.html !-- 模板内容 -- /script然后 ng-include 指向这个 id 路径即可。利用$templateCache预注册模板是处理跨域问题的常用思路。4. 从“能用”到“好用”AngularJS 实战优化技巧4.1 减少 $watch给 digest 循环减负前面提到脏检查机制的性能瓶颈在$watch数量上。只要页面上的$watch数量上去了任何一次 digest 都会变慢不管是输入、点击还是定时器。所以优化 AngularJS 应用的第一步就是想办法减少$watch。最简单有效的技巧是使用一次性绑定。如果你的数据只渲染一次不会动态变化那就别让它一直待在$watch列表里。AngularJS 1.3 之后支持了一次性绑定语法p{{ ::user.name }}/p具名的user.name只会在首次渲染时被监听数据稳定后$watch会被移除。这个语法在处理用户信息、静态列表、配置项等只读数据时非常实用。我接手过一个项目通过给大量静态模板内容加::把$watch数量从 2800 多个压到了 1500 左右页面明显顺畅了很多。另外一个思路是手动控制ng-model的更新时机。ng-model默认在每次 input 事件触发时就更新数据启动一次 digest。如果你不需要即时响应可以用ng-model-options把更新推迟到失焦或指定延时之后input typetext ng-modelkeyword ng-model-options{ updateOn: blur } /从每个字触发一次 digest 变成整个输入框失焦才触发一次性能提升非常明显。4.2 controller as摆脱 $scope 的隐式依赖早期 AngularJS 的代码风格是到处注入$scope在控制器里往$scope上挂一堆属性和方法。这种写法有一个问题模板中变量来源不清晰不知道是来自当前控制器还是继承的父作用域排查问题全靠猜。使用 controller as 语法可以彻底改变这一点。看个例子app.controller(UserController, function() { this.name 张三; this.sayHello function() { alert(你好 this.name); }; });div ng-controllerUserController as user p{{ user.name }}/p button ng-clickuser.sayHello()打招呼/button /divcontroller as 的核心价值在于模板中所有变量都明确归属于某个控制器实例不会再出现“这个变量到底是谁定义的”这种问题。同时它天然规避了作用域继承引发的基本类型修改陷阱因为你操作的是对象的属性而非原始值。从我这些年维护老项目的经验来看凡是使用 controller as 风格的模块后期改代码的意愿和能力都远超使用裸$scope的模块。你能清楚地知道每个数据的来源、每个方法的归属排查问题时心里有数。4.3 指令化改造用组件思维替代 ng-include当你发现一个模板片段被多个页面重复使用时第一反应可能是用 ng-include 把它塞进各个页面。这在项目初期没有问题但随着业务复杂化模板之间的数据传递、事件通信会变得越来越别扭——因为 ng-include 本身不支持参数传递或事件回调。更合理的做法是把这个模板改造成自定义指令。用一个简单例子说明。假设你有一个用户信息卡片在首页、列表页、详情页都在使用。用 ng-include 的方式div ng-includepartials/user-card.html/div模板里直接硬编码使用了$scope.user三个页面都要先在控制器里初始化好user这个变量。命名冲突、字段不一致的问题很快会出现。改成指令之后app.directive(userCard, function() { return { restrict: E, templateUrl: partials/user-card.html, scope: { user: , onRemove: } }; });user-card useruserData on-removeremoveUser(user)/user-card指令通过隔离作用域明确了输入输出边界user是传入的数据onRemove是交给父页面处理的事件。数据和行为都被显式声明复用时再也不会相互污染。这种组件化思路虽然比直接用 ng-include 多写一些代码但可维护性提升了一个档次。4.4 与后端交互$http 与拦截器的实用玩法AngularJS 项目与后端通信主要靠$http服务。平时用$http.get、$http.post写业务请求没什么问题但为了让代码更规范我会建议在项目中加入拦截器机制。拦截器的作用是在请求发出前、响应返回后统一处理一些逻辑。最常见的场景是加请求头app.factory(authInterceptor, function($rootScope, $q) { return { request: function(config) { config.headers config.headers || {}; var token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer token; } return config; }, responseError: function(response) { if (response.status 401) { // 跳转登录页 } return $q.reject(response); } }; }); app.config(function($httpProvider) { $httpProvider.interceptors.push(authInterceptor); });把认证信息、错误码处理统一放在拦截器里业务代码就不用再到处重复处理 401 跳转、全局异常提示这类逻辑了。另外与后端交互时还有个常见需求避免重复请求。比如用户连续点击搜索按钮可能会发出多个相同参数的请求造成数据混乱。可以在拦截器里做一个简单的请求队列相同 URL 和参数的请求只保留最新的一次或者忽略相同参数的重复请求。5. 踩坑实录AngularJS 高频问题与排查思路5.1 页面空白不渲染先检查这三件事AngularJS 页面空白运行起来什么都看不到这是排查频率最高的问题。按照我的经验按以下顺序排查一般不会落空第一看控制台有没有报错。AngularJS 对指令写错、表达式解析失败都会抛异常而且错误信息算是比较明确的。没有异常的话继续往下查。第二检查模块和控制器有没有挂载。常见问题是脚本加载顺序错了或者 ng-app、ng-controller 指定的名字和代码里注册的不一致。AngularJS 是按名字匹配的拼写不同直接不渲染。第三看模板有没有找到。如果使用了 ng-include 或其他外部模板打开 Network 面板确认模板文件是否正常加载有没有 404。一个很迷惑的现象是模板 404 时页面不会白屏而是保留空元素看起来像什么都没有渲染。5.2 双向绑定失效是谁动了我 $scope双向绑定失效的情况也经常遇到典型的症状是输入框内容变了但页面上其他绑定同一变量的地方不同步。首要想一下是不是用了原生 DOM 事件修改数据。如果你用 jQuery 或者其他原生方式改了$scope上的属性但没有通知 AngularJS视图当然不会更新。解决办法是改完数据手动调用$scope.$apply(function() { $scope.user.name 李四; });或者从设计上避免混用原生事件统一用ng-click、ng-change等框架指令。其次检查变量类型。上一节讲过的基本类型继承陷阱也常常以“绑定失效”的形式出现。如果数据在子作用域里应该是基本类型改成对象类型一般能解决大部分问题。还有一种情况和 ng-model 有关。如果你给ng-model绑定了一个不存在于$scope上的变量名AngularJS 会自动在作用域上创建这个属性。一旦后续代码里同名属性被其他逻辑修改就可能出现各种非预期的表现。保持命名统一、写前先声明能避掉这类问题。5.3 内存泄漏被忽略的 $on(destroy)AngularJS 应用跑久了会变得越来越卡这往往是内存泄漏造成的。一个非常典型的来源是$scope.$on(someEvent, handler)监听事件后作用域销毁时忘记释放。在 AngularJS 里监听器默认是在当前$scope上注册的清理机制不完全可靠。如果你在某个页面使用 ng-if 或路由切换时销毁了当前控制器但那个$scope.$on的监听仍然生效反复进出页面几次监听器就会越积越多内存占用持续上升。较稳妥的写法是在$scope.$on($destroy, callback)里手动解绑var unbind $scope.$on(someEvent, handler); $scope.$on($destroy, unbind);$on方法返回的是解绑函数把它挂到$destroy事件上作用域销毁时监听器也会被解除。这虽然多写几行代码但在长时间运行的应用里非常有用。另外一个泄漏来源是定时器。用$interval或$timeout创建的定时器在控制器销毁后不会自动停止。老项目里经常看到页面切走之后控制台还在不断打印请求日志多半就是定时器没清理。同样在$destroy里调用$interval.cancel()即可。6. 写在最后一些真实体会AngularJS 的江湖地位已经是过去式了但对我来说它依然是一套很好的“框架思维启蒙教材”。双向绑定让我第一次意识到“视图状态是可以被数据自动驱动的”ng-include 让我理解了作用域、模板复用和组件化的边界。即便是现在主攻 Vue 或 React当年在 AngularJS 上踩过的坑、总结出的规律依然在发挥作用——比如关注数据流的单向清晰性、重视组件边界、警惕隐式依赖、在写代码前先想清楚谁拥有数据。如果你现在正在维护一个 AngularJS 老项目我的建议很简单先从减少$watch和理清作用域开始把那些莫名奇妙的问题一个个消灭。不用急着全面重构先把那些高频问题处理掉老项目依然能稳定地跑上几年。最后分享一个小偏好碰到比较复杂的 ng-include 模板时我一般会先在$templateCache里预注册然后用静态 id 引入。这样既能绕开 HTTP 请求也方便在构建时统一合并模板文件。实测下来不管是开发调试还是上线部署都少了很多幺蛾子。这套组合拳也算是 AngularJS 时代留下来的一点实用手艺。