Skip to content

feat(theme-generator): vue3 migration#883

Open
liweijie0812 wants to merge 46 commits into
mainfrom
feat/theme-generator-vue3-migration
Open

feat(theme-generator): vue3 migration#883
liweijie0812 wants to merge 46 commits into
mainfrom
feat/theme-generator-vue3-migration

Conversation

@liweijie0812

Copy link
Copy Markdown
Collaborator

🤔 这个 PR 的性质是?

  • 日常 bug 修复
  • 新特性提交
  • 文档改进
  • 演示代码改进
  • 组件样式/交互改进
  • CI/CD 改进
  • 重构
  • 代码风格优化
  • 测试用例
  • 分支合并
  • 其他

🔗 相关 Issue

💡 需求背景和解决方案

📝 更新日志

  • fix(组件名称): 处理问题或特性描述 ...

  • 本条 PR 不需要纳入 Changelog

☑️ 请求合并前的自查清单

⚠️ 请自检并全部勾选全部选项⚠️

  • 文档已补充或无须补充
  • 代码演示已提供或无须提供
  • TypeScript 定义已补充或无须补充
  • Changelog 已提供或无须提供

将 theme-generator 从 Vue 2.7 迁移到 Vue 3.5,构建工具由 vue-cli-service
切换为 Vite + defineCustomElement(保持 Web Component 产物对外接口不变),
UI 库升级为 tdesign-vue-next,所有 SFC 改写为 <script setup>。$root 事件总线
改为 themeStore 计数器 + watch(顺带修复原 $on 未解绑的内存泄漏),/deep/、
.sync、v-model 契约等 Vue 3 破坏性变更同步适配。补基础设施单测与 Web Component
场景测试(32 个用例)。
1. wc-entry.js 显式传入 shadowRoot: false,与原 --inline-vue 行为一致。
   Vue 3 的 defineCustomElement 默认创建 shadow root,会导致注入 document.head
   的 TDesign 基础样式无法穿透 shadow 边界,生产 WC 内组件样式失效。
2. ShadowEditor.vue 的 watch(shadow) 添加 { deep: true }。shadow 是数组 ref,
   template 用 v-model="shadow[i]" 按索引 mutate,不加 deep 则 watch 不触发,
   用户编辑阴影值后 change 事件不回传(Vue 2 的 v-model 索引编译为 $set 会触发,
   迁移后行为回归)。

补充 wc.spec.js 的 shadowRoot 断言与 ShadowEditor.spec.js 回归测试。
- SwitchTabs: 使用 markRaw 标记 SVG 组件,避免被 reactivity 代理
- FloatDock: 移除 TPopconfirm 的 :theme="null",使用默认 theme 修复 undefined vnode 警告
当 size < 4 时,内部标记矩形的 width/height 计算为负值(如 -1),
导致 SVG <rect> 报错。新增 innerHeight 计算属性,使用 Math.max(0, ...) 钳制下界。
SegmentSelection 的 t-slider min 硬编码为 1,而 ShadowSelectType.Super_Light = 0,
导致滑块最左只能到 Light(1),无法到达超清(0)。
新增 minSliderValue 计算属性,取 selectOptions[0].value,与 maxSliderValue 写法对齐。
- 修复 size-panel 下 5 个 SVG 组件模板和 script 的循环错位:
  SectionDynamicSvg → 动态尺寸条、MarginSvg → 静态 margin 图标、
  MarginAdjustSvg → 动态竖向指示器、HorizontalPaddingAdjustSvg → 动态横向指示器、
  HorizontalPaddingSvg → 静态横向内边距图标
- 修复 ShadowEditor 中 hasInit 仅在 color watcher 首次触发时置 true 的问题,
  改为在 onMounted + nextTick 中统一设置,避免先修改 shadow 数值时变更被静默丢弃
@pkg-pr-new

pkg-pr-new Bot commented Jul 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@tdesign/site-components@883
npm i https://pkg.pr.new/@tdesign/theme-generator@883

