@lit-labs/gen-manifest 深度解析:为 Lit 组件生成 Custom Elements Manifest 的完整指南
2026/9/13 12:32:03 网站建设 项目流程

@lit-labs/gen-manifest 深度解析:为 Lit 组件生成 Custom Elements Manifest 的完整指南

【免费下载链接】litLit is a simple library for building fast, lightweight web components.项目地址: https://gitcode.com/GitHub_Trending/li/lit

导读

@lit-labs/gen-manifest是 Lit 官方实验性工具链(@lit-labs/*)中负责为组件库生成Custom Elements Manifest(自定义元素清单,简称 CEM)的代码生成器。它把一个 TypeScript 包的静态分析结果(由@lit-labs/analyzer产出)转换成为社区标准的custom-elements.json清单文件,从而让 IDE 智能提示、文档站点、代码生成器等下游工具可以自动理解你的 Lit 组件对外暴露的 API。读完本文,你将掌握@lit-labs/gen-manifest的两种使用方式(编程 API 与 CLI 命令)、完整的 manifest 输出结构,以及从 TypeScript 源码到标准 JSON 清单的逐级转换原理。

什么是 Custom Elements Manifest,它解决什么问题

Custom Elements Manifest 是 webcomponents 社区提出的一种 JSON 格式规范(本仓库实现中遵循其 schema1.0.0),用一个名为custom-elements.json的机器可读文件描述一个包中所有自定义元素及其相关模块的公共 API。它解决的核心痛点是:自定义元素的知识只存在于源码的 JSDoc 与 TypeScript 类型中,缺少一种标准化的、可供工具消费的元数据格式

有了 manifest 之后,组件库作者一次生成、多方受益:IDE 可以根据清单提供属性补全与文档悬停;文档站点可以直接渲染 API 参考;包装器生成器可以据此为其他框架生成绑定代码。这一点与本仓库中 gen-wrapper-react、gen-wrapper-vue 等包的目标一脉相承——@lit-labs/gen-manifest正是这套“从 Lit 组件出发,生成标准元数据”链路中的关键一环。

快速上手:两种使用方式

官方 README 将该库定位为 “Utility library for generating a Custom Elements Manifest for Lit components”,并指出命令行入口在@lit-labs/cli。因此使用方式分为编程 API 与 CLI 两种。

方式一:编程方式调用(Library API)

在代码中直接引入生成函数,把分析器输出的Package模型交给generateManifest,即可得到一个文件树对象:

import {createPackageAnalyzer} from '@lit-labs/analyzer/package-analyzer.js'; import {generateManifest} from '@lit-labs/gen-manifest'; import {writeFileTree} from '@lit-labs/gen-utils/lib/file-utils.js'; const analyzer = createPackageAnalyzer('/path/to/your/package'); const pkg = analyzer.getPackage(); const fileTree = await generateManifest(pkg); // { 'custom-elements.json': '{...}' } await writeFileTree('/path/to/output', fileTree);

其中:

  • createPackageAnalyzer来自依赖@lit-labs/analyzer(见 package.json),负责读取tsconfig.json并静态分析整个包;
  • generateManifest是 src/index.ts 导出的核心函数,返回一个以custom-elements.json为键名的FileTree
  • writeFileTree来自@lit-labs/gen-utils,将文件树递归写入磁盘(详见下文“FileTree 抽象”)。

FileTree是生成器之间传递的统一数据结构,定义在 packages/labs/gen-utils/src/lib/file-utils.ts:

export interface FileTree { [path: string]: string | FileTree; }

键名既可以是文件名(值为文件内容字符串),也可以是子目录名(值为嵌套的FileTree);文件名甚至可以包含/,写入时会自动创建中间目录。

方式二:CLI 命令(推荐)

@lit-labs/gen-manifest本身不提供命令行,而是通过@lit-labs/clilit labs gen子命令驱动(详见 cli README):

lit labs gen --manifest

--manifest标志用于开启 manifest 生成。结合 labs.ts 中的选项定义,labs gen完整参数如下:

选项类型默认值说明
--package可多次指定./要分析的包目录;对 TypeScript 项目,若目录下无tsconfig.json,也可直接指定某个tsconfig.json路径
--framework可多次指定同时生成的框架包装器(reactvue),与 manifest 生成互不冲突
--manifest布尔false是否生成custom-elements.jsonmanifest
--out字符串./gen输出目录
--exclude可多次指定[]从分析中排除的源文件 glob

例如同时生成 manifest 与 React 包装器:

lit labs gen --manifest --framework=react --package=packages/my-elements --out=./gen

CLI 的底层执行逻辑在 packages/labs/cli/src/lib/generate/generate.ts 中,其流程为:

  1. 对每个--package路径执行path.resolve归一化,并用createPackageAnalyzer(root, {exclude})创建分析器;
  2. 校验package.json必须包含name字段(manifest 的类型引用解析依赖包名);
  3. 收集生成器引用:--manifest对应@lit-labs/gen-manifest--framework对应各框架生成器;
  4. 先并行尝试import()所有生成器,若未安装则逐个询问安装(resolveCommandAndMaybeInstallNeededDeps);
  5. 对每个生成器调用其generate(options, console)拿到FileTree,再用writeFileTree写入--out目录;
  6. 打印分析器收集到的 TypeScript 诊断信息,并将Promise.allSettled捕获到的生成错误汇总抛出。

可以看到,manifest 生成器与 React/Vue 包装器生成器通过同一套“生成器引用”机制被 CLI 动态加载,这正是 src/index.ts 中导出getCommand()的原因——它把该包包装成 CLI 可解析的生成器命令:

export const getCommand = () => { return { name: 'manifest', description: 'Generate custom-elements.json manifest.', kind: 'resolved', async generate(options: {package: Package}): Promise<FileTree> { return await generateManifest(options.package); }, }; };

CLI 侧对应的引用定义见 generate.ts:installFrom: '@lit-labs/gen-manifest'importSpecifier: '@lit-labs/gen-manifest/index.js'

工作原理:从 TypeScript 源码到 CEM JSON 的三级管线

generateManifest只是入口,真正的转换发生在convertPackageconvertModuleconvertDeclaration的三级调用链中。整个管线可以用下图概括:

TypeScript 源码 │ ▼ @lit-labs/analyzer(createPackageAnalyzer) Package 模型(modules / declarations / exports / 类型引用) │ ▼ convertPackage CEM 根对象:{ schemaVersion: '1.0.0', modules: [...] } │ ▼ convertModule(每个源码模块) { kind: 'javascript-module', path, declarations, exports } │ ▼ convertDeclaration(按声明类型分派) class / custom-element / mixin / function / variable │ ▼ JSON.stringify custom-elements.json

第一级:包(Package)

convertPackage 只做两件事:写死schemaVersion: '1.0.0',并把分析结果中每个模块映射为cem.Module

const convertPackage = (pkg: Package): cem.Package => { return { schemaVersion: '1.0.0', modules: [...pkg.modules.map(convertModule)], }; };

schemaVersion是 CEM 规范的版本号,本实现固定输出1.0.0,消费者可以据此判断如何解析清单。

第二级:模块(Module)

convertModule 将一个 ES 模块转换为javascript-module条目:

{ kind: 'javascript-module', path: module.jsPath, // 编译后的 JS 路径,如 'element-a.js' description, summary, deprecated, // 取自 JSDoc,空值会被剔除 declarations: [...], // 模块内声明的类/函数/变量等 exports: [ ...module.exportNames.map(convertJavascriptExport), // 普通 JS 导出 ...module.getCustomElementExports().map(convertCustomElementExport), // 自定义元素注册 ], }

其中导出被分为两类:

  • js导出{kind: 'js', name, declaration: {name, package?, module?}},记录符号名及其真实定义位置(支持export {Foo, Baz}这类别名重导出,golden 中Bazdeclaration.name即指向原符号Bar);
  • custom-element-definition导出{kind: 'custom-element-definition', name: tagname, declaration: {name}},记录@customElement('element-a')注册出的标签名与类的对应关系。

第三级:声明(Declaration)分派

convertDeclaration 依据分析器模型的运行时类型把每种声明映射为对应的 CEM 形态:

分析器模型CEM 输出关键字段
LitElementDeclarationcustom-elementtagNamecustomElement: trueattributeseventsslotscssPartscssProperties
ClassDeclarationclasssuperclassmixinsmembers
MixinDeclarationmixinparametersreturn
FunctionDeclarationfunctionparametersreturn
VariableDeclarationvariabletype
其他抛错Unknown declaration: ...

遇到尚未支持的类型(如CustomElementMixinDeclaration),转换器会直接抛出Unknown declaration错误(源码中有对应的 TODO 注释),这保证了输出不会被静默降级成残缺数据。

生成的 manifest 长什么样:以 golden 文件为解剖样本

仓库在 goldens/test-element-a/custom-elements.json 保存了一份 1212 行的完整基准输出(golden 文件),其输入是 test-projects/test-element-a 下的测试组件包。下面以它为准,逐块解剖输出结构。

自定义元素声明(CustomElementDeclaration)

以 element-a.ts 为例,源文件中的 JSDoc 标注:

/** * This is a description of my element. It's pretty great. The description has * text that spans multiple lines. * * @summary My awesome element * @fires a-changed - An awesome event to fire * @slot default - The default slot * @slot stuff - A slot for stuff * @cssProperty --foreground-color - The foreground color * @cssProp --background-color The background color * @cssPart header The header * @cssPart footer - The footer */ @customElement('element-a') export class ElementA extends LitElement { static override styles = css`...`; @property() foo?: string; override render() { return html` <h1 part="header">${this.foo}</h1> <slot></slot> <slot name="stuff"></slot> <footer part="footer">Footer</footer> `; } }

生成的声明片段如下:

{ "kind": "class", "name": "ElementA", "description": "This is a description of my element...", "summary": "My awesome element", "superclass": {"name": "LitElement", "package": "lit"}, "members": [ {"kind": "field", "name": "foo", "privacy": "public", "type": {"text": "string | undefined"}, "attribute": "foo"}, {"kind": "method", "name": "render", "privacy": "public", "return": {"type": {"text": "TemplateResult<1>", "references": [...]}}} ], "tagName": "element-a", "customElement": true, "attributes": [{"name": "foo", "type": {"text": "string"}, "fieldName": "foo"}], "events": [{"name": "a-changed", "type": {"text": "Event"}, "description": "An awesome event to fire"}], "slots": [{"name": "default", "description": "The default slot"}, {"name": "stuff", "description": "A slot for stuff"}], "cssParts": [{"name": "header", "description": "The header"}, {"name": "footer", "description": "The footer"}], "cssProperties": [ {"name": "--foreground-color", "description": "The foreground color"}, {"name": "--background-color", "description": "The background color"} ] }

可见description/summary/slots/cssParts/cssProperties/events 全部来自 JSDoc 标签@summary@fires@slot@cssProperty/@cssProp@cssPart),这正是组件库作者需要养成的文档习惯——写注释即产出元数据。

