AngularJS双向绑定实战:从零搭建宠物商城全记录 搞过几个电商类的单页应用之后我一直觉得AngularJS是个“爱恨交织”的选择——它的双向绑定确实爽可一旦数据流不按套路走排查起来也真让人头疼。这次用AngularJS做宠物商城前前后后花了差不多两个月从商品列表到购物车再到结算表单把框架的脏检查机制、指令复用、服务层设计全都实战了一遍。如果你正准备用AngularJS做商城类项目或者对它的双向绑定机制只停留在“会用但不懂原理”的阶段那么这篇分享应该能帮你少走不少弯路。文章里的代码和方案都是我在实际项目中跑通的参数和写法可以直接拿去参考。我不打算给你堆一堆框架文档里已经写烂的东西而是按着“项目拆解 — 机制解读 — 代码实现 — 问题排查”这条主线把宠物商城从零搭出来的全过程以及踩坑后的解决思路完整捋一遍。1. 项目拆解宠物商城的核心需求与AngularJS的契合点1.1 宠物商城有哪些核心模块任何商城类项目不管面向宠物还是面向服装骨架基本都是同一套商品展示、分类筛选、购物车、订单结算然后是会员中心和后台管理。宠物商城在此基础上还有几个特色场景比如按宠物种类猫、狗、鱼、爬宠分类、按体重区间推荐口粮、展示宠物用品的用户评价和评分等等。我当时梳理出的核心模块如下商品模块商品卡片展示、多条件筛选种类、价格区间、品牌、商品详情页、库存状态。购物车模块加入购物车、修改数量、删除商品、实时计算小计和总价、优惠码输入。订单模块收货人表单、地址选择、支付方式选择、订单提交前的校验。会员模块登录状态管理、购物车与用户身份绑定、收藏列表。列表页的分页与加载状态。这些模块交互密度不低特别是购物车和筛选部分。数量一改总价要立刻变化筛选条件一勾商品列表要立刻刷新表单一行没填完校验状态要实时反馈。毋庸置疑这类场景就是AngularJS的舒适区——数据驱动视图视图反过来又影响数据双向绑定让这些联动逻辑省掉了一大堆手动操作DOM的代码。1.2 为什么选择AngularJS而不是Vue或React放到现在来看新项目选AngularJS确实有点“考古”的意味但我这个项目启动时Vue还在2.x早期React生态的Redux也才刚普及不久AngularJS 1.x恰恰是社区资料最全、上手门槛最低的选择。更重要的是团队里几个前端之前用AngularJS做过内部管理系统模板语法、依赖注入那一套已经很熟宠物商城这种中后台加C端页面的混合体用它开发效率最高。从技术特性上讲AngularJS有几个点正好打在商城需求上双向绑定解决“表单密集型交互”商城就是表单怪兽搜索框、筛选器、购物车数量、优惠码输入、收货地址表单全部需要实时联动。指令系统适合封装可复用UI商品卡片、评分星星、数量选择器每个页面都可能用到用自定义指令封装后到处插。依赖注入天生好测试CartService、ProductService、OrderService全部注入到控制器里业务逻辑和视图解耦后面写单元测试也方便。当然AngularJS的缺点我也很清楚比如脏检查的性能瓶颈、作用域继承的坑、以及整个框架对新手不那么友好的“魔法感”。但这不影响它在当年的语境下是一个落地能力很强的框架。如果你现在仍然因为某些老项目原因在维护AngularJS代码这篇文章里的思路一样能帮你把代码理得更顺。2. 双向绑定机制商城数据流的“隐形管道”“angularjs双向绑定”这个热词确实是理解整个框架的钥匙。很多初学者只记得一句话“视图变了数据变数据变视图变”但真正到了排查性能问题或者视图不更新的时候才发现自己根本不知道背后发生了什么。2.1 双向绑定的工作原理脏检查到底在检查什么AngularJS的双向绑定底层依赖的是$scope对象和一套名叫“脏检查”dirty-checking的机制。你可以把$scope理解成连接视图和控制器的“数据载体”视图里的输入框会读取$scope上的属性控制器里改数据也改的是$scope上的属性。当你在模板里写ng-modelcartItem.quantityAngularJS其实做了两件事一是给这个输入框注册了一个$watch监听二是把输入框的值和$scope.cartItem.quantity建立了映射。当用户输入新数字浏览器触发input事件ng-model指令捕获到这个事件后会立刻把输入值写回$scope然后调用$apply触发一轮$digest循环。$digest循环干了什么它会把所有注册过的$watch监听全部跑一遍看看$scope上的值相比上一轮有没有变化。如果有变化AngularJS就会去更新对应的DOM绑定。这不是检查一次就完事因为一个监听的回调里很可能又改了另一个$scope属性所以要循环检查直到连续两轮检查结果完全一致或者循环次数超过10次的上限。超过上限就会抛出一个我们都很眼熟的错误Maximum iteration limit exceeded。很多人在项目里遇到的视图不更新问题根子往往不在双向绑定本身而是数据变化发生在了AngularJS的“管辖范围之外”。比如你在setTimeout回调里改了$scope上的数据浏览器确实执行了你的代码但AngularJS根本没有感知到这次修改自然不会跑$digest循环视图自然也就纹丝不动。这种情况需要手动调用$scope.$apply()或者用$timeout服务来包一层。2.2 商城场景里的双向绑定哪些地方用起来最爽理论说完回到宠物商城本身。双向绑定在商城项目里应用最舒服的我总结下来有三个场景。第一个是搜索框加筛选条件的“即时过滤”。页面上有个搜索框输入“猫粮”两个字下面的商品列表要立刻把匹配的结果刷出来。用AngularJS写只需要在控制器里维护一个searchText字段和一个categoryFilter字段然后在ng-repeat上接一个filter过滤器$scope.searchText ; $scope.categoryFilter ; $scope.changeFilter function(val, type) { if (type category) { $scope.categoryFilter val; } }; $scope.filterProducts function(product) { var matchSearch !$scope.searchText || product.name.indexOf($scope.searchText) ! -1 || product.description.indexOf($scope.searchText) ! -1; var matchCategory !$scope.categoryFilter || product.category $scope.categoryFilter; return matchSearch matchCategory; };模板部分用ng-repeatproduct in products | filter:filterProducts用户每敲一个字符searchText变化触发$digest列表立刻重新过滤。整个过程中我没有手动去操作任何一个DOM节点也没有去监听任何键盘事件这就是双向绑定带来的最直接的效率提升。第二个是购物车数量与金额的联动。用户把猫罐头数量从1改成3小计、运费、总价要立刻跟着变。我在控制器里只需要写一个计算总价的方法然后在模板里直接调用tr ng-repeatitem in cart.items td input typenumber ng-modelitem.quantity ng-changeupdateSubtotal(item) / /td td classtext-right{{ item.quantity * item.price | currency : }}/td /tr tr td colspan2 classtext-right总价{{ getCartTotal() | currency : }}/td /trgetCartTotal()在模板里每次脏检查都会被调用一次所以购物车数量变了总价自然就跟着变了。这种做法简单直观唯一需要注意的是如果计算逻辑特别复杂频繁调用会影响性能。优化方案是改成监听购物车items列表的变化然后主动去更新一个cartTotal字段。第三个是优惠码输入。用户输入“PET50”如果码有效立刻在界面上显示出“已优惠50元”无效则显示红色提示。这个逻辑用ng-change加一个validateCoupon方法就能轻松实现ng-class根据校验结果切换样式完全不用写addEventListener和classList.add/remove。2.3 双向绑定的坑watch太多之后的性能问题双向绑定好用但代价是每一个绑定都是一个$watch而$digest时不管你的数据有没有变所有$watch都会被检查一遍。宠物商城商品列表原本只有几十个商品时没问题但如果商品数量到了几百上千再加上每个商品卡片上有五六个绑定字段图片、名称、价格、库存、评分、加入购物车按钮的状态页面就会出现肉眼可见的卡顿。我实测下来的一个经验是单个页面上的$watch数量超过2000以后每次输入或者点击操作造成的卡顿就已经很明显了。宠物商城首页商品卡片加筛选条件加购物车图标上的角标数量轻松就能突破这个数字。针对这个问题的避坑方案我放在后面第5章详细说这里先提前剧透三个方向一是ng-repeat加上track by减少DOM重建二是用::一次性绑定语法让基本不会变的字段比如商品编码、图片URL只被监听一次三是把不需要实时更新的内容从$scope上挪到指令内部的link函数里去处理完全不注册$watch。3. 从零搭建宠物商城项目结构与核心功能实现下面进入实操环节。我尽量把每一步的意图说清楚告诉你为什么这样做、有没有其他选择、各自代价是什么。3.1 项目目录结构与依赖引入AngularJS项目不需要什么复杂的构建工具起步早期我甚至直接用script标签引入文件就能跑起来。但商城项目模块一多文件碎片化之后还是建议用一套清晰的目录结构。我当时的项目结构如下pet-shop/ ├── index.html ├── app/ │ ├── app.js # 根模块定义、路由配置、全局常量 │ ├── controllers/ │ │ ├── product-list.js │ │ ├── product-detail.js │ │ ├── cart.js │ │ └── order.js │ ├── services/ │ │ ├── product-service.js │ │ ├── cart-service.js │ │ └── order-service.js │ ├── directives/ │ │ ├── product-card.js │ │ ├── rating-stars.js │ │ └── quantity-input.js │ └── partials/ │ ├── product-list.html │ ├── product-detail.html │ ├── cart.html │ └── order.html └── assets/ ├── css/ └── img/这种按功能类型分层的做法在AngularJS项目里最普适。控制器只做“粘合层”的工作从服务拿数据、把数据放到$scope上、接收视图里的交互事件并调用服务方法。真正的业务逻辑比如购物车的增删改查、优惠价计算全部下沉到service里这样视图层即使整个换掉核心逻辑也不受影响。依赖方面我用的核心库是AngularJS 1.7.8这是1.x系列里维护时间最长、坑相对最少的版本。路由用了ui-router因为商城的商品详情页、购物车、结算页之间不仅有简单跳转还有不少“嵌套视图”需求比如商品列表页里点击某个分类要同时更新左侧分类高亮和右侧商品列表ui-router的命名视图和嵌套状态比ngRoute要灵活得多。3.2 商品列表页数据从哪来、怎么渲染商品数据在真实项目里肯定来自后端API。我在开发阶段先用了一份本地json数据文件模拟接口等后端接口的Swagger文档出来之后再切换成真正的$http请求。这一步对前后端并行开发很有用你得在ProductService里把数据源封装好让控制器感知不到数据来源的差异。ProductService的简化写法app.factory(ProductService, [$http, $q, function($http, $q) { var PRODUCT_API /api/v1/products; return { getAll: function() { return $http.get(PRODUCT_API).then(function(res) { return res.data.data; }); }, getById: function(id) { return $http.get(PRODUCT_API / id).then(function(res) { return res.data.data; }); } }; }]);商品列表的控制器里拿到商品数组之后放在$scope.products上模板用ng-repeat渲染卡片。这里有个细节必须提ng-repeat遍历数组时默认会为每一项添加一个唯一的$$hashKey如果列表数据频繁从接口刷新每次都会重新生成$$hashKey导致整个列表的DOM重建影响体验。解决办法是ng-repeat加track bydiv classproduct-card ng-repeatproduct in products track by product.id !-- 商品卡片内容 -- /divtrack by product.id告诉AngularJS用商品的id字段来识别每个元素而不是默认的$$hashKey。这样即使整个products数组被替换只要id没变对应的DOM节点就不会被重建。这个习惯我从这个项目之后一直保持到现在因为不管是AngularJS还是Vue的v-for都支持类似的key机制原理完全相通。3.3 购物车模块CartService的设计与总价计算购物车是整个宠物商城里逻辑密度最高的模块。我建议从一开始就把购物车状态封装成服务而不是直接挂在控制器上因为商品列表页要往购物车加东西、详情页也要加、购物车页面要改数量如果每个控制器各自维护一份购物车数据同步就是一场灾难。CartService的核心方法如下app.factory(CartService, [function() { var items []; var listeners []; function notifyChange() { angular.forEach(listeners, function(fn) { fn(); }); } return { getItems: function() { return items; }, addItem: function(product, quantity) { var found null; for (var i 0; i items.length; i) { if (items[i].productId product.id) { found items[i]; break; } } if (found) { found.quantity quantity; } else { items.push({ productId: product.id, name: product.name, price: product.price, quantity: quantity }); } notifyChange(); }, removeItem: function(index) { items.splice(index, 1); notifyChange(); }, updateQuantity: function(index, quantity) { if (quantity 0) { this.removeItem(index); return; } items[index].quantity quantity; notifyChange(); }, getTotal: function() { var total 0; for (var i 0; i items.length; i) { total items[i].price * items[i].quantity; } return total; }, onChange: function(callback) { listeners.push(callback); return function() { var idx listeners.indexOf(callback); if (idx ! -1) listeners.splice(idx, 1); }; } }; }]);这里有一个值得展开的设计点为什么addItem里要判断是否已有同类商品而不是无脑增加一条新记录因为用户很可能会重复点击“加入购物车”如果每次都新插入一条记录购物车列表里就会出现好几行一模一样的产品显得很不专业。合并数量这种处理是商城购物车的“基本礼仪”。数量变化之后要重新计算金额我上面的写法是模板里直接调getTotal()。这个方法的性能隐患在购物车条目很少的时候完全看不出来但如果以后要支持几百条购物车记录就要改成“在updateQuantity里计算好总价并缓存到total字段”让模板只绑定total变量而不是每轮脏检查都去遍历一次列表。3.4 订单表单用AngularJS表单校验拦截无效提交订单页面需要收集收货人信息包括姓名、电话、地址、邮箱另外还要选支付方式。AngularJS内置的表单校验是我当时最看重的功能原因很简单它能让我少写大量手动校验DOM类名的代码。AngularJS表单校验的核心是form的name属性加ng-model配合$valid、$dirty、$touched这些状态。比如电话字段的判断form nameorderForm novalidate ng-submitsubmitOrder(orderForm) div classform-group label手机号/label input typetext namephone ng-modelorder.phone ng-pattern/^1[3-9]\d{9}$/ required / div classerror-msg ng-showorderForm.phone.$dirty orderForm.phone.$invalid 请输入11位有效手机号 /div /div button typesubmit ng-disabledorderForm.$invalid提交订单/button /form这里说一下novalidate的作用。AngularJS希望由自己来控制表单校验所以要在form标签上加上novalidate让浏览器原生的HTML5校验机制不生效否则浏览器会在AngularJS处理之前就弹出一个系统校验提示两个体系混在一起会产生奇怪的交互体验。ng-pattern直接接受正则表达式校验手机号、邮政编码这类固定格式非常方便。ng-disabledorderForm.$invalid会在表单存在任何校验错误时自动禁用提交按钮这比“用户点提交后弹个警告框”的体验友好得多。另外一个实用细节是ng-messages指令。AngularJS 1.3之后的版本支持用它来集中管理报错信息的显示逻辑不用写一大堆ng-show加$dirty加$invalid的组合判断。写法更清爽div ng-messagesorderForm.phone.$error ng-showorderForm.phone.$dirty rolealert div ng-messagerequired手机号不能为空/div div ng-messagepattern手机号格式不正确/div /div我自己是从老式的ng-show写法趟过来的后来重构才换成ng-messages。别小看这个改动当一个表单有七八个字段、每个字段又可能报两三种不同错误时ng-messages能把模板代码量压缩掉一半以上。4. 服务、指令与路由商城代码的组织之道控制器代码越写越大的时候就是你需要停下来重新思考架构的时候。宠物商城做到一半我发现商品列表控制器和购物车控制器里各有一大段重复逻辑于是我开始着手把它们抽成服务然后用自定义指令把那些反复出现的UI片段封装起来。4.1 Service与Factory的区别用对才能好测试AngularJS里创建服务有两种常用方式service和factory。简单理解factory返回一个自定义的对象可以是任意类型service则是用new方式实例化一个构造函数。在宠物商城里两种都用到了但大部分场景我用factory因为它更直接返回一个“直白的对象字面量”就够了。不过这里有一个更重要的约定无论用哪种方式服务都应该是“无状态共享”的至少服务内部暴露给控制器的应该是清晰的方法而不是让控制器直接操作服务内部的私有数组。我在CartService里暴露了getItems()、addItem()、removeItem()、updateQuantity()、getTotal()这些明确的方法控制器只能在业务层面调用它们无法绕过方法直接修改购物车数据。这样做的好处是所有数据变动都集中在service内部以后想加日志、想加数据持久化比如存到localStorage只需要改service内部所有控制器自动受益。实际项目里我还给CartService加了localStorage持久化。用户刷新页面后购物车数据还在体验提升非常明显。这个逻辑放在service内部加两三行代码就能实现function saveToStorage() { localStorage.setItem(pet_shop_cart, angular.toJson(items)); } function loadFromStorage() { var saved localStorage.getItem(pet_shop_cart); if (saved) { items angular.fromJson(saved); } }angular.toJson和angular.fromJson会在序列化时忽略$$hashKey之类的内部属性比直接用JSON.stringify更干净。4.2 自定义指令封装商品卡片与评分星星宠物商城里多个页面都会展示商品卡片首页热卖推荐、列表页搜索、详情页的“相关推荐”。如果每个页面都复制一大段商品卡片HTML后续改样式或者添加“加入购物车”按钮时就得到处改很容易漏。我的做法是封装一个productCard指令app.directive(productCard, [function() { return { restrict: E, templateUrl: app/partials/directives/product-card.html, scope: { product: , onAdd: }, link: function(scope, element, attrs) { scope.addToCart function() { scope.onAdd({ product: scope.product, quantity: 1 }); }; } }; }]);这里有几个关键点。restrict: E表示这个指令只能作为自定义元素使用也就是product-card productp on-addaddToCart(p)/product-card这种写法语义更清晰。scope里通过传对象、通过传回调让指令与外部完全解耦。我不希望指令内部直接依赖CartService那样的话指令就变成了全局单例的耦合者很难复用。现在调用方可以自主决定“加入购物车时到底要做什么”比如有的页面加完还要弹一个提示框有的页面加完要跳去购物车这个差异完全由调用方控制指令不做任何假设。评分星星组件是另一个值得封装的东西。我实现了一个支持半星显示和点击评分的ratingStars指令app.directive(ratingStars, [function() { return { restrict: E, templateUrl: app/partials/directives/rating-stars.html, scope: { ratingValue: , readonly: , onRate: }, link: function(scope) { scope.stars [1, 2, 3, 4, 5]; scope.setRating function(val) { if (scope.readonly true) return; scope.ratingValue val; scope.onRate({ rating: val }); }; scope.getStarClass function(star) { return star Math.round(scope.ratingValue) ? star-active : star-inactive; }; } }; }]);模板里用ng-repeat渲染5个星星ng-click触发评分。用户评分之后调onRate回调由父控制器决定把评分提交到接口还是临时显示。这个指令在商品详情页和评价列表里都复用了把重复的星星UI和交互逻辑收敛到一个地方。4.3 路由配置ui-router的状态嵌套与页面守卫商城项目里页面跳转关系比普通站点复杂得多。商品列表页有多种筛选状态、商品详情页要能记录来源、购物车为空时跳去首页、订单页需要校验是否已经登录。我用ui-router的$stateProvider来管理所有页面状态核心状态定义如下app.config([$stateProvider, $urlRouterProvider, function($stateProvider, $urlRouterProvider) { $urlRouterProvider.otherwise(/products); $stateProvider .state(products, { url: /products, templateUrl: app/partials/product-list.html, controller: ProductListCtrl, controllerAs: vm }) .state(product-detail, { url: /products/:productId, templateUrl: app/partials/product-detail.html, controller: ProductDetailCtrl, controllerAs: vm }) .state(cart, { url: /cart, templateUrl: app/partials/cart.html, controller: CartCtrl, controllerAs: vm }) .state(order, { url: /order, templateUrl: app/partials/order.html, controller: OrderCtrl, controllerAs: vm, resolve: { loginRequired: [$state, function($state) { // 这里做登录校验未登录则跳转登录页 }] } }); }]);resolve是ui-router里一个特别好用的机制在进入订单页之前可以提前执行一些异步逻辑比如确认购物车不为空、确认用户已登录。如果校验失败在resolve阶段抛错或直接$state.go跳走页面根本不会渲染避免了用户看到一帧空白页面再被弹走的糟糕体验。对于路由方式如果条件允许现在的新项目我会建议直接用组件化路由但AngularJS的老项目中ui-router已经是最成熟的选择。它的状态嵌套能力、命名视图和resolve机制覆盖了一个商城项目绝大多数的页面管理需求。5. 常见问题排查与性能优化实录做宠物商城的过程中我在这类框架上踩过的坑几乎都能归类成“视图没更新”“列表太卡”“作用域不对”三个大方向。下面把每个问题的排查过程和最终解法整理成一份速查表附带我在项目里的实操经验。5.1 视图不更新$apply的时机和正确用法最经典的场景用户在页面点了“加载更多”按钮你用setTimeout模拟一个异步请求拿到新数据后赋给$scope.products结果列表毫无反应。原因我在2.1说过——数据变化发生在AngularJS的上下文之外$digest没有跑。排查思路是先在数据赋值的地方加一行console.log确认数据真的更新了而不是后端返回的是空数组。确认数据有变化但视图没动再考虑是不是$scope引用的问题。比如你直接给$scope.products重新赋了一个数组这种整体替换一般没问题但如果你是对$scope.products里的某个对象添加了新属性AngularJS默认不会监听这个新属性视图自然不会更新。解决办法是用$scope.$apply()包一层或者更推荐用$timeout$scope.loadMore function() { $timeout(function() { $scope.products $scope.products.concat(newProducts); }, 300); };$timeout其实会自动执行$apply省得你手动调用。强烈建议不要手动调用$apply因为你没法确定当前是不是已经处在$digest循环中。如果正在循环里又调用$applyAngularJS会直接抛$digest already in progress错误排查起来比原问题还麻烦。5.2 列表卡顿2000个watch怎么降到500个我的宠物商城商品列表页最开始每个商品卡片绑定字段有图片、名称、描述、价格、原价、库存、评分、分类标签、加入购物车按钮的ng-disabled状态再加上ng-repeat本身一个商品就轻松产生10个以上$watch。100个商品就突破1000个watch每次输入筛选关键字时页面都有明显迟滞。我的优化方案有三个层次从易到难加track by避免列表整体重建。这一步能把最贵的DOM销毁重建开销省掉。用一次性绑定::把静态字段从watch体系里摘出去。商品编码、图片URL、品牌名称这些基本不变的字段写成::product.imageUrl就不再被监听直接减少大量watch。把复杂的格式化逻辑挪到$scope之外。比如价格格式化、评分百分比计算都预先处理成纯展示字段模板里直接绑定而不是每次都跑一个函数。这里还要提一个很多人容易忽略的细节filter过滤器在$digest循环中的开销非常大。购物车列表和商品列表的过滤操作如果每次$digest都重新计算一遍代价极高。我的做法是在控制器里监听筛选条件变化手动完成过滤把结果存到$scope.filteredProducts里模板只绑定这个结果数组。这样筛选功能照用但重计算只发生在条件变化的那一刻而不是每次脏检查都全量执行。5.3 作用域继承的坑ng-repeat子作用域与controller asAngularJS的作用域继承是原型继承这意味着在子作用域里给父级属性重新赋值时你不会真正修改到父级作用域的属性而是会在子作用域上新建一个同名属性。经典翻车现场div ng-repeatitem in cartItems button ng-clickremoveItem($index)删除/button /div$index没问题但如果你在ng-repeat内部试图通过item.quantity 0来“清空”购物项你修改的只是子作用域里的item对象的属性而item本身是从父作用域的cartItems数组里取出来的对象引用。因为对象是引用类型所以修改属性还能通到父数组里但如果你直接给item重新赋值item null就完全没用只会创建一个局部变量。避免这类问题最彻底的方式是全面转向controller as语法。模板里用vm.xxx明确访问控制器实例上的属性不再隐式依赖$scope。比如购物车控制器app.controller(CartCtrl, [function() { var vm this; vm.items []; vm.total 0; vm.removeItem function(index) { vm.items.splice(index, 1); }; vm.updateTotal function() { vm.total vm.items.reduce(function(sum, item) { return sum item.price * item.quantity; }, 0); }; }]);模板里对应写成ng-repeatitem in vm.itemsng-clickvm.removeItem($index)。controller as最大的优势是让“哪个控制器里的属性”一目了然作用域继承带来的隐式覆盖问题从源头上被切断了。下面是我整理的一个问题排查速查表可以直接印出来贴在工位上现象可能原因排查方向推荐解法视图不更新数据在异步回调里修改AngularJS没感知检查数据修改处是否在$apply上下文内用$timeout或$scope.$apply视图不更新对象新增属性未被监听打印$scope检查属性是否存在使用$scope.$watch或初始化时就定义好属性页面卡顿watch数量过多脏检查过慢Chrome DevTools Performance录制track by、一次性绑定、减少过滤器列表渲染错乱ng-repeat没有track by观察数据更新后DOM是否正确复用track by product.id子页面拿不到父数据作用域继承被新属性覆盖检查是否存在同名属性赋值使用controller as语法$digest already in progress控制器里重复调用$apply查看调用栈定位$apply来源改用$evalAsync或在指令link阶段合理使用表单校验不生效浏览器原生校验拦截检查form是否有novalidate加上novalidate5.4 页面跳转后控制器重复初始化resolve与控制器生命周期商城有一个典型场景用户从商品列表页进入详情页再点“加入购物车”后跳回购物车页面发现购物车列表不见了。这通常是因为每次路由切换时控制器都会重新执行一遍初始化逻辑如果你的初始化逻辑里直接覆盖了vm.items []或从服务重新拉数据那么之前在购物车里积累的数据就会丢失。我当时用CartService管理购物车好处是数据存在service里不会因为控制器重建而丢失。但新手容易犯的错是在控制器构造函数里执行vm.items CartService.getItems()看似拿到了引用可如果后续对vm.items进行整体赋值vm.items []、vm.items response.data就会把service里的引用也替换掉造成“我明明清空了页面上的购物车但重新进入时购物车数据还在”或者相反“页面刷新了但购物车数据没了”的错乱。稳定做法是只把CartService.getItems()返回的数组引用暴露给视图所有的增删改都通过service方法操作。控制器里绝不直接vm.items []清空而是调CartService.clear()。这个边界在代码Review时值得反复强调。另外resolve除了做鉴权还能优雅地处理“购物车为空时不进入订单页”的逻辑。在order状态的resolve里检查购物车购物项数量为空则直接重定向到商品列表页并弹一条“请先选购商品”的提示。这比在控制器里初始化后再判断体验好得多。实操心得几个值得长期保留的AngularJS习惯做完这个宠物商城我对AngularJS的认知比之前任何一次都要深入。如果用一句话总结这段经历那就是双向绑定是AngularJS的“术”而弄懂它背后的$digest循环和作用域机制才是真正让你从“会写”变成“会修”的“道”。如果让我现在再给做AngularJS项目的人几个习惯性建议我会说第一从第一天就用controller as语法不要留恋原始的$scope注入方式。虽然两者最终会映射到同一套机制但controller as能帮你规避掉大量作用域继承引发的隐性bug代码审阅时也更容易看出数据到底属于哪个控制器。第二服务层是AngularJS项目的安全垫。购物车、用户信息、商品缓存这类跨页面数据永远放在service里让控制器只做转发。这个原则不只在AngularJS里成立换到React或者Vue状态管理的思路依然相通。第三性能问题要在写代码时预防而不是上线后抢救。每次在模板里新增一个{{ }}绑定心里就要默默记一笔这会是第几个watch如果页面卡了先去检查哪些绑定是可以去掉的再考虑换框架换方案。宠物商城项目里最让我挠头的一次性能优化最后就只是删掉了一个不必要的过滤器外加两个一次性绑定收益却立竿见影。项目上线后我还顺手把CartService改成了支持localStorage持久化的版本用户关闭浏览器再打开购物车里的三罐猫粮和那袋狗粮都还在测试组的同事说这个细节让他们对页面好感度提升不少。说真的AngularJS老归老但只要你摸清了它的脾性做个宠物商城这种体量的项目它依然是一把非常称手的老工具。