Egret项目自动生成ExmlIdDef.d.ts
ChrisXie Lv5

一、什么是 ExmlIdDef.d.ts

编辑 EXML 皮肤文件时,给组件设置 id 后,可以在 TypeScript 中通过 this.xxx 访问。ExmlIdDef.d.ts 会自动扫描所有 EXML 文件,为每个皮肤生成 interface,让 this.xxx 有完整的类型提示和代码补全。

1
2
3
4
5
6
<!-- MyViewSkin.exml -->
<e:Skin class="MyViewSkin" xmlns:e="http://ns.egret.com/eui">
<e:Label id="titleLabel" text="标题" />
<e:Button id="closeBtn" label="关闭" />
<ns1:PowerText id="powerLabel" />
</e:Skin>

自动生成:

1
2
3
4
5
6
// ExmlIdDef.d.ts
interface MyViewSkin {
titleLabel: eui.Label;
closeBtn: eui.Button;
powerLabel: PowerText;
}

二、文件结构

文件 作用
scripts/watchExml.js 监听 EXML 变化,实时生成
scripts/ExmlIdPlugin.ts 构建时兜底生成
scripts/config.ts 注册 ExmlIdPlugin 到构建流程
.vscode/tasks.json 打开项目自动启动监听
.vscode/settings.json 启用自动任务
egretProperties.json 配置输出路径
libs/ExmlIdDef.d.ts 自动生成的定义文件(不用手动编辑)

三、步骤 1:创建 scripts/watchExml.js

在项目根目录下创建 scripts/watchExml.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
/**
* 监听 EXML 文件变化,自动生成 ExmlIdDef.d.ts
* 由 VS Code 任务自动启动(runOn: folderOpen)
*/
const fs = require('fs');
const path = require('path');

const TYPE_MAP = {
'Image': 'eui.Image',
'Label': 'eui.Label',
'Button': 'eui.Button',
'Group': 'eui.Group',
'Scroller': 'eui.Scroller',
'List': 'eui.List',
'DataGroup': 'eui.DataGroup',
'ViewStack': 'eui.ViewStack',
'TabBar': 'eui.TabBar',
'TextInput': 'eui.TextInput',
'EditableText': 'eui.EditableText',
'CheckBox': 'eui.CheckBox',
'RadioButton': 'eui.RadioButton',
'ToggleSwitch': 'eui.ToggleSwitch',
'HSlider': 'eui.HSlider',
'VSlider': 'eui.VSlider',
'HScrollBar': 'eui.HScrollBar',
'VScrollBar': 'eui.VScrollBar',
'ProgressBar': 'eui.ProgressBar',
'BitmapLabel': 'eui.BitmapLabel',
'Component': 'eui.Component',
'Panel': 'eui.Panel',
'Rect': 'eui.Rect',
};

function inferType(fullTag) {
const parts = fullTag.split(':');
const tagName = parts.length > 1 ? parts[1] : parts[0];
const ns = parts.length > 1 ? parts[0] : '';

if (TYPE_MAP[tagName]) return TYPE_MAP[tagName];
if (ns === 'ns1') return tagName;
if (ns && ns !== 'e') return 'any';
return 'any';
}

function parseExml(filePath) {
const content = fs.readFileSync(filePath, 'utf-8');
const classMatch = content.match(/class\s*=\s*"([^"]+)"/);
if (!classMatch) return null;

const className = classMatch[1];
const cleaned = content
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<e:Skin>[\s\S]*?<\/e:Skin>/g, '');
const components = [];
const seenIds = {};
const idRegex = /<(\w+:)?(\w+)\s[^>]*?\bid\s*=\s*"([^"]+)"[^>]*?\/?>/g;
let match;

while ((match = idRegex.exec(cleaned)) !== null) {
const tagName = match[2];
const id = match[3];
if (tagName === 'Config') continue;
if (seenIds[id]) continue;
seenIds[id] = true;
components.push({
id: id,
type: inferType((match[1] || '') + match[2])
});
}

return { className, components };
}

function walkDir(dir, callback) {
if (!fs.existsSync(dir)) return;
const items = fs.readdirSync(dir);
for (const item of items) {
const fullPath = path.join(dir, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
walkDir(fullPath, callback);
} else if (item.endsWith('.exml')) {
callback(fullPath);
}
}
}

function readConfig(projectRoot) {
const configPath = path.join(projectRoot, 'egretProperties.json');
let outputPath = 'libs/ExmlIdDef.d.ts';
let exmlRoots = ['resource/skins'];

try {
const raw = fs.readFileSync(configPath, 'utf-8');
const config = JSON.parse(raw);
const customConfig = config.customConfig || {};
if (customConfig.outputExmlId) {
outputPath = customConfig.outputExmlId;
}
const euiConfig = config.eui || {};
if (euiConfig.exmlRoot) {
exmlRoots = euiConfig.exmlRoot;
}
} catch (e) {
// 使用默认配置
}

return { outputPath, exmlRoots };
}

function generate(projectRoot) {
const { outputPath, exmlRoots } = readConfig(projectRoot);
const skins = {};

for (const root of exmlRoots) {
const fullPath = path.join(projectRoot, root);
walkDir(fullPath, (filePath) => {
const def = parseExml(filePath);
if (def && !def.className.includes('.')) skins[def.className] = def;
});
}

const sorted = Object.keys(skins).sort();

let content = `/**
* 自动生成 - 由 watchExml.js 监听 EXML 变化时更新
* 生成时间: ${new Date().toISOString()}
*/

`;
for (const className of sorted) {
const def = skins[className];
content += `interface ${className} {\n`;
for (const comp of def.components) {
content += ` ${comp.id}: ${comp.type};\n`;
}
content += `}\n\n`;
}

const outPath = path.join(projectRoot, outputPath);
const outDir = path.dirname(outPath);
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(outPath, content, 'utf-8');

const time = new Date().toLocaleTimeString();
console.log(`[${time}] ✅ ${outputPath} (${sorted.length} 个皮肤)`);
}

// 启动时立即生成一次
const projectRoot = path.resolve(__dirname, '..');
generate(projectRoot);

// 监听所有 EXML 目录
const { exmlRoots } = readConfig(projectRoot);
for (const root of exmlRoots) {
const watchDir = path.join(projectRoot, root);
if (!fs.existsSync(watchDir)) continue;

console.log(`[watchExml] 监听: ${root}`);
fs.watch(watchDir, { recursive: true }, (eventType, filename) => {
if (filename && filename.endsWith('.exml')) {
console.log(`[watchExml] 检测到变化: ${filename}`);
generate(projectRoot);
}
});
}

四、步骤 2:创建 scripts/ExmlIdPlugin.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
/**
* ExmlId 自动生成插件
* 在 egret build 时自动扫描所有 EXML 文件,生成 ExmlIdDef.d.ts
*/
import * as fs from 'fs';
import * as path from 'path';

const TYPE_MAP: { [key: string]: string } = {
'Image': 'eui.Image',
'Label': 'eui.Label',
'Button': 'eui.Button',
'Group': 'eui.Group',
'Scroller': 'eui.Scroller',
'List': 'eui.List',
'DataGroup': 'eui.DataGroup',
'ViewStack': 'eui.ViewStack',
'TabBar': 'eui.TabBar',
'TextInput': 'eui.TextInput',
'EditableText': 'eui.EditableText',
'CheckBox': 'eui.CheckBox',
'RadioButton': 'eui.RadioButton',
'ToggleSwitch': 'eui.ToggleSwitch',
'HSlider': 'eui.HSlider',
'VSlider': 'eui.VSlider',
'HScrollBar': 'eui.HScrollBar',
'VScrollBar': 'eui.VScrollBar',
'ProgressBar': 'eui.ProgressBar',
'BitmapLabel': 'eui.BitmapLabel',
'Component': 'eui.Component',
'Panel': 'eui.Panel',
'Rect': 'eui.Rect',
};

function inferType(fullTag: string): string {
const parts = fullTag.split(':');
const tagName = parts.length > 1 ? parts[1] : parts[0];
const ns = parts.length > 1 ? parts[0] : '';

if (TYPE_MAP[tagName]) return TYPE_MAP[tagName];
if (ns === 'ns1') return tagName;
if (ns && ns !== 'e') return 'any';
return 'any';
}

function parseExml(filePath: string) {
const content = fs.readFileSync(filePath, 'utf-8');
const classMatch = content.match(/class\s*=\s*"([^"]+)"/);
if (!classMatch) return null;

const className = classMatch[1];
const cleaned = content
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<e:Skin>[\s\S]*?<\/e:Skin>/g, '');
const components: { id: string; type: string }[] = [];
const seenIds: { [id: string]: boolean } = {};
const idRegex = /<(\w+:)?(\w+)\s[^>]*?\bid\s*=\s*"([^"]+)"[^>]*?\/?>/g;
let match: RegExpExecArray | null;

while ((match = idRegex.exec(cleaned)) !== null) {
const tagName = match[2];
const id = match[3];
if (tagName === 'Config') continue;
if (seenIds[id]) continue;
seenIds[id] = true;
components.push({
id: id,
type: inferType((match[1] || '') + match[2])
});
}

return { className, components };
}

function walkDir(dir: string, callback: (filePath: string) => void) {
if (!fs.existsSync(dir)) return;
const items = fs.readdirSync(dir);
for (const item of items) {
const fullPath = path.join(dir, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
walkDir(fullPath, callback);
} else if (item.endsWith('.exml')) {
callback(fullPath);
}
}
}

export class ExmlIdPlugin implements plugins.Command {

async onFile(file: plugins.File) {
return file;
}

async onFinish(commandContext: plugins.CommandContext) {
const projectRoot = commandContext.buildConfig.projectRoot;

const configPath = path.join(projectRoot, 'egretProperties.json');
let outputPath = 'libs/ExmlIdDef.d.ts';
let exmlRoots: string[] = ['resource/skins'];

try {
const raw = fs.readFileSync(configPath, 'utf-8');
const config = JSON.parse(raw);
const customConfig = config.customConfig || {};
if (customConfig.outputExmlId) {
outputPath = customConfig.outputExmlId;
}
const euiConfig = config.eui || {};
if (euiConfig.exmlRoot) {
exmlRoots = euiConfig.exmlRoot;
}
} catch (e) {
// 使用默认配置
}

const skins: { [className: string]: { className: string; components: { id: string; type: string }[] } } = {};

for (const root of exmlRoots) {
const fullPath = path.join(projectRoot, root);
walkDir(fullPath, (filePath) => {
const def = parseExml(filePath);
if (def && !def.className.includes('.')) skins[def.className] = def;
});
}

const sorted = Object.keys(skins).sort();

let content = `/**
* 自动生成 - 由 ExmlIdPlugin 在构建时生成
* 生成时间: ${new Date().toISOString()}
*/

`;
for (const className of sorted) {
const def = skins[className];
content += `interface ${className} {\n`;
for (const comp of def.components) {
content += ` ${comp.id}: ${comp.type};\n`;
}
content += `}\n\n`;
}

const outPath = path.join(projectRoot, outputPath);
const outDir = path.dirname(outPath);
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(outPath, content, 'utf-8');

console.log(`[ExmlIdPlugin] ${outputPath} (${sorted.length} 个皮肤)`);
}
}

五、步骤 3:注册插件到 scripts/config.ts

config.ts 顶部添加 import 和注册命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { ExmlIdPlugin } from './ExmlIdPlugin';

// ...

if (command == 'build') {
return {
outputDir,
commands: [
new ExmlPlugin('debug'),
new ExmlIdPlugin(), // ← 添加
new IncrementCompilePlugin(),
]
}
}
else if (command == 'publish') {
return {
outputDir,
commands: [
// ...
new ExmlPlugin('commonjs'),
new ExmlIdPlugin(), // ← 添加
// ...
]
}
}

六、步骤 4:配置 egretProperties.json

1
2
3
4
5
6
7
8
9
10
{
"eui": {
"exmlRoot": ["resource/skins"],
"themes": ["resource/default.thm.json"],
"exmlPublishPolicy": "gjs"
},
"customConfig": {
"outputExmlId": "libs/ExmlIdDef.d.ts"
}
}
字段 说明
eui.exmlRoot EXML 文件目录,脚本根据此项扫描
customConfig.outputExmlId 生成文件路径,默认 libs/ExmlIdDef.d.ts

七、步骤 5:配置 .vscode/tasks.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
"tasks": [
{
"label": "egret: build",
"type": "shell",
"command": "egret",
"args": ["build", "-sourcemap"],
"isBackground": true,
"problemMatcher": "$tsc",
"group": {
"kind": "build",
"isDefault": true
}
},
{
"label": "watch-exml",
"type": "shell",
"command": "node",
"args": ["scripts/watchExml.js"],
"isBackground": true,
"problemMatcher": [],
"runOptions": {
"runOn": "folderOpen"
}
}
],
"version": "2.0.0"
}

"runOn": "folderOpen" — 打开项目时自动启动监听。


八、步骤 6:配置 .vscode/settings.json

1
2
3
{
"task.allowAutomaticTasks": "on"
}

必须设置此项,否则打开项目时不会自动运行任务。


九、如何使用 ExmlIdDef(核心)

9.1 自动生效(推荐)

打开项目,VS Code 自动运行 watch-exml 任务。修改 EXML 保存后,ExmlIdDef.d.ts 自动更新,终端输出:

如果监听未启动,手动执行:

1
node scripts/watchExml.js

9.2 构建时兜底

egret buildegret publish 时,ExmlIdPlugin 会再生成一次,确保定义文件始终最新。


9.3 场景一:标准 View 中使用 this.xxx

这是最常见的用法。只要你的类名和 skinName 一致,TypeScript 的声明合并自动生效,无需任何额外代码。

1
2
3
4
5
6
7
8
// MyViewSkin.exml
<e:Skin class="MyViewSkin" xmlns:e="http://ns.egret.com/eui">
<e:Label id="titleLabel" />
<e:Button id="closeBtn" />
<e:Scroller id="listScroller">
<e:DataGroup id="listGroup" />
</e:Scroller>
</e:Skin>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// MyView.ts
class MyView extends eui.Component implements eui.UIComponent {
public constructor() {
super();
this.skinName = "MyViewSkin"; // ← 类名和 skinName 一致
}

protected childrenCreated(): void {
super.childrenCreated();

// ✅ 直接 this.xxx,有完整类型提示
this.titleLabel.text = "标题"; // 类型: eui.Label
this.closeBtn.addEventListener( // 类型: eui.Button
egret.TouchEvent.TOUCH_TAP,
this.onClose, this
);
this.listScroller.viewport = this.listGroup; // Scroller 和 DataGroup

// ❌ 写错 id 会报错
// this.titleLable.text = ""; // 属性 "titleLable" 不存在
}
}

生效原理:ExmlIdDef.d.ts 中已有 interface MyViewSkin,你的 class MyView 和它在同一个全局命名空间,TypeScript 自动做声明合并,this 上就有了所有 id 属性。


9.4 场景二:类名和 skinName 不一致

如果你的类名和 skinName 不一样,需要手动声明合并:

1
2
3
4
// BagViewSkin.exml
<e:Skin class="BagViewSkin" ...>
<e:List id="itemList" />
</e:Skin>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// BagView.ts
class BagView extends eui.Component implements eui.UIComponent {
public constructor() {
super();
this.skinName = "BagViewSkin"; // skinName 和类名不同
}
}

// ← 手动声明合并
interface BagView extends BagViewSkin { }

// 现在可以用了
const view = new BagView();
view.itemList.dataProvider = ...; // ✅ 类型: eui.List

9.5 场景三:继承已有皮肤扩展新皮肤

子皮肤继承父皮肤,自动获得父皮肤的所有 id:

1
2
3
4
<!-- BaseViewSkin.exml -->
<e:Skin class="BaseViewSkin" ...>
<e:Button id="backBtn" />
</e:Skin>
1
2
3
4
<!-- ChildViewSkin.exml -->
<e:Skin class="ChildViewSkin" ...>
<e:Label id="titleLabel" />
</e:Skin>
1
2
3
4
5
6
7
// 自动生成
interface BaseViewSkin {
backBtn: eui.Button;
}
interface ChildViewSkin {
titleLabel: eui.Label;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// ChildView.ts
class ChildView extends eui.Component {
public constructor() {
super();
this.skinName = "ChildViewSkin";
}

// 如果 ChildView 的 skinName 设置的是 ChildViewSkin,
// 但运行时也包含 BaseViewSkin 的组件,需要手动合并
}

// 手动合并父皮肤
interface ChildView extends BaseViewSkin, ChildViewSkin { }

9.6 场景四:组件内部访问子组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class MyPanel extends eui.Panel {
// 如果 skinName 和类名一致,自动合并
public constructor() {
super();
this.skinName = "MyPanelSkin";
}

protected childrenCreated(): void {
super.childrenCreated();

// 所有 id 都有类型提示,IDE 自动补全
this.titleDisplay.text = "标题"; // 继承自 Panel 的 titleDisplay
this.btnGroup.visible = false; // EXML 中自定义的 btnGroup
this.contentList.itemRenderer = ...; // List 的 itemRenderer
}
}

// 声明合并,合并 Panel 自带的属性和 EXML 中的 id
interface MyPanel extends eui.Panel {
// Panel 自带属性
titleDisplay: eui.Label;
// EXML 中的 id 由 ExmlIdDef 自动合并
}

9.7 场景五:自定义组件类型

EXML 中使用 ns1:PowerText,生成的类型是 PowerText(全局类):

1
2
3
interface MyViewSkin {
powerLabel: PowerText; // ← 自定义组件,类型正确
}
1
2
// 使用自定义组件的方法
this.powerLabel.setPower(9999); // ✅ 有 PowerText 的专属方法提示

如果组件类型显示为 any(如 tween:TweenGroup),可以手动声明覆盖:

1
2
3
interface MyViewSkin {
tween: egret.TweenGroup; // 手动覆盖为正确类型
}

9.8 完整示例:一个功能 View

1
2
3
4
5
6
7
8
// AwardViewSkin.exml
<e:Skin class="AwardViewSkin" ...>
<e:Label id="titleLabel" />
<e:Button id="closeBtn" />
<e:List id="awardList" />
<ns1:PriceIcon id="price" />
<e:Group id="emptyGroup" />
</e:Skin>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// AwardView.ts
class AwardView extends eui.Component implements eui.UIComponent {

private awardData: any[];

public constructor() {
super();
this.skinName = "AwardViewSkin";
}

protected childrenCreated(): void {
super.childrenCreated();

// 初始化标题
this.titleLabel.text = "奖励列表"; // eui.Label

// 关闭按钮
this.closeBtn.addEventListener( // eui.Button
egret.TouchEvent.TOUCH_TAP,
() => this.parent.removeChild(this),
this
);

// 列表渲染
this.awardList.itemRenderer = AwardItem; // eui.List
this.awardList.dataProvider = new eui.ArrayCollection(this.awardData);

// 价格显示
this.price.setPrice(100); // PriceIcon (自定义组件)

// 空状态
this.emptyGroup.visible = this.awardData.length === 0; // eui.Group
}

public setData(data: any[]): void {
this.awardData = data;
if (this.awardList) {
this.awardList.dataProvider = new eui.ArrayCollection(data);
this.emptyGroup.visible = data.length === 0;
}
}
}

效果:敲 this. 时 IDE 自动弹出 titleLabelcloseBtnawardListpriceemptyGroup 供选择,选中后自动显示对应类型的方法和属性。


十、类型推断规则

EXML 标签 生成类型 说明
e:Image eui.Image TYPE_MAP 映射
e:Label eui.Label TYPE_MAP 映射
ns1:PowerText PowerText ns1 项目自定义组件,全局可用
tween:TweenGroup any 非 e/ns1 命名空间,避免找不到类型
ce:CScroller any 同上
<w:Config> 跳过 非 UI 组件

十一、常见问题

Q1:打开项目没有自动生成?

确认终端有输出 [watchExml] 监听: resource/skins。如果没有:

  • 检查 .vscode/settings.json"task.allowAutomaticTasks": "on"
  • 检查 .vscode/tasks.json 版本为 "2.0.0"
  • 手动执行 node scripts/watchExml.js 看是否有报错

Q2:this.xxx 没有类型提示?

确认:

  1. 类名和 skinName 一致(或手动写了 interface Xxx extends SkinName { }
  2. ExmlIdDef.d.ts 文件存在且有内容
  3. tsconfig.jsoninclude 包含 "libs" 目录

Q3:生成的文件有 any 类型?

正常现象。非 eui 命名空间的自定义组件(如 tween:TweenGroup)因为不是全局类型,使用 any 避免编译错误。可手动覆盖。

Q4:Cannot find name 'XXX'

重新生成即可:

1
node scripts/watchExml.js

Q5:egret build 报错?

确保 scripts/ExmlIdPlugin.tsscripts/config.ts 中的 import 路径正确,且 egretProperties.jsonoutputExmlId 不为空。

Q6:SVN 同步会影响其他人吗?

libs/ExmlIdDef.d.ts 放在 libs/ 目录下,建议加入 .gitignore / SVN ignore,因为它是自动生成的,每个开发者本地生成即可。

 评论
相关文章
标签云 更多