
做中后台系统的同学应该都有这种体验页签栏上开着好几个页面用户在 A 页面填了一半表单切到 B 页面查资料再切回来表单数据还在。很多人以为这是某个组件缓存做到的其实背后更稳的做法是把这部分数据放到页签之外的“无页签资源”里。所谓无页签资源就是不挂在视图层、不随页面切换销毁的独立代码模块。这类资源在项目里常以类实例的形式存在于是“类实例中方法调用”就成了最基础也最容易被写崩的一环。这篇文章就专门聊聊这个话题包括为什么用类实例、方法调用时 this 怎么丢、资源生命周期怎么管以及一个可直接落地的资源管理器示例。1. 先把“无页签资源”这事说清楚1.1 页签是界面层资源和它不是一回事在写后台管理系统时我们经常听到“页签”这个概念。很多中后台框架会把菜单打开的页面以标签页形式展示在顶部方便用户在多个功能间来回切换。这个页签本质上是视图层的导航状态它管理的是组件、路由、页面实例。但并不是所有东西都应该塞进页签里比如登录用户的 token、权限码、购物车数据、埋点队列、字典缓存、全局配置。这些东西通常需要跨页面共享甚至页面关了以后还要继续存在。它们就是无页签资源。无页签资源的重要特征有三个第一生命周期比页面长至少和当前登录会话一致第二作用域比组件大需要多个页面同时读取和修改第三对外暴露的是方法而不是单纯的属性。比如用户点击退出登录所有页面都要感知这个时候如果每个页面各自保存用户信息就得靠事件同步很容易漏。如果做一个 UserSession 类的实例页面每次通过同一个实例去获取和修改就不存在同步问题。1.2 哪些无页签资源适合用类实例封装用类实例去封装无页签资源不是因为 class 比对象字面量高级而是因为我们需要“状态 行为”绑定在一起而且可能同时存在多个有相同结构但内容不同的实例。比如一个购物车资源可能有“当前用户购物车”和“临时访客购物车”两个实例它们的方法相同、数据不同。这种情况下用类定义行为再分别实例化比复制两份对象字面量要合理得多。适合用类实例封装的无页签资源我总结了几类全局用户会话、权限缓存、配置字典、消息总线、事件中心、请求队列、埋点采集器、本地存储封装。这些模块的共同点是都有内部状态都有一组操作方法都需要在多个页面之间保持同一份数据。如果只是纯工具函数比如日期格式化、字符串判断那直接用 export function 就行没必要强行套 class。1.3 无页签资源在单页应用里的承载方式单页应用里承载无页签资源最朴素的方式就是模块级单例。也就是在一个 js 文件里导出一个实例所有页面 import 同一个引用。比如// session.js class UserSession { constructor() { this.user null; this.token ; } login(user, token) { this.user user; this.token token; } } export const session new UserSession();这种写法的好处是简单直观只要不重新加载页面session 永远只有一个所有模块拿到的都是同一份状态。坏处是不方便测试多个模块直接耦合到一个具体实例上想 mock 的时候比较麻烦。稍微复杂一点的项目会用依赖注入或者用一个资源管理器统一管理这点后面第五部分会给出完整示例。不管是简单的模块单例还是复杂的资源管理器底层都离不开“实例化之后调用方法”这件听起来不起眼、写起来到处是坑的事。2. 类实例方法调用的基本功与设计取舍2.1 从声明到调用一个类实例是怎么跑起来的类定义本身是一张“图纸”实例是根据图纸造出来的“实体”。JavaScript 里用 class 关键字定义类用 new 关键字创建实例括号里传的参数会进入 constructor 构造方法完成内部状态初始化。来看这段常见代码class Counter { constructor(initValue 0) { this.count initValue; } increment() { this.count 1; return this.count; } reset() { this.count 0; return this.count; } } const c1 new Counter(10); const c2 new Counter(100); console.log(c1.increment()); // 11 console.log(c2.increment()); // 101这里最容易忽略的一点是实例方法 increment 并不是每个实例自己复制了一份而是定义在类的原型上实例通过原型链访问。这带来的实际影响就是c1.increment c2.increment 这个比较结果是 true。方法只有一个但每次调用时 this 指向调用者所以 c1 和 c2 的结果互不干扰。很多新手会在这里产生误解以为方法复制到了实例上然后在事件回调里直接把 c1.increment 交给 setTimeout结果发现 this 指向变了。这个问题的根源恰恰就是“方法没有复制到实例上”丢失了与实例的绑定关系。理解原型链是理解后面 this 绑定问题的基础。2.2 实例方法、静态方法、私有方法的调用差异在无页签资源里方法并不全都要通过实例调用。JavaScript 的 class 支持三种方法形态实例方法、静态方法、私有方法。它们的调用方式和使用场景差别很大简单列个表方法类型定义方式调用方式典型用途实例方法method() {}instance.method()操作实例内部状态静态方法static method() {}Class.method()工具函数、工厂创建实例私有方法#method() {}仅类内部 this.#method()隐藏内部实现细节写无页签资源时最常见的误区是把所有逻辑都写成实例方法。其实有些逻辑跟具体实例状态无关比如根据响应头解析 token 的纯函数放静态方法更合理外部调用时不需要先 new 一个实例。也有些逻辑只是内部辅助比如“校验参数是否合法”这类功能如果暴露出去容易被外部错误调用用 # 私有方法收口更安全。我见过不少项目把内部辅助方法也写成普通实例方法结果外部随意调用最后接口约束形同虚设。2.3 返回 this 实现链式调用有些无页签资源的接口设计成链式调用会特别顺手。比如一个埋点上报器希望写成像这样tracker.setEventName(click).setBizType(order).appendData({ id: 123 }).send();实现这种方式很简单只需要让每个不返回具体数据的方法最后 return thisclass Tracker { constructor() { this.eventData {}; } setEventName(name) { this.eventData.eventName name; return this; } setBizType(type) { this.eventData.bizType type; return this; } appendData(data) { Object.assign(this.eventData, data); return this; } send() { console.log(send:, this.eventData); return this; } }这里要注意一个细节链式调用和异步方法在一起时容易出问题。比如 send 是一个异步上报方法内部会向服务端发请求如果直接把 pending 的 promise 返回给链式调用者外部拿到的其实是一个正在进行的请求句柄一旦后续还有 appendData 的操作时序上就会出现先发请求后补数据这种诡异现象。正确的做法是在 send 内部管理好异步状态外部不要依赖链式调用的返回值如果确实需要知道发送结果可以把 send 设计成返回 promise 的终结方法并且在文档里明确标注。否则方法链一旦出现时序依赖排查的时候会非常痛苦。3. 无页签场景下的 this 绑定踩坑最多3.1 为什么回调里 this 会变成 undefined我记得第一次在项目里遇到 this 丢失是在一个全局登录态资源里写定时刷新 token 的逻辑class Session { refreshToken() { console.log(this); } startTimer() { setInterval(this.refreshToken, 60000); } }这段代码运行时refreshToken 里的 this 不是 Session 实例浏览器环境里通常是 undefinedNode 环境里可能是 Timeout 对象。原因很简单setInterval 回调执行时调用者是定时器本身JavaScript 没有魔法去记住“这个方法当初是从哪个实例取出来的”。实例方法放在原型上谁调用它this 就是谁。这种问题不仅仅出现在定时器里事件监听、Promise 回调、数组遍历方法里都可能出现尤其是把资源方法直接传给第三方库时基本一传一个准。3.2 四种修复绑定问题的方法对比解决 this 绑定问题我见过四种常见做法实际项目中要根据场景选。第一种是 bind 绑定。在把方法传给回调之前先 bind 一下setInterval(this.refreshToken.bind(this), 60000);这种做法最直观但每次 bind 都会生成一个新函数如果还需要解绑事件得把 bind 后的函数存起来。第二种是箭头函数。箭头函数没有自己的 this它会继承定义时外层的 thisstartTimer() { setInterval(() { this.refreshToken(); }, 60000); }这是目前最推荐的写法代码可读性好也不容易出额外的内存问题。缺点是每次 startTimer 都创建了新箭头函数不过对多数场景来说这开销可以忽略。第三种是类字段箭头函数。直接在类里定义成箭头函数class Session { refreshToken () { // ... }; }这种写法把方法从原型挪到了实例上每个实例都有一份独立函数好处是天然绑定 this坏处是内存占用比原型方法高而且子类继承时行为可能和预期不同。对单例资源来说问题不大但如果一个类会创建大量实例要慎重。第四种是调用时显式指定 this也就是 Function.prototype.call 或 applysetInterval(() { Session.prototype.refreshToken.call(session); }, 60000);这种写法适合某些特殊场景比如想复用原型方法但不希望实例上有额外的字段不过日常代码里可读性偏差不建议大面积使用。3.3 绑定问题在事件订阅、消息总线里的表现无页签资源经常扮演消息总线的角色比如一个全局事件中心class EventBus { constructor() { this.listeners new Map(); } on(event, callback) { // ... } emit(event, payload) { // ... } }页面在 mounted 里注册事件回调时如果直接把另一个类实例的方法传进去同样会遇到 this 丢失问题shoppingCart.on(updated, userStatus.refresh.bind(userStatus));更隐蔽的问题出在解绑时。如果用 bind 生成新函数保存到 listener 列表那解绑时也得用同一个 bind 结果否则怎么都解不掉导致内存泄漏。我建议消息总线的回调设计最好一开始就约定“回调方法不允许携带 this 依赖”接收方自己处理上下文绑定。比如在组件里这样写const onUpdated () { this.someResource.refresh(); }; this.bus.on(updated, onUpdated); this.bus.off(updated, onUpdated);这样 onUpdated 和 off 用的是同一个引用事件订阅读写安全this 也明确指向组件实例后期排查轻松不少。4. 资源生命周期初始化、复用、销毁4.1 单例模式全局资源该不该只有一个实例无页签资源通常天然要求全局只有一个实例比如用户信息、权限数据整个系统只应该有一份。JavaScript 里实现单例最简洁的方式就是模块级变量class UserSession { constructor() { if (UserSession.instance) { return UserSession.instance; } UserSession.instance this; this.user null; } } export const session new UserSession();不过我更推荐在模块里直接 new 一个实例然后导出而不是在类构造里做单例判断。原因是构造器里做单例会隐藏实例化成本测试时也不方便每次创建干净环境。模块级单例每次 import 拿到的都是同一个引用已经足够满足“无页签资源”的要求。想要更灵活可以加一个资源管理器统一持有实例后面第五章就是这么干的。只有那些需要“按域隔离”的资源才应该允许创建多个实例。4.2 资源初始化与懒加载无页签资源的初始化时机值得仔细设计。有些资源可以在应用启动时就同步初始化好比如字典缓存启动时拉一次之后页面直接读有些资源则要等用户操作后才初始化比如购物车用户没登录前可能是空的。这种延迟初始化叫懒加载常见写法是class ShoppingCart { getCart() { if (!this.cartData) { this.cartData this.loadFromStorage(); } return this.cartData; } }懒加载的坑在于多个页面同时第一次访问资源时可能触发重复初始化。比如两个模块同时调用 getCart都发现 cartData 为空于是各自加载一份后写的覆盖了先写的数据就丢了。解决办法是初始化时加一个状态标记或者用一个 init promise 保证只执行一次。这部分我在第五部分的资源管理器示例里会有体现核心思路是把初始化变成一个幂等操作。4.3 销毁不及时的内存泄漏典型情况无页签资源的生命周期长如果不做好清理很容易变成内存泄漏源。最常见的三种泄漏第一页面销毁后没有解绑全局事件监听导致页面上的实例一直被 EventBus 引用第二setInterval 定时器没有在资源销毁时清掉循环调用一直持续第三把大量数据保存在单例资源里页面切换后从未清理越积越多。针对这些资源类最好显式提供 destroy 方法class UserSession { destroy() { if (this.timer) { clearInterval(this.timer); this.timer null; } this.eventOffAll this.eventOffAll(); this.user null; this.token ; } }在应用退出登录、切换账号时调用 destroy再重新创建或重置资源。很多项目不做 reset 只做 destroy结果下次登录时拿到的是残留数据bug 非常隐蔽。我后来在项目里统一约定每个资源必须同时提供 reset 和 destroyreset 负责把状态恢复成初始值destroy 额外负责释放外部引用和定时器两个方法职责不重叠。5. 实操写一个无页签全局资源管理器5.1 需求与设计现在我不讲理论了直接带大家写一个可用的资源管理器。需求很简单项目里有多个无页签资源分别负责用户会话、购物车、消息中心希望有一个统一入口来注册、获取、调用它们的实例方法同时要解决重复初始化和 this 绑定问题。我设计成两个核心类ResourceManager 负责注册和获取资源BaseResource 作为其他资源的基类提供统一的初始化、重置钩子。调用方不需要关心资源是怎么创建的只需要通过 manager.get(cart).addItem(...) 这样的方式使用。注册时支持两种模式同步资源和异步初始化资源。对于异步资源我用一个 initPromises 的 Map 做缓存保证并发调用时不会重复执行初始化。5.2 代码实现先看资源管理器class ResourceManager { constructor() { this.resources new Map(); this.initPromises new Map(); } register(name, factory, options {}) { if (this.resources.has(name)) { throw new Error(Resource ${name} has been registered); } this.resources.set(name, { factory, singleton: options.singleton ! false, instance: null, }); } get(name) { const descriptor this.resources.get(name); if (!descriptor) { throw new Error(Resource ${name} is not found); } if (!descriptor.instance) { descriptor.instance descriptor.factory(); } return descriptor.instance; } async getAsync(name) { const descriptor this.resources.get(name); if (!descriptor) { throw new Error(Resource ${name} is not found); } if (!descriptor.instance) { if (!this.initPromises.has(name)) { const promise Promise.resolve() .then(() descriptor.factory()) .then((instance) { descriptor.instance instance; return instance; }) .finally(() { this.initPromises.delete(name); }); this.initPromises.set(name, promise); } return this.initPromises.get(name); } return descriptor.instance; } reset(name) { const descriptor this.resources.get(name); if (descriptor descriptor.instance descriptor.instance.reset) { descriptor.instance.reset(); } } resetAll() { for (const name of this.resources.keys()) { this.reset(name); } } } export const resourceManager new ResourceManager();资源基类class BaseResource { constructor() { this.initialized false; } async init() { this.initialized true; } reset() { this.initialized false; } }具体资源示例用户会话class UserSessionResource extends BaseResource { constructor() { super(); this.user null; this.token ; } async init() { if (this.initialized) return; const token localStorage.getItem(session_token); if (token) { this.token token; this.user { name: 从缓存恢复 }; } this.initialized true; } login(user, token) { this.user user; this.token token; localStorage.setItem(session_token, token); } logout() { this.user null; this.token ; localStorage.removeItem(session_token); } get displayName() { return this.user ? this.user.name : 未登录; } } resourceManager.register(session, () new UserSessionResource());购物车资源class CartResource extends BaseResource { constructor() { super(); this.items []; } addItem(item) { const exist this.items.find((it) it.id item.id); if (exist) { exist.count item.count || 1; } else { this.items.push({ ...item, count: item.count || 1 }); } } removeItem(id) { this.items this.items.filter((it) it.id ! id); } get totalCount() { return this.items.reduce((sum, it) sum it.count, 0); } reset() { super.reset(); this.items []; } } resourceManager.register(cart, () new CartResource());5.3 在页面中调用资源方法页面里使用这些资源时不用关心实例是怎么创建的只要从 manager 取async function onLogin() { const session await resourceManager.getAsync(session); await session.init(); session.login({ name: 张三 }, token123); const cart resourceManager.get(cart); cart.addItem({ id: 1, name: 键盘, count: 2 }); }要注意 getAsync 和 get 的选择。如果资源初始化是异步的比如要从接口拉用户信息就别用 get 直接拿否则拿到的是尚未初始化好的实例。用 getAsync 之后因为 initPromises 的缓存效果多个并发调用最终拿到的是同一个初始化好的实例避免重复拉取。调用资源上的实例方法时尽量通过 manager 取到实例后再调用而不是把实例方法引用到处传。比如在 Vue 组件里如果在 methods 里写this.userSession.login.bind(this.userSession)很容易出问题不如在需要时重新resourceManager.get(session).login(...)。这样写虽然每次多了一次 Map 查询但代码语义清楚this 指向永远不会错排查时也只需要关注 resourceManager 这一个入口。6. 常见问题与排查技巧6.1 高发问题速查表现象可能原因处理建议方法里 this 是 undefined方法作为回调传入丢失绑定用箭头函数或 bind 包一层反复调用 init数据被重置缺少 initialized 标记在资源里用 Promise 或标志位做幂等切换账号后还有旧用户数据logout 或 destroy 没清理干净在 reset 中清空所有实例字段事件解绑无效bind 生成新函数后解绑用的不是同一个引用保存绑定后的函数引用并在解绑时复用修改数据后页面不刷新无页签资源不是响应式对象手动触发页面刷新或使用状态库桥接多个并发首次 get 触发重复初始化没有等 init 完成就调用使用 getAsync 或初始化缓存6.2 排查方法和一点心得遇到类实例方法调用问题我的排查套路是先看 this 指向再看出处再看生命周期。第一站在方法内部打一个 console.log(this)确认调用时上下文第二站看这个实例是谁创建的是从 resourceManager 取的还是 new 出来的一个全新对象第三站看这个资源有没有被 reset 或 destroy 过。这套顺序基本能覆盖八成问题。最后分享一个小技巧无页签资源的类设计最好把所有内部状态初始化都放在 constructor 里并且提供一个干净的 reset 方法它能重置所有字段而不是只重置一部分。这样在测试和排查时可以非常方便地制造“全新实例”的状态。很多项目里的诡异 bug最后都是因为某些资源在某种路径下被残留字段污染了。把 reset 写清楚能省掉大量排查时间。我在实际项目中踩过几次坑之后已经把“任何资源类都必须有 reset”写进了团队代码规范新成员照着写基本不会再出那种登录切换后用户信息串了的低级问题。