成员转换:reactive property 如何变成字段与属性

convertLitElementMembers 对 LitElement 的成员做了特殊处理:普通字段原样输出,而响应式属性(reactive property)会额外补充attributereflects字段。属性名解析规则(与 Lit 自身的默认规则一致):

  • attribute: false→ 不生成attribute字段(且该属性也不会出现在attributes数组);
  • attribute: 'my-attr'(字符串)→ 使用指定的属性名;
  • 其他情况 → 属性名全小写(camelCasecamelcase)。

以 element-props.ts 为例,它混合了@property({type: Number})@property({type: Boolean, reflect: true})@property({type: Array})@property({type: Object, attribute: false})@state(),生成的字段与属性为:

{ "members": [ {"kind": "field", "name": "aStr", "type": {"text": "string"}, "default": "'aStr'", "attribute": "astr"}, {"kind": "field", "name": "aNum", "type": {"text": "number"}, "default": "-1", "attribute": "anum"}, {"kind": "field", "name": "aBool", "type": {"text": "boolean"}, "default": "false", "attribute": "abool", "reflects": true}, {"kind": "field", "name": "aMyType", "type": {"text": "MyType", "references": [...]}, "default": "{...}", "attribute": "amytype"} ], "attributes": [ {"name": "astr", "type": {"text": "string"}, "fieldName": "aStr"}, {"name": "abool", "type": {"text": "boolean"}, "fieldName": "aBool"}, {"name": "astrarray", "type": {"text": "array"}, "fieldName": "aStrArray"}, {"name": "amytype", "type": {"text": "object"}, "fieldName": "aMyType"} ] }

值得注意的细节:

  • attribute: falseaMyType仍出现在attributes数组中(golden 中可看到amytype条目)——等等,对照源码:convertReactivePropertiesToAttributesproperty.attribute === falsecontinue跳过。golden 中aMyType之所以有amytype属性,是因为其声明写的是@property({type: Object, attribute: false})…… 实际上 golden 里aMyType的字段带attribute: "amytype",说明当前测试工程中该属性的attribute并未设置为false(golden 反映的是源码的实际状态)。结论是:只要attribute不是false,响应式属性就会同时出现在members(带attribute字段)和attributes数组(带fieldName回指)两处,这种冗余是有意设计的,方便按“字段”或按“HTML 属性”两个视角查询;
  • 类型文本映射convertReactivePropertiesToAttributestypeOption?.toLowerCase() ?? 'string'把 Lit 的type: Number/Boolean/Array/Object选项映射为 CEM 的类型文本number/boolean/array/object(源码注释说明这对默认支持类型有效,其他类型会直接输出类型名文本);
  • reflect: true会被转换为字段上的"reflects": true,这正是给框架包装器或文档工具判断属性是否需要反射的关键信息;
  • @state()属性(如aState)没有出现在attributes数组中,因为 state 默认attribute: false,但字段本身仍会输出。

类型引用(references):位置偏移量与包解析

CEM 允许在type.text之外附带references数组,标注类型文本中每个标识符的真实定义位置。转换器在 convertTypeReferences 中通过在类型文本里顺序查找符号名来计算start/end字符偏移:

const start = text.indexOf(ref.name, curr); curr = start + ref.name.length;

例如CustomEvent<MyDetail>会生成两个引用,start: 0, end: 11指向CustomEventstart: 12, end: 20指向MyDetail(见 golden 中my-detail-custom-event条目)。

convertReference 还负责解析引用的归属:

  • 全局对象(HTMLElementEventCustomEvent)→"package": "global:"
  • 外部包(LitElementTemplateResult)→package取依赖包名(如"lit""lit-html""@lit/reactive-element"),存在时附上module
  • 包内符号 →package为包自身名称(@lit-internal/test-element-a),module为定义模块路径。

golden 中externalTypeVar: LitElementglobalTypeVar: HTMLElementpackageTypeVar: Foo<Bar>三个变量正是这三种情况的直接演示。

其他声明类型

  • 函数convertFunctionDeclaration输出kind: 'function'、参数列表(含optionalrestdefault)与返回值;golden 中function2还展示了@deprecated、参数/返回值描述等多行 JSDoc 的透传;
  • Mixinkind: 'mixin',同样携带参数与返回类型(mixin的泛型参数T会作为引用被解析);
  • 普通变量kind: 'variable'+type;若类型缺失则兜底为{text: 'unknown'}(源码中有 TODO 注释,指出 CEM 规范中该字段并非可选);
  • 事件convertEvent中若事件类型缺失,兜底为{text: 'Event'}

空值裁剪:控制 manifest 体积的关键细节

custom-elements.json最终会被 IDE、文档工具反复解析,体积直接影响体验。为此 src/index.ts 定义了两个守卫函数:

const ifNotEmpty = <T>(v: T): T | undefined => { if ( (v as unknown) === false || ((typeof v === 'string' || Array.isArray(v)) && v.length === 0) ) { return undefined; } return v; };

规则很简单:值为false、空字符串、空数组时返回undefined。由于JSON.stringify会直接跳过值为undefined的键,生成结果中就不会出现"summary": """members": []这类无意义条目(golden 中element-without-props就没有attributes键,ElementMixinsattributes键同样被省略)。transformIfNotEmpty则是“先判空、再变换、再判空”的组合形态,用于declarationseventsslots等需要二次映射的字段。这是全文件被反复调用的模式,也是输出 JSON 之所以紧凑的原因。

测试与基准校验:golden 驱动的质量保障

该包的测试完全由 golden 文件驱动,核心测试在 src/test/generate_test.ts:

test('basic manifest generation', async () => { const project = 'test-element-a'; const inputPackage = path.resolve(testProjects, project); const analyzer = createPackageAnalyzer(inputPackage as AbsolutePath); const pkg = analyzer.getPackage(); await writeFileTree(outputFolder, await generateManifest(pkg)); await assertGoldensMatch(outputFolder, path.join('goldens', project), { formatGlob: '**/*.json', }); });

流程为:对../test-projects/test-element-a运行分析 → 生成 manifest → 写入临时目录 → 与 goldens/test-element-a/custom-elements.json 逐字节比对。测试输出 JSON 会经过格式化(formatGlob: '**/*.json')保证比对稳定。

测试工程 test-element-a 内的每个源文件对应一类覆盖场景,可对照 golden 验证:

源文件覆盖的转换特性
element-a.tsJSDoc(summary/fires/slot/cssProperty/cssPart)、子目录模块、别名重导出(Baz)、全局/包内/外部类型引用
element-props.ts各类型@property的字段/属性映射、reflect、自定义 interface 类型
element-events.ts多种事件 payload 类型(string/number/自定义类/TemplateResult)与事件引用解析
element-mixins.tsmixin 声明、mixins继承列表
element-without-props.ts无属性元素的空值裁剪

当组件 API 行为变更需要更新基准时,可运行npm run test:update-goldens(即UPDATE_TEST_GOLDENS=true npm run test,见 package.json)自动重写 golden 文件后再人工审查 diff。

扩展与集成:把生成器接入自己的工具链

如果你不想依赖 CLI,而希望把 manifest 生成嵌入自己的构建流程,有三种思路:

  1. 直接调用generateManifest,拿到FileTree后用自己的逻辑写盘(如合并进自定义发布流程);
  2. 复用getCommand()返回的命令对象,它满足 CLI 生成器接口(name/description/kind/generate),可以被任何遵循该接口的宿主加载;
  3. 参照 CLI 的引用式加载:在 generate.ts 中,生成器以{name, installFrom, importSpecifier}引用注册、按需安装并动态import(),你可以在自己的 CLI 中复刻这套“引用 → 解析 → 安装 → 导入 → 执行”的机制。

generateManifest的输出是纯数据(FileTree),不触达文件系统,因此可以方便地在内存中组合多个生成器产物(例如 manifest + React 包装器一起输出到--out目录)。

当前实现的限制与注意事项

源码注释明确标注了若干尚未完成或需要留意的点,使用时应知晓:

  • source.hrefdemos字段未输出convertClassDeclarationconvertLitElementDeclaration中均有// TODO注释(source: {href: 'TODO'}// demos: [], // TODO),因此目前 manifest 不包含源码链接与示例地址;
  • 个别字段使用非空断言convertCommonDeclarationInfo中的declaration.name!、变量类型的?? {text: 'unknown'}、事件类型的?? {text: 'Event'}都源于 CEM schema 中这些字段并非可选,转换器只能用兜底值保证输出合法;
  • 类型文本的准确度convertReactivePropertiesToAttributes的类型映射只对 String/Number/Boolean/Array/Object 保证理想结果,其余类型直接输出类型名文本,对 CEM 消费者而言“够用但不保证完整语义”;
  • 实验性定位:包名带labs前缀(版本见 package.json,当前0.3.6),API 仍可能演进;要求 Node>= 14.8.0
  • JSDoc 是元数据的源泉:描述、事件、插槽、CSS 自定义属性、CSS Parts 等丰富信息全部来自注释标签,注释缺失的元素在 manifest 中会相应“瘦身”。

小结

@lit-labs/gen-manifest是一个小而精的代码生成器:输入是@lit-labs/analyzer的静态分析结果,输出是符合社区规范、体积经过裁剪的custom-elements.json。它把“组件公共 API 文档化”这件事从人工维护变成自动生成,并且通过 golden 测试保证每次输出可复现、可审查。无论是想为自己的 Lit 组件库生成标准清单,还是想理解 CEM 生成管线的内部实现,都可以以 src/index.ts 与 golden 文件 为第一手参考。

【免费下载链接】litLit is a simple library for building fast, lightweight web components.项目地址: https://gitcode.com/GitHub_Trending/li/lit

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询