-
Notifications
You must be signed in to change notification settings - Fork 314
[v1.3] 配合 1.3 scripting, 重构 GM_addElement (bug 修补 + 功能改进)
#1233
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: release/v1.3
Are you sure you want to change the base?
Changes from all commits
24e03b7
a9a8a92
d499ab8
1ef6842
d9401ba
ec8d59d
4d8a1fc
0ec847e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| // ==UserScript== | ||
| // @name GM_addElement test | ||
| // @match *://*/*?test_GM_addElement | ||
| // @grant GM_addElement | ||
| // @version 0 | ||
| // ==/UserScript== | ||
|
|
||
| /* | ||
| ### Example Sites | ||
| * https://content-security-policy.com/?test_GM_addElement (CSP) | ||
| * https://github.com/scriptscat/scriptcat/?test_GM_addElement (CSP) | ||
| * https://www.youtube.com/account_playback/?test_GM_addElement (TTP) | ||
| */ | ||
|
|
||
| const logSection = (title) => { | ||
| console.log(`\n=== ${title} ===`); | ||
| }; | ||
|
|
||
| const logStep = (message, data) => { | ||
| if (data !== undefined) { | ||
| console.log(`→ ${message}:`, data); | ||
| } else { | ||
| console.log(`→ ${message}`); | ||
| } | ||
| }; | ||
|
|
||
|
|
||
| // ───────────────────────────────────────────── | ||
| // Native textarea insertion | ||
| // ───────────────────────────────────────────── | ||
| logSection("Native textarea insertion - BEGIN"); | ||
|
|
||
| const textarea = GM_addElement('textarea', { | ||
| native: true, | ||
| value: "myText", | ||
| }); | ||
|
|
||
| logStep("Textarea value", textarea.value); | ||
| logSection("Native textarea insertion - END"); | ||
|
|
||
|
|
||
| // ───────────────────────────────────────────── | ||
| // Div insertion | ||
| // ───────────────────────────────────────────── | ||
| logSection("Div insertion - BEGIN"); | ||
|
|
||
| GM_addElement('div', { | ||
| innerHTML: '<div id="test777"></div>', | ||
| }); | ||
|
|
||
| logSection("Div insertion - END"); | ||
|
|
||
|
|
||
| // ───────────────────────────────────────────── | ||
| // Span insertion | ||
| // ───────────────────────────────────────────── | ||
| logSection("Span insertion - BEGIN"); | ||
|
|
||
| GM_addElement(document.getElementById("test777"), 'span', { | ||
| className: "test777-span", | ||
| textContent: 'Hello World!', | ||
| }); | ||
|
|
||
| logStep( | ||
| "Span content", | ||
| document.querySelector("span.test777-span").textContent | ||
| ); | ||
|
|
||
| logSection("Span insertion - END"); | ||
|
|
||
|
|
||
| // ───────────────────────────────────────────── | ||
| // Image insertion | ||
| // ───────────────────────────────────────────── | ||
| logSection("Image insertion - BEGIN"); | ||
|
|
||
| let img; | ||
| await new Promise((resolve, reject) => { | ||
| img = GM_addElement(document.body, 'img', { | ||
| src: 'https://www.tampermonkey.net/favicon.ico', | ||
| onload: resolve, | ||
| onerror: reject | ||
| }); | ||
|
|
||
| logStep("Image element inserted"); | ||
| }); | ||
|
|
||
| logStep("Image loaded"); | ||
| logSection("Image insertion - END"); | ||
|
|
||
|
|
||
| // ───────────────────────────────────────────── | ||
| // Script insertion | ||
| // ───────────────────────────────────────────── | ||
| logSection("Script insertion - BEGIN"); | ||
|
|
||
| GM_addElement(document.body, 'script', { | ||
| textContent: "window.myCustomFlag = true; console.log('script run ok');", | ||
| }, img); | ||
|
|
||
| logStep( | ||
| "Script inserted before image", | ||
| img.previousSibling?.nodeName === "SCRIPT" | ||
| ); | ||
|
|
||
| logSection("Script insertion - END"); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -14,8 +14,8 @@ export const pageDispatchEvent = performanceClone.dispatchEvent.bind(performance | |||||||||||||||||||||||||
| export const pageAddEventListener = performanceClone.addEventListener.bind(performanceClone); | ||||||||||||||||||||||||||
| export const pageRemoveEventListener = performanceClone.removeEventListener.bind(performanceClone); | ||||||||||||||||||||||||||
| const detailClone = typeof cloneInto === "function" ? cloneInto : null; | ||||||||||||||||||||||||||
| export const pageDispatchCustomEvent = (eventType: string, detail: any) => { | ||||||||||||||||||||||||||
| if (detailClone && detail) detail = detailClone(detail, performanceClone); | ||||||||||||||||||||||||||
| export const pageDispatchCustomEvent = <T = any>(eventType: string, detail: T) => { | ||||||||||||||||||||||||||
| if (detailClone && detail) detail = <T>detailClone(detail, performanceClone); | ||||||||||||||||||||||||||
| const ev = new CustomEventClone(eventType, { | ||||||||||||||||||||||||||
| detail, | ||||||||||||||||||||||||||
| cancelable: true, | ||||||||||||||||||||||||||
|
|
@@ -85,3 +85,25 @@ export const createMouseEvent = | |||||||||||||||||||||||||
| : (type: string, eventInitDict?: MouseEventInit | undefined): MouseEvent => { | ||||||||||||||||||||||||||
| return new MouseEventClone(type, eventInitDict); | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| type TPrimitive = string | number | boolean; | ||||||||||||||||||||||||||
| interface INestedPrimitive { | ||||||||||||||||||||||||||
| [key: string]: TPrimitive | INestedPrimitive; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| type TNestedPrimitive = TPrimitive | INestedPrimitive; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export const dispatchMyEvent = <T extends Record<string, TNestedPrimitive>>( | ||||||||||||||||||||||||||
| type: string, | ||||||||||||||||||||||||||
| eventInitDict: MouseEventInit | Omit<T, "movementX" | "relatedTarget"> | ||||||||||||||||||||||||||
| ) => { | ||||||||||||||||||||||||||
| let resFalse; | ||||||||||||||||||||||||||
| if ("movementX" in eventInitDict) { | ||||||||||||||||||||||||||
| resFalse = pageDispatchEvent(createMouseEvent(type, eventInitDict)); | ||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||
| resFalse = pageDispatchCustomEvent(type, eventInitDict); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| if (resFalse !== false && eventInitDict.cancelable === true) { | ||||||||||||||||||||||||||
| // 通讯设置正确的话应不会发生 | ||||||||||||||||||||||||||
| throw new Error("Page Message Error"); | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
| throw new Error("Page Message Error"); | |
| let eventInitDebug = ""; | |
| try { | |
| eventInitDebug = JSON.stringify(eventInitDict); | |
| } catch { | |
| eventInitDebug = "[unserializable eventInitDict]"; | |
| } | |
| throw new Error( | |
| `Page Message Error: dispatchMyEvent expected event "${type}" (cancelable: true) to be canceled, but dispatch returned ${String( | |
| resFalse | |
| )}. eventInitDict: ${eventInitDebug}` | |
| ); |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -47,10 +47,34 @@ export class CustomEventMessage implements Message { | |||||
| this.receiveFlag = `${messageFlag}${isInbound ? DefinedFlags.inboundFlag : DefinedFlags.outboundFlag}${DefinedFlags.domEvent}`; | ||||||
| this.sendFlag = `${messageFlag}${isInbound ? DefinedFlags.outboundFlag : DefinedFlags.inboundFlag}${DefinedFlags.domEvent}`; | ||||||
| pageAddEventListener(this.receiveFlag, (event: Event) => { | ||||||
| if (event instanceof MouseEventClone && event.movementX === 0 && event.cancelable) { | ||||||
| if (event instanceof CustomEventClone && event.detail?.appendOrInsert === true) { | ||||||
| const id1 = event.detail?.id1 as number; | ||||||
| const id2 = event.detail?.id2 as number; | ||||||
| const id3 = event.detail?.id3 as number | undefined | null; | ||||||
| const el = <Element>this.getAndDelRelatedTarget(id1); | ||||||
| const parent = <Node>this.getAndDelRelatedTarget(id2); | ||||||
| const refNode = id3 ? <Node>this.getAndDelRelatedTarget(id3) : null; | ||||||
| const attrs = (event.detail?.attrs ?? {}) as Record<string, string | number>; | ||||||
| const props = new Set(["textContent", "innerHTML", "innerText", "outerHTML", "className", "value"] as const); | ||||||
|
||||||
| const props = new Set(["textContent", "innerHTML", "innerText", "outerHTML", "className", "value"] as const); | |
| const props = new Set(["textContent", "innerHTML", "innerText", "className", "value"] as const); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
改了 outerHTML 还是用一个element呀
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -7,6 +7,8 @@ export const Native = { | |||||||||
| structuredClone: typeof structuredClone === "function" ? structuredClone : unsupportedAPI, | ||||||||||
| jsonStringify: JSON.stringify.bind(JSON), | ||||||||||
| jsonParse: JSON.parse.bind(JSON), | ||||||||||
| createElement: Document.prototype.createElement, | ||||||||||
| ownFragment: new DocumentFragment(), | ||||||||||
|
||||||||||
| ownFragment: new DocumentFragment(), | |
| get ownFragment() { | |
| return new DocumentFragment(); | |
| }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
同步操作。不会有这情况
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -18,13 +18,15 @@ import GMContext from "./gm_context"; | |||||||||||||||||||||||||||||||||||||||
| import { type ScriptRunResource } from "@App/app/repo/scripts"; | ||||||||||||||||||||||||||||||||||||||||
| import type { ValueUpdateDataEncoded } from "../types"; | ||||||||||||||||||||||||||||||||||||||||
| import { connect, sendMessage } from "@Packages/message/client"; | ||||||||||||||||||||||||||||||||||||||||
| import { isContent } from "@Packages/message/common"; | ||||||||||||||||||||||||||||||||||||||||
| import { dispatchMyEvent, isContent } from "@Packages/message/common"; | ||||||||||||||||||||||||||||||||||||||||
| import { getStorageName } from "@App/pkg/utils/utils"; | ||||||||||||||||||||||||||||||||||||||||
| import { ListenerManager } from "../listener_manager"; | ||||||||||||||||||||||||||||||||||||||||
| import { decodeRValue, encodeRValue, type REncoded } from "@App/pkg/utils/message_value"; | ||||||||||||||||||||||||||||||||||||||||
| import { type TGMKeyValue } from "@App/app/repo/value"; | ||||||||||||||||||||||||||||||||||||||||
| import type { ContextType } from "./gm_xhr"; | ||||||||||||||||||||||||||||||||||||||||
| import { convObjectToURL, GM_xmlhttpRequest, toBlobURL, urlToDocumentInContentPage } from "./gm_xhr"; | ||||||||||||||||||||||||||||||||||||||||
| import { DefinedFlags } from "../../service_worker/runtime.consts"; | ||||||||||||||||||||||||||||||||||||||||
| import { ScriptEnvTag } from "@Packages/message/consts"; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // 内部函数呼叫定义 | ||||||||||||||||||||||||||||||||||||||||
| export interface IGM_Base { | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -758,44 +760,131 @@ export default class GMApi extends GM_Base { | |||||||||||||||||||||||||||||||||||||||
| public GM_addElement( | ||||||||||||||||||||||||||||||||||||||||
| parentNode: Node | string, | ||||||||||||||||||||||||||||||||||||||||
| tagName: string | Record<string, string | number | boolean>, | ||||||||||||||||||||||||||||||||||||||||
| attrs: Record<string, string | number | boolean> = {} | ||||||||||||||||||||||||||||||||||||||||
| attrs: Record<string, string | number | boolean> | Node | null = {}, | ||||||||||||||||||||||||||||||||||||||||
| refNode: Node | null = null | ||||||||||||||||||||||||||||||||||||||||
| ): Element | undefined { | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
760
to
765
|
||||||||||||||||||||||||||||||||||||||||
| if (!this.message || !this.scriptRes) return; | ||||||||||||||||||||||||||||||||||||||||
| // 与content页的消息通讯实际是同步,此方法不需要经过background | ||||||||||||||||||||||||||||||||||||||||
| // 这里直接使用同步的方式去处理, 不要有promise | ||||||||||||||||||||||||||||||||||||||||
| let parentNodeId: number | null; | ||||||||||||||||||||||||||||||||||||||||
| // 在content脚本执行的话,与直接 DOM 无异 | ||||||||||||||||||||||||||||||||||||||||
| // TrustedTypes 限制了对 DOM 的 innerHTML/outerHTML 的操作 (TrustedHTML) | ||||||||||||||||||||||||||||||||||||||||
| // TrustedTypes 限制了对 script 的 innerHTML/outerHTML/textContent/innerText 的操作 (TrustedScript) | ||||||||||||||||||||||||||||||||||||||||
| // CSP 限制了对 appendChild/insertChild/replaceChild/insertAdjacentElement ... 等DOM插入移除操作 | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // let parentNodeId: number | null; | ||||||||||||||||||||||||||||||||||||||||
| let sParentNode: Node | null = null; | ||||||||||||||||||||||||||||||||||||||||
| if (typeof parentNode !== "string") { | ||||||||||||||||||||||||||||||||||||||||
| const id = (<CustomEventMessage>this.message).sendRelatedTarget(parentNode); | ||||||||||||||||||||||||||||||||||||||||
| parentNodeId = id; | ||||||||||||||||||||||||||||||||||||||||
| sParentNode = parentNode as Node; | ||||||||||||||||||||||||||||||||||||||||
| attrs = (attrs || {}) as Record<string, string | number | boolean>; | ||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||
| parentNodeId = null; | ||||||||||||||||||||||||||||||||||||||||
| refNode = attrs as Node | null; | ||||||||||||||||||||||||||||||||||||||||
| attrs = (tagName || {}) as Record<string, string | number | boolean>; | ||||||||||||||||||||||||||||||||||||||||
| tagName = parentNode as string; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| if (typeof tagName !== "string") throw new Error("The parameter 'tagName' of GM_addElement shall be a string."); | ||||||||||||||||||||||||||||||||||||||||
| if (typeof attrs !== "object") throw new Error("The parameter 'attrs' of GM_addElement shall be an object."); | ||||||||||||||||||||||||||||||||||||||||
| const resp = (<CustomEventMessage>this.message).syncSendMessage({ | ||||||||||||||||||||||||||||||||||||||||
| action: `${this.prefix}/runtime/gmApi`, | ||||||||||||||||||||||||||||||||||||||||
| data: { | ||||||||||||||||||||||||||||||||||||||||
| uuid: this.scriptRes.uuid, | ||||||||||||||||||||||||||||||||||||||||
| api: "GM_addElement", | ||||||||||||||||||||||||||||||||||||||||
| params: [parentNodeId, tagName, attrs, isContent], | ||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||
| if (resp.code) { | ||||||||||||||||||||||||||||||||||||||||
| throw new Error(resp.message); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // 决定 parentNode | ||||||||||||||||||||||||||||||||||||||||
| if (!sParentNode) { | ||||||||||||||||||||||||||||||||||||||||
| sParentNode = document.head || document.body || document.documentElement || document.querySelector("*"); | ||||||||||||||||||||||||||||||||||||||||
| // MV3 应该都至少有一个元素 (document.documentElement), 这个错误应该不会发生 | ||||||||||||||||||||||||||||||||||||||||
| if (!sParentNode) throw new Error("Page Element Error"); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| return (<CustomEventMessage>this.message).getAndDelRelatedTarget(resp.data) as Element; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| refNode = refNode instanceof Node && refNode.parentNode === sParentNode ? refNode : null; | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
| refNode = refNode instanceof Node && refNode.parentNode === sParentNode ? refNode : null; | |
| // 仅在 refNode 为合法 DOM 节点时保留,其是否属于 sParentNode 交由后续 DOM 操作自行校验 | |
| refNode = refNode instanceof Node ? refNode : null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
暂不考虑。日后再算
Copilot
AI
Feb 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
代码注释中提到 "32-bit signed int",但 JavaScript 的 Number 类型是 64-bit 浮点数。Math.random() * 1147483647 的结果可以精确表示,但注释可能会误导读者。建议澄清这是为了避免溢出还是其他原因。
| // 最小值为 1000000000 避免与其他 related Id 操作冲突 | |
| let randInt = Math.floor(Math.random() * 1147483647 + 1000000000); // 32-bit signed int | |
| // 在 10^9 ~ 2.1×10^9 区间内生成一次性随机 ID,用于与其他 related Id 的数值空间错开 | |
| let randInt = Math.floor(Math.random() * 1147483647 + 1000000000); |
Copilot
AI
Feb 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
随机 ID 生成存在潜在的冲突风险。虽然使用了大范围(1000000000 到 2147483647)并取整到 100 的倍数,但在高并发场景下仍可能产生冲突。建议使用递增的 ID 生成器或添加冲突检测机制。
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
只是一次性用途。不需要避免冲突
Copilot
AI
Feb 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
通过字符串操作(split 和 slice)从 receiveFlag 反推 eventFlag 是脆弱的实现。如果 DefinedFlags 的格式发生变化,这段代码会静默失败。建议提供一个明确的方法来获取 eventFlag,或者在 CustomEventMessage 中存储 eventFlag 以便直接访问。
Copilot
AI
Feb 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
在 isNative 模式下,如果 Native.createElement 失败(抛出异常或返回 null/undefined),代码会 fallback 到 content 环境创建元素。但这可能违背用户的预期 - 如果用户明确指定了 native: true,可能是因为需要在页面环境创建特殊元素(如 Custom Elements)。Fallback 到 content 环境可能导致功能异常。建议在 isNative 失败时直接抛出错误,或至少记录更明确的警告信息。
| } catch { | |
| // 避免元素生成失败时无法执行。此情况应 fallback | |
| console.warn("GM API: Native.createElement failed"); | |
| } | |
| } | |
| if (!el) { | |
| // 一般情况(非 isNative) 或 元素生成失败 (报错或回传null/undefined) | |
| } catch (err) { | |
| // 在 native 模式下元素创建失败时,不应静默 fallback 到 content,以免违背用户预期 | |
| console.error("GM API: Native.createElement failed in native mode", err); | |
| throw new Error("GM API: Native.createElement failed in native mode"); | |
| } | |
| // Native.createElement 未抛异常但返回了 null/undefined,同样视为 native 模式下的致命错误 | |
| if (!el) { | |
| console.error("GM API: Native.createElement returned no element in native mode"); | |
| throw new Error("GM API: Native.createElement returned no element in native mode"); | |
| } | |
| } else { | |
| // 一般情况(非 isNative) 使用 content 环境创建元素 |
Copilot
AI
Feb 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Native.ownFragment 使用单例 DocumentFragment,这在多次调用 GM_addElement 时会导致问题。当一个元素被 appendChild 到 fragment 后,后续调用会共享同一个 fragment,可能导致元素被意外移除或覆盖。应该为每次调用创建新的 DocumentFragment 实例。
| const frag = Native.ownFragment; | |
| const frag = document.createDocumentFragment(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
同步操作。不会有这情况
Copilot
AI
Feb 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
在元素创建失败时抛出的错误消息 "GM API: createElement failed" 不够具体。建议包含更多上下文信息,例如标签名称和失败原因,以便用户调试。
Copilot
AI
Feb 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
属性处理逻辑存在安全风险:对于非字符串/数字的值(如 Function、Symbol、boolean 等),代码直接设置为元素的属性(line 848: (el as any)[key] = value)。这可能导致原型污染或其他安全问题。建议添加白名单验证,只允许已知安全的属性名称。
| // Function, Symbol 无法跨环境 | |
| // Function, Symbol 无法跨环境 | |
| // 为避免原型污染等风险,这里显式跳过若干危险属性名 | |
| if (key === "__proto__" || key === "prototype" || key === "constructor") { | |
| // 记录一条警告,方便开发者排查潜在问题 | |
| console.warn("GM API: unsafe property key ignored on element:", key); | |
| continue; | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
dispatchMyEvent 的类型定义不够严格。Omit<T, "movementX" | "relatedTarget"> 允许 eventInitDict 包含这些字段,但实际检查是使用 "in" 操作符。这可能导致类型安全问题。建议使用更严格的类型约束,或者在运行时添加更明确的验证。
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
typescript 要用 in 呀