commit: 418a719

Vue 3 迁移(fd2bdd5)误用 tdesign-vue-next/es/style/index.css 替代老定制版
tdesign.min.css,丢失两项关键定制:
  1. :host 选择器(Web Component 兼容)
  2. --td-brand-color-* 系列 Token 的剥离(与外部主题色同步)

新增 scripts/build-tdesign-css.mjs 从 dist/tdesign.min.css 正则重建定制版:
  - 为每个 :root 选择器追加 :host 变体
  - 删除 34 个 --td-brand-color* 定义(保留 var() 使用)
  - 删除 sourceMappingURL 注释
package.json 加 build:css 脚本并接入 build,升级 tdesign-vue-next 后
运行 npm run build:css 即可重新同步。
原方案 shadowRoot: false + cssInjectedByJsPlugin 把 441KB 桌面端 tdesign.min.css
注入 document.head(全局),污染宿主页:
  - --td-radius-default:3px 覆盖 mobile-vue 的 .t-button:after border-radius 计算
  - .t-button 等类名直接冲突

改为 shadowRoot: true + defineCustomElement styles 选项:
  - tdesign.min.css / reset.min.css 用 ?inline 导入为字符串,传入 styles 数组
  - Vue 自动注入 shadowRoot,与宿主页完全隔离
  - handleAttach() 已支持 shadowRoot(弹层挂到 shadowRoot 内 .theme-generator)
  - SFC <style> 由 Vue 通过 comp.styles 自动注入 shadowRoot

vite.config.js 新增 stripTdesignComponentCSS 插件,屏蔽 tdesign-vue-next /
tdesign-icons-vue-next 的 style/css.{mjs,js} import(组件 CSS 已包含在全量
tdesign.min.css 中,避免重复打包)。

移除 vite-plugin-css-injected-by-js 依赖。wc.spec.js 断言改为 shadowRoot
内查找 .theme-generator。
shadowRoot: true 切换后 SFC <style> 块被抽到单独 .css 文件,shadowRoot 无法
消费,导致 recommend-theme__flex-theme 等 scoped 样式丢失。

配置 vue({ customElement: true }) 让 @vitejs/plugin-vue 以 custom element 模式
处理所有 SFC:<style> 块用 &inline 导入为字符串,并作为 comp.styles 注入。
Vue 的 defineCustomElement 在 shadowRoot: true 时会从 comp.styles 读取并注入
shadowRoot。

验证 build 产物:25 个 SFC 组件均正确设置 comp.styles,shadowRoot 内 21 个
style 元素、478KB CSS(含 tdesign 基础样式 + SFC scoped 样式)。
customElement: true 全局启用后 dev 模式不显示:main.js 用 createApp 渲染到
#app(light DOM),但 createApp 不消费 comp.styles,SFC 样式字符串被 import
后丢弃 → 页面无样式。

改为按 command 区分:
  - build(WC 产物):vue({ customElement: true }),SFC <style> 以 &inline
    字符串注入 comp.styles,配合 shadowRoot: true 注入 shadowRoot
  - dev(createApp 渲染):vue({}),SFC <style> 正常注入 document.head

stripTdesignComponentCSS 同步加 includeComponentCSS 开关,dev 时保留组件
CSS import 让 Vite 正常注入。
shadowRoot: true 模式下 t-drawer 的 showInAttachedElement(position:absolute)
包含块解析异常(:host 是 inline 零尺寸),drawer 尺寸算成 0 无法显示。

改为默认 position:fixed(相对 viewport,不依赖祖先包含块)+ :attach=handleAttach
(teleport 到 shadowRoot 内 .theme-generator,保持样式隔离)。

mask 加 pointer-events:none 避免拦截宿主页面交互,content-wrapper 加
pointer-events:auto 保持可点击。
dev 与 build 产物差异显著(light DOM vs shadow DOM、createApp vs
defineCustomElement、CSS 注入 document.head vs shadowRoot),导致
'dev 正常但 build 出问题'(drawer、样式隔离等只在 build 暴露)。

统一 dev/build 走 wc-entry.js 路径:
  - index.html: <div id="app"> + main.js → <td-theme-generator> + wc-entry.js
  - 删除 src/main.js(不再需要 createApp 入口)
  - vite.config.js: vue({ customElement: true }) 不再按 command 区分
  - tdesign-vue-next alias 到 lib/(无样式 ESM 入口,0 个 CSS import),
    组件级 CSS 不会被引入;全量 tdesign.min.css 已通过 ?inline 注入 shadowRoot
  - tdesign-icons-vue-next 保留 esm(tree-shaking 好),其 style/css.js
    CSS import(357B,已包含在全量 tdesign.min.css)由 stripTdesignIconsCSS
    插件屏蔽
  - 源码 tdesign-vue-next/es/common-components/fake-arrow 改为
    tdesign-vue-next/common-components/fake-arrow(让 alias 生效)

dev 现在与 build 行为一致:shadowRoot 隔离、SFC styles 注入 shadowRoot、
defineCustomElement 实例化,提前暴露 WC 环境问题。
drawer 用 :attach=handleAttach teleport 到 .theme-generator 后脱离
panel-drawer 根节点,scoped 的 [data-v-xxx] 前缀让 :deep() 选择器失配,
.t-drawer__body 等样式不生效。

shadow DOM 本身已隔离宿主页,scoped 属性多余。移除 scoped + :deep(),
样式作为 shadowRoot 内的全局规则,能命中 teleport 后的 drawer/popup 内容。
background:none 是简写,与 tdesign 的 background-color:var(--td-mask-active)
长属性混用时层叠行为易混淆(happy-dom 下未生效)。改为 background-color:transparent,
同 specificity 下声明在后明确胜出。
- 选择器 .t-drawer__mask → .theme-generator .t-drawer__mask,特异性 (0,1,0)→(0,2,0),可靠覆盖 tdesign.min.css 中 --td-mask-active 默认背景
- 删除 pointer-events: none,mask 恢复可点击,TDesign overlay-click 关闭逻辑(默认 closeOnOverlayClick: true)重新生效
根因是 scripts/build-tdesign-css.mjs 给 shadowRoot 内的 tdesign.min.css
生成的 :host 变体用了复合形式(:host[theme-mode=dark]、:host.dark),Chrome
只支持函数形式(:host([theme-mode=dark])、:host(.dark)),导致 :host 选择器
永不匹配,shadow DOM 内所有 TDesign 组件(t-select 下拉、t-popup、t-drawer
等)始终卡在浅色。已用 agent-browser 实测验证:切到 dark 后
--td-bg-color-container 仍是 #fff,改用函数形式后变为 #242424。

同步修复其它配套缺陷:
- vars.css 缺 :root.dark 选择器(与 dark.css / tdesign.min.css 对齐)
- getThemeMode/setUpModeObserver 不识别 .dark class(attributeFilter 加 class)
- syncModeToGenerator 无初始同步(宿主页加载已处于 dark 时 :host 不被设置)
- 模式切换不 bump refreshId(font/shadow/size 面板显示旧值)

测试:新增 src/common/utils/__tests__/index.spec.js 覆盖 getThemeMode 三种
状态与 setUpModeObserver 对 theme-mode 属性 / .dark class 的响应;40 个测试
全过。
@liweijie0812 liweijie0812 marked this pull request as ready for review July 1, 2026 16:11
将所有裸导入 `tdesign-vue-next` 及子路径 `tdesign-vue-next/common-components/*` 统一改为无样式入口 `tdesign-vue-next/lib`,并移除 vite 中对应 alias 重定向。本挂件以 Web Component 形式渲染,组件级 CSS 无法落到 shadowRoot,全量样式已由 `?inline` 注入 shadowRoot;裸导入会引入组件级 CSS 污染宿主页并重复打包。同步更新 CONTRIBUTING 开发规范与 setup.js 注释说明此约定。
@liweijie0812 liweijie0812 changed the title Feat/theme generator vue3 migration feat(theme-generator): vue3 migration Jul 2, 2026
theme-generator 以 Web Component 形态运行(shadowRoot: true),所有 t-popup
弹层通过 handleAttach() teleport 进 shadowRoot 以命中隔离样式。TDesign v1.20.3
的 t-popup 在 shadow DOM 下有两处 broken:

1. onDocumentMouseDown 用 popperEl.contains(ev.target) 判断点击是否在弹层内,
   shadow DOM 下 document 层的 ev.target 被重定向到 shadow host,contains 恒为
   false → 点击弹层内部误关闭。
2. onMouseLeave → getPopperTree 用 document.querySelectorAll 查找子弹层判断光标
   是否仍在弹层上,querySelectorAll 不穿透 shadow 边界 → 返回 [] → hover 移向
   子弹层时父弹层误关闭。

新增 ShadowPopup 包装组件(src/common/components/ShadowPopup)自管理 visible,
在 visible-change 拦截层否决误关闭,对外 API/插槽/事件与 t-popup 一致:

- 点击否决:onMounted 时 capture 监听 document mousedown,用 composedPath() 同步
  记录点击是否落在任意 [data-td-popup] overlay 内;visible-change 里仅当
  trigger==='document' + target 是 shadow host + 点击确实在 overlay 内时才否决。
  这样既保留"点击弹层内部不关闭",又不破坏"点另一个触发器切换弹层"。
- hover 否决:overlay 的 mouseleave 时光标移向子弹层(沿 data-td-popup-parent 链
  判断)→ 否决;子弹层 assertMouseLeave 转发且 relatedTarget 在父 overlay 内 → 否决。
  叶子弹层移向外部正常关闭。

将 color-panel / shadow-panel / font-panel / size-panel / radius-panel 下所有
自管理 t-popup 迁移到 shadow-popup(10 个文件),float-dock 保持原有兜底逻辑。
新增 ShadowPopup 单测 9 例(点击/hover 各场景)+ WC 冒烟回归全过。
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown

TDesign Component Site Preview CI Open

Sites (9)
Component Preview
tdesign-site 完成
tdesign-vue 完成
tdesign-react 完成
tdesign-vue-next 完成
@tdesign-vue-next/chat 完成
tdesign-mobile-vue 完成
tdesign-mobile-react 完成
tdesign-miniprogram 完成
tdesign-uniapp 完成

根因有两处:

1. handleMiniProgramModeChange 用 document.createElement 创建 <style>
   再 append 到 iframe,ownerDocument 与 iframe document 不一致,
   导致后续 iframe.contentDocument.getElementById 命不中该节点。
   handleMiniProgramTokenChange 走到 else 分支后因 id 为
   CUSTOM_THEME_ID / CUSTOM_DARK_ID 提前 return,iframe 内样式永不更新。
   改为 iframeDom.createElement 与其它两处保持一致。

2. syncThemeToIframe 小程序分支仅设 previewIframe.onload,若外层
   iframe 已加载完成 onload 不会再触发,嵌套 webview iframe 永不被监听。
   改为立即调用一次 watchNestedIframes 并保留 onload,加
   NESTED_OBSERVED_FLAG 守卫避免重复创建 MutationObserver。

测试:新增 src/common/themes/__tests__/iframe.spec.js,4 个用例覆盖
uniapp 初始同步/修改 token、小程序已加载场景的嵌套监听/修改 token;
回退修复后全部失败,应用修复后全部通过;全量 44 个测试通过。
根因:小程序分支只调用 watchNestedIframes 查找 id 以 "webview" 开头的
嵌套 iframe,但文档站实际使用 m2w web 预览(td-doc-phone > iframe.mobile-iframe),
小程序直接在 previewIframe 内渲染,没有嵌套 webview iframe。结果是
watchNestedIframes 找不到任何目标,主题永远不会被注入,修改主题完全无效。

修复:小程序分支先调用 beforeWatchThemeChange(previewIframe, device)
直接给 previewIframe 本身设置主题同步(覆盖 m2w web 预览场景),再链式
包装 watchThemeChange 设置的 onload,在原有重新初始化逻辑之后追加
watchNestedIframes(覆盖开发者工具模拟器场景)。iframe 已加载完成时
立即检查一次嵌套 iframe(onload 不会再触发)。

测试:新增 2 个用例覆盖 m2w web 预览(无嵌套 webview)场景下初始同步
与修改 token;原嵌套 webview 用例增加 previewIframe 本身也被注入的
断言。回退修复后 m2w 用例失败,应用后全部通过;全量 46 个测试通过。
根因:iframe 同步用 `body`(小程序)/ `uni-page-body`(uni-app)选择器,
但导出的 app.wxss 用 `page, .page`。m2w 预览把小程序渲染在 `page` 元素内,
预览自身的 app.wxss 把 CSS 变量定义在 `page` 上。注入的 `body { --td-x }`
会被 `page { --td-x }` 覆盖(更近的祖先优先),导致修改主题色后 phone 预览
不生效——父页面 custom-theme 已更新成黄色,iframe 内却仍是默认蓝色。

修复:新增 getMobileSelector() 返回 `page, .page`,与 exportCustomStyleSheet
导出逻辑一致。handleMiniProgramModeChange / handleMiniProgramTokenChange
统一调用它。移除不再需要的 uniapp 参数。

测试:已有用例的 selector 断言从 `body`/`uni-page-body` 改为 `page, .page`;
全量 46 个测试通过。
两个问题导致小程序和 uniapp 预览主题仍不生效:

1. 选择器误统一为 `page, .page`:上一次提交把预览 iframe 的选择器
   对齐导出逻辑用 `page, .page`,但导出运行在真实小程序/uni-app 环境
   (根节点是 `page` 元素),而预览 iframe 是 H5 渲染:
   - 小程序 m2w 预览内容在 `body` 内,无 `page` 元素
   - uni-app H5 预览页面根节点是 `uni-page-body`
   `page, .page` 在预览 iframe 内匹配不到任何元素,CSS 变量无处定义。
   恢复预览专用选择器 `body`(小程序)/ `uni-page-body`(uni-app),
   导出仍保持 `page, .page`。

2. syncThemeToIframe 仅靠 MutationObserver:td-doc-phone > iframe 若在
   syncThemeToIframe 调用前已在 DOM 中(SPA 已渲染完成),不会再触发
   childList,observer 永远不 fire,iframe 不被处理(mini.html 中 iframe
   无 device/data-observed 属性即为此问题)。增加初始扫描 handleDocPhoneIframe()
   在设 observer 前立即检查一次。

测试:新增 2 个用例覆盖"iframe 已在 DOM 中"场景;已有用例 selector 断言
改回 `body`/`uni-page-body`。全量 48 个测试通过。
根因:surge.sh 等预览环境的页面与 iframe(tdesign.tencent.com)跨域,
iframe.contentDocument 为 null,handleMiniProgramModeChange 等函数
调用 iframeDom.getElementById 抛 TypeError: Cannot read properties of null。

生产环境(tdesign.tencent.com)页面与 iframe 同源,contentDocument 可访问,
主题同步正常工作,此问题仅影响跨域预览环境。

修复:新增 getIframeDoc() 统一获取 contentDocument 并做 null 检查,
跨域时 console.warn 提示(仅一次)并安全返回,不再抛未捕获异常。
handleMobileModeChange / handleMiniProgramModeChange /
handleMobileTokenChange / handleMiniProgramTokenChange / watchNestedIframes
全部改用 getIframeDoc()。全量 48 个测试通过。
@Tencent Tencent deleted a comment from github-actions Bot Jul 6, 2026
@Tencent Tencent deleted a comment from github-actions Bot Jul 6, 2026
@Tencent Tencent deleted a comment from github-actions Bot Jul 6, 2026
@Tencent Tencent deleted a comment from github-actions Bot Jul 6, 2026
@Tencent Tencent deleted a comment from github-actions Bot Jul 6, 2026
@Tencent Tencent deleted a comment from github-actions Bot Jul 6, 2026
@Tencent Tencent deleted a comment from github-actions Bot Jul 6, 2026
@Tencent Tencent deleted a comment from github-actions Bot Jul 6, 2026
@liweijie0812

liweijie0812 commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

t-popup 在 shadow DOM 下有两处 broken:

onDocumentMouseDown 用 popperEl.contains(ev.target) 判断点击是否在弹层内,
shadow DOM 下 document 层的 ev.target 被重定向到 shadow host,contains 恒为
false → 点击弹层内部误关闭。
onMouseLeave → getPopperTree 用 document.querySelectorAll 查找子弹层判断光标
是否仍在弹层上,querySelectorAll 不穿透 shadow 边界 → 返回 [] → hover 移向
子弹层时父弹层误关闭。

补充两个修复方案

  1. [wip]方案一 fix(popup): 修复 Shadow DOM 下事件穿透导致弹层异常关闭 tdesign-vue-next#6777

  2. [wip]方案二 fix(popup): 修复 Shadow DOM 下事件穿透导致弹层异常关闭 tdesign-vue-next#6779

主要变更:
- 生成器专用变量改注入 Shadow Root(generator-vars.css,:root→:host 变体),
  --brand-main 改设到 td-theme-generator host 上,不再污染宿主页 documentElement
- Generator/color-panel/float-dock/animation 在 onUnmounted 断开 observer、
  清除 timer、取消 RAF;color-panel 增加 disposed 守卫避免 nextTick 内启动泄漏
- iframe.js innerText→textContent(Firefox 下 <style>.innerText 返回空);
  syncThemeToIframe 返回 cleanup 供 WC 卸载时断开 document 级 observer
- i18n 路由匹配 endsWith('en')→endsWith('-en'),与 site-components 约定一致,
  避免误匹配 /scene、/open 等
- 修正 borerRadius→borderRadius typo、onVisibleChange→onPopupVisibleChange API
- SizeAdjust comp-margin 分支补 --td- 前缀(之前读不到 token)
- ColorCollapse copyHex 优先 navigator.clipboard,失败降级 execCommand
- getTokenValue 统一从 documentElement 读取,避免命中 host 浅色继承值
- modifyToken 正则重写:容忍冒号后空白、全局替换、$1 保留原空白

测试:
- core.spec.js 补 modifyToken 7 例(存在/不存在/值相同/空白/全局/多表/saveToLocal)
- utils 补 getTokenValue 3 例、colorAnimation cancel 3 例
- iframe.spec.js 补 syncThemeToIframe cleanup 契约 2 例
- store.spec.js beforeEach 重置 brandColor 避免串扰
dev 宿主页 index.html 增加设备切换/showSetting/重置/导出复制 CSS/Token 检视等开发控件,
并新增轻量变量预览区覆盖颜色/字体/圆角/阴影/尺寸等维度,随生成器实时变化,便于测试验证主题效果。
新增 src/dev-tools.js 承载调试逻辑(独立 module 以走 Vite 常规转译,不进入构建产物)。
- mountGenerator 重建生成器时恢复 theme-mode,避免深浅色状态丢失
- collectCss 空输入保护,避免 cssbeautify 异常
- refreshInspector 增加空值保护,避免 2s 兜底超时后清空检视面板
- dev-mode-toggle 样式从 JS 内联移到 CSS,用 class 切换替代直接操作 style
- 品牌色阶色块顶部显示编号(data-index + ::before 伪元素)
- 非 web 设备隐藏尺寸色阶预览区(对应 extra.css 不含 --td-size-*)
- 修复多处 CSS 语法问题(冒号后缺空格、var() 逗号后缺空格等)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant