ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Angular依赖注入与模块化架构:从原理到工程实践的完整指南

Angular依赖注入与模块化架构:从原理到工程实践的完整指南 1. 依赖注入到底给前端带来了什么先看这套设计解决的问题先说个背景我前后端都写Java后端用Spring用多了刚开始接触Angular的依赖注入时并没有觉得陌生反而有种“这东西终于被搬到前端来了”的亲切感。但后来带团队时发现很多从React或Vue转过来的同事对Angular的第一反应都是“上手有门槛报错看不懂”尤其是依赖注入相关的错误比如NullInjectorError: No provider for XXX一出现就慌不知道去哪找问题。说实话Angular的依赖注入系统与模块化架构恰恰是它和React、Vue这类框架最大的分水岭。React给你的是自由度组件想要什么数据自己通过props传或者引一个全局状态库自己管理Angular给你的是一套倒置的控制流组件不主动创建依赖而是声明“我需要什么”由框架在合适的时机把实例送进来。这个思维转换过来之后你会发现它解决的是前端项目规模变大以后最头疼的问题对象创建逻辑散落、模块之间隐式耦合、单测里mock依赖费劲。这篇文章我就围绕Angular依赖注入系统和模块化架构这条主线把我实际项目中怎么设计模块边界、怎么组织Provider、怎么处理懒加载作用域以及踩过的一堆坑完整梳理一遍。适合两类人看一是刚学完Angular基础语法、想搞清楚模块和依赖注入到底怎么配合的初学者二是有一定经验、但总是在运行时遇到注入异常和模块设计别扭的开发同学。看完之后至少你能自己搭出一套结构清晰、方便扩展的Angular应用骨架而不是把业务代码堆在几个巨型模块里。2. 依赖注入系统的核心机制不只是new一个对象那么简单2.1 Injector、Provider、Token三者的关系Angular的依赖注入本质上是一个三级结构的协作。Injector是负责创建和维护实例的容器Provider是告诉Injector“怎么创建这个依赖”的配方Token是这个依赖的标识符相当于取件码。三者关系可以类比成一个餐厅后厨Injector是厨房Provider是做菜的菜谱Token是菜名组件点菜的时候只报菜名后厨按菜谱做完端上来至于菜是现炒的还是提前预制好的点菜的人完全不关心。在代码层面Angular里最常见的三种Token来源分别是类本身、InjectionToken对象和字符串。类本身就是Token这种情况最直观比如构造函数里写constructor(private http: HttpClient)Angular看到HttpClient这个类型就知道要去Injector里找对应的ProviderInjectionToken通常用于非类的对象比如配置项、接口实现字符串Token是早期AngularJS时代的产物现在官方不太推荐因为容易命名冲突。Provider的写法有五种我先把它们列出来后面再逐个讲适用场景。useClass告诉Injector用指定的类来创建实例典型场景是给某个服务提供不同的实现比如开发环境用MockService生产环境用RealService。useValue直接提供一个现成的值比如配置对象、常量、第三方库实例。useFactory提供一个工厂函数由函数返回实例适合需要根据运行时条件动态创建依赖的情况。useExisting给已有的Provider起个别名相当于指向同一个实例。provide...开头的函数式ProviderAngular 14之后推荐的新写法比如provideHttpClient()、provideRouter()本质上是把一组配置封装成Provider数组后面我单独说。2.2 层级Injector是怎么影响实例共享范围的Angular的Injector不是只有一个而是一棵有层级的树。从上到下分别是环境级Injector、模块级Injector现在在Standalone模式下这个概念弱化了、组件级Injector。这个层级结构直接决定了服务的实例是全局单例还是每个组件独立一份。环境级Injector里提供的服务整个应用共享同一个实例适合放全局性的能力比如认证服务、全局配置、HTTP拦截器。组件级Injector则是在每个组件实例创建时生成一个子Injector如果组件在Provider里声明了某个服务那么这个组件的每个实例都会拿到自己的一份额外实例组件销毁时这个实例也被销毁适合做局部的状态缓存或者临时数据。这里有个关键机制叫“注入器的冒泡查找”当组件请求一个依赖时Angular会先从组件自己的Injector找找不到就逐级往父级Injector找一直找到环境级Injector还是找不到就抛出NullInjectorError。很多初学者踩的坑就在这里明明在某个NgModule里提供了服务但组件不在这个模块的导入链路上于是运行时直接报错而且报错信息里只有Token名称不会告诉你具体是哪个模块漏配了。我之前接手过一个项目有个DataService在SharedModule里被声明为providers: [DataService]这个模块被很多功能模块导入。后来有个新来的同事在自己模块里import了SharedModule组件里也注入了DataService但运行时就是报No provider。排查到最后发现他把模块import写到了SharedModule的声明文件里但没有在组件所属模块的imports数组中引入。这个问题就说明理解Injector层级比背API重要得多。2.3 providedIn和providers数组到底该用哪个这是Angular提供服务的两种主要方式。Injectable({ providedIn: root })是最省事的方案服务会被注册到环境级Injector而且Angular做了tree-shaking优化没被用到时这个服务不会打包进最终产物。另一种是在NgModule的providers数组或者组件的providers属性里声明这种方式在Angular的AOT编译下也能tree-shake但需要模块或组件被引用才行。我在实际项目里的选择标准很简单默认无脑用providedIn: root除非这个服务有明确的作用域需求。什么问题下用非root方式呢典型的场景是某个服务只服务某个懒加载路由模块而且希望这个模块被卸载时服务数据也能释放那就在这个路由模块的providers里注册。另一个场景是需要不同实例隔离状态比如多标签页场景每个标签页需要独立的查询状态就用组件级provider来提供。可以肯定的是不要为了图方便把所有服务都塞到providers: [XXXService]这样一种方式里尤其是放到SharedModule的providers里。这个做法常见于老项目和新手教程但坑在于如果SharedModule被多个懒加载模块导入Angular会在每个懒加载模块里各自创建一份服务实例导致你以为是全局状态实际上是各个模块各一份数据不同步排查起来特别的憋屈。3. 模块化架构从NgModule到Standalone的演进逻辑3.1 NgModule到底在管什么与DI紧密相关的模块化Angular里经历了从重NgModule到轻NgModule的演进。先理解NgModule管的三件事声明组件指令管道、导出这些声明供其他模块使用、提供模块级服务。过去一个功能模块的文件结构长这样NgModule({ declarations: [ DashboardComponent, StatCardComponent, ], imports: [ CommonModule, RouterModule.forChild([ { path: , component: DashboardComponent }, ]), ], providers: [ DashboardService, ], }) export class DashboardModule {}这套设计在当初是被当成最佳实践来推广的按功能拆模块每个模块封装内聚的功能模块之间通过imports和exports建立依赖关系。实际工程里也确实解决了不少问题比如代码组织清晰了按路由懒加载的时候只加载对应模块的代码编译粒度和打包粒度也更有章法。但用久了你会发现几个痛点。第一声明和导出的样板代码很多每个组件都要找到归属模块才能被使用组件多了以后很容易漏声明。第二模块级providers的作用域语义比较隐晦尤其是模块被懒加载时很多人搞不清楚服务到底是全局单例还是模块单例导致状态共享问题频发。第三NgModule的嵌套关系一旦复杂imports数组变得和意大利面一样每个模块导入哪个模块全凭添加顺序维护成本很高。3.2 Standalone模式模块化架构的下一步Angular 14开始引入Standalone组件到17、18、19后的版本里Standalone已经是默认方向。所谓Standalone就是组件不依赖于NgModule直接用standalone: true标记并在组件自身的imports数组里引入它依赖的组件、指令和管道。这样一来模块化架构的组织逻辑就从“一个大容器包着很多组件”变成了“每个组件自己声明依赖”Small Pieces Loosely Coupled这个原则被贯彻得更彻底。同样一个功能用Standalone写法就是这样Component({ selector: app-dashboard, standalone: true, imports: [CommonModule, RouterModule, StatCardComponent], providers: [DashboardService], template: app-stat-card *ngForlet item of items [data]item /, }) export class DashboardComponent { constructor(private dashboardService: DashboardService) {} // ... }需要明确的是Standalone不等于没有模块化而是把模块化从“编译单元层面的NgModule”转移到了“依赖声明的显式数组层面”。你可以继续按业务领域分目录、按路由分割代码块只不过不再需要为了使用某个组件而创建一个声明它的NgModule了。这种演进对DI的影响很大组件级providers的使用方式变得更加自然服务和组件的作用域关系一目了然。我实际项目里的策略是新代码统一用Standalone写法旧代码逐步迁移。这个策略初期会面临一个混合局面有的组件是Standalone有的还在NgModule里两者可以共存Standalone组件可以被NgModule声明NgModule里的组件也能被Standalone组件导入。迁移时可以按路由模块为单位逐个改造不必一次到位。3.3 特性域模块设计的几个原则不管用NgModule还是Standalone模块化的核心始终是“按特性域拆分而不是按技术类型拆分”。这句话是我带项目时反复强调的。什么叫按技术类型拆分就是建一个DirectivesModule把全局指令都放进去建一个PipesModule把所有管道塞进去再建一个ServicesModule把服务全部放进去。这种模式看着井然有序实际上把没有业务关联的东西硬绑定在一起任何小改动都会引起这个模块依赖链的连锁重构。按特性域拆分的思路是每个业务功能一块比如订单、用户、商品、报表各成一个模块模块内包含这个功能自己的组件、管道、指令和服务。模块之间的依赖关系经过精心设计保持单向避免循环依赖。我在带团队时定过几条硬性规矩分享出来供参考一个模块只依赖更底层的模块或共享模块不允许同级模块之间互相依赖。共享模块只放纯展示性组件、通用管道、通用指令不放业务服务。懒加载路由模块内部的服务默认放到该路由模块下不放到全局。全局单例服务必须用providedIn: root严禁在共享模块providers里声明业务服务。这些规则看着简单实际维护起来需要Code Review把关。模块化架构不是写出来就完了而是一种持续约束的工程纪律。4. 实战搭建一套可维护的依赖注入体系4.1 从路由驱动模块边界开始设计依赖注入体系的设计不应该从服务类开始写而应该从路由结构开始。因为路由结构决定了代码拆分边界而代码拆分边界又决定了Injector的作用域。一个合理的设计流程是先画出应用的功能地图确定哪些页面属于同一个特性域然后为每个特性域规划路由入口最后在每个路由节点上挂载独立的模块或Standalone组件。拿我最近写的一个后台管理项目举例。功能地图大致分认证、用户管理、商品管理、订单管理、数据报表、系统设置几大块。路由设计成这样的嵌套结构export const routes: Routes [ { path: login, component: LoginComponent, }, { path: , canActivate: [authGuard], children: [ { path: users, loadChildren: () import(./users/users.routes).then(m m.usersRoutes) }, { path: products, loadChildren: () import(./products/products.routes).then(m m.productsRoutes) }, { path: orders, loadChildren: () import(./orders/orders.routes).then(m m.ordersRoutes) }, { path: reports, loadChildren: () import(./reports/reports.routes).then(m m.reportsRoutes) }, { path: settings, loadChildren: () import(./settings/settings.routes).then(m m.settingsRoutes) }, ], }, ];这里要重点说下懒加载。loadChildren这个写法意味着用户访问/users时Angular才会去加载users.routes.ts所在的代码块。和这个代码块对应的组件、服务、管道都被打包到同一个懒加载chunk里。这样做的收益是首屏只加载必要的代码而副作用是这个懒加载模块内部会生成一个独立的Injector。关键来了如果某个服务在懒加载模块的providers里注册它会在这个独立Injector里新建实例和全局的环境级Injector是两套。你在环境级Injector里有个GlobalService在懒加载模块的providers里又注册了一次同名的GlobalService那么该模块里的组件拿到的就是模块级的新实例不是全局那一个。我在项目里遇到过一个特别典型的状态不同步问题最后定位到就是因为在某个懒加载模块的providers里重复注册了一个全局服务导致登录用户信息在部分页面是旧的这部分排查过程我放到后面的问题章节详细说。4.2 用“服务分类法”确定每个Provider的注册位置依赖注入体系的可靠性很大程度上取决于Provider注册位置是否和它的生命周期匹配。我在项目里把服务按以下四种类型分开处理每种类型的注册策略都不同。无状态工具型服务比如格式化工具、纯函数封装用providedIn: root因为无状态意味着它们的实例放哪里都安全全局共享最省资源。有状态的全局业务服务比如当前登录用户信息、应用级偏好设置、WebSocket连接管理器用providedIn: root保证所有组件拿到同一个实例避免状态不一致。有状态的特性域服务比如订单列表的筛选条件、用户管理页的选中项放在对应路由模块的providers里模块卸载时实例销毁页面状态随之重置。有状态的组件域服务比如一个复杂表单的临时校验状态、一个多步骤向导的当前步骤数据放在组件自己的providers里每个组件实例一份组件销毁时自然释放。举一个具体的例子。我在用户管理模块里有一个UserFilterService保存筛选条件用户搜索之后切到别的模块再回来希望筛选条件还在。这个服务如果放全局就会造成一个问题所有访问用户管理页面的用户共享同一份筛选条件不符合隔离要求如果放组件里则组件销毁时条件就丢了切换路由返回之后状态不存在。放到路由模块的providers里则模块在会话期内一直存活切换路由再回来状态还在而当用户退出登录或真正释放这个模块时状态被清理掉。这个例子加入实战以后能明显体会到不同注册位置带来的差异。需要补充的是路由模块里通过providers数组提供的服务在Angular的懒加载语义下是在该路由模块对应的独立Injector里注册的。在Standalone模式下如果路由配置直接使用loadComponent则每个懒加载组件自身的providers也可以达到类似效果只是粒度更细。4.3 配置型Provider的最佳姿势useFactory和InjectionToken项目中经常需要把一些配置对象注入到服务里比如API的基础地址、分页默认条数、缓存过期时间。这些配置在开发环境和生产环境往往不同。用useValue当然可以但更好的做法是用InjectionToken配合useFactory来做这样可以把配置的组装逻辑集中在一个地方而且依赖的其他服务也可以被一并解析出来。我通常的做法是定义一个环境令牌export interface AppConfig { apiBaseUrl: string; defaultPageSize: number; cacheTimeoutMs: number; enableFeatureX: boolean; } export const APP_CONFIG new InjectionTokenAppConfig(APP_CONFIG);然后在应用启动时用工厂函数提供具体值export const appConfigProvider: Provider { provide: APP_CONFIG, useFactory: (env: EnvironmentService) ({ apiBaseUrl: env.apiBaseUrl, defaultPageSize: env.defaultPageSize, cacheTimeoutMs: env.cacheTimeoutMs, enableFeatureX: env.enableFeatureX, }), deps: [EnvironmentService], };使用方只需要声明constructor( Inject(APP_CONFIG) private appConfig: AppConfig, ) {}这种写法最大的好处是想改配置来源时只需改Provider所有注入方都不用动。我曾在项目里把静态配置切换到远程配置中心下发就是靠这个设计做到的只改了appConfigProvider的实现所有消费方零改动。另一个场景是单元测试测试环境中覆盖Provider即可非常灵活。4.4 函数式Provider现代Angular推荐的默认写法Angular 14之后官方引入了一批以provide开头的函数式Provider比如provideHttpClient()、provideRouter(routes, withComponentInputBinding())、provideAnimations()。它们在底层本质上都是返回Provider数组的函数但在可用性和可组合性上大大优于手写数组。来看一个应用启动配置的对比。传统写法依赖NgModule需要把HttpClientModule、RouterModule等等都导入到AppModule里模块一旦多起来职责容易混。现代写法是直接在app.config.ts里集中配置export const appConfig: ApplicationConfig { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes, withComponentInputBinding(), withEnabledBlockingInitialNavigation()), provideHttpClient(), provideAnimations(), provideClientHydration(), appConfigProvider, authServiceProvider, ], };然后在main.ts里用bootstrapApplication(AppComponent, appConfig)启动。这套写法让依赖注入的配置完全独立于组件测试时也容易替换。项目从14开始用这套体系到现在已经非常稳定。我额外注意到有些团队还在用老式NgModule的写法想着“等有空再迁移”。但新代码如果继续用NgModule会越来越难受因为Angular的新生态工具和后端支持都在朝Standalone倾斜。如果团队是从新项目启动建议直接走bootstrapApplication这条路径别走回头路。5. 常见注入问题与排查技巧实录5.1 NullInjectorErrorNo provider的定位思路这是Angular报错里出现频率最高、也最让人头疼的一个。报错信息一般长这样ERROR NullInjectorError: No provider for CartService!第一反应不该是去问“为什么没有Provider”而是按链路排查这个CartService在哪注册的使用它的组件属于哪个模块它需要的其他依赖有没有提供。我把排查步骤总结成一个套路照着走基本能定位看CartService是否标注了Injectable({ providedIn: root })没有的话继续。看组件是Standalone还是属于某个NgModule。Standalone组件必须在自身的imports或providers中引入提供者NgModule组件则要看所属模块是否在providers中注册服务。看模块是否是懒加载模块如果组件在某懒加载模块里而服务只注册在另一个非懒加载模块里则必然No provider因为懒加载模块的Injector不会回溯到另一个兄弟模块的Injector。看服务自身有没有构造参数如果它依赖另一个服务那个服务可能也找不到Angular有时会报第一层的异常但根因在第二层。之前遇到过一个最阴间的场景服务本身有providedIn: root但还是报了No provider。最后发现是手滑在服务类定义的同时在某个模块的providers里也注册了一遍而那个模块又广播式的反复导入了低层级模块激发了某条特殊的层级关系导致AOT编译后生成的注入指令是模块级Token。这属于比较边缘的情况但也说明不要到处重复注册同一个服务一个服务一个注册位置就够。5.2 循环依赖Dont worryAngular能创建但容易有隐藏陷阱Angular允许构造函数注入的循环依赖因为Injector在创建实例时会先注册一个占位对象再填充真实实例。这意味着ServiceA依赖ServiceB而ServiceB又依赖ServiceA在运行时并不会直接报错但会有个隐患如果你在构造函数里立刻调用对方的方法拿到的可能是未初始化完全的对象导致控制流异常。我在项目里遇到过一次真实的循环依赖问题AuthService依赖UserService获取当前用户信息而UserService依赖AuthService做权限校验两个服务在构造时都会访问对方的某个方法。运行期明明不报错但用户登录后偶尔拿不到用户信息。排查了半天才发现是因为初始化顺序导致的。解决循环依赖最好的办法是重构依赖方向而不是绕过报错。常见的手段包括将一个服务里的公共逻辑抽到第三个服务打破A和B的直接依赖。改用事件或消息机制服务之间不直接调用通过一个EventBus通信。使用inject()函数配合forwardRef(() ...)延迟解析依赖但这只是兜底方案不宜作为长期设计。forwardRef本身有一种误导性的安全感特别是在Angular的源码或网上示例里偶尔能看到。它能帮你在建模时将循环依赖标记为“当前无法避免”但如果你在项目里看到很多forwardRef就要反思一下是不是架构已经开始腐化了。5.3 useFactory的deps漏配问题useFactory看起来强大但有个很容易忽略的坑如果工厂函数参数里需要注入别的服务必须在deps数组里明确列出否则Angular会在工厂解析时给你一个摸不着头脑的报错。举例说明如果写成这样const provider { provide: API_SERVICE, useFactory: (http: HttpClient) new ApiService(http), };而忘了写deps: [HttpClient]运行时你会在控制台看到一个NullInjectorError指向API_SERVICE。乍一看是API_SERVICE没有提供者实际是HttpClient没有作为依赖注入到工厂函数里。这种错误在排查的时候很容易走弯路。正确写法const provider { provide: API_SERVICE, useFactory: (http: HttpClient) new ApiService(http), deps: [HttpClient], };这个问题之所以高频出现是很多同学把useFactory理解成了“一个普通函数需要什么自己创建”压根没想到它的参数也要靠Angular注入。记住一点工厂函数不是手动调用的它的参数完全依赖deps数组来匹配Token。5.4 懒加载模块中的服务隔离全局态与局部态的博弈这是Angular项目状态管理的重灾区。很多状态管理库如NgRx、Ngxs都在用全局Store但当你把store服务注册在懒加载模块providers里时意外就来了。要么是懒加载模块多了一份Store实例页面状态时有时无要么是懒加载模块和全局Store灰度不同步界面显示错乱。我处理这个问题的原则是四条全局Store和认证、用户、权限等服务一律providedIn: root。懒加载模块内的服务默认注册在module providers或组件providers里。模块需要拿到全局Store时只做注入不在自己providers里重复声明。模块的局部状态可以丢给NgRx的Feature Store或者独立的轻量状态服务但必须明确生命周期。有一次排查了很久的问题就是这么来的。同事把AuthStore在一个懒加载的订单模块里又配了一份Provider于是用户在订单页能看到登录态但到商品页又是未登录。因为两份AuthStore实例各持一份user数据而登录流程只更新了全局那一份。经验之谈全局有状态服务无论如何不要在懒加载模块里重新providers。5.5 Standalone模式下providers与视图提供者的边界Standalone组件出现后很多人以为providers只在组件上写忽略了路由级和路由子级注入的作用域。实际上Standalone组件的providers只对该组件和它的子组件可见不能跨路由兄弟组件共享。因此如果一个DashboardComponent是Standalone它提供了一些服务给内部子组件用那没问题但如果你希望它和DetailComponent共享某个状态就必须把服务注册到更上层。Routed Standalone组件还可以用providers数组和loadComponent结合在懒加载时按需注册服务。Angular 17之后可以直接在路由配置里给Component挂providers这是新的标准做法。遇到跨路由共享状态的需求时我会往上挪一级放在父级路由或应用级配置中不建议把有状态服务放在普通Standalone组件里。6. 给新项目的几个依赖注入设计建议回到Angular依赖注入系统与模块化架构这个话题本身我个人更愿意给出一些可以落地的设计建议而不是单纯罗列特性。第一点服务的作用域是DI设计中最关键的决策一定要在写服务之前先问自己三个问题这个服务有没有状态状态需要跨多少页面共享生命周期应该跟着谁走想清楚这三个问题再决定用providedIn: root还是模块级还是组件级providers。不要上来就无脑root也不要为了“局部化”而局部化。滥用root的问题通常在项目规模变大后集中爆发尤其是内存占用和跨页面状态污染滥用局部Provider的问题则是状态不方便共享写代码时要一层层向上传递。第二点Provider注册要遵循“一服务一位置”原则。同一个Token全项目只注册一次。确实需要多实例场景时用工厂或显式命名来区分不要重复注册。重复注册不只是覆盖面问题一旦懒加载模块参与进来就是数据的隐蔽分裂。第三点模块边界要跟着路由和业务逻辑走不要跟着技术类型走。技术类型分组在前期项目小的时候看着整洁后期项目大时是重构的噩梦。如果你的代码里已经出现CommonModule里面塞了业务服务、SharedModule被十几个功能模块引用、features之间互相import过深的迹象最好及时把服务挪到对应的业务模块里。第四点新代码尽可能使用Standalone组件和函数式Provider方案。这不是为了赶潮流而是这套写法能更好地表达依赖关系也能利用tree-shaking减小打包体积。Angular团队已经把Standalone作为默认推荐生态伙伴也在同步适配。团队里如果还在使用旧NgModule可以先对一个新的路由模块实践积累经验后再推广。第五点用测试驱动依赖设计。一个依赖注入设计得好不好单测写得顺不顺是一个很好的检验标准。服务职责单一、依赖清晰时单测只需要Mock掉了不起两个依赖如果发现Mock依赖极其困难或需要一大堆反馈设置通常是DI设计过于复杂、服务之间耦合过深的表现。这也是我在每个项目里都会留意的“设计信号”。7. 我的经验体会与避坑总结写到这里其实我最想说的是Angular的依赖注入系统和模块化架构是同一个问题的两面前者是运行时怎么把依赖组装起来后者是编译期怎么把代码组织起来两者互相影响。很多项目报错看起来是DI问题根子却在模块拆分混乱很多模块看似规划合理实际上因为service作用域设置不当最终代码仍然揉成了一团。回头看我经手的Angular项目凡是稳定、可维护、迭代速度快的无不在依赖注入和模块化上下过真功夫。一开始掉进的坑肯定不少比如无脑把所有服务都塞到SharedModule里、把全局状态服务注册在懒加载模块里、useFactory漏写deps等但踩过之后才会真正理解设计的重要性。好在这套机制的容错度比较高只要愿意把Provider的注册位置梳理清楚项目的可维护性会明显上一个台阶。最后分享一个小技巧在启动阶段把整个应用的Provider注册信息打印出来配合environmentInjector和createEnvironmentInjector可以做一个快速诊断工具判断重复注册和错误作用域。我通常在开发模式下写一段脚本收集所有Provider的Token按注册位置生成树状图项目大了以后这个脚本帮我揪出了不少隐蔽的重复注册问题。如果你也经常被这类问题困扰不妨试试这种做法。
返回列表