mcpField
U
ui5-typescript-conversion

ui5-typescript-conversion

github.com/cline开发40950 次安装15 周前更新
代码仓库

关于 ui5-typescript-conversion

面向 SAPUI5/OpenUI5 项目的 JavaScript 转 TypeScript 指南,按步骤涵盖项目配置、应用代码和测试。重点是保留注释,并使用具体的 UI5 类型。

主要功能

  1. 01项目设置与 tsconfig 指引
  2. 02UI5 类和模块转换
  3. 03方法、变量与事件类型标注
  4. 04共享类型定义
  5. 05注释保留规则

适用场景

  1. 01规划 UI5 项目的 TypeScript 迁移
  2. 02转换 UI5 类和模块定义
  3. 03添加精确的 UI5 控件与事件类型

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/cline/skills --skill ui5-typescript-conversion

安装后在 Claude Code 中输入/ui5-typescript-conversion确认已加载。

附带文件references/test_conversion.md

详细介绍

UI5 TypeScript 转换指南

本文概述了如何将 UI5(SAPUI5/OpenUI5)项目转换为 TypeScript。内容包括以下部分:

  1. 重要的一般规则
  2. 如何更改项目设置
  3. 转换代码本身
  4. 转换测试(参见单独的文件)

一般转换规则

保留所有注释

你必须保留现有的 JSDoc、文档和注释——转换期间绝不能删除 JSDoc 或注释。

输入示例:

/**
 * My cool controller, it does things.
 */
return Controller.extend("com.myorg.myapp.controller.BaseController", {
    /**
     * Convenience method for accessing the component of the controller's view.
     * @returns {sap.ui.core.Component} The component of the controller's view
     */
    getOwnerComponent: function () {
        // comment
        return Controller.prototype.getOwnerComponent.call(this);
    },
    ...
});

错误的输出:

export default class BaseController extends Controller {
    public getOwnerComponent(): UIComponent {
        return super.getOwnerComponent() as UIComponent;
    }
}

正确的输出:

/**
 * My cool controller, it does things.
 * @namespace com.myorg.myapp.controller
 */
export default class BaseController extends Controller {
    /**
     * Convenience method for accessing the component of the controller's view.
     * @returns {sap.ui.core.Component} The component of the controller's view
     */
    public getOwnerComponent(): UIComponent {
        // comment
        return super.getOwnerComponent() as UIComponent;
    }
}
认真细致

仔细遵循本文档中的所有指南(并在需要时进行适当调整)。每次转换前,都要考虑本文档中的所有相关细节。

逐步进行

你应逐步转换项目,先设置 TypeScript 项目,然后转换最核心的文件;其他文件依赖这些文件,因此在这些核心文件完成转换后,其他文件便可以使用它们的类型版本。如有需要,在运行部分转换的项目时,tsconfig.json 的 compilerOptions 中的 "allowJs": true 可能会有所帮助。

避免使用 any 类型

不要走捷径,应尝试寻找合适的类型或创建接口,而不是使用 any。

不推荐:

(this.getOwnerComponent() as any).getContentDensityClass();

推荐:

(this.getOwnerComponent() as AppComponent).getContentDensityClass()
避免转换为 unknown

请导入并使用实际的 UI5 控件类型(可以是基类 sap/ui/core/Control,也可以是为了访问相应属性而使用的更具体的类)。检查 XMLView,确认在控制器中调用 this.byId(...) 时实际获取的控件类型! 别忘了使用特定的事件类型,例如路由事件中的 Route$PatternMatchedEvent。

类型转换示例

不推荐:

(this.byId("form") as unknown as {setVisible: (v: boolean) => void}).setVisible(false);

推荐:

import SimpleForm from "sap/ui/layout/form/SimpleForm";
(this.byId("form") as SimpleForm).setVisible(false);
创建共享类型定义

你创建的许多类型定义可用于不同的文件。请在 src/types/ 等中央位置创建这些类型定义。

项目设置转换

1. package.json

如果 package.json 文件中尚未包含以下开发依赖项,则必须添加(非常重要):

{{dependencies}}

但是,如果 package.json 中已经存在某个依赖项,请勿提高其主版本号。 不要删除现有依赖项,只能添加新的配置。请尽早安装这些依赖项,以验证是否能找到相应类型。

重要:此外,你还必须将与 UI5 项目版本匹配的 @sapui5/types(或 @openui5/types)包作为开发依赖项添加。可在 ui5.yaml 中查找框架类型和版本,或使用 get_project_info MCP 工具获取。

另外,只有在依赖项或其版本发生变化时,才需确保(或告知用户)执行 npm install / yarn install(以项目中使用的工具为准),以安装已更改的依赖项。

typescript-eslint 依赖项仅适用于已配置 eslint 的项目(详情见下文)。

还要将 "ts-typecheck": "tsc --noEmit" 脚本添加到 package.json,以便你和开发者轻松检查 TypeScript 错误。

2. tsconfig.json

添加一个 tsconfig.json 文件。请参考以下示例,并根据当前项目的需要进行调整,例如调整 paths 映射:

{
	"compilerOptions": {
		"target": "es2023",
		"module": "es2022",
		"moduleResolution": "node",
		"skipLibCheck": true,
		"allowJs": true,
		"strict": true,
		"strictNullChecks": false,
		"strictPropertyInitialization": false,
		"outDir": "./dist",
		"rootDir": "./webapp",
		"types": ["@sapui5/types", "@types/jquery", "@types/qunit"],
		"paths": {
			"com/myorg/myapp/*": ["./webapp/*"],
			"unit/*": ["./webapp/test/unit/*"],
			"integration/*": ["./webapp/test/integration/*"]
		}
	},
	"exclude": ["./webapp/test/e2e/**/*"],
	"include": ["./webapp/**/*"]
}
3. ui5.yaml

更新 ui5.yaml 文件,使其使用 ui5-tooling-transpile-task 和 ui5-tooling-transpile-middleware,并确保至少包含以下配置:

builder:
  customTasks:
    - name: ui5-tooling-transpile-task
      afterTask: replaceVersion
server:
  customMiddleware:
    - name: ui5-tooling-transpile-middleware
      afterMiddleware: compression
    - name: ui5-middleware-livereload
      afterMiddleware: compression

确保生成的 ui5.yaml 文件有效——避免重复条目,每个根配置只能存在一次。 如果 server 等配置已存在,必须在其中添加内容,而不是再添加一个条目。

4. Eslint 配置

仅当项目已设置 eslint 时,才扩展 eslint 配置,加入 TypeScript 专用部分。如果项目未在 package.json 中配置 eslint 依赖,也没有 eslint 配置文件,则无需进行任何操作。 完整的、兼容 eslint v9 的 eslint.config.mjs 文件可以如下所示,但实际内容取决于具体项目,因此你必须根据项目进行调整!

import eslint from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";

export default tseslint.config(
	eslint.configs.recommended,
	...tseslint.configs.recommended,
	...tseslint.configs.recommendedTypeChecked,
	{
		languageOptions: {
			globals: {
				...globals.browser,
				sap: "readonly"
			},
			ecmaVersion: 2023,
			parserOptions: {
				project: true,
				tsconfigRootDir: import.meta.dirname
			}
		}
	},
	{
		ignores: ["eslint.config.mjs"]
	}
);

应用代码转换

步骤 1:将专有的 UI5 类语法改为标准 ES 类语法

所有 UI5 类定义(SuperClass.extend(...))都必须转换为标准 JavaScript class。 UI5 类配置对象(extend 的第二个参数)中的属性将成为标准 JavaScript 类的成员。 重要的是,要在 JSDoc 注释中为类添加命名空间,以便反向转换时能够重新添加该命名空间。此 @namespace 注释必须紧邻类声明之前。 命名空间是完整包名和类名(extend 的第一个参数)中位于类名之前的部分。

转换前(示例):

[... other code, e.g. loading the dependencies "App", "Controller" etc. ...]
 
var App = Controller.extend("ui5tssampleapp.controller.App", {
    onInit: function _onInit() {
        // apply content density mode to root view
        this.getView().addStyleClass(this.getOwnerComponent().getContentDensityClass());
    }
});

转换后(示例,请勿直接照用此代码):

[... other code, e.g. loading the dependencies "App", "Controller" etc. ...]
 
/**
* @namespace ui5tssampleapp.controller
*/
class App extends Controller {
    public onInit() {
        // apply content density mode to root view
        this.getView().addStyleClass((this.getOwnerComponent()).getContentDensityClass());
    };
};
步骤 2:改用 ECMAScript 模块和导入

TypeScript UI5 应用必须使用现代 ES 模块和导入语法。 因此,请将所有 UI5 模块定义和依赖项加载调用(sap.ui.require(...)、sap.ui.define(...)) 转换为 ES 模块和导入(对于 sap.ui.define,还需添加模块导出)。

以上面的示例为例,转换如下。

转换前:

sap.ui.define(["sap/ui/core/mvc/Controller"], function (Controller) {
    /**
     * @namespace ui5tssampleapp.controller
     */
    class App extends Controller {
        ... // as above
    };
 
  return App;
});

转换后:

import Controller from "sap/ui/core/mvc/Controller";
 
/**
* @namespace ui5tssampleapp.controller
*/
export default class App extends Controller {
    ... // as above
};

sap.ui.require 应转换为仅包含导入、不包含导出的形式。 避免导入的模块名称冲突。

提示:导入 sap/ui/core/Core 不会提供类(大多数其他 UI5 模块也是如此),而是提供 UI5 Core 的单例实例。因此,可以直接使用导入的模块调用 byId(...) 等方法,而不必调用 sap.ui.getCore(),后者会在 JavaScript 中返回该单例实例。

如果动态使用 sap.ui.require,例如在方法体中使用 sap.ui.require(["sap/m/MessageBox"], function(MessageBox) { ... }),则应将其转换为动态导入,例如 import("sap/m/MessageBox").then((MessageBox) => { ... })。

步骤 3:标准 TypeScript 代码改造

运用你在将 JavaScript 代码转换为 TypeScript 方面的一般知识。具体来说:

  • 在需要的地方为方法参数和变量添加类型信息。
  • 在类定义的开头添加缺失的私有成员变量(并标注类型)。在 JavaScript 中,这些变量通常会在类实例的生命周期中途动态创建。
  • 当使用 someFunction.bind(...) 时,将常规的 function 转换为箭头函数,因为 TypeScript 似乎不会将绑定的 this 上下文类型传递到函数体中。
  • 在适用的情况下,在代码中定义所需的其他类型和结构。

重要:使用 UI5 类型(例如为变量或方法参数/返回类型添加注解)时,不要使用带有全局命名空间的 UI5 类型(例如 sap.m.Button 或 sap.ui.core.Popup)!请改为从相应模块导入此 UI5 类型(例如 sap/m/Button 或 sap/ui/core/Popup;如有需要,请添加导入语句),并使用导入的模块。

示例:

错误:

const b: sap.m.Button;
function getPopup(): sap.ui.core.Popup  { ... }

正确:

import Button from "sap/m/Button";
import Popup from "sap/ui/core/Popup";
 
const b: Button;
function getPopup(): Popup  { ... }

提示:在 UI5 控件的事件处理程序中,使用实际的 UI5 控件事件,而不是 Event 或 MouseEvent 之类的浏览器事件。UI5 事件并不相同。例如,处理 sap/m/Button 的 press 事件时,请使用来自 sap/m/Button 模块的 Button$PressEvent 和 Button$PressEventParameters。

注意:对于 UI5 控件 ABC 的任意事件 XYZ,都可以使用 ABC$XYZEvent 和 ABC$XYZEventParameters 之类的类型!

示例:

之前:

sap.ui.define(["./BaseController"], function (BaseController) {
    return BaseController.extend("my.app.controller.Main", {
        onPress: function(oEvent) {
            const button = oEvent.getSource();
        },
        
        onSelectionChange: function(oEvent) {
            const items = oEvent.getParameter("selectedItems");
        }
    });
});

之后:

import BaseController from "./BaseController";
import Button from "sap/m/Button";
import {Button$PressEvent} from "sap/m/Button";
import {Table$RowSelectionChangeEvent} from "sap/ui/table/Table";

export default class Main extends BaseController {
    onPress(oEvent: Button$PressEvent): void {
        const button = oEvent.getSource() as Button;
    }
    
    onRowSelectionChange(oEvent: Table$RowSelectionChangeEvent): void {
        const selectedContext = oEvent.getParameter("rowContext");
    }
}

提示:使用能够提供所有所需属性的最具体类型。例如:

  • 对于浏览器事件,使用 KeyboardEvent 或 MouseEvent 等具体类型,而不只是 Event。
  • 使用来自 sap/m/Button 模块的 Button$PressEvent,而不是 sap/ui/base/Event。
  • 这同样适用于所有类型,并非仅适用于事件。
步骤 4:为泛型方法的返回值添加类型断言

泛型 getter 方法(例如 document.getElementById(...)、someUI5Control.getModel(),或控制器中的 this.byId())会返回所有可能类型的超类型(在示例中分别是 HTMLElement、sap.ui.model.Model 和 sap.ui.core.Element),尽管实际通常会是某个具体的子类型(例如 HTMLAnchorElement、sap.ui.model.odata.v4.ODataModel 或 sap.m.Input)。

很多情况下,你需要将返回值断言为具体类型才能使用。通常可以根据上下文推断实际类型。如果无法推断,宁可避免类型断言,也不要猜测错误的类型。此外,如果返回类型本身就是 sap.ui.model.Model,也不要将其断言为 sap.ui.model.Model 这样的超类。

这同样适用于多种 UI5 方法,尤其是以下方法:

  • core.byId() / view.byId()
  • control.getBinding()
  • ownerComponent.getModel()
  • event.getSource()
  • component.getRootControl()
  • this.getOwnerComponent()

有时,此类型断言还需要额外导入模块,以便识别相应类型(例如上文的 ODataModel)。

在上文的应用控制器示例中,此步骤会额外导入应用的组件(名为 AppComponent),这样便可在 onInit 实现中执行所需的类型断言。若不进行此类型断言,getOwnerComponent 的返回类型会是 sap.ui.core.Component,而该类型没有定义应用组件中的 getContentDensityClass 方法。

之前:

import Controller from "sap/ui/core/mvc/Controller";
 
/**
* @namespace ui5tssampleapp.controller
*/
export default class App extends Controller {
 
    public onInit() {
        // apply content density mode to root view
        this.getView().addStyleClass(this.getOwnerComponent().getContentDensityClass());
    };
 
};

之后:

import Controller from "sap/ui/core/mvc/Controller";
import AppComponent from "../Component";
 
/**
* @namespace ui5tssampleapp.controller
*/
export default class App extends Controller {
 
    public onInit() : void {
        // apply content density mode to root view
        this.getView().addStyleClass((this.getOwnerComponent() as AppComponent).getContentDensityClass());
    };
 
};

(注意:TypeScript 并不严格要求在方法返回类型中声明 void,但这样做有益处,例如可适配不同的 lint 配置。)

步骤 5:解决剩余问题

此时,剩余的 TypeScript 错误数量应该会大幅减少。 如果能明确识别出其中一些问题,请修复;如有疑问,请将最后剩余的问题告知开发者。

UI5 控件 TypeScript 转换指南

本节介绍如何将 UI5 自定义控件从 JavaScript 转换为 TypeScript。此指南适用于应用中的单个自定义控件以及控件库。

将 UI5 自定义控件转换为 TypeScript,除了遵循通用的 TypeScript 转换方式(转换专有 UI5 类和语法)外,还需要采用特定模式。

运行时生成的方法问题(重要)

这是最需要理解的一点。

UI5 会在运行时为所有属性、聚合、关联和事件生成 getter/setter(以及其他)方法。这意味着 TypeScript 在开发时无法识别这些方法,因而会产生类型错误。

问题

在元数据中定义了 text 属性的控件中:

static readonly metadata: MetadataOptions = {
    properties: {
        "text": "string"
    }
};

尝试使用生成的方法时,TypeScript 会显示错误:

rm.text(control.getText());  // ERROR: Property 'getText' does not exist on type 'MyControl'

此外,TypeScript 不知道用于初始化控件的构造函数签名结构:

new MyControl("myId", {text: "Hello"}); // TypeScript doesn't know about the settings object structure

这会影响:

  • 属性 getter/setter:getText()、setText()、bindText()
  • 聚合方法:addItem()、removeItem()、getItems(),……
  • 关联方法:getLabel()、setLabel()
  • 事件方法:attachPress()、detachPress()、firePress()
  • 构造函数设置对象结构
解决方案:@ui5/ts-interface-generator

将接口生成器工具安装为开发依赖项:

npm install --save-dev @ui5/ts-interface-generator@{{ts-interface-generator-version}}

为方便后续开发,可在 package.json 中添加如下脚本:

{
    "scripts": {
        "watch:controls": "npx @ui5/ts-interface-generator --watch"
    }
}

注意:与控件相关的 tsconfig 文件必须与启动接口生成器的目录相同。如果你在项目根目录中启动它,而涵盖 TypeScript 控件的 tsconfig 位于子目录中,或其文件名不是 tsconfig.json,则需像这样调用: npx @ui5/ts-interface-generator --watch --config path/to/tsconfig.json。

将所有控件转换为 TypeScript 后,运行一次生成器以生成所需的控件接口:

npm run watch:controls

这会生成一个 *.gen.d.ts 文件(例如 MyControl.gen.d.ts),其中包含具有所有运行时生成方法的 TypeScript 接口。TypeScript 会将这些接口与控件类合并。

这些生成的文件应提交到版本控制中,且不得手动编辑。

必需的构造函数签名(重要手动步骤)

运行接口生成器后,必须手动将终端输出中的构造函数签名复制到相应的控件类中。

生成器会输出类似如下内容:

===== BEGIN =====
// The following three lines were generated and should remain as-is to make TypeScript aware of the constructor signatures 
constructor(id?: string | $MyControlSettings);
constructor(id?: string, settings?: $MyControlSettings);
constructor(id?: string, settings?: $MyControlSettings) { super(id, settings); }
===== END =====

将这些行复制到类体开头、元数据定义之前:

export default class MyControl extends Control {
    // The following three lines were generated and should remain as-is to make TypeScript aware of the constructor signatures 
    constructor(id?: string | $MyControlSettings);
    constructor(id?: string, settings?: $MyControlSettings);
    constructor(id?: string, settings?: $MyControlSettings) { super(id, settings); }

    static readonly metadata: MetadataOptions = {
        // ...
    };
}
控件元数据类型

控件元数据必须声明为 MetadataOptions 类型:

import type { MetadataOptions } from "sap/ui/core/Element";

export default class MyControl extends Control {
    static readonly metadata: MetadataOptions = {
        properties: {
            "text": "string"
        }
    };
}

重要事项:

  • 控件需从 sap/ui/core/Element 导入 MetadataOptions(或从最接近的基类导入;sap/ui/core/Object、sap/ui/core/ManagedObject 和 sap/ui/core/Component 也提供此类型)
  • 使用 import type 而不是 import(仅用于设计时,不会影响运行时)
  • UI5 1.110 起提供 MetadataOptions;更早版本请使用 object
  • 类型声明可防止继承控件时出现问题(不应重复声明继承的属性)
必需的命名空间注解

必须使用 @namespace JSDoc 注解,转换器才能生成正确的 UI5 类名:

/**
 * @namespace ui5.typescript.helloworld.control
 */
export default class MyControl extends Control {
    // ...
}
导出模式

定义类时必须立即使用 export default,否则 ts-interface-generator 会失败:

// CORRECT:
export default class MyControl extends Control {
    // ...
}

// WRONG - separate export:
class MyControl extends Control {
    // ...
}
export default MyControl;
元数据和渲染器的静态成员

元数据和渲染器都定义为 static 类成员:

import RenderManager from "sap/ui/core/RenderManager";

export default class MyControl extends Control {
    static readonly metadata: MetadataOptions = {
        properties: {
            "text": "string"
        }
    };

    static renderer = {
        apiVersion: 2,
        render: function (rm: RenderManager, control: MyControl): void {
            rm.openStart("div", control);
            rm.openEnd();
            rm.text(control.getText());
            rm.close("div");
        }
    };
}

渲染器也可以放在单独的文件中(库中常见这种做法),这种情况下将其转换为 TypeScript 时应继续保持独立。

以下 JavaScript 代码:

sap.ui.define([
    "sap/ui/core/Control",
    "./MyControlRenderer"
], function (Control, MyControlRenderer) {
    "use strict";

    return Control.extend("com.myorg.myapp.control.MyControl", {
        ...
        renderer: MyControlRenderer,
        ...

转换为以下 TypeScript 代码:

import Control from "sap/ui/core/Control";
import type { MetadataOptions } from "sap/ui/core/Element";
import MyControlRenderer from "./MyControlRenderer";

/**
 * @namespace com.myorg.myapp.control
 */
export default class MyControl extends Control {
    ...
    static renderer = MyControlRenderer;
    ...
完整控件示例
JavaScript(转换前):
sap.ui.define([
    "sap/ui/core/Control",
    "sap/ui/core/RenderManager"
], function (Control, RenderManager) {
    "use strict";
    
    var MyControl = Control.extend("ui5.typescript.helloworld.control.MyControl", {
        metadata: {
            properties: {
                "text": "string"
            },
            events: {
                "press": {}
            }
        },
        
        renderer: function (rm, control) {
            rm.openStart("div", control);
            rm.openEnd();
            rm.text(control.getText());
            rm.close("div");
        },
        
        onclick: function() {
            this.firePress();
        }
    });

    return MyControl;
});
TypeScript(之后):
import Control from "sap/ui/core/Control";
import type { MetadataOptions } from "sap/ui/core/Element";
import RenderManager from "sap/ui/core/RenderManager";

/**
 * @namespace ui5.typescript.helloworld.control
 */
export default class MyControl extends Control {
    // The following three lines were generated and should remain as-is to make TypeScript aware of the constructor signatures 
    constructor(id?: string | $MyControlSettings);
    constructor(id?: string, settings?: $MyControlSettings);
    constructor(id?: string, settings?: $MyControlSettings) { super(id, settings); }

    static readonly metadata: MetadataOptions = {
        properties: {
            "text": "string"
        },
        events: {
            "press": {}
        }
    };

    static renderer = {
        apiVersion: 2,
        render: function (rm: RenderManager, control: MyControl): void {
            rm.openStart("div", control);
            rm.openEnd();
            rm.text(control.getText());
            rm.close("div");
        }
    };

    onclick(): void {
        this.firePress();
    }
}
库专用指南

转换整个控件库(而不只是应用中的单个控件)时,需要执行额外步骤:

含枚举的库模块(对于避免 XSS 问题至关重要!)

在 library.ts 中,必须将枚举附加到全局库对象,以确保 UI5 运行时兼容性:

import ObjectPath from "sap/base/util/ObjectPath";

// Define enum as TypeScript enum
export enum ExampleColor {
    Red = "Red",
    Green = "Green",
    Blue = "Blue"
}

// CRITICAL: Attach to global library object
const thisLib = ObjectPath.get("com.myorg.myui5lib") as {[key: string]: unknown};
thisLib.ExampleColor = ExampleColor;

为什么库中的每个枚举都至关重要:

  • 控件属性将类型引用为全局名称:type: "com.myorg.myui5lib.ExampleColor"
  • UI5 运行时需要通过此全局路径查找枚举
  • 如果没有此设置,UI5 将无法验证属性类型
  • 这会破坏类型检查,并可能造成 XSS 漏洞,因为未经检查的内容可能会意外写入 HTML
在 tsconfig.json 中进行路径映射

对于库,请为库命名空间添加路径映射:

{
    "compilerOptions": {
        "paths": {
            "com/myorg/mylib/*": ["./src/*"]
        }
    }
}
控件转换检查清单

将控件从 JavaScript 转换为 TypeScript 时:

  1. 像常规 UI5 模块一样转换为 ES6 类/模块
  2. 添加 @namespace JSDoc 注释
  3. 在类定义处立即使用 export default
  4. 将元数据类型定义为 MetadataOptions(从适当的基类导入)
  5. 将元数据和渲染器定义为 static 成员
  6. 安装并运行 @ui5/ts-interface-generator
  7. 将构造函数签名从生成器输出复制到类中
  8. 如果位于库中:手动将枚举附加到全局库对象
  9. 保留所有 JSDoc 注释和文档

测试转换

将 UI5 测试代码从 JavaScript 转换为 TypeScript 时,有一些关键但不易察觉的模式。需要转换测试时,请参阅测试转换文档了解详情。

评论

支持 Markdown