首页 / 资讯中心 / 文章详情

RxJS+Lodash+Angular Material三库协同:Angular中后台响应式数据流实战

RxJS+Lodash+Angular Material三库协同:Angular中后台响应式数据流实战 ★ FEATURED ARTICLE
1. 为什么偏偏是这三个库项目选型与组合逻辑Angular 项目做到第三四个我基本摸清了它的脾气。组件、路由、依赖注入这些骨架能力确实顺手但业务一复杂起来大列表筛选、多接口联动、表单联动校验、状态在组件间跳来跳去光靠框架本身还是会手忙脚乱。所以做 Angular 综合应用04 这个项目时我直接决定把 RxJS、Lodash 和 Angular Material 一起纳入技术底座。目的很简单让异步数据流、数据处理、界面组件各管一摊谁也别抢谁的活。1.1 三个工具在项目里的分工很多人一看到“集成”两个字就以为是把仨库摞在一起实际上三者的职责差得很远边界不划清楚就会互相打架。RxJS 解决的是“时间”问题。组件什么时候收到数据、用户连续输入时怎么截流、多个请求怎么合并、页面销毁时怎么干净地退订这些都是 RxJS 的领域。Angular 本身内置了 RxJS但它更像框架的血管系统。评论“你把 RxJS 用好Angular 的异步模型才真正是响应式的用不好一切都成了回调地狱的变体。”Lodash 解决的是“数据形状”问题。后端接口返回的数组、对象往往跟页面要求的形态对不上。需要按状态分组、按时间排序、把嵌套字段拍平、过滤掉重复项、深拷贝一份对象再局部修改这些操作是 Lodash 的舒适区。Angular 虽然也有 map、filter 这些原生方法但真到嵌套对象深合并、按多个字段排序这种场景手写代码容易出边界 bug。Angular Material 解决的是“界面表达”问题。它提供表格、表单输入、对话框、分页、日期选择器、自动补全、Snackbar 这些中后台天天要用的组件。关键不只是省写 CSS而是它天然支持 Angular 的表单体系、无障碍访问和响应式布局主题还能按品牌色定制。我的理解是RxJS 是神经系统Lodash 是工具箱Angular Material 是装修队。三者协同页面才有活的响应和稳定的形态。1.2 什么样的项目值得上这套组合这套组合不是万能药选型之前想清楚项目类型。如果你做的是中后台管理系统、数据大屏、运营后台、CRM、ERP、报表查询平台这种页面典型特征就是大量表格、多个筛选条件、按钮触发下载、弹窗表单、状态流转那我强烈建议把这三者一起用起来。因为这些页面 80% 的问题可以抽象为“用户输入条件 → 请求数据 → 加工展示”。RxJS 处理条件变化Lodash 加工数据Angular Material 提供交互控件整个链路会非常顺。如果你做的是轻量展示站、官网、静态内容页那这套组合很可能多余。简单页面用原生 Angular 加少量样式就够强行引入 Material 和 Lodash 只会拖累首屏包体积。还有一个容易踩的误区“我们用不到 Material只想用 RxJS 和 Lodash”。这类取舍没问题但要注意 Material 不只是 UI 组件它附带的MatTableDataSource和表单状态封装在很多场景下和 RxJS 配合能省下大量胶水代码。只缺一半体验差一半。1.3 组合使用的整体结构建议我的做法是给项目立三条不成文的规矩所有外部数据请求必须走 Service Observable组件不能直接 new 一个HttpClient去发请求。组件模板里只用 async 管道拥抱 Observable不手动在ngOnInit里 subscribe 赋值。凡是涉及集合清洗、对象深拷贝、复杂排序一律通过工具模块调用 Lodash不散落在组件里。这套规则的好处是新人接手代码时路径非常清晰页面数据从哪个流来、数据在哪个环节被加工、UI 控件绑定到哪个表单字段一目了然。下面我从三个库各自的角度展开讲最后一章再把它们串成一个完整业务示例。2. RxJS 与 Angular 的响应式协作从数据流到状态管理2.1 组件生命周期里最常用的 RxJS 模式先从一个最常见的团队病说起很多开发者在组件里直接this.service.getList().subscribe(res this.list res)。第一次这么干没问题但一旦页面里同时有搜索条件、分页、排序、刷新按钮这种写法的订阅会爆炸而且一个请求失败就把组件状态带崩。我在 Angular 综合应用04 里给团队定的标准姿势是每个组件建一个destroy$所有流都挂一个takeUntil(this.destroy$)。这个模式虽然老但极其可靠。export class BaseListComponent implements OnInit, OnDestroy { private readonly destroy$ new Subjectvoid(); ngOnInit(): void { this.searchControl.valueChanges .pipe( debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$) ) .subscribe(value { this.query value; this.loadPage(1); }); } ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); } }很多新手不理解takeUntil为什么关键。因为 Angular 组件销毁以后订阅没取消异步数据流还在跑。比如用户已经跳到别的路由前一个页面还在等接口返回返回后去更新一个已经不存在的组件轻则控制台报错重则引起内存泄漏。这个坑我在早期项目里踩过不止一次。2.2 搜索防抖与无效请求取消搜索框的联动是实现响应式搜索的关键。现在很多人知道用debounceTime但还有两件事经常被忽略一个是distinctUntilChanged另一个是switchMap。distinctUntilChanged负责让用户输入了相同关键词时不重复发请求例如用户删掉一个字再补回同一个词接口不应该再拖一次。switchMap负责取消旧请求用户输入“Angular”后没等接口返回又输入了“Angular Material”这时新请求会取代旧请求旧响应直接被丢弃。this.searchControl.valueChanges.pipe( debounceTime(400), distinctUntilChanged(), switchMap(keyword this.orderService.search(keyword)), takeUntil(this.destroy$) ).subscribe(result { this.rows result; });有人会问网络请求真的能被“取消”吗严格说浏览器端发出去的 HTTP 请求不一定能被中断但switchMap取消了上游订阅也就意味着开发者不会再拿到旧响应去更新页面。这对用户意味着界面上最终展示的一定是最后一次输入对应的结果。这个细节对体验影响极大。2.3 Observable 与 AsyncPipe模板里的响应式表达组件和模板之间我推荐尽量用async管道而不是手动订阅。因为async管道会自动处理订阅和退订让模板真正声明式地表达数据流。举个多数据流合并的例子。一个订单列表页需要同时依赖搜索关键词、过滤状态、当前页码三个条件我会把它们合并成一个vm$readonly vm$ combineLatest({ search: this.searchControl.valueChanges.pipe(startWith()), status: this.statusControl.valueChanges.pipe(startWith(ALL)), page: this.page$.asObservable() }).pipe( switchMap(({ search, status, page }) this.orderService.query({ search, status, page }).pipe( startWith(null) ) ), map(result ({ loading: result null, orders: result?.items ?? [] })) );模板里只需要*ngIfvm$ | async as vm然后绑定vm.loading和vm.orders。整套逻辑没有一处手动订阅响应式联动却完整实现了。combineLatest有个常见坑它要求每个数据流都至少 emit 一次才会合并出新值。所以上面的代码里表单控件要用startWith()或startWith(ALL)垫一个初始值否则页面加载完成后第一轮合并永远不触发。3. Lodash 在 Angular 中的高价值场景数组、对象与不可变更新3.1 值得引入的 Lodash 函数而不是全员引用Lodash 包体积不小但现代构建工具可以做到按需引入。我的习惯是只从lodash-es里 import 实际用到的函数并且配合 tree-shaking最终打进主包的内容远比想象中小。import { groupBy, orderBy, uniqBy, cloneDeep, isEqual, debounce } from lodash-es;说句实话ES 原生数组方法在变强map、filter、find、some 这些完全不需要 Lodash。真正值得为 Lodash 买单的场景是这几个cloneDeep深拷贝带嵌套结构的对象。Angular 开发里修改深层字段后触发变更检测这个函数太常用了。groupBy / orderBy / uniqBy接口返回的扁平时序数据需要按状态分组、按时间倒序、按 ID 去重时一行搞定。isEqual两个对象是否深度相等。常用于判断筛选条件是否有变化避免重复请求。debounce虽然 RxJS 有debounceTime但某些非响应式的地方Lodash 的 debounce 传入普通回调更方便比如窗口尺寸变化后的重算。3.2 数据清洗管线接口返回数据的落地处理前端最烦的一件事是接口返回格式和页面结构不一致。比如后端返回一个订单集合每条订单里有一个嵌套的 items 数组而页面最上方需要展示“去重后商品总数”“各状态订单数量”。我习惯在 Service 里就完成清洗组件拿到的是已经可以绑定的结构。const orders apiResult.orders; const grouped groupBy(orders, order order.status); const trendData orderBy(orders, [createdAt], [desc]) .slice(0, 30) .map(order ({ date: formatDate(order.createdAt), amount: order.amount })); const totalProducts uniqBy( orders.flatMap(order order.items), item item.productId ).length;这样做最大的收益是组件逻辑变得非常薄。组件只关心trendData画图表grouped渲染角标totalProducts展示数字加工过程被隔离在数据层。等到后一个页面也要用同样的清洗逻辑时直接复用 Service 方法就行。3.3 cloneDeep 与 Angular 变更检测的配合Angular 默认变更检测走引用比对你修改一个数组里的嵌套对象属性如果引用没变视图可能不刷新。Lodash 的cloneDeep能帮上大忙但用的时候要想明白什么时候该用。比如一个编辑弹窗里用户改了表单里某个嵌套对象数组中的一个字段我通常会先深拷贝当前行数据在副本上修改再把副本赋值回数组const editableRow cloneDeep(this.currentRow); editableRow.detail.note 新备注; this.rows this.rows.map(row row.id editableRow.id ? editableRow : row );这样this.rows指向的是新数组元素也换成了新对象Angular 的引用检测能立刻抓到变化。如果你直接this.rows[0].detail.note xxx大多数情况下视图不会更新而且这种 bug 非常隐蔽不报错只是界面静默无反应。这里也说一句题外话。如果整个模块用的是ChangeDetectionStrategy.OnPush那“必须产生新引用”这个铁律会更明显。我建议从项目一开始就开 OnPush哪怕前期效率低一些也比后期几十个组件一起改策略轻松得多。4. Angular Material快速搭出可用的业务界面骨架4.1 按需选材按模块组织 Material 组件Angular Material 的组件是按模块提供的项目里不需要把整个组件库一股脑塞进共享模块。我见过很多项目把所有 Material 模块都导入一个SharedModule结果首屏包体迅速膨胀。更合理的做法是每个页面或每个功能域独立导入自己需要的模块。所以我在 Angular 综合应用04 里的做法是先跑一次ng add angular/material生成主题和基础结构然后手动建立几个功能模块比如MatFormControlsModule、MatTableModule等。如果用独立组件则在每个组件里直接imports: [...相关组件模块]体积控制得很干净。需要确认一件事Angular 版本不同Material 模块名和组件用法会有细微变化。我建议团队锁定一个主版本不要频繁跨大版本升级否则组件 API 变动会让工作量大增。4.2 用 MatTable 搭建强壮的列表页中后台页面有一半以上是列表加筛选。Material 的MatTable和MatSort、MatPaginator配合得很好。经典写法是export class UserListComponent implements AfterViewInit, OnDestroy { readonly displayedColumns [name, role, status, createdAt, actions]; readonly dataSource new MatTableDataSourceUser(); ViewChild(MatPaginator) paginator!: MatPaginator; ViewChild(MatSort) sort!: MatSort; ngAfterViewInit(): void { this.dataSource.paginator this.paginator; this.dataSource.sort this.sort; } }MatTableDataSource有一个很实用的filter属性可以直接把输入框的值绑上去做前端过滤。对于数据量不大几百条以内的场景这比每次都请求接口快。但要注意一点如果是大数据量、服务端分页就不要用MatTableDataSource的默认排序和分页逻辑了。这时候应该把排序事件、翻页事件交给 RxJS接口返回时用MatTableDataSource只是单纯用来承载数据。4.3 表单验证与实时反馈Material 的表单组件和 Angular 响应式表单天然兼容。拿订单筛选页举例日期范围选择、关键词输入、状态下拉它们绑定FormControl后模板里直接用mat-error展示校验信息即可。我特别爱用MatSnackBar做全局操作结果提示。以前大家都自己写样式还要处理自动关闭、多份弹窗堆叠问题。Material 组件自带这些能力一行代码就能弹this.snackBar.open(保存成功, 关闭, { duration: 2000 });还有一个小技巧在MatSelect里做选项联动时用valueChanges加startWith初始化一个默认值能避免首次渲染时下拉框空白或值不同步。这样既用了 Material 的交互又和 RxJS 的数据流接上了。5. 把三者串起来一个完整的订单列表筛选与分页业务实战5.1 页面整体功能拆解前面讲了那么多单元能力这一章我把它们放在一个真实页面里订单管理页。页面功能不算复杂但非常典型顶部有一个搜索框支持按订单号模糊搜索。一个状态下拉框全部、待支付、已支付、已取消。一个日期范围选择器。中间是订单表格展示订单号、客户名、商品数、金额、状态、创建时间。底部有分页器支持切换页码和每页条数。左上角显示“符合当前筛选条件的订单总数”。这个页面如果用“组件内 subscribe 手工赋值”的写法后续每加一个筛选条件都要额外写好多逻辑。而用 RxJS 管理数据流用 Lodash 加工数据用 Material 渲染界面结构会清爽得多。5.2 核心 TypeScript 逻辑数据怎么流动我先定义数据源对象export class OrderDataSource extends DataSourceOrder { constructor(private vm$: ObservableOrder[]) { super(); } connect(): ObservableOrder[] { return this.vm$; } disconnect() {} }组件内部组合所有筛选条件export class OrderListComponent implements OnInit, OnDestroy { private readonly destroy$ new Subjectvoid(); private readonly refresh$ new Subjectvoid(); private readonly page$ new BehaviorSubjectPageEvent({ pageIndex: 0, pageSize: 10 } as PageEvent); readonly searchControl new FormControl(); readonly statusControl new FormControl(ALL); readonly dateRangeControl new FormControl(null); readonly dataSource new OrderDataSource(this.vm$); displayColumns [orderNo, customer, productCount, amount, status, createdAt]; vm$ combineLatest({ keyword: this.searchControl.valueChanges.pipe( debounceTime(300), distinctUntilChanged(), startWith() ), status: this.statusControl.valueChanges.pipe(startWith(ALL)), page: this.page$.pipe(startWith({ pageIndex: 0, pageSize: 10 })), refresh: this.refresh$.pipe(startWith(0)) }).pipe( switchMap(params this.orderService.query(params).pipe(startWith(null))), map(result { if (result null) { return { loading: true, total: 0, orders: [] }; } // Lodash 在这里做分组统计展示顶部角标数据 const statusCounts groupBy(result.orders, o o.status); return { loading: false, total: result.total, pendingCount: statusCounts[PENDING]?.length ?? 0, paidCount: statusCounts[PAID]?.length ?? 0, orders: result.orders }; }), shareReplay({ bufferSize: 1, refCount: true }) ); }这一段代码几乎把所有关键点都覆盖了debounceTime处理搜索防抖combineLatest合并多条件switchMap取消旧请求startWith保证首次触发Lodash 的groupBy做状态统计shareReplay保证多个订阅者共享同一份计算结果。5.3 模板与 Angular Material 组件怎么绑定模板部分没有魔法但胜在清晰form [formGroup]filterForm (ngSubmit)onSubmit() mat-form-field input matInput formControlNamekeyword placeholder搜索订单号 / /mat-form-field mat-form-field mat-select formControlNamestatus mat-option valueALL全部状态/mat-option mat-option valuePENDING待支付/mat-option mat-option valuePAID已支付/mat-option mat-option valueCANCELLED已取消/mat-option /mat-select /mat-form-field button mat-raised-button typesubmit [disabled](vm$ | async)?.loading {{ (vm$ | async)?.loading ? 加载中... : 查询 }} /button /form表格部分用mat-table不需要自己维护tr循环table mat-table [dataSource]dataSource classorder-table ng-container matColumnDeforderNo th mat-header-cell *matHeaderCellDef订单号/th td mat-cell *matCellDeflet row{{ row.orderNo }}/td /ng-container ng-container matColumnDefstatus th mat-header-cell *matHeaderCellDef状态/th td mat-cell *matCellDeflet row mat-chip colorprimary{{ row.status }}/mat-chip /td /ng-container tr mat-header-row *matHeaderRowDefdisplayColumns/tr tr mat-row *matRowDeflet row; columns: displayColumns/tr /table这个模板的真正价值在于我没有任何手工订阅。dataSource连接的是vm$这个可观察对象表格数据一旦有变化Angular 的async管道会第一时间驱动界面更新。加载状态通过vm$ | async直接反映在按钮文案上数据流和视图状态完整对应。5.4 扩展一个导出场景筛选结果的落地订单列表最常见的后续动作是“导出当前结果”。这里可以再展示一下 Lodash 和 RxJS 的协作用户点了导出前端只需要从当前vm$里取最后一次结果加工成 CSV 或交给后端生成文件。this.vm$.pipe(take(1)).subscribe(vm { const csvRows vm.orders.map(order ({ orderNo: order.orderNo, amount: order.amount, status: order.status })); // 继续走 CSV 导出或 POST 到后端 });take(1)保证只取当前状态而不会因为搜索条件变化而重复触发导出。6. 性能优化、调试工具与那些容易被忽略的坑6.1 从默认变更检测切换到 OnPush做这套集成的时候我把所有业务组件都设成ChangeDetectionStrategy.OnPush。这对 Angular 综合应用04 这种数据流驱动页面尤其重要因为async管道天然会在可观察对象有新值时精确触发变更检测比全局默认策略高效得多。切换之后的典型收益大幅减少无谓的组件检查和 DOM diff。代价是如果你还习惯在组件里直接修改数组元素属性、指望它自动刷新那就会踩到“引用不变视图不动”的坑。所以 OnPush 不是一个孤立决策它要求整个团队养成不可变数据的习惯而这恰好和 Lodash 的cloneDeep、map这些函数形成配合。6.2 内存泄漏的隐蔽来源不只是组件订阅很多开发者都知道在ngOnDestroy里退订但 RxJS 里有些写法会让退订失效。比如用combineLatest组合了一个全局service里长期存活的Subject组件销毁后只取消了组件自己的订阅全局流还在这未必是泄漏但如果你往一个全局 subject 里塞入了组件相关的引用那组件相关的内存就会一直被持有。我处理这个问题的方式是所有由组件创建的流必须走到takeUntil(this.destroy$)。所有全局流的订阅要么用async管道要么显式退订。Angular 的async管道在这里是救星它被销毁时会自动调用DataObserver的退订逻辑省去了大量样板代码。6.3 构建体积与按需加载Material 和 Lodash 不加以控制会让首屏包很大。Lodash 我上面说过用lodash-es配合 tree-shaking 控制体积。 Material 这边建议把路由级页面做成懒加载让列表页用的所有 Material 组件只进入对应页面 chunk而不是全部挤进主包。还有一个细节容易被忽略Material 主题文件。不要直接import angular/material/prebuilt-themes/indigo-pink.css一把梭。更合理的做法是自定义一个精简主题文件把用不到的颜色、字体、密度配置去掉。中后台项目通常都有品牌主色自定义主题反而更贴合实际需求。6.4 调试工具和最后的实战建议响应式流调试其实有一套实用方法论。我会在关键流里临时加一个tap(console.log)来看数据有没有走到对应管线。不过线上代码我不会保留这种日志一般把日志打印封到一个debug()操作符里方便按环境开关。Angular DevTools 是必备插件它能直观看到组件树里 OnPush 组件有没有被不必要地检查。RxJS 层面则可以借 RxJS DevTools 或简单的时间线录制来看流的触发顺序。遇到combineLatest一直不触发时先检查是不是某个上游流没有初始值这是最常见的静默问题。关于内存泄漏排查Chrome DevTools 的 Memory 面板里录制堆快照连续多次进入退出列表页看 detached component 数量有没有持续增长。如果每次退出后都残留组件实例基本可以断定某个订阅没有断开。最后说几条实战经验都是我实际踩过坑之后沉淀下来的模板里尽量只通过async管道拿到数据不要同时 subscribe 一份又手动赋值一份两套数据源一旦不同步调试会非常痛苦。Lodash 的groupBy返回的是普通对象模板里访问分组时最好通过keyvalue管道或者在组件里转换成更规整的结构避免模板语法绕来绕去。Material 的自定义主题色建议只维护一份_variables.scss不要每个组件文件里写死颜色值。换肤、暗色模式开启后零散的颜色值会让人改到怀疑人生。任何时候都不要在模板里直接调用返回新数据的函数比如getFilteredList()。这类函数每次变更检测都会执行大数据量下页面会肉眼可见地卡顿。应该把计算结果缓存到字段里或者用管道加pure: true。这套组合真正跑顺之后我会明显感觉到开发一个新的列表页往往半天就能完成。因为页面的骨架是 Material 给的数据流的骨架是 RxJS 给的数据加工是 Lodash 给的剩下的业务代码只是把三者粘起来。这也是我近两年来最推荐的 Angular 页面开发姿势——先搭好数据流的骨架再让 UI 组件往里填内容而不是反过来先画界面再硬接数据。
阅读完成 · 觉得有帮助?
咨询建站