ARTICLE DETAIL

资讯详情

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

@lit/context 实战指南:用 Context Protocol 在 Lit 组件树中传递数据

@lit/context 实战指南:用 Context Protocol 在 Lit 组件树中传递数据 lit/context 实战指南用 Context Protocol 在 Lit 组件树中传递数据【免费下载链接】litLit is a simple library for building fast, lightweight web components.项目地址: https://gitcode.com/GitHub_Trending/li/lit导读lit/context是 Lit 对 Web Components Community Group 提出的 Context Protocol 的官方实现通过context-request/context-provider事件让数据可以在整棵组件子树中共享彻底告别prop drilling式的层层透传。读完本文你将掌握createContext创建类型安全上下文、provide/consume装饰器与ContextProvider/ContextConsumer控制器两种消费与提供方式、以及用ContextRoot解决提供方晚升级难题的完整实战方案。什么是 Context Protocol为什么需要它在 Web Components 体系中自定义元素之间天然的通信手段是 DOM 事件与属性property/attribute。但当组件树层级很深时把数据从根组件传到深层叶子组件往往需要每一层组件都显式接收并转发数据这就是常说的 prop drilling属性钻透。Context Protocol 的核心思想是处于 DOM 树低层的组件向祖先发出我需要某个数据的请求由离它最近的、能提供该数据的祖先响应这个请求。数据沿树向下传递但中间层组件完全无需感知、无需透传解耦了数据的提供者与数据的消费者。lit/context就是这一协议的 Lit 实现它提供createContext创建带类型信息的上下文标识context keyContextConsumer控制器与consume属性装饰器在组件中发起数据请求ContextProvider控制器与provide属性装饰器在组件中提供数据ContextRoot拦截并重放暂时无人应答的上下文请求解决提供方晚升级问题。从源码结构看这些能力集中在 packages/context/src/lib 下并由 packages/context/src/index.ts 统一对外导出安装包名为lit/context当前仓库版本为 1.1.6见 packages/context/package.json仅依赖lit/reactive-element。创建 ContextcreateContext与类型安全使用 Context API 的第一步是定义一个上下文标识也就是约定双方通过哪个 key 来匹配。惯例上会把上下文定义放在一个独立模块中供提供方与消费方共同导入。以下是lit/contextREADME 中的完整示例我们先定义一个 Logger 接口与对应的上下文logger.tsimport {createContext} from lit/context; export interface Logger { log: (msg: string) void; } export const loggerContext createContextLogger(logger);createContext的实现非常轻量见 packages/context/src/lib/create-context.tsexport function createContextValueType, K unknown(key: K) { return key as ContextK, ValueType; }它本质上只是把传入的 key 断言为带类型标记的Context类型运行时不做任何包装export type ContextKeyType, ValueType KeyType {__context__: ValueType};Context是一个类型品牌type brand——{__context__: ValueType}仅存在于类型层面用于在编译期把上下文 key与上下文值的类型绑定起来。配套的类型工具ContextTypeKey可以从Context中提取值类型create-context.ts供ContextConsumer、ContextProvider等做泛型推导。key 的选择决定上下文如何匹配createContext的文档注释create-context.ts明确说明了关键语义上下文之间以严格相等strict equality比较。因此想在不同模块、甚至不同包中指向同一个上下文请使用严格相等下相等的 key// 均为 true createContext(my-context) createContext(my-context) createContext(Symbol.for(my-context)) createContext(Symbol.for(my-context))想保证上下文独一无二、绝不与其他上下文冲突请使用严格相等下唯一的 key// 均为 false createContext({}) createContext({}) createContext(Symbol(my-context)) createContext(Symbol(my-context))这也意味着若两个地方用相同字符串调用createContext它们会被视为同一个上下文若使用新的Symbol()或对象字面量则各自独立。实践中跨包共享上下文应优先使用Symbol.for或约定好的字符串常量。消费 Context 的两种方式方式一consume属性装饰器在组件内声明一个属性并用consume标注它需要从祖先处获取上下文值my-element.tsimport {LitElement, property} from lit; import {consume} from lit/context; import {Logger, loggerContext} from ./logger.js; export class MyElement extends LitElement { consume({context: loggerContext, subscribe: true}) property({attribute: false}) public logger?: Logger; private doThing() { this.logger?.log(a thing was done); } }要点说明consume接收{context, subscribe}选项对应 packages/context/src/lib/decorators/consume.ts 中ConsumeDecorator的入参context必填即createContext创建的上下文标识subscribe可选布尔值为true时允许上下文值后续被多次更新并反映到属性上不传则视为一次性请求。consume会在元素初始化时创建一个ContextConsumer控制器并把提供方回传的值写入该属性consume装饰器的addInitializer回调中即new ContextConsumer(element, {context, callback: ...})。因此务必与property({attribute: false})搭配让值变更能够触发 Lit 的响应式更新使新值反映到模板中。消费方请求是通过向上冒泡的context-request事件完成的见下文事件协议小节。方式二ContextConsumer控制器如果不想用装饰器可以直接实例化ContextConsumer控制器packages/context/src/lib/controllers/context-consumer.tsmy-element.tsimport {LitElement, property} from lit; import {ContextConsumer} from lit/context; import {Logger, loggerContext} from ./logger.js; export class MyElement extends LitElement { public logger new ContextConsumer( this, loggerContext, undefined, // dont need to pass a callback true // pass true to get updates if the logger changes ); private doThing() { this.logger.value?.log(a thing was done); } }ContextConsumer的构造函数支持两种签名context-consumer.tsnew ContextConsumer(host, options) // 推荐options 对象形式 new ContextConsumer(host, context, callback?, subscribe?) // 旧式位置参数形式标记为 deprecatedOptions字段为context-consumer.ts字段类型说明contextC要请求的上下文标识callback(value, dispose?) void可选值就绪时回调若传入了该回调则不再自动更新属性subscribeboolean可选true表示订阅后续的值变化ContextConsumer的生命周期行为context-consumer.ts连接时hostConnected向宿主元素派发context-request事件请求数据获得值后把值存入value属性调用host.requestUpdate()触发一次宿主更新并通过callback通知使用者订阅管理内部_callback会记录提供方传入的unsubscribe函数若subscribe为false收到首次值后立即退订若发现unsubscribe函数发生变化说明换了提供方先清理旧提供方的订阅断开时hostDisconnected调用unsubscribe解除订阅避免内存泄漏。提供 Context 的两种方式方式一provide属性装饰器在组件树中较高的位置提供数据用provide标注属性my-app.tsimport {LitElement, property, html} from lit; import {provide} from lit/context; import {loggerContext, Logger} from ./logger.js; export class MyApp extends LitElement { provide({context: loggerContext}) property({attribute: false}) public logger: Logger { log: (msg) { console.log([my-app] ${msg}); }, }; protected render(): TemplateResult { return htmlmy-thing/my-thing; } }provide的选项只有context必填。装饰器内部packages/context/src/lib/decorators/provide.ts会为每个元素实例创建一个ContextProvider控制器并且代理属性的 setter每当属性被赋予新值时同步调用provider.setValue(value)把新值推送给所有订阅的消费者。这正是provide与property搭配使用时属性一变、所有消费者跟着更新的原因。方式二ContextProvider控制器同样可以直接实例化控制器packages/context/src/lib/controllers/context-provider.tsmy-app.tsimport {LitElement, html} from lit; import {ContextProvider} from lit/context; import {loggerContext, Logger} from ./logger.js; export class MyApp extends LitElement { // create a provider controller and a default logger private provider new ContextProvider(this, loggerContext, { log: (msg) { console.log([my-app] ${msg}); }, }); protected render(): TemplateResult { return htmlmy-thing/my-thing; } public setLogger(newLogger: Logger) { // update the provider with a new logger value this.provider.setValue(newLogger); } }ContextProvider同样支持两种构造签名context-provider.tsnew ContextProvider(host, options) // 推荐{context, initialValue?} new ContextProvider(host, context, initialValue?) // 旧式位置参数形式标记为 deprecatedOptions字段context-provider.ts字段类型说明contextC必填要提供的上下文标识initialValueContextTypeC可选初始值ContextProvider继承自ValueNotifierpackages/context/src/lib/value-notifier.ts内部维护subscriptions订阅表setValue(v, force?)更新值默认用Object.is比较新旧值值变化时才通知所有订阅者force true可强制通知addCallback(callback, consumerHost, subscribe?)注册订阅subscribe为false时只回调一次即完成true时把回调登记进订阅表并随值更新反复回调updateObservers()遍历订阅表把当前值连同disposer一起回调给每个消费者。用普通 HTML 元素做提供方contextTarget与initialValueContextProvider的宿主并不要求是 Lit 自定义元素——任何 HTML 元素都可以作为提供方这在不想额外引入自定义元素时非常实用。README 给出的完整示例my-app.jsimport {ContextProvider} from lit/context; import {loggerContext, Logger} from ./logger.js; // create a provider for the whole document body. const loggingProvider new ContextProvider(document.body, { context: loggerContext, initialValue: { log: (msg) { console.log([global] ${msg}); }, }, );注意这里传入了{context, initialValue}选项对象因为document.body不是组件实例无法通过属性同步值初始值必须显式给出。从源码看context-provider.tsContextProvider的宿主类型被定义为PartialReactiveControllerHost HTMLElement——即实现了ReactiveControllerHost的 HTML 元素可选。对自定义元素而言hostConnected()会由 Lit 的控制器生命周期自动调用而对普通元素需要用户手动管理。普通元素宿主的注意事项手动调用hostConnected()README 特别强调如果创建提供方时它的父级已经有消费者注册过或者已挂载了ContextRoot则创建后必须手动调用.hostConnected()。这是因为ContextProvider.hostConnected()context-provider.ts负责派发context-provider事件宣告本提供方可用对于普通元素这个信号不会自动发出手动调用才能确保已存在的下游消费者重新解析改从最近的父级提供方取值。事件协议context-request与context-provider理解了控制器与装饰器之后底层的事件协议就一目了然了。两类关键事件都注册在全局HTMLElementEventMap中见 packages/context/src/lib/context-request-event.ts 与 context-provider.tscontext-request消费者发起——由ContextConsumer在hostConnected时通过dispatchRequest派发context-consumer.tsthis.host.dispatchEvent( new ContextRequestEvent(this.context, this.host, this._callback, this.subscribe) );ContextRequestEventcontext-request-event.ts的字段字段说明context请求的上下文 key严格相等匹配contextTarget请求发起元素防止 shadow DOM 重定向后丢失原始目标callback提供方应调用它来回传值形如(value, unsubscribe?) voidsubscribe为true表示订阅后续值变化缺省为false即一次性请求事件以{bubbles: true, composed: true}创建因此可以从 shadow DOM 内部一路冒泡到外部祖先。context-provider提供方宣告——由ContextProvider.hostConnected()派发携带context与contextTargetcontext-provider.ts。它的作用是让ContextRoot之类的监听者知道有新的提供方上线了。提供方的应答逻辑ContextProvider.onContextRequestcontext-provider.ts先检查事件携带的context是否与自身context严格相等不相等直接忽略排除自身消费自身的情况同一元素既消费又提供相同上下文时避免自注册——通过比较consumerHostev.contextTarget ?? ev.composedPath()[0]与this.host调用ev.stopPropagation()阻止请求继续向更外层冒泡保证最近的提供方优先调用this.addCallback(ev.callback, consumerHost, ev.subscribe)完成注册与回调。嵌套提供方的重挂载onProviderRequestcontext-provider.ts监听子级新出现的同 key 提供方事件并把自身名下可能应该归子提供方管的订阅全部重放重新派发context-request让消费者自动迁移到更近的提供方同时用seen集合去重防止同一宿主上多个同 key 提供方相互重挂造成死循环。解决晚升级问题ContextRoot问题场景Late Upgraded Context Providers当一个提供方元素**被较晚升级late upgrade**时它 LightDOM 里已先一步连接的消费者可能已经发出过context-request而当时没有任何提供方应答——该请求就落空了。针对这种情况lit/context提供ContextRoot类拦截并暂存所有未被满足的context-request事件当新的提供方上线时再重放这些请求。README 中的最小用法index.tsimport {ContextRoot} from lit/context; const root new ContextRoot(); root.attach(document.body);ContextRoot的实现要点packages/context/src/lib/context-root.tsattach(element)/detach(element)在任意元素上挂载/卸载对context-request与context-provider两个事件的监听context-root.tsonContextRequestcontext-root.ts只暂存subscribe true的请求一次性请求不缓存按 context key 分组记录元素 回调对并用WeakMapWeakSet去重避免同一元素重复连接时重复入列onContextProvidercontext-root.ts当收到context-provider事件且存在匹配 key 的待处理请求时清空该 key 的待处理列表并从请求源元素重新派发context-request。仍未被满足的请求会在重放时被再次收集形成请求-等待-再重放的闭环。权衡与限制README 明确指出这种方案的一个固有开销如果新提供方并不在那些未满足请求的 DOM 层级内ContextRoot依然会重放这些请求因为很难在 slot 重投影、元素被 reparenting 等复杂 DOM 变动下精确判断层级关系这些重放属于无谓触发。但这是最安全、最正确的做法。另一个需要留意的限制ContextRoot内部使用WeakRef见 context-root.ts 中elementRef/callbackRef均为WeakRef而WeakRef在 IE11 中不受支持因此该功能在 IE11 下不可用。已知问题protected / private 属性的类型检查缺失README 还记录了一个 TypeScript 层面的已知限制consume与provide可以用在protected和private属性上但此时不存在上下文类型与属性类型之间的编译期校验。原因在于TypeScript 编译器不会把protected/private属性的类型信息暴露给装饰器装饰器只能拿到属性名。作为对比public属性上的类型不匹配会在编译期报错类型错误信息由FieldMustMatchProvidedType/FieldMustMatchContextType条件类型生成见 consume.ts 与 provide.ts。此外标准#private属性完全不支持。官方计划在切换到标准装饰器standard decorators后一并解决。有意思的是lit/context源码中其实已经为标准装饰器做了适配分支consume/provide中typeof nameOrContext object即标准装饰器分支并且仓库通过独立的tsconfig.std-decorators-tests.json编译运行标准装饰器测试见 packages/context/package.json 中build:ts:std-decorators-tests脚本说明对两种装饰器语法都已有覆盖。深入源码测试与类型验证如果想验证本文涉及的行为仓库在 packages/context/src/test 下提供了较完整的测试矩阵可以直接作为参考资料context-provider_test.ts 与 context-request_test.ts提供方与请求方的基础行为provider-and-consumer_test.ts、provider-consumer_test.ts提供与消费的联动场景late-provider_test.ts晚升级提供方与ContextRoot的配合decorators/provide_test.ts 与 std-decorators/context-provider_test.ts装饰器两种语法下的测试protocol-types-test.ts、type_check_test.ts类型层面的校验。在仓库中你还可以参考lit/context在真实组件间的配合示例如 packages/labs/react、packages/labs/ssr-react 等包中都有对上下文相关能力的引用以及通用协作约定见仓库根目录的 CONTRIBUTING.md。小结lit/context以约两百行核心源码实现了完整的 Context ProtocolcreateContext用类型品牌为 key 绑定值类型ContextConsumer/ContextProvider这对控制器分别负责发起与响应context-request事件consume/provide装饰器把它们无缝接入 Lit 的响应式属性系统ContextRoot则兜底解决提供方晚升级时的请求落空问题。无论组件树多深数据都能直达需要的组件同时保持类型安全与解耦。【免费下载链接】litLit is a simple library for building fast, lightweight web components.项目地址: https://gitcode.com/GitHub_Trending/li/lit创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表