From 0cc78fc4978891d20f1cf59c5afc3d3991e9c035 Mon Sep 17 00:00:00 2001 From: Linzp Date: Tue, 21 Apr 2026 13:40:49 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4=E4=B8=80=E4=B8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 233 +++++++++++++- doc/api.md | 120 ++++++- doc/example.json | 23 ++ doc/height-test.js | 70 ++++ doc/summary.md | 35 +- package.json | 4 +- prompts/README.md | 73 +++++ ...72\344\276\213\346\217\217\350\277\260.md" | 50 --- .../\345\233\275\351\231\205\345\214\226.md" | 217 ------------- ...73\345\236\213\345\243\260\346\230\216.md" | 103 ------ ...17\350\277\260\346\226\207\344\273\266.md" | 179 ----------- ...37\346\210\220\346\226\207\346\241\243.md" | 79 ----- ...20\350\257\255\350\250\200\345\214\205.md" | 125 -------- ...72\344\276\213\347\274\226\345\206\231.md" | 299 ------------------ src/components/DriverItem.js | 9 +- src/components/ExampleDriver.js | 17 +- src/components/LiveCode.js | 26 +- src/hooks/__tests__/heightStability.test.js | 186 +++++++++++ src/hooks/__tests__/useInView.test.js | 112 +++++++ src/hooks/__tests__/useReactRoot.test.js | 234 ++++++++++++++ src/hooks/useInView.js | 97 +++++- src/hooks/useLazyCompile.js | 176 ++++++++++- src/hooks/useReactRoot.js | 161 ++++++++-- src/index.d.ts | 24 ++ src/setupTests.js | 39 +++ 25 files changed, 1535 insertions(+), 1156 deletions(-) create mode 100644 doc/height-test.js create mode 100644 prompts/README.md delete mode 100644 "prompts/\345\221\275\345\220\215\347\244\272\344\276\213\347\274\226\345\206\231\347\244\272\344\276\213\346\217\217\350\277\260.md" delete mode 100644 "prompts/\345\233\275\351\231\205\345\214\226.md" delete mode 100644 "prompts/\346\267\273\345\212\240ts\347\261\273\345\236\213\345\243\260\346\230\216.md" delete mode 100644 "prompts/\347\224\237\346\210\220\345\214\205\345\212\237\350\203\275\346\217\217\350\277\260\346\226\207\344\273\266.md" delete mode 100644 "prompts/\347\224\237\346\210\220\346\226\207\346\241\243.md" delete mode 100644 "prompts/\347\224\237\346\210\220\350\257\255\350\250\200\345\214\205.md" delete mode 100644 "prompts/\347\273\204\344\273\266\347\244\272\344\276\213\347\274\226\345\206\231.md" create mode 100644 src/hooks/__tests__/heightStability.test.js create mode 100644 src/hooks/__tests__/useInView.test.js create mode 100644 src/hooks/__tests__/useReactRoot.test.js create mode 100644 src/setupTests.js diff --git a/README.md b/README.md index 1aa5be8..2b695df 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,42 @@ npm i --save @kne/example-driver ### 概述 +### 项目概述 + @kne/example-driver 是一个用于在线展示和编辑 React 组件的工具库,特别适合用于组件库文档、教程演示和技术文档中。它提供了实时代码预览和编辑功能,让用户可以直接在浏览器中查看和修改组件代码,无需搭建完整的开发环境。 -核心特性包括实时代码编辑、即时代码预览、语法高亮显示、错误边界处理和灵活的布局控制。支持两种展示模式:LiveCode 模式提供完整的在线编辑和实时预览能力,MiniCode 模式则通过二维码引导用户在移动端查看示例。内置 Monaco Editor 提供专业的代码编辑体验,使用 Prism 实现代码语法高亮,并通过 Debounce 优化性能,避免频繁重新渲染。 +### 主要特性 + +#### 实时编辑与预览 + +- **LiveCode 模式**:提供完整的在线编辑和实时预览能力,内置 Monaco Editor 提供专业的代码编辑体验 +- **MiniCode 模式**:通过二维码引导用户在移动端查看示例,适用于移动端场景 +- **语法高亮**:使用 Prism 实现代码语法高亮 +- **Debounce 优化**:避免频繁重新渲染,提升编辑体验 + +#### 性能与加载 + +- **视口懒加载**:组件进入视口时才挂载渲染,离开视口自动卸载并保留占位高度,减少页面初始负载 +- **懒编译机制**:使用 Babel Standalone 实现浏览器端代码编译,支持 ES2015 和 React 预设,无需后端转换;采用 LRU 缓存和编译队列优化编译性能 +- **优先级调度**:首次可见时优先编译,后续编辑变更走 debounce 500ms 延迟编译 -适用于组件库文档网站、在线教程和培训、技术博客和文档、以及 React 组件展示平台。通过动态加载和实时编译,让文档中的示例代码真正"活"起来,用户可以直接修改代码并立即看到效果,大大提升了学习效率和用户体验。 +#### 错误处理与布局 -技术亮点方面,项目采用 Babel Standalone 实现浏览器端的代码编译,支持 ES2015 和 React 预设,无需后端转换。错误边界机制确保代码错误不会影响整个页面,提供友好的错误提示。支持自定义上下文组件,方便在不同场景中嵌入示例代码。Monaco Editor 配置暴露给外部,允许深度定制编辑器行为。响应式布局设计,支持单列和双列模式,适应不同屏幕尺寸。 +- **错误边界**:代码错误不会影响整个页面,提供友好的错误提示 +- **响应式布局**:支持单列(全宽)和双列模式,自动适应不同屏幕尺寸 +- **自定义上下文**:支持全局和单个示例级别的自定义上下文组件,方便在不同场景中嵌入示例代码 +- **编辑器可配置**:Monaco Editor 配置暴露给外部,允许深度定制编辑器行为(如自定义 CDN 路径) + +#### 国际化 + +- 内置中文(zh-CN)和英文(en-US)语言支持,自动适配编辑器加载提示、扫码提示等文案 + +### 使用场景 + +- **组件库文档网站**:在线展示组件示例,支持用户直接修改代码查看效果 +- **在线教程和培训**:让学员在浏览器中即时实践代码 +- **技术博客和文档**:嵌入可交互的代码示例,提升阅读体验 +- **React 组件展示平台**:通过动态加载和实时编译,让文档中的示例代码真正"活"起来 ### 示例(全屏) @@ -270,6 +299,84 @@ render( { + const [count, setCount] = useState(0); + return ( +
+ + +
+ {count} +
+ + + + + +
+
+
+ ); +}; + +render(); +`; + +const HeightTestExample = () => { + const [mounted, setMounted] = useState(true); + + const list = [{ + title: '高度稳定性测试', + description: '通过开关控制组件挂载/卸载,观察卸载后占位高度与渲染高度是否一致', + code, + mounted, + scope: [{ + name: 'antd', packageName: 'antd', component: antd + }] + }]; + + return ( + + + + + {mounted ? '已挂载' : '已卸载'} + + + + 切换后观察页面是否抖动,占位高度是否与渲染高度一致 + + + + + + ); +}; + +render(); + +``` + - 双列布局 - 使用 isFull 属性控制布局,默认双列展示多个示例 - _ExampleDriver(@kne/current-lib_example-driver)[import * as _ExampleDriver from "@kne/example-driver"],(@kne/current-lib_example-driver/dist/index.css),antd(antd),remoteLoader(@kne/remote-loader) @@ -373,7 +480,7 @@ render( void` | + +### DescriptionBar +示例描述栏子组件,显示标题、描述和代码面板切换按钮。 + +#### 属性说明 +| 属性名 | 类型 | 必填 | 默认值 | 说明 | +|--------|------|------|--------|------| +| title | string | 是 | - | 示例标题 | +| description | string | 是 | - | 示例描述,支持 HTML | +| codeOpen | boolean | 是 | - | 代码面板是否打开 | +| onToggle | function | 是 | - | 切换代码面板的回调 | + +### HighlightCode +代码高亮展示子组件,使用 Prism 实现语法高亮。 + +#### 属性说明 +| 属性名 | 类型 | 必填 | 默认值 | 说明 | +|--------|------|------|--------|------| +| code | string | 是 | - | 要高亮显示的代码字符串 | + +### ErrorComponent +错误展示子组件,用于显示编译或运行时错误。 + +#### 属性说明 +| 属性名 | 类型 | 必填 | 默认值 | 说明 | +|--------|------|------|--------|------| +| error | string \| Error | 否 | - | 错误信息,字符串直接显示,Error 对象显示 .message | + +### Hooks + +#### useInView +视口检测 Hook,用于判断元素是否进入视口,实现懒加载。 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|--------|------|------|--------|------| +| ref | React.RefObject | 是 | - | 要监听的 DOM 容器 ref | +| options | object | 否 | - | 配置选项 | +| options.disabled | boolean | 否 | false | 是否禁用视口检测 | + +| 返回值 | 类型 | 说明 | +|--------|------|------| +| shouldRender | boolean | 元素是否在视口内 | +| heightRef | React.MutableRefObject\ | 记录元素最后一次渲染高度的 ref | + +内部使用全局共享的 IntersectionObserver 实例,rootMargin 为 200px 预加载,离开视口且 intersectionRatio 为 0 时卸载并保留占位高度。SSR 环境或浏览器不支持 IntersectionObserver 时直接返回 shouldRender 为 true。 + +#### useLazyCompile +懒编译 Hook,使用 Babel Standalone 在浏览器端编译代码,支持缓存和优先级调度。 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|--------|------|------|--------|------| +| code | string | 是 | - | 要编译的代码字符串 | +| shouldCompile | boolean | 是 | - | 是否应该编译,通常关联 useInView 的 shouldRender | + +| 返回值 | 类型 | 说明 | +|--------|------|------| +| compiledCode | string \| null | 编译后的代码,未编译时为 null | +| error | Error \| null | 编译错误,无错误时为 null | + +内部采用 LRU 缓存(上限 200 条),编译队列最大并发为 1。首次可见时作为优先任务立即编译,后续编辑变更走 debounce 500ms。非优先任务使用 requestIdleCallback 延迟执行。 + +#### useReactRoot +React Root 管理 Hook,使用 react-dom/client 的 createRoot API 管理组件挂载/卸载。 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|--------|------|------|--------|------| +| containerRef | React.RefObject | 是 | - | 渲染容器 DOM ref | +| shouldRender | boolean | 是 | - | 是否应该渲染 | +| renderJsx | ReactNode \| null | 是 | - | 要渲染的 React 元素 | +| heightRef | React.MutableRefObject\ | 是 | - | 用于记录/恢复高度的 ref | + +卸载时将 runner 替换为 placeholder 并保留上次高度,使用 ResizeObserver 监控高度变化。组件卸载时自动清理。 + ### config -Monaco Editor 的配置对象,用于自定义编辑器行为。 +Monaco Editor 的配置方法,用于自定义编辑器加载行为。 -#### 方法说明 | 方法名 | 参数 | 返回值 | 说明 | |--------|------|--------|------| -| config | options: object | void | 配置 Monaco Editor 的加载选项,如模块路径等 | +| config | options: { paths?: Record\, [key: string]: any } | void | 配置 Monaco Editor 的加载选项,最常用场景是通过 paths 自定义 Monaco 的 CDN 路径 | + +### 国际化 +内置中文(zh-CN)和英文(en-US)语言支持,默认语言为 zh-CN。 + +| Key | zh-CN | en-US | +|-----|-------|-------| +| CodePanel.loading | 正在加载代码编辑器... | Loading code editor... | +| MiniCode.example | 示例 | Example | +| MiniCode.scanQrcode | 请扫描二维码查看示例程序 | Please scan the QR code to view the example | diff --git a/doc/api.md b/doc/api.md index b05f32b..48f31ce 100644 --- a/doc/api.md +++ b/doc/api.md @@ -5,7 +5,7 @@ | 属性名 | 类型 | 必填 | 默认值 | 说明 | |--------|------|------|--------|------| | list | array | 是 | - | 示例列表数组,每个元素代表一个示例 | -| isFull | boolean | 否 | false | 是否全宽显示,true 时单列显示,false 时双列显示 | +| isFull | boolean | 否 | false | 是否全宽显示,true 时单列显示,false 时双列显示。当 list 仅有一项时自动为 true | | contextComponent | React Component | 否 | - | 自定义上下文组件,用于包裹渲染的代码内容 | | className | string | 否 | - | 自定义 CSS 类名 | | ...props | object | 否 | - | 其他 HTML div 元素的属性 | @@ -18,15 +18,17 @@ | title | string | 是 | - | 示例标题 | | description | string | 是 | - | 示例描述,支持 HTML 格式 | | qrcodeUrl | string | 否 | - | 二维码图片 URL,传入此项则使用 MiniCode 模式 | -| contextComponent | React Component | 否 | - | 单个示例的自定义上下文组件 | +| contextComponent | React Component | 否 | - | 单个示例的自定义上下文组件,优先级高于 ExampleDriver 的 contextComponent | +| mounted | boolean | 否 | - | 控制组件是否挂载。传入时由该值决定;不传时由视口检测决定是否挂载 | +| useInView | boolean | 否 | true | 是否启用视口检测懒加载,仅在 mounted 未传入时生效。设为 false 则组件始终挂载 | #### scope 数组项属性说明 | 属性名 | 类型 | 必填 | 默认值 | 说明 | |--------|------|------|--------|------| -| component | React Component | 是 | - | 要暴露给示例代码的组件 | -| name | string | 是 | - | 组件名称,在代码中使用 | +| component | any | 否 | - | 要暴露给示例代码的组件,不传时仅生成 import 语句(如 CSS 导入) | +| name | string | 否 | - | 组件名称,在代码中使用。component 为真值且 name 为非空字符串时才注入到示例作用域 | | packageName | string | 否 | - | 包名,用于生成导入语句 | -| importStatement | string | 否 | - | 自定义导入语句,覆盖自动生成的导入 | +| importStatement | string | 否 | - | 自定义导入语句,提供后不再自动生成。优先级最高 | ### LiveCode 提供实时代码编辑和预览功能的子组件(通过 ExampleDriver 自动使用)。 @@ -34,11 +36,13 @@ #### 属性说明 | 属性名 | 类型 | 必填 | 默认值 | 说明 | |--------|------|------|--------|------| -| code | string | 是 | - | 要展示的代码 | -| scope | array | 是 | - | 作用域数组 | +| code | string | 否 | '' | 要展示的代码 | +| scope | array | 否 | [] | 作用域数组 | | title | string | 是 | - | 标题 | -| description | string | 是 | - | 描述 | -| contextComponent | React Component | 否 | - | 上下文组件 | +| description | string | 是 | - | 描述,支持 HTML | +| contextComponent | React Component | 否 | - | 上下文组件,包裹渲染的示例内容 | +| mounted | boolean | 否 | - | 控制组件是否挂载。传入时由该值决定;不传时由视口检测决定 | +| useInView | boolean | 否 | true | 是否启用视口检测懒加载,仅在 mounted 未传入时生效 | ### MiniCode 显示二维码预览的只读模式子组件(通过 ExampleDriver 自动使用)。 @@ -52,10 +56,102 @@ | title | string | 是 | - | 标题 | | description | string | 是 | - | 描述 | +### CodePanel +代码编辑面板子组件,用于显示和编辑代码。 + +#### 属性说明 +| 属性名 | 类型 | 必填 | 默认值 | 说明 | +|--------|------|------|--------|------| +| code | string | 是 | - | 代码字符串 | +| scope | array | 是 | - | 作用域数组,用于生成 import 语句 | +| error | Error \| null | 否 | - | 编译错误对象 | +| editable | boolean | 是 | - | 代码是否可编辑 | +| onChange | function | 否 | - | 代码变更回调 `(newCode: string) => void` | + +### DescriptionBar +示例描述栏子组件,显示标题、描述和代码面板切换按钮。 + +#### 属性说明 +| 属性名 | 类型 | 必填 | 默认值 | 说明 | +|--------|------|------|--------|------| +| title | string | 是 | - | 示例标题 | +| description | string | 是 | - | 示例描述,支持 HTML | +| codeOpen | boolean | 是 | - | 代码面板是否打开 | +| onToggle | function | 是 | - | 切换代码面板的回调 | + +### HighlightCode +代码高亮展示子组件,使用 Prism 实现语法高亮。 + +#### 属性说明 +| 属性名 | 类型 | 必填 | 默认值 | 说明 | +|--------|------|------|--------|------| +| code | string | 是 | - | 要高亮显示的代码字符串 | + +### ErrorComponent +错误展示子组件,用于显示编译或运行时错误。 + +#### 属性说明 +| 属性名 | 类型 | 必填 | 默认值 | 说明 | +|--------|------|------|--------|------| +| error | string \| Error | 否 | - | 错误信息,字符串直接显示,Error 对象显示 .message | + +### Hooks + +#### useInView +视口检测 Hook,用于判断元素是否进入视口,实现懒加载。 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|--------|------|------|--------|------| +| ref | React.RefObject | 是 | - | 要监听的 DOM 容器 ref | +| options | object | 否 | - | 配置选项 | +| options.disabled | boolean | 否 | false | 是否禁用视口检测 | + +| 返回值 | 类型 | 说明 | +|--------|------|------| +| shouldRender | boolean | 元素是否在视口内 | +| heightRef | React.MutableRefObject\ | 记录元素最后一次渲染高度的 ref | + +内部使用全局共享的 IntersectionObserver 实例,rootMargin 为 200px 预加载,离开视口且 intersectionRatio 为 0 时卸载并保留占位高度。SSR 环境或浏览器不支持 IntersectionObserver 时直接返回 shouldRender 为 true。 + +#### useLazyCompile +懒编译 Hook,使用 Babel Standalone 在浏览器端编译代码,支持缓存和优先级调度。 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|--------|------|------|--------|------| +| code | string | 是 | - | 要编译的代码字符串 | +| shouldCompile | boolean | 是 | - | 是否应该编译,通常关联 useInView 的 shouldRender | + +| 返回值 | 类型 | 说明 | +|--------|------|------| +| compiledCode | string \| null | 编译后的代码,未编译时为 null | +| error | Error \| null | 编译错误,无错误时为 null | + +内部采用 LRU 缓存(上限 200 条),编译队列最大并发为 1。首次可见时作为优先任务立即编译,后续编辑变更走 debounce 500ms。非优先任务使用 requestIdleCallback 延迟执行。 + +#### useReactRoot +React Root 管理 Hook,使用 react-dom/client 的 createRoot API 管理组件挂载/卸载。 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|--------|------|------|--------|------| +| containerRef | React.RefObject | 是 | - | 渲染容器 DOM ref | +| shouldRender | boolean | 是 | - | 是否应该渲染 | +| renderJsx | ReactNode \| null | 是 | - | 要渲染的 React 元素 | +| heightRef | React.MutableRefObject\ | 是 | - | 用于记录/恢复高度的 ref | + +卸载时将 runner 替换为 placeholder 并保留上次高度,使用 ResizeObserver 监控高度变化。组件卸载时自动清理。 + ### config -Monaco Editor 的配置对象,用于自定义编辑器行为。 +Monaco Editor 的配置方法,用于自定义编辑器加载行为。 -#### 方法说明 | 方法名 | 参数 | 返回值 | 说明 | |--------|------|--------|------| -| config | options: object | void | 配置 Monaco Editor 的加载选项,如模块路径等 | +| config | options: { paths?: Record\, [key: string]: any } | void | 配置 Monaco Editor 的加载选项,最常用场景是通过 paths 自定义 Monaco 的 CDN 路径 | + +### 国际化 +内置中文(zh-CN)和英文(en-US)语言支持,默认语言为 zh-CN。 + +| Key | zh-CN | en-US | +|-----|-------|-------| +| CodePanel.loading | 正在加载代码编辑器... | Loading code editor... | +| MiniCode.example | 示例 | Example | +| MiniCode.scanQrcode | 请扫描二维码查看示例程序 | Please scan the QR code to view the example | diff --git a/doc/example.json b/doc/example.json index 326c25f..4ce763d 100644 --- a/doc/example.json +++ b/doc/example.json @@ -93,6 +93,29 @@ } ] }, + { + "title": "高度稳定性测试", + "description": "通过 state 控制组件挂载与卸载,验证视口检测和占位高度的一致性", + "code": "./height-test.js", + "scope": [ + { + "name": "_ExampleDriver", + "packageName": "@kne/current-lib_example-driver", + "importStatement": "import * as _ExampleDriver from \"@kne/example-driver\"" + }, + { + "packageName": "@kne/current-lib_example-driver/dist/index.css" + }, + { + "name": "antd", + "packageName": "antd" + }, + { + "name": "remoteLoader", + "packageName": "@kne/remote-loader" + } + ] + }, { "title": "双列布局", "description": "使用 isFull 属性控制布局,默认双列展示多个示例", diff --git a/doc/height-test.js b/doc/height-test.js new file mode 100644 index 0000000..abaecca --- /dev/null +++ b/doc/height-test.js @@ -0,0 +1,70 @@ +const {default: ExampleDriver} = _ExampleDriver; +const {useState} = React; +const {Button, Card, Space, Switch, Tag} = antd; + +const code = ` +const { Button, Card, Space } = antd; +const { useState } = React; + +const Component = () => { + const [count, setCount] = useState(0); + return ( +
+ + +
+ {count} +
+ + + + + +
+
+
+ ); +}; + +render(); +`; + +const HeightTestExample = () => { + const [mounted, setMounted] = useState(true); + + const list = [{ + title: '高度稳定性测试', + description: '通过开关控制组件挂载/卸载,观察卸载后占位高度与渲染高度是否一致', + code, + mounted, + scope: [{ + name: 'antd', packageName: 'antd', component: antd + }] + }]; + + return ( + + + + + {mounted ? '已挂载' : '已卸载'} + + + + 切换后观察页面是否抖动,占位高度是否与渲染高度一致 + + + + + + ); +}; + +render(); diff --git a/doc/summary.md b/doc/summary.md index 3e48adf..426fd7b 100644 --- a/doc/summary.md +++ b/doc/summary.md @@ -1,7 +1,36 @@ +### 项目概述 + @kne/example-driver 是一个用于在线展示和编辑 React 组件的工具库,特别适合用于组件库文档、教程演示和技术文档中。它提供了实时代码预览和编辑功能,让用户可以直接在浏览器中查看和修改组件代码,无需搭建完整的开发环境。 -核心特性包括实时代码编辑、即时代码预览、语法高亮显示、错误边界处理和灵活的布局控制。支持两种展示模式:LiveCode 模式提供完整的在线编辑和实时预览能力,MiniCode 模式则通过二维码引导用户在移动端查看示例。内置 Monaco Editor 提供专业的代码编辑体验,使用 Prism 实现代码语法高亮,并通过 Debounce 优化性能,避免频繁重新渲染。 +### 主要特性 + +#### 实时编辑与预览 + +- **LiveCode 模式**:提供完整的在线编辑和实时预览能力,内置 Monaco Editor 提供专业的代码编辑体验 +- **MiniCode 模式**:通过二维码引导用户在移动端查看示例,适用于移动端场景 +- **语法高亮**:使用 Prism 实现代码语法高亮 +- **Debounce 优化**:避免频繁重新渲染,提升编辑体验 + +#### 性能与加载 + +- **视口懒加载**:组件进入视口时才挂载渲染,离开视口自动卸载并保留占位高度,减少页面初始负载 +- **懒编译机制**:使用 Babel Standalone 实现浏览器端代码编译,支持 ES2015 和 React 预设,无需后端转换;采用 LRU 缓存和编译队列优化编译性能 +- **优先级调度**:首次可见时优先编译,后续编辑变更走 debounce 500ms 延迟编译 + +#### 错误处理与布局 + +- **错误边界**:代码错误不会影响整个页面,提供友好的错误提示 +- **响应式布局**:支持单列(全宽)和双列模式,自动适应不同屏幕尺寸 +- **自定义上下文**:支持全局和单个示例级别的自定义上下文组件,方便在不同场景中嵌入示例代码 +- **编辑器可配置**:Monaco Editor 配置暴露给外部,允许深度定制编辑器行为(如自定义 CDN 路径) + +#### 国际化 + +- 内置中文(zh-CN)和英文(en-US)语言支持,自动适配编辑器加载提示、扫码提示等文案 -适用于组件库文档网站、在线教程和培训、技术博客和文档、以及 React 组件展示平台。通过动态加载和实时编译,让文档中的示例代码真正"活"起来,用户可以直接修改代码并立即看到效果,大大提升了学习效率和用户体验。 +### 使用场景 -技术亮点方面,项目采用 Babel Standalone 实现浏览器端的代码编译,支持 ES2015 和 React 预设,无需后端转换。错误边界机制确保代码错误不会影响整个页面,提供友好的错误提示。支持自定义上下文组件,方便在不同场景中嵌入示例代码。Monaco Editor 配置暴露给外部,允许深度定制编辑器行为。响应式布局设计,支持单列和双列模式,适应不同屏幕尺寸。 +- **组件库文档网站**:在线展示组件示例,支持用户直接修改代码查看效果 +- **在线教程和培训**:让学员在浏览器中即时实践代码 +- **技术博客和文档**:嵌入可交互的代码示例,提升阅读体验 +- **React 组件展示平台**:通过动态加载和实时编译,让文档中的示例代码真正"活"起来 diff --git a/package.json b/package.json index 74193c6..f7480d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kne/example-driver", - "version": "0.1.18", + "version": "0.1.19", "description": "用于在线展示和编辑React组件", "syntax": { "esmodules": true @@ -64,6 +64,8 @@ "@kne/md-doc": "^0.1.8", "@kne/microbundle": "^0.15.5", "@kne/modules-dev": "^2.1.18", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "antd": "^5.21.6", "axios": "^1.12.0", "cross-env": "^7.0.3", diff --git a/prompts/README.md b/prompts/README.md new file mode 100644 index 0000000..9eb5936 --- /dev/null +++ b/prompts/README.md @@ -0,0 +1,73 @@ +# Prompts 文档索引 + +本目录包含多个专业领域的提示词文档集合,用于指导 AI 完成特定的开发任务。 + +## 文档集合列表 + +### 1. Prompts Frontend Libs + +**版本**: 1.0.1 + +**功能**: 前端组件库开发辅助提示词集合,提供 React 组件库开发全流程的指导文档 + +**适用场景**: +- React 组件库开发与维护 +- 项目文档自动化生成 +- TypeScript 类型定义完善 +- 组件国际化支持 +- 组件示例代码编写 + +**核心内容**: +- 组件国际化处理方案 +- TypeScript 类型声明添加 +- package.json 信息完善 +- 包功能描述文件生成 +- 项目文档自动生成 +- 组件示例代码编写规范 + +**包含文档**: +- [国际化](./prompts-frontend-libs/国际化.md) - 组件多语言支持指南 +- [添加ts类型声明](./prompts-frontend-libs/添加ts类型声明.md) - TypeScript 类型定义指南 +- [完善package.json描述和关键词](./prompts-frontend-libs/完善package.json描述和关键词.md) - 包信息优化指南 +- [生成包功能描述文件](./prompts-frontend-libs/生成包功能描述文件.md) - API 描述文件生成 +- [生成文档](./prompts-frontend-libs/生成文档.md) - 项目文档生成指南 +- [组件示例编写](./prompts-frontend-libs/组件示例编写.md) - 示例代码编写规范 + +**详细索引**: 查看 [Prompts Frontend Libs 详细索引](./prompts-frontend-libs/README.md) + +--- + +## 快速选择指南 + +根据您的需求快速定位到合适的文档: + +| 需求 | 推荐文档 | 所属集合 | +|------|----------|----------| +| 组件需要支持多语言 | [国际化](./prompts-frontend-libs/国际化.md) | Prompts Frontend Libs | +| 为 JS 组件库添加 TS 类型支持 | [添加ts类型声明](./prompts-frontend-libs/添加ts类型声明.md) | Prompts Frontend Libs | +| 完善 package.json 描述和关键词 | [完善package.json描述和关键词](./prompts-frontend-libs/完善package.json描述和关键词.md) | Prompts Frontend Libs | +| 生成包的 API 描述文件 | [生成包功能描述文件](./prompts-frontend-libs/生成包功能描述文件.md) | Prompts Frontend Libs | +| 生成项目概述和 API 文档 | [生成文档](./prompts-frontend-libs/生成文档.md) | Prompts Frontend Libs | +| 编写组件演示示例代码 | [组件示例编写](./prompts-frontend-libs/组件示例编写.md) | Prompts Frontend Libs | + +--- + +## 文档说明 + +### 如何使用 + +1. **浏览索引**:根据上方表格或文档集合列表,找到符合您需求的文档 +2. **查看详情**:点击文档链接查看详细的提示词内容 +3. **应用提示词**:将文档内容作为指令提供给 AI,指导其完成相应任务 + +### 版本信息 + +各文档集合的版本信息记录在 `prompts.json` 文件中,便于追踪和更新管理。 + +### 贡献指南 + +如需添加新的提示词文档集合,请: +1. 在本目录下创建子目录 +2. 编写 README.md 说明文档集合的用途和内容 +3. 在 `prompts.json` 中添加版本信息 +4. 更新本索引文档 diff --git "a/prompts/\345\221\275\345\220\215\347\244\272\344\276\213\347\274\226\345\206\231\347\244\272\344\276\213\346\217\217\350\277\260.md" "b/prompts/\345\221\275\345\220\215\347\244\272\344\276\213\347\274\226\345\206\231\347\244\272\344\276\213\346\217\217\350\277\260.md" deleted file mode 100644 index c7952d3..0000000 --- "a/prompts/\345\221\275\345\220\215\347\244\272\344\276\213\347\274\226\345\206\231\347\244\272\344\276\213\346\217\217\350\277\260.md" +++ /dev/null @@ -1,50 +0,0 @@ -# 任务:完善示例文档的标题和描述 - -## 目标 -根据 `doc/example.json` 中 `code` 字段引用的代码实现内容,完善该文件中的 `title` 和 `description` 字段。 - -## 具体要求 - -### 1. 分析代码实现 -- 仔细阅读 `doc/example.json` 中每个示例项的 `code` 字段引用的代码文件 -- 理解每个代码示例的核心功能和实现方式 -- 识别组件的主要用途和特点 - -### 2. 编写标题 (title) -- **简洁明了**:用4-8个字概括示例的核心功能 -- **突出重点**:体现示例的最主要特性 -- **用户视角**:从使用者的角度命名 -- **避免技术术语**:使用通俗易懂的表达 - -### 3. 编写描述 (description) -- **功能说明**:清晰描述示例展示的功能 -- **使用场景**:说明适用的业务场景 -- **特点优势**:突出实现的特点或优势 -- **长度控制**:建议在20-50字之间 - -## 示例格式 - -### 原始结构 -```json -{ - "code": "./form-modal.js", - "title": "", - "description": "" -} -``` - -### 优化后示例 -```json -{ - "code": "./form-modal.js", - "title": "模态框表单", - "description": "在模态框中展示表单,支持弹窗内数据提交和验证" -} -``` - -## 注意事项 -- 不要修改 `code` 字段的路径引用 -- 确保每个示例都有完整的 `title` 和 `description` -- 描述要准确反映代码实现的功能 -- 保持所有示例的命名风格一致 -- 重点突出用户体验和实用价值 \ No newline at end of file diff --git "a/prompts/\345\233\275\351\231\205\345\214\226.md" "b/prompts/\345\233\275\351\231\205\345\214\226.md" deleted file mode 100644 index 9ec750e..0000000 --- "a/prompts/\345\233\275\351\231\205\345\214\226.md" +++ /dev/null @@ -1,217 +0,0 @@ -# 组件国际化指南 - -## 概述 - -本指南用于将组件完成国际化。 - -## 一、创建的文件 - -1. **`src/withLocale.js`** - 统一的国际化 HOC,所有组件共用 -2. **`src/locale/zh-CN.js`** - 中文语言包,所有组件共用 -3. **`src/locale/en-US.js`** - 英文语言包,所有组件共用 - -## 二、需要修改的文件类型 - -### 主组件文件 -- 添加 `useIntl` Hook -- 用 `withLocale` 包裹导出 - -### FormInner 表单组件 -- 添加 `useIntl` Hook -- 用 `withLocale` 包裹导出 -- 表单 label 国际化 - -### getColumns 等工具函数 -- 通过参数接收 `formatMessage` -- 移除内部的 `useIntl` 和 `withLocale` 引入 - -### Action 操作组件 -- 添加 `useIntl` Hook -- 用 `withLocale` 包裹导出 - -## 三、国际化的关键模式 - -### 1. useIntl Hook 使用 -```javascript -import { useIntl } from '@kne/react-intl'; - -const Component = () => { - const { formatMessage } = useIntl(); - return
{formatMessage({ id: 'ComponentName.Key' })}
; -}; -``` - -### 2. withLocale 包裹普通组件 -```javascript -import withLocale from '../withLocale'; -import { useIntl } from '@kne/react-intl'; - -const ComponentInner = ({ ...props }) => { - const { formatMessage } = useIntl(); - // 将所有中文替换为 formatMessage({ id: 'ComponentName.Key' }) - return
{formatMessage({ id: 'ComponentName.Key' })}
; -}; - -const Component = withLocale(ComponentInner); - -export default Component; -``` - -### 3. createWithRemoteLoader 组件(推荐格式) -```javascript -import withLocale from '../withLocale'; -import { useIntl } from '@kne/react-intl'; - -const ComponentInner = createWithRemoteLoader({...})(({ remoteModules, ...props }) => { - const { formatMessage } = useIntl(); - // ... -}); - -const Component = withLocale(ComponentInner); - -export default Component; -``` - -**注意:** 对于 `createWithRemoteLoader` 创建的组件,可以先用 `ComponentInner` 存储,再用 `withLocale` 包裹。 - -### 4. getColumns 等工具函数(formatMessage 从父组件传入) -```javascript -const getColumns = ({formatMessage}) => { - return [ - { - name: 'name', - title: formatMessage({ id: 'ComponentName.Key' }) - } - ]; -}; - -// 父组件中使用 -const columns = getColumns({formatMessage}); -``` - -### 5. 带参数的翻译 -```javascript -formatMessage({ id: 'ComponentName.KeyWithParam' }, { name: value }) -``` - -## 四、注意事项 - -1. **所有使用 `useIntl` 的组件必须用 `withLocale` 包裹** -2. **`getColumns` 等工具函数通过参数接收 `formatMessage`,不使用 `useIntl`** -3. **语言包中避免重复的 key**,命名规则:`组件名 + 功能名`,如 `PersonalCard.status.online` -4. **`createWithRemoteLoader` 创建的组件内部使用 useIntl 时,外层需要重命名并用 withLocale 包裹** -5. 注意检查 `withLocale`文件的引用地址,根目录组件使用 `../withLocale` - ---- - -# 组件国际化操作步骤 - -## 步骤 - -### 1. 更新语言包文件 -在 `src/locale/zh-CN.js` 和 `src/locale/en-US.js` 中添加对应的翻译文本: -```javascript -// zh-CN.js -const locale = { - ComponentName: { - status: { - online: '在线', - offline: '离线' - }, - summary: '简介' - } -}; - -// en-US.js -const locale = { - ComponentName: { - status: { - online: 'Online', - offline: 'Offline' - }, - summary: 'Summary' - } -}; -``` - -### 2. 修改组件文件 - -#### 主组件修改模式: -```javascript -import withLocale from '../withLocale'; -import { useIntl } from '@kne/react-intl'; - -const ComponentInner = ({ ...props }) => { - const { formatMessage } = useIntl(); - // 将所有中文替换为 formatMessage({ id: 'ComponentName.Key' }) - return ( - // ... - ); -}; - -const Component = withLocale(ComponentInner); - -export default Component; -``` - -#### FormInner 修改模式: -```javascript -import withLocale from '../withLocale'; -import { useIntl } from '@kne/react-intl'; - -const FormInnerInner = createWithRemoteLoader({...})(({ remoteModules, ...props }) => { - const { formatMessage } = useIntl(); - // label={formatMessage({ id: 'ComponentName.Key' })} - // ... -}); - -const FormInner = withLocale(FormInnerInner); - -export default FormInner; -``` - -#### Action 操作组件修改模式: -```javascript -import withLocale from '../withLocale'; -import { useIntl } from '@kne/react-intl'; - -const ActionInner = createWithRemoteLoader({...})(({ remoteModules, ...props }) => { - const { formatMessage } = useIntl(); - // ... -}); - -const ActionComponent = withLocale(ActionInner); - -export default ActionComponent; -``` - -#### getColumns 修改模式: -```javascript -// 移除 useIntl 和 withLocale 引入 -const getColumns = ({formatMessage}) => { - return [ - { - name: 'xxx', - title: formatMessage({ id: 'ComponentName.Key' }) - } - ]; -}; -``` - -父组件中调用:`getColumns({formatMessage})` - -### 3. 语言包 key 命名规范 -- 避免重复,使用 `组件名+功能名` 格式,如 `PersonalCard.status.online`、`UserName`、`UserRole`、`SettingType` -- 中文和英文语言包保持完全一致的 key 结构 - -### 4. 检查要点 -- [ ] 所有使用 `useIntl` 的组件都用 `withLocale` 包裹 -- [ ] `getColumns` 等工具函数通过参数接收 `formatMessage` -- [ ] 语言包中无重复 key -- [ ] `withLocale` 引用路径正确(组件在子目录使用 `../withLocale`) - -### 5. 最后检查 -运行命令找到所有使用 useIntl 的文件,确保都已正确包裹: -```bash -grep -r "useIntl" src/ --include="*.js" -l -``` diff --git "a/prompts/\346\267\273\345\212\240ts\347\261\273\345\236\213\345\243\260\346\230\216.md" "b/prompts/\346\267\273\345\212\240ts\347\261\273\345\236\213\345\243\260\346\230\216.md" deleted file mode 100644 index f6117f6..0000000 --- "a/prompts/\346\267\273\345\212\240ts\347\261\273\345\236\213\345\243\260\346\230\216.md" +++ /dev/null @@ -1,103 +0,0 @@ -# 为React组件库添加TypeScript类型定义的通用提示词 - -## 任务描述 - -为一个已有的React组件库添加完整的TypeScript类型定义,包括所有组件的Props接口、类型声明和导出。 - -## 实施步骤 - -### 1. 创建类型定义文件 - -在源代码目录创建`index.d.ts`文件,位置与主入口文件`index.js`同级。 - -### 2. 分析组件结构 - -- 查看所有组件文件的Props参数 -- 确定每个组件的输入属性类型 -- 识别回调函数的参数和返回值类型 - -### 3. 定义基础类型 - -```typescript -import { ReactNode, ComponentType, FC } from 'react'; -``` - -### 4. 为每个组件创建Props接口 - -- 组件名 + Props 作为接口名(如:FormInfoProps) -- 所有可选属性使用`?`标记 -- ReactNode类型用于支持字符串和JSX元素 -- 函数类型明确定义参数和返回值 -- 使用`[key: string]: any`支持动态属性 - -### 5. 处理复杂场景 - -- **嵌套对象属性**:定义内联对象类型 -- **函数属性**:明确参数类型和返回值类型 -- **联合类型**:使用`|`支持多种类型 -- **泛型组件**:使用ComponentType - -### 6. 导出组件类型声明 - -```typescript -export declare const ComponentName: FC; -``` - -## 类型定义模板 - -### 基础组件Props模板 - -```typescript -export interface ComponentNameProps { - className?: string; - children?: ReactNode; - - // 其他属性... - [key: string]: any; // 支持动态属性 -} -``` - -### 函数回调模板 - -```typescript -onEvent ? : (data: any, context: any, ...args: any[]) => Promise | any; -``` - -### 组件声明模板 - -```typescript -export declare const ComponentName: FC; -``` - -## 最佳实践 - -1. **类型完整性**:覆盖所有组件的公开API -2. **向后兼容**:保持现有JavaScript代码的正常运行 -3. **可选属性**:合理使用可选属性,提供良好的开发体验 -4. **文档注释**:为复杂类型添加JSDoc注释 -5. **版本控制**:在package.json中指定types字段指向类型文件 - -## 使用场景 - -- 为现有组件库添加TypeScript支持 -- 提升代码可维护性和开发体验 -- 支持IDE智能提示和类型检查 -- 便于团队协作和代码审查 - -## 示例项目结构 - -``` -src/ -├── index.js # 主入口文件 -├── index.d.ts # 类型定义文件(新增) -├── ComponentA.js # 组件A -├── ComponentB.js # 组件B -└── ... -``` - -## 注意事项 - -- 类型定义文件应与实际组件实现保持同步 -- 考虑使用更具体的类型替代`any`类型 -- 定期检查和更新类型定义以匹配组件API的变化 -- 对于复杂的组件,考虑拆分类型定义到多个文件中 \ No newline at end of file diff --git "a/prompts/\347\224\237\346\210\220\345\214\205\345\212\237\350\203\275\346\217\217\350\277\260\346\226\207\344\273\266.md" "b/prompts/\347\224\237\346\210\220\345\214\205\345\212\237\350\203\275\346\217\217\350\277\260\346\226\207\344\273\266.md" deleted file mode 100644 index 39dfcd3..0000000 --- "a/prompts/\347\224\237\346\210\220\345\214\205\345\212\237\350\203\275\346\217\217\350\277\260\346\226\207\344\273\266.md" +++ /dev/null @@ -1,179 +0,0 @@ -# 生成包功能描述文件(package-manifest.json)的通用提示词 - -## 任务描述 - -为一个JavaScript/TypeScript包项目生成`package-manifest.json`文件,该文件用于描述包的主要功能、导出模块、组件API等详细信息。 - -## 实施步骤 - -### 1. 分析项目结构 - -- 查看`package.json`了解包的基本信息 -- 分析主入口文件(`index.js`/`index.ts`)的导出内容 -- 检查源代码目录结构,识别所有可导出的模块/组件 - -### 2. 识别导出模块 - -- 默认导出:通常标记为`default` -- 命名导出:所有具名导出的组件、函数、类等 - -### 3. 分析每个模块的功能 - -对于每个导出的模块,需要收集以下信息: - -#### React组件 -- **description**: 组件的主要功能描述 -- **type**: 通常为`ReactNode` -- **props**: 组件的所有属性,包括: - - 属性名 - - 属性描述 - - 属性类型(支持联合类型,如`string|number`,不能使用下面`类型定义规范`定义之外的类型) - -#### 函数/类 -- **description**: 功能描述 -- **type**: 返回值类型 -- **parameters**: 参数列表(如果有) -- **properties**: 类属性(如果是类) - -#### 常量/对象 -- **description**: 用途描述 -- **type**: 数据类型 -- **properties**: 对象属性结构 - -### 4. 构建JSON结构 - -```json -{ - "description": "包的主要导出模块入口", - "modules": { - "模块名": { - "description": "模块功能描述", - "type": "类型声明", - // 根据模块类型可能包含以下字段 - "props": {}, // React组件属性 - "parameters": [], // 函数参数 - "properties": {}, // 对象/类属性 - "returns": "返回值类型" // 函数返回值 - } - } -} -``` - -## 类型定义规范 - -### 基础类型 -- `string`: 字符串 -- `number`: 数字 -- `boolean`: 布尔值 -- `object`: 对象 -- `array`: 数组 -- `function`: 函数 -- `Date`: 日期对象 -- `Promise`: Promise对象 -- `RegExp`: 正则表达式 -- `Error`: 错误对象 -- `undefined`: 未定义 -- `null`: 空值 -- `any`: 任意类型 -- `void`: 无返回值 -- `unknown`: 未知类型 -- `ReactNode`: React节点(组件专用) - -### 联合类型 -使用`|`连接多种类型: -- `string|ReactNode`: 字符串或React节点 -- `number|object`: 数字或对象 -- `function|null`: 函数或null - -### 复杂类型 -- `object[]`: 对象数组 -- `ReactNode[]`: React节点数组 -- `string[]`: 字符串数组 - -## 优先级排序 - -1. **主要组件**: 包中最核心、最常用的组件 -2. **辅助组件**: 支持性组件 -3. **工具函数**: 纯函数、工具方法 -4. **类型定义**: TypeScript类型、接口 -5. **常量配置**: 配置对象、常量 - -## 属性命名约定 - -### 通用属性 -- `className`: CSS类名 -- `children`: 子元素 -- `style`: 样式对象 -- `title`: 标题 -- `description`: 描述 - -## 描述编写原则 - -1. **简洁明确**: 一句话说明核心功能 -2. **用户视角**: 从使用者的角度描述 -3. **突出价值**: 说明解决了什么问题 -4. **避免实现细节**: 不描述内部实现逻辑 - -## 示例格式 - -### React组件示例 -```json -{ - "FormModal": { - "description": "模态框表单组件,在弹窗中展示表单", - "type": "ReactNode", - "props": { - "open": { - "description": "是否打开模态框", - "type": "boolean" - }, - "title": { - "description": "模态框标题", - "type": "ReactNode" - }, - "onSubmit": { - "description": "提交回调函数", - "type": "function" - } - } - } -} -``` - -### 工具函数示例 -```json -{ - "formatDate": { - "description": "格式化日期字符串", - "type": "string", - "parameters": [ - { - "name": "format", - "description": "格式化模板", - "type": "string" - } - ] - } -} -``` - -## 文件位置 - -将生成的`package-manifest.json`文件放置在: -- `src/package-manifest.json` - -## 使用场景 - -- 自动化API文档生成 -- IDE智能提示和自动补全 -- 包管理和分发平台的组件展示 -- 团队协作时的API参考 -- 第三方工具的集成和分析 - -## 注意事项 - -- 避免包含内部实现细节 -- 不要包含私有方法和属性 -- 确保向后兼容性 -- 定期更新以匹配代码变更 -- 考虑国际化需求(如需要) \ No newline at end of file diff --git "a/prompts/\347\224\237\346\210\220\346\226\207\346\241\243.md" "b/prompts/\347\224\237\346\210\220\346\226\207\346\241\243.md" deleted file mode 100644 index b02891b..0000000 --- "a/prompts/\347\224\237\346\210\220\346\226\207\346\241\243.md" +++ /dev/null @@ -1,79 +0,0 @@ -# 任务:生成项目文档 - -## 目标 -根据代码实现生成完整的项目文档,包括项目概述文件 (`doc/summary.md`) 和 API 文档 (`doc/api.md`)。 - -## 文档生成要求 - -### doc/summary.md - 项目概述 -#### 格式要求 -- **禁止使用 h1、h2 标题**:直接从内容开始编写 -- **无需标题**:不需要"项目概述"等标题文字 -- **不包含依赖项说明**:专注于项目特点和功能介绍 -- **吸引用户**:突出项目优势和使用价值 - -#### 内容结构 -1. **项目简介**:简明扼要介绍项目的主要功能和定位 -2. **核心特性**:列出3-5个最突出的特点或优势 -3. **适用场景**:描述适用的业务场景和使用场景 -4. **技术亮点**:重点说明技术创新或设计优势 - -#### 写作风格 -- **简洁明了**:用通俗语言表达,避免过度技术化 -- **用户导向**:从使用者角度描述价值 -- **重点突出**:强调解决什么问题,带来什么便利 - -### doc/api.md - API 文档 -#### 格式要求 -- **无需标题**:不添加"API文档"等标题 -- **使用 h3 及以下级别**:组件标题用 h3,子部分用 h4、h5 -- **优先使用表格格式**:API 参数和属性用表格展示 -- **不包含示例代码**:专注于 API 说明,不提供代码示例 - -#### 内容结构 -```markdown -### 组件名称 -组件功能描述文字 - -#### 属性说明 -| 属性名 | 类型 | 必填 | 默认值 | 说明 | -|--------|------|------|--------|------| -| prop1 | string | 是 | - | 属性说明 | -| prop2 | number | 否 | 0 | 属性说明 | - -#### 方法说明 -| 方法名 | 参数 | 返回值 | 说明 | -|--------|------|--------|------| -| method1 | data: object | void | 方法说明 | -``` - -#### API 文档要点 -- **完整性**:涵盖所有公开的 API -- **准确性**:类型、必填项、默认值要准确 -- **清晰性**:说明文字简洁明确 -- **一致性**:命名和格式保持一致 - -## 生成流程 - -### 1. 分析代码结构 -- 阅读 `src/index.js` 了解导出的组件 -- 分析组件的 TypeScript 类型定义 (`src/index.d.ts`) -- 理解组件的属性和功能 - -### 2. 提取 API 信息 -- 从组件代码中提取 props 定义 -- 识别方法的参数和返回值 -- 确定属性的类型和默认值 - -### 3. 编写文档内容 -- 按照格式要求编写两个文档 -- 确保内容准确性和完整性 -- 检查格式规范符合要求 - -## 严格注意事项 -- **禁止使用 h1、h2 标签**:严格遵守 markdown 标题级别限制 -- **summary.md 无标题**:直接从内容开始 -- **api.md 无文档标题**:直接从第一个组件 h3 标题开始 -- **使用表格格式**:API 说明必须使用表格 -- **不包含示例代码**:专注于文档说明 -- **保持格式一致性**:确保所有文档格式符合要求 \ No newline at end of file diff --git "a/prompts/\347\224\237\346\210\220\350\257\255\350\250\200\345\214\205.md" "b/prompts/\347\224\237\346\210\220\350\257\255\350\250\200\345\214\205.md" deleted file mode 100644 index e583148..0000000 --- "a/prompts/\347\224\237\346\210\220\350\257\255\350\250\200\345\214\205.md" +++ /dev/null @@ -1,125 +0,0 @@ -# 任务:生成和更新国际化语言包 - -## 目标 -从代码中抽取中文文案,生成完整的国际化语言包,并同步更新到英文语言包。 - -## 任务要求 - -### 1. 抽取中文文案 -#### 扫描范围 -- **组件文件**:扫描 `src/` 目录下的所有 `.js` 组件文件 -- **模板字符串**:提取模板字符串中的中文文本 -- **字符串字面量**:提取字符串常量中的中文内容 -- **JSX 文本**:提取 JSX 中的中文显示文本 - -#### 抽取规则 -- **纯中文文本**:只提取包含中文字符的字符串 -- **过滤无关内容**:忽略 CSS 类名、技术术语、调试信息 -- **保持原意**:确保提取的文案保持原始含义 -- **去重处理**:相同文案只保留一个 key - -### 2. 生成语言包结构 -#### zh-CN.js 格式 -```javascript -const locale = { - // 已有的 key 保持不变 - submit: '提交', - cancel: '取消', - - // 新增的 key - addText: '添加', - deleteText: '删除', - // ... 其他新增文案 -}; - -export default locale; -``` - -#### Key 命名规范 -- **驼峰命名**:使用 camelCase 命名规则 -- **语义化**:key 名称要能表达文案含义 -- **简洁明了**:避免过长的 key 名称 -- **一致性**:相似功能使用相同命名模式 - -### 3. 翻译到英文版本 -#### 翻译原则 -- **准确翻译**:确保英文翻译准确传达中文含义 -- **自然表达**:使用符合英文习惯的表达方式 -- **保持一致**:相同概念使用一致的英文词汇 -- **简洁明了**:避免冗长复杂的表达 - -#### en-US.js 格式 -```javascript -const locale = { - // 与 zh-CN.js 对应的英文翻译 - submit: 'Submit', - cancel: 'Cancel', - complete: 'Complete', - next: 'Next', - addText: 'Add', - deleteText: 'Delete', - // ... 其他对应的英文文案 -}; - -export default locale; -``` - -## 实施步骤 - -### 第一步:代码扫描 -1. 遍历 `src/` 目录下的所有组件文件 -2. 使用正则表达式匹配中文文本 -3. 收集所有中文文案并去重 -4. 过滤掉不需要国际化的内容 - -### 第二步:更新中文语言包 -1. 打开 `src/locale/zh-CN.js` -2. 保留现有的 key-value 对 -3. 添加新发现的文案和对应的 key -4. 确保格式正确,导出语句完整 - -### 第三步:更新英文语言包 -1. 打开 `src/locale/en-US.js` -2. 为所有中文 key 添加对应的英文翻译 -3. 确保翻译准确、自然、一致 -4. 保持与中文版本相同的 key 结构 - -### 第四步:质量检查 -1. **完整性检查**:确保中文和英文版本 key 一致 -2. **准确性检查**:验证翻译的准确性 -3. **格式检查**:确保文件格式符合要求 -4. **语法检查**:确保 JavaScript 语法正确 - -## 严格注意事项 -- **不修改原始文件**:绝对不要修改任何组件源代码 -- **保留现有内容**:不要删除或修改已有的语言包内容 -- **保持文件结构**:维持两个语言包文件的完整结构 -- **确保导出正确**:保持 `export default locale` 语句 -- **格式一致性**:确保代码格式和缩进一致 - -## 常见中文文案类型 -- **按钮文本**:确认、取消、添加、删除、保存等 -- **提示信息**:成功、失败、警告、信息提示等 -- **表单标签**:用户名、密码、邮箱、手机号等 -- **操作描述**:编辑、查看、删除、复制等 -- **状态说明**:加载中、已完成、进行中等 -- **错误信息**:必填项验证、格式错误、网络错误等 - -## 输出格式示例 -```javascript -// zh-CN.js -const locale = { - // 原有内容 - submit: '提交', - cancel: '取消', - - // 新增内容 - confirmDelete: '确认删除', - deleteSuccess: '删除成功', - addNewItem: '添加新项目', - loading: '加载中...', - required: '此字段为必填项' -}; - -export default locale; -``` \ No newline at end of file diff --git "a/prompts/\347\273\204\344\273\266\347\244\272\344\276\213\347\274\226\345\206\231.md" "b/prompts/\347\273\204\344\273\266\347\244\272\344\276\213\347\274\226\345\206\231.md" deleted file mode 100644 index 74c5b64..0000000 --- "a/prompts/\347\273\204\344\273\266\347\244\272\344\276\213\347\274\226\345\206\231.md" +++ /dev/null @@ -1,299 +0,0 @@ -# 组件示例编写提示词 - -本提示词用于编写遵循特定目录结构规范的 React 项目组件示例。 - -## 1. 目录结构规范 - -### 项目目录布局 - -``` -{PROJECT_ROOT}/ -├── src/ # 源代码目录 -│ └── components/ # 组件源代码 -│ ├── ComponentA/ # 单个组件目录 -│ ├── ComponentB/ # 带子组件的目录 -│ │ ├── Sub1/ -│ │ ├── Sub2/ -│ │ └── index.js -│ └── ... -├── doc/ # 文档及示例目录(所有组件共用) -│ ├── example.json # 示例配置文件(核心) -│ ├── api.md # 组件API文档 -│ ├── summary.md # 组件简要描述 -│ ├── base.js # 基础示例代码 -│ ├── component-a.js # ComponentA示例 -│ ├── component-b.js # ComponentB主组件示例 -│ ├── component-b-sub1.js # ComponentB子组件Sub1示例 -│ ├── component-b-sub2.js # ComponentB子组件Sub2示例 -│ └── style.scss # 示例样式(可选) -└── package.json # 项目配置文件 -``` - -### 示例文件规则 - -**重要**:所有组件的示例文件都统一放在项目根目录的 `doc/` 中,而不是在每个组件目录下创建单独的 `doc/` 目录。 - -- 单个组件:使用组件名的小写短横线形式命名示例文件,如 `component-a.js` -- 带子组件的组件:主组件示例使用组件名,子组件示例使用组件名加子组件名,如 `component-b.js`、`component-b-sub1.js` - -### 项目配置变量 - -在编写示例时需要替换以下变量: - -| 变量名 | 说明 | 示例值 | -|--------|------|--------| -| `{PACKAGE_NAME}` | 从 package.json 的 name 字段获取 | `@kne/react-file` | -| `{PROJECT_NAME}` | 项目名称,用于 getPublicPath | `react-file` | -| `{LIB_NAME}` | 组件库名称,通常是驼峰形式 | `ReactFile` | - -## 2. example.json 配置结构 - -所有组件的示例配置统一在 `doc/example.json` 中管理: - -```json -{ - "isFull": true, - "list": [ - { - "title": "ComponentA", - "description": "组件A的描述", - "code": "./component-a.js", - "scope": [ - { - "name": "_{LIB_NAME}", - "packageName": "{PACKAGE_NAME}", - "importStatement": "import * as _{LIB_NAME} from \"{PACKAGE_NAME}\"" - }, - { - "packageName": "{PACKAGE_NAME}/dist/index.css" - }, - { - "name": "antd", - "packageName": "antd" - }, - { - "name": "remoteLoader", - "packageName": "@kne/remote-loader" - } - ] - }, - { - "title": "ComponentB", - "description": "组件B主组件", - "code": "./component-b.js", - "scope": [] - }, - { - "title": "ComponentB-Sub1", - "description": "组件B子组件Sub1", - "code": "./component-b-sub1.js", - "scope": [] - } - ] -} -``` - -**重要说明**: -- `packageName` 可以使用当前组件包名的特殊格式:`@kne/current-lib_{PACKAGE_NAME_SUFFIX}` -- 例如:`@kne/react-file` 项目可以使用 `@kne/current-lib_react-file` 来引用当前正在开发的组件包 -- 这种特殊写法用于文档示例系统自动解析当前包名 - -## 3. 示例代码规范 - -### 导入方式 - -- 使用 `scope` 中声明的变量名:`const { FilePreview } = _{LIB_NAME};` -- 工具包引用:`const { createWithRemoteLoader, getPublicPath } = remoteLoader;` - -### 标准示例模板(需要 PureGlobal 模拟数据) - -```javascript -const { YourComponent } = _{LIB_NAME}; -const { createWithRemoteLoader, getPublicPath } = remoteLoader; - -const BaseExample = createWithRemoteLoader({ - modules: ['components-core:Global@PureGlobal', 'components-core:InfoPage'] -})(({ remoteModules }) => { - const [PureGlobal, InfoPage] = remoteModules; - return ( - { - return { data: { code: 0, data: api.loader() } }; - }, - apis: { - file: { - staticUrl: getPublicPath('{PROJECT_NAME}') || window.PUBLIC_URL, - getUrl: { - loader: async ({ params }) => { - // 模拟数据 - return 'mock-url'; - } - } - } - } - }}> - - - - - - - ); -}); - -render(); -``` - -### 简化示例模板(无需远程加载) - -```javascript -const { YourComponent } = _{LIB_NAME}; -const { Flex, Switch } = antd; -const { useState } = React; - -const BaseExample = () => { - const [state, setState] = useState(false); - return ( - - - - ); -}; - -render(); -``` - -## 4. scope 依赖声明规则 - -| 场景 | name | packageName | importStatement(可选) | -|------|------|-------------|------------------------| -| 项目组件 | `_{LIB_NAME}` | `{PACKAGE_NAME}` 或 `@kne/current-lib_{PACKAGE_NAME_SUFFIX}` | `import * as _{LIB_NAME} from "{PACKAGE_NAME}"` | -| 样式文件 | - | `{PACKAGE_NAME}/dist/index.css` 或 `@kne/current-lib_{PACKAGE_NAME_SUFFIX}/dist/index.css` | - | -| 远程加载器 | `remoteLoader` | `@kne/remote-loader` | - | -| Antd组件 | `antd` | `antd` | - | -| React原生 | `React` | - | -(无需声明) | - -**当前包引用规则**: -- 在 `example.json` 的 `scope` 中引用当前正在开发的组件包时,使用 `@kne/current-lib_{PACKAGE_NAME_SUFFIX}` -- 例如:`@kne/info-page` 项目应使用 `@kne/current-lib_info-page` -- 这样可以让文档系统自动识别并加载当前开发中的组件版本 - -## 5. API文档编写 - -`doc/api.md` 中包含所有组件的API文档,使用表格格式: - -```markdown -### 组件名称 - -组件描述 - -#### 属性 - -| 属性 | 类型 | 默认值 | 描述 | -|------|------|-------|------| -| propName | string | - | 属性描述 | -``` - -## 6. 数据模拟 - -在示例中使用 PureGlobal 的 preset 模拟数据: - -```javascript -apis: { - file: { - staticUrl: getPublicPath('{PROJECT_NAME}') || window.PUBLIC_URL, - getUrl: { - loader: async ({ params }) => { - // 根据 params 返回对应的 mock 数据 - const urlMap = { - 1: '/mock/example.png', - 2: '/mock/example.pdf' - }; - return new Promise(resolve => { - setTimeout(() => { - resolve(urlMap[params.id]); - }, 500); - }); - } - } - } -} -``` - -## 7. 组件导出规范 - -在组件的主入口 `index.js` 中,需要导出所有子组件: - -```javascript -export { default } from './MainComponent'; -export { default as SubComponent1 } from './SubComponent1'; -export { default as SubComponent2 } from './SubComponent2'; -``` - -在示例中通过解构引用: - -```javascript -const { MainComponent, SubComponent1, SubComponent2 } = _{LIB_NAME}; -``` - -## 8. 使用说明 - -### 创建新组件示例步骤 - -1. 在 `src/components/` 下创建或编辑组件 -2. 在 `doc/example.json` 中添加示例配置 -3. 在 `doc/` 目录下创建对应的 `.js` 示例文件 -4. 在 `doc/api.md` 中添加组件API文档(如果是新组件) -5. 在组件的 `index.js` 中确保正确导出 - -### 变量替换示例 - -假设项目 `package.json` 中配置为: - -```json -{ - "name": "@your-org/your-project" -} -``` - -则变量值为: - -- `{PACKAGE_NAME}` → `@your-org/your-project` -- `{PROJECT_NAME}` → `your-project` -- `{LIB_NAME}` → `YourProject` -- `{PACKAGE_NAME_SUFFIX}` → `your-project`(用于当前包引用) - -实际使用时需要替换为: - -```json -{ - "name": "_YourProject", - "packageName": "@your-org/your-project", - "importStatement": "import * as _YourProject from \"@your-org/your-project\"" -} -``` - -**引用当前开发包时的特殊写法**: -```json -{ - "name": "_YourProject", - "packageName": "@kne/current-lib_your-project", - "importStatement": "import * as _YourProject from \"@your-org/your-project\"" -} -``` - -```javascript -const { YourComponent } = _YourProject; -const { createWithRemoteLoader, getPublicPath } = remoteLoader; - -// ... -staticUrl: getPublicPath('your-project') || window.PUBLIC_URL, -``` - -## 9. 项目参考 - -本项目的示例文件位于 `doc/` 目录: - -- `doc/example.json` - 所有组件的示例配置 -- `doc/api.md` - 所有组件的API文档 -- `doc/summary.md` - 项目概述和核心特性说明 \ No newline at end of file diff --git a/src/components/DriverItem.js b/src/components/DriverItem.js index b7ed723..a041036 100644 --- a/src/components/DriverItem.js +++ b/src/components/DriverItem.js @@ -1,18 +1,17 @@ import React from 'react'; import classnames from 'classnames'; -import uniqueId from 'lodash/uniqueId'; import LiveCode from './LiveCode'; import MiniCode from './MiniCode'; const DriverItem = ({isFull, contextComponent, list}) => { return
- {list.map((props) => ( -
- {props.hasOwnProperty('qrcodeUrl') ? : + {list.map((props, index) => ( +
+ {props.qrcodeUrl ? : }
))}
; }; -export default DriverItem; +export default React.memo(DriverItem); diff --git a/src/components/ExampleDriver.js b/src/components/ExampleDriver.js index 7f4d45d..f8c04c0 100644 --- a/src/components/ExampleDriver.js +++ b/src/components/ExampleDriver.js @@ -1,13 +1,22 @@ -import React from 'react'; +import React, {useMemo} from 'react'; import classnames from 'classnames'; import DriverItem from './DriverItem'; const ExampleDriver = ({list, isFull, contextComponent, className, ...props}) => { - const groupList = isFull === true ? [list] : [list.filter((_, index) => index % 2 === 0), list.filter((_, index) => index % 2 !== 0)]; + const isFullItem = list.length < 2 || isFull; + + const groupList = useMemo(() => { + if (isFull === true) return [list]; + return [ + list.filter((_, index) => index % 2 === 0), + list.filter((_, index) => index % 2 !== 0) + ]; + }, [list, isFull]); + return (
{groupList.map((item, index) => )} + isFull={isFullItem} list={item}/>)}
); }; -export default ExampleDriver; +export default React.memo(ExampleDriver); diff --git a/src/components/LiveCode.js b/src/components/LiveCode.js index 5833943..789421e 100644 --- a/src/components/LiveCode.js +++ b/src/components/LiveCode.js @@ -6,15 +6,25 @@ import DescriptionBar from './DescriptionBar'; import CodePanel from './CodePanel'; import ErrorComponent from './ErrorComponent'; -const LiveCodeInner = ({code, scope, title, description, contextComponent}) => { +const LiveCodeInner = ({code = '', scope = [], title, description, contextComponent, mounted, useInView: enableInView = true}) => { const [_code, setCode] = useState(code); const [codeOpen, setCodeOpen] = useState(false); const containerRef = useRef(null); - const {shouldRender, heightRef} = useInView(containerRef); + useEffect(() => { + setCode(code || ''); + }, [code]); + + const useViewport = enableInView !== false && typeof mounted !== 'boolean'; + const {shouldRender: inViewShouldRender, heightRef} = useInView(containerRef, {disabled: !useViewport}); + // mounted has highest priority; otherwise use viewport if enabled, else always render + const shouldRender = typeof mounted === 'boolean' ? mounted : (useViewport ? inViewShouldRender : true); const {compiledCode, error} = useLazyCompile(_code, shouldRender); - const currentScope = useMemo(() => scope.filter(({component, name}) => !!component && !!name), [scope]); + const safeScope = useMemo(() => Array.isArray(scope) ? scope : [], [scope]); + const currentScope = useMemo(() => safeScope.filter(({ + component, name + }) => !!component && typeof name === 'string' && name), [safeScope]); const [renderJsx, setRenderJsx] = useState(null); @@ -22,13 +32,11 @@ const LiveCodeInner = ({code, scope, title, description, contextComponent}) => { if (!compiledCode || !shouldRender) return; try { // eslint-disable-next-line no-new-func - const runnerFunction = new Function('React', 'render', ...currentScope.map(({name}) => name), compiledCode); + const runnerFunction = new Function('React', 'render', ...currentScope.map(({name}) => String(name)), String(compiledCode)); const Component = contextComponent || (({children}) => children); - runnerFunction(React, jsx => setRenderJsx( - - {jsx} - - ), ...currentScope.map(({component}) => component)); + runnerFunction(React, jsx => setRenderJsx( + {jsx} + ), ...currentScope.map(({component}) => component)); } catch (e) { setRenderJsx(null); } diff --git a/src/hooks/__tests__/heightStability.test.js b/src/hooks/__tests__/heightStability.test.js new file mode 100644 index 0000000..b41aec4 --- /dev/null +++ b/src/hooks/__tests__/heightStability.test.js @@ -0,0 +1,186 @@ +import React, {useRef, useState, useEffect} from 'react'; +import {render, act, waitFor} from '@testing-library/react'; +import useInView from '../useInView'; +import useReactRoot from '../useReactRoot'; + +/** + * Integration test: verifies height consistency when + * useInView and useReactRoot work together. + */ + +const InnerComponent = ({height}) => ( +
+ Rendered Content +
+); + +const TestWrapper = ({mockHeight = 200}) => { + const containerRef = useRef(null); + const {shouldRender, heightRef} = useInView(containerRef); + const [renderJsx, setRenderJsx] = useState(null); + + useEffect(() => { + if (shouldRender) { + setRenderJsx(); + } else { + setRenderJsx(null); + } + }, [shouldRender, mockHeight]); + + useReactRoot(containerRef, shouldRender, renderJsx, heightRef); + + return
; +}; + +describe('Height stability integration test', () => { + let observerInstances = []; + let originalGBCR; + + beforeEach(() => { + observerInstances = []; + originalGBCR = window.HTMLElement.prototype.getBoundingClientRect; + + window.IntersectionObserver = class { + constructor(callback, options) { + this.callback = callback; + this.options = options; + observerInstances.push(this); + } + observe(element) { this.element = element; } + unobserve() {} + disconnect() {} + }; + }); + + afterEach(() => { + window.HTMLElement.prototype.getBoundingClientRect = originalGBCR; + }); + + const mockGetBoundingClientRect = (height) => { + window.HTMLElement.prototype.getBoundingClientRect = function () { + return {height, width: 300, top: 0, left: 0, bottom: height, right: 300}; + }; + }; + + const triggerIntersection = (isIntersecting, intersectionRatio) => { + const observer = observerInstances[observerInstances.length - 1]; + act(() => { + observer.callback([{ + isIntersecting, + intersectionRatio, + target: observer.element + }]); + }); + }; + + const waitForHeightRecord = async () => { + await act(async () => { + await new Promise(resolve => requestAnimationFrame(resolve)); + await new Promise(resolve => requestAnimationFrame(resolve)); + }); + }; + + const waitForPlaceholder = async (container) => { + await waitFor(() => { + expect(container.querySelector('.example-driver-placeholder')).toBeInTheDocument(); + }); + }; + + it('should maintain consistent height through viewport in/out cycle', async () => { + const mockHeight = 250; + mockGetBoundingClientRect(mockHeight); + + const {container} = render(); + + // Step 1: Element enters viewport + triggerIntersection(true, 0.1); + await waitForHeightRecord(); + + const containerDiv = container.querySelector('[data-testid="container"]'); + const heightAfterRender = containerDiv.getBoundingClientRect().height; + + // Step 2: Element leaves viewport + triggerIntersection(false, 0); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + }); + await waitForPlaceholder(container); + + const placeholder = containerDiv.querySelector('.example-driver-placeholder'); + const placeholderHeight = parseInt(placeholder.style.height, 10); + + // Placeholder height must match rendered height + expect(placeholderHeight).toBe(heightAfterRender); + + // Step 3: Element re-enters viewport + triggerIntersection(true, 0.1); + await waitForHeightRecord(); + + const heightAfterRerender = containerDiv.getBoundingClientRect().height; + expect(heightAfterRerender).toBe(heightAfterRender); + + // Step 4: Leave viewport again + triggerIntersection(false, 0); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + }); + await waitForPlaceholder(container); + + const placeholder2 = containerDiv.querySelector('.example-driver-placeholder'); + const placeholderHeight2 = parseInt(placeholder2.style.height, 10); + expect(placeholderHeight2).toBe(heightAfterRender); + }); + + it('should not lose height when quickly toggling in/out', async () => { + const mockHeight = 180; + mockGetBoundingClientRect(mockHeight); + + const {container} = render(); + + triggerIntersection(true, 0.1); + await waitForHeightRecord(); + + const initialHeight = container.querySelector('[data-testid="container"]') + .getBoundingClientRect().height; + + // Multiple in/out toggles + for (let i = 0; i < 3; i++) { + triggerIntersection(false, 0); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + }); + + triggerIntersection(true, 0.1); + await waitForHeightRecord(); + } + + const finalHeight = container.querySelector('[data-testid="container"]') + .getBoundingClientRect().height; + + expect(finalHeight).toBe(initialHeight); + }); + + it('placeholder height should equal the last rendered height exactly', async () => { + const mockHeight = 320; + mockGetBoundingClientRect(mockHeight); + + const {container} = render(); + + triggerIntersection(true, 0.1); + await waitForHeightRecord(); + + const renderedHeight = container.querySelector('[data-testid="container"]') + .getBoundingClientRect().height; + + triggerIntersection(false, 0); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + }); + await waitForPlaceholder(container); + + const placeholder = container.querySelector('.example-driver-placeholder'); + const placeholderHeight = parseInt(placeholder.style.height, 10); + + expect(placeholderHeight).toBe(renderedHeight); + }); +}); diff --git a/src/hooks/__tests__/useInView.test.js b/src/hooks/__tests__/useInView.test.js new file mode 100644 index 0000000..b34566e --- /dev/null +++ b/src/hooks/__tests__/useInView.test.js @@ -0,0 +1,112 @@ +import React, {useRef} from 'react'; +import {render, act} from '@testing-library/react'; +import useInView from '../useInView'; + +const TestComponent = ({onStateChange, style}) => { + const ref = useRef(null); + const {shouldRender, heightRef} = useInView(ref); + if (onStateChange) { + onStateChange({shouldRender, heightRef, ref}); + } + return
; +}; + +describe('useInView', () => { + let observerInstances = []; + + beforeEach(() => { + observerInstances = []; + window.IntersectionObserver = class { + constructor(callback, options) { + this.callback = callback; + this.options = options; + observerInstances.push(this); + } + observe(element) { this.element = element; } + unobserve() {} + disconnect() {} + }; + }); + + it('should set shouldRender to true when element is intersecting', () => { + let state; + render( state = s}/>); + + const observer = observerInstances[0]; + act(() => { + observer.callback([{ + isIntersecting: true, + intersectionRatio: 0.1, + target: observer.element + }]); + }); + + expect(state.shouldRender).toBe(true); + }); + + it('should set shouldRender to false when fully out of view (intersectionRatio === 0)', () => { + let state; + render( state = s}/>); + + const observer = observerInstances[0]; + + act(() => { + observer.callback([{ + isIntersecting: true, + intersectionRatio: 0.5, + target: observer.element + }]); + }); + expect(state.shouldRender).toBe(true); + + act(() => { + observer.callback([{ + isIntersecting: false, + intersectionRatio: 0, + target: observer.element + }]); + }); + + expect(state.shouldRender).toBe(false); + }); + + it('should not set shouldRender to false when partially out of view (intersectionRatio > 0)', () => { + let state; + render( state = s}/>); + + const observer = observerInstances[0]; + + act(() => { + observer.callback([{ + isIntersecting: true, + intersectionRatio: 0.5, + target: observer.element + }]); + }); + + // Partially out of view + act(() => { + observer.callback([{ + isIntersecting: false, + intersectionRatio: 0.3, + target: observer.element + }]); + }); + + // shouldRender stays true since intersectionRatio > 0 + expect(state.shouldRender).toBe(true); + }); + + it('should default shouldRender to false', () => { + let state; + render( state = s}/>); + expect(state.shouldRender).toBe(false); + }); + + it('should expose heightRef for external height management', () => { + let state; + render( state = s}/>); + expect(state.heightRef).toBeDefined(); + expect(state.heightRef.current).toBe(0); + }); +}); diff --git a/src/hooks/__tests__/useReactRoot.test.js b/src/hooks/__tests__/useReactRoot.test.js new file mode 100644 index 0000000..917ce4c --- /dev/null +++ b/src/hooks/__tests__/useReactRoot.test.js @@ -0,0 +1,234 @@ +import React, {useRef} from 'react'; +import {render, waitFor, act} from '@testing-library/react'; +import useReactRoot from '../useReactRoot'; + +const TestComponent = ({shouldRender, fixedHeight = 200}) => { + const containerRef = useRef(null); + const heightRef = useRef(0); + const jsx = shouldRender ? +
Content
: null; + useReactRoot(containerRef, shouldRender, jsx, heightRef); + return
; +}; + +describe('useReactRoot', () => { + let originalGBCR; + + beforeEach(() => { + originalGBCR = window.HTMLElement.prototype.getBoundingClientRect; + }); + + afterEach(() => { + window.HTMLElement.prototype.getBoundingClientRect = originalGBCR; + }); + + const mockGetBoundingClientRect = (height) => { + window.HTMLElement.prototype.getBoundingClientRect = function () { + return {height, width: 300, top: 0, left: 0, bottom: height, right: 300}; + }; + }; + + it('should render content when shouldRender is true', async () => { + const {container} = render(); + + await waitFor(() => { + expect(container.querySelector('.example-driver-runner')).toBeInTheDocument(); + }); + }); + + it('should record height before unmounting and create placeholder with that height', async () => { + const fixedHeight = 250; + mockGetBoundingClientRect(fixedHeight); + + const {rerender, container} = render( + + ); + + // Wait for render + double rAF height recording + await act(async () => { + await new Promise(resolve => requestAnimationFrame(resolve)); + await new Promise(resolve => requestAnimationFrame(resolve)); + }); + + // Unmount the content + rerender(); + + // Wait for setTimeout(0) placeholder creation + await waitFor(() => { + const placeholder = container.querySelector('.example-driver-placeholder'); + expect(placeholder).toBeInTheDocument(); + expect(placeholder.style.height).toBe(fixedHeight + 'px'); + }); + }); + + it('should preserve height consistency across mount/unmount cycles', async () => { + const fixedHeight = 300; + mockGetBoundingClientRect(fixedHeight); + + const {rerender, container} = render( + + ); + + // Wait for initial render + double rAF + await act(async () => { + await new Promise(resolve => requestAnimationFrame(resolve)); + await new Promise(resolve => requestAnimationFrame(resolve)); + }); + + // Cycle 1: unmount + rerender(); + await waitFor(() => { + const placeholder = container.querySelector('.example-driver-placeholder'); + expect(placeholder).toBeInTheDocument(); + expect(placeholder.style.height).toBe(fixedHeight + 'px'); + }); + + // Cycle 1: remount + rerender(); + await waitFor(() => { + expect(container.querySelector('.example-driver-runner')).toBeInTheDocument(); + }); + + await act(async () => { + await new Promise(resolve => requestAnimationFrame(resolve)); + await new Promise(resolve => requestAnimationFrame(resolve)); + }); + + // Cycle 2: unmount again + rerender(); + await waitFor(() => { + const placeholder = container.querySelector('.example-driver-placeholder'); + expect(placeholder).toBeInTheDocument(); + expect(placeholder.style.height).toBe(fixedHeight + 'px'); + }); + }); + + it('should remove placeholder before rendering new content', async () => { + const fixedHeight = 180; + mockGetBoundingClientRect(fixedHeight); + + const {rerender, container} = render( + + ); + + await act(async () => { + await new Promise(resolve => requestAnimationFrame(resolve)); + await new Promise(resolve => requestAnimationFrame(resolve)); + }); + + // Unmount to create placeholder + rerender(); + await waitFor(() => { + expect(container.querySelector('.example-driver-placeholder')).toBeInTheDocument(); + }); + + // Remount + rerender(); + await waitFor(() => { + expect(container.querySelector('.example-driver-placeholder')).not.toBeInTheDocument(); + expect(container.querySelector('.example-driver-runner')).toBeInTheDocument(); + }); + }); + + it('should not create placeholder if never rendered and height is 0', async () => { + const {container} = render(); + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); + + expect(container.querySelector('.example-driver-placeholder')).not.toBeInTheDocument(); + expect(container.querySelector('.example-driver-runner')).not.toBeInTheDocument(); + }); + + it('should create placeholder when shouldRender becomes false without reactRoot but heightRef has value', async () => { + const fixedHeight = 200; + mockGetBoundingClientRect(fixedHeight); + + const heightRefValue = {current: 0}; + const ContainerWithRef = ({shouldRender}) => { + const containerRef = useRef(null); + const jsx = shouldRender ?
Content
: null; + useReactRoot(containerRef, shouldRender, jsx, heightRefValue); + return
; + }; + + const {rerender, container} = render(); + + await act(async () => { + await new Promise(resolve => requestAnimationFrame(resolve)); + await new Promise(resolve => requestAnimationFrame(resolve)); + }); + + // Unmount: should create placeholder + rerender(); + + await waitFor(() => { + const placeholder = container.querySelector('.example-driver-placeholder'); + expect(placeholder).toBeInTheDocument(); + expect(placeholder.style.height).toBe(fixedHeight + 'px'); + }); + + // Re-render with shouldRender=false again (reactRootRef is null now): + // placeholder should still exist + rerender(); + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + }); + + const placeholder2 = container.querySelector('.example-driver-placeholder'); + expect(placeholder2).toBeInTheDocument(); + expect(placeholder2.style.height).toBe(fixedHeight + 'px'); + }); + + it('should not have residual DOM nodes after unmount', async () => { + const fixedHeight = 200; + mockGetBoundingClientRect(fixedHeight); + + const {rerender, container} = render( + + ); + + await act(async () => { + await new Promise(resolve => requestAnimationFrame(resolve)); + await new Promise(resolve => requestAnimationFrame(resolve)); + }); + + // Unmount + rerender(); + await waitFor(() => { + const placeholder = container.querySelector('.example-driver-placeholder'); + expect(placeholder).toBeInTheDocument(); + }); + + // Only placeholder should remain + const containerDiv = container.querySelector('[data-testid="container"]'); + expect(containerDiv.children.length).toBe(1); + expect(containerDiv.children[0].className).toBe('example-driver-placeholder'); + }); + + it('should record height synchronously before unmount (not relying on async)', async () => { + const fixedHeight = 275; + mockGetBoundingClientRect(fixedHeight); + + const heightRefValue = {current: 0}; + const ContainerWithRef = () => { + const containerRef = useRef(null); + const jsx =
Content
; + useReactRoot(containerRef, true, jsx, heightRefValue); + return
; + }; + + const {unmount} = render(); + + // Wait for double rAF to record height + await act(async () => { + await new Promise(resolve => requestAnimationFrame(resolve)); + await new Promise(resolve => requestAnimationFrame(resolve)); + }); + + // heightRef should have been recorded + expect(heightRefValue.current).toBe(fixedHeight); + }); +}); diff --git a/src/hooks/useInView.js b/src/hooks/useInView.js index cefeac7..623329d 100644 --- a/src/hooks/useInView.js +++ b/src/hooks/useInView.js @@ -1,31 +1,96 @@ import {useState, useEffect, useRef} from 'react'; -const useInView = (ref) => { +let sharedObserver = null; +let sharedObserverCtor = null; +const elementCallbacks = new Map(); + +const OBSERVER_OPTIONS = { + threshold: [0], + // preload a bit outside viewport to reduce frequent mount/unmount near boundary + rootMargin: '200px 0px' +}; + +// Delay before unmounting to avoid rapid mount/unmount cycles during scrolling. +// If the element re-enters the viewport before the delay expires, the unmount is cancelled. +const UNMOUNT_DELAY = 300; + +const getSharedObserver = () => { + if (!sharedObserver || sharedObserverCtor !== window.IntersectionObserver) { + sharedObserverCtor = window.IntersectionObserver; + elementCallbacks.clear(); + sharedObserver = new window.IntersectionObserver((entries) => { + entries.forEach((entry) => { + const callbacks = elementCallbacks.get(entry.target); + if (!callbacks) return; + callbacks.forEach(cb => cb(entry)); + }); + }, OBSERVER_OPTIONS); + } + return sharedObserver; +}; + +const useInView = (ref, options) => { + const disabled = !!(options && options.disabled); const [shouldRender, setShouldRender] = useState(false); const heightRef = useRef(0); + const unmountTimerRef = useRef(null); useEffect(() => { + if (disabled) return; const container = ref.current; if (!container) return; + if (typeof window === 'undefined' || typeof window.IntersectionObserver !== 'function') { + setShouldRender(true); + return; + } - const observer = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - setShouldRender(true); + const observer = getSharedObserver(); + const cb = (entry) => { + if (entry.isIntersecting) { + // Cancel pending unmount if element re-enters viewport + if (unmountTimerRef.current) { + clearTimeout(unmountTimerRef.current); + unmountTimerRef.current = null; } - if (!entry.isIntersecting && entry.intersectionRatio === 0) { - const h = container.getBoundingClientRect().height; - if (h > 0) { - heightRef.current = h; - } - setShouldRender(false); + setShouldRender(true); + return; + } + if (!entry.isIntersecting && entry.intersectionRatio === 0) { + // Delay unmount to avoid rapid mount/unmount cycles during scrolling + if (unmountTimerRef.current) { + clearTimeout(unmountTimerRef.current); } - }); - }, {threshold: [0]}); + unmountTimerRef.current = setTimeout(() => { + unmountTimerRef.current = null; + setShouldRender(false); + }, UNMOUNT_DELAY); + } + }; + + let callbacks = elementCallbacks.get(container); + if (!callbacks) { + callbacks = new Set(); + elementCallbacks.set(container, callbacks); + observer.observe(container); + } + callbacks.add(cb); - observer.observe(container); - return () => observer.disconnect(); - }, [ref]); + return () => { + const setRef = elementCallbacks.get(container); + if (!setRef) return; + setRef.delete(cb); + if (setRef.size === 0) { + elementCallbacks.delete(container); + if (typeof observer.unobserve === 'function') { + observer.unobserve(container); + } + } + if (unmountTimerRef.current) { + clearTimeout(unmountTimerRef.current); + unmountTimerRef.current = null; + } + }; + }, [ref, disabled]); return {shouldRender, heightRef}; }; diff --git a/src/hooks/useLazyCompile.js b/src/hooks/useLazyCompile.js index dac99e0..c346eaf 100644 --- a/src/hooks/useLazyCompile.js +++ b/src/hooks/useLazyCompile.js @@ -1,31 +1,185 @@ -import {useState, useEffect} from 'react'; +import {useState, useEffect, useRef, useCallback} from 'react'; import {transform as _transform} from '@babel/standalone'; import {useDebouncedCallback} from 'use-debounce'; +const COMPILE_PRESETS = ['es2015', 'react']; +const COMPILE_CACHE_LIMIT = 200; +const compileCache = new Map(); + +// Limit compile "concurrency" (actually scheduling) to avoid a long main-thread block +// when lots of cards enter the viewport at once. +const COMPILE_MAX_INFLIGHT = 1; +let inflight = 0; +let pumpScheduled = false; +const jobsByKey = new Map(); +const queue = []; + +const hashString = (str) => { + let h = 5381; + for (let i = 0; i < str.length; i++) { + h = ((h << 5) + h) ^ str.charCodeAt(i); + } + return (h >>> 0).toString(16); +}; + +const getCacheKey = (code) => { + const meta = 'presets=' + COMPILE_PRESETS.join(',') + '|babel=standalone'; + return hashString(meta + '\u0000' + code) + ':' + code.length; +}; + +const cacheGet = (key) => { + if (!compileCache.has(key)) return null; + const v = compileCache.get(key); + compileCache.delete(key); + compileCache.set(key, v); + return v; +}; + +const cacheSet = (key, value) => { + if (compileCache.has(key)) compileCache.delete(key); + compileCache.set(key, value); + if (compileCache.size > COMPILE_CACHE_LIMIT) { + const firstKey = compileCache.keys().next().value; + compileCache.delete(firstKey); + } +}; + +const compileSync = (codeToCompile) => { + const key = getCacheKey(codeToCompile); + const cached = cacheGet(key); + if (cached) return cached; + const transformCode = _transform(codeToCompile, {presets: COMPILE_PRESETS}).code; + cacheSet(key, transformCode); + return transformCode; +}; + +const schedulePump = () => { + if (pumpScheduled) return; + pumpScheduled = true; + setTimeout(() => { + pumpScheduled = false; + pump(); + }, 0); +}; + +const pump = () => { + if (inflight >= COMPILE_MAX_INFLIGHT) return; + const job = queue.shift(); + if (!job) return; + + jobsByKey.delete(job.key); + inflight += 1; + + const run = () => { + let compiled = null; + let err = null; + try { + compiled = compileSync(job.code); + } catch (e) { + err = e; + } + + inflight -= 1; + job.listeners.forEach(fn => fn(compiled, err)); + schedulePump(); + }; + + // priority jobs run ASAP; non-priority jobs yield to idle time when possible + if (!job.priority && typeof requestIdleCallback === 'function') { + requestIdleCallback(() => run(), {timeout: 1000}); + } else { + setTimeout(run, 0); + } +}; + +const enqueueCompile = (key, code, priority, listener) => { + const existing = jobsByKey.get(key); + if (existing) { + existing.listeners.push(listener); + if (priority && !existing.priority) { + existing.priority = true; + const idx = queue.indexOf(existing); + if (idx > 0) { + queue.splice(idx, 1); + queue.unshift(existing); + } + } + return; + } + + const job = {key, code, priority: !!priority, listeners: [listener]}; + jobsByKey.set(key, job); + if (job.priority) { + queue.unshift(job); + } else { + queue.push(job); + } + schedulePump(); +}; + const useLazyCompile = (code, shouldCompile) => { const [compiledCode, setCompiledCode] = useState(null); const [error, setError] = useState(null); - const compileCode = useDebouncedCallback((codeToCompile) => { + const prevShouldCompileRef = useRef(false); + const requestTokenRef = useRef(0); + + const compileImpl = useCallback((codeToCompile, priority) => { if (!codeToCompile) { + setError(null); setCompiledCode(null); return; } - try { + + const token = ++requestTokenRef.current; + const key = getCacheKey(codeToCompile); + + const cached = cacheGet(key); + if (cached) { setError(null); - const transformCode = _transform(codeToCompile, {presets: ['es2015', 'react']}).code; - setCompiledCode(transformCode); - } catch (e) { - setError(e); - setCompiledCode(null); + setCompiledCode(cached); + return; } + + enqueueCompile(key, codeToCompile, priority, (compiled, err) => { + if (requestTokenRef.current !== token) return; + if (err) { + setError(err); + setCompiledCode(null); + return; + } + setError(null); + setCompiledCode(compiled); + }); + }, []); + + const compileDebounced = useDebouncedCallback((codeToCompile) => { + compileImpl(codeToCompile, false); }, 500); useEffect(() => { - if (shouldCompile) { - compileCode(code); + if (!shouldCompile) { + prevShouldCompileRef.current = false; + if (typeof compileDebounced.cancel === 'function') { + compileDebounced.cancel(); + } + return; } - }, [code, shouldCompile, compileCode]); + + const entering = prevShouldCompileRef.current === false; + prevShouldCompileRef.current = true; + + // first visible: enqueue as priority (ASAP), but still go through scheduler to avoid massive burst + if (entering) { + if (typeof compileDebounced.cancel === 'function') { + compileDebounced.cancel(); + } + compileImpl(code, true); + return; + } + + compileDebounced(code); + }, [code, shouldCompile, compileDebounced, compileImpl]); return {compiledCode, error}; }; diff --git a/src/hooks/useReactRoot.js b/src/hooks/useReactRoot.js index b1bcf34..8678da0 100644 --- a/src/hooks/useReactRoot.js +++ b/src/hooks/useReactRoot.js @@ -1,50 +1,155 @@ -import {useEffect, useRef} from 'react'; +import {useEffect, useRef, useCallback} from 'react'; import {createRoot} from 'react-dom/client'; const useReactRoot = (containerRef, shouldRender, renderJsx, heightRef) => { const reactRootRef = useRef(null); + const runnerRef = useRef(null); + const mountedRef = useRef(false); + const resizeObserverRef = useRef(null); + + const getRunner = useCallback(() => { + const container = containerRef.current; + if (!container) return null; + const existing = runnerRef.current; + if (existing && container.contains(existing)) return existing; + const found = container.querySelector('.example-driver-runner, .example-driver-placeholder'); + if (found) { + runnerRef.current = found; + return found; + } + return null; + }, [containerRef]); + + const createRunner = useCallback((className) => { + const container = containerRef.current; + if (!container) return null; + container.innerHTML = ''; + const runner = document.createElement('div'); + runner.className = className; + container.appendChild(runner); + runnerRef.current = runner; + return runner; + }, [containerRef]); + + const ensureRoot = useCallback((runner) => { + if (!runner) return null; + if (reactRootRef.current) return reactRootRef.current; + const root = createRoot(runner); + reactRootRef.current = root; + return root; + }, []); + + const recordHeight = useCallback((runner) => { + if (!runner) return 0; + const h = runner.getBoundingClientRect().height; + if (h > 0) { + heightRef.current = h; + } + return h; + }, [heightRef]); + + const ensureResizeObserver = useCallback((runner) => { + if (!runner) return; + if (resizeObserverRef.current) return; + if (typeof window === 'undefined' || typeof window.ResizeObserver !== 'function') return; + + const ro = new window.ResizeObserver(() => { + const h = runner.getBoundingClientRect().height; + if (h > 0) { + heightRef.current = h; + } + }); + ro.observe(runner); + resizeObserverRef.current = ro; + }, [heightRef]); useEffect(() => { const container = containerRef.current; if (!container) return; + let runner = getRunner(); + + // shouldRender=true but JSX not ready yet: keep current DOM (typically placeholder with last height) + if (shouldRender && !renderJsx) { + return; + } + if (!shouldRender) { - if (reactRootRef.current) { - const root = reactRootRef.current; - const savedHeight = heightRef.current; - reactRootRef.current = null; + mountedRef.current = false; - setTimeout(() => { - root.unmount(); - container.innerHTML = ''; - const placeholder = document.createElement('div'); - placeholder.className = 'example-driver-placeholder'; - placeholder.style.height = savedHeight + 'px'; - container.appendChild(placeholder); - }, 0); + // If we never rendered and there's no recorded height, do nothing (avoid creating empty placeholder nodes) + if (!runner && !(heightRef.current > 0)) { + return; + } + + if (!runner) { + runner = createRunner('example-driver-placeholder'); + } + + // record last rendered height, then keep it by setting runner height + recordHeight(runner); + const savedHeight = heightRef.current; + + runner.className = 'example-driver-placeholder'; + if (savedHeight > 0) { + runner.style.height = savedHeight + 'px'; + } + + if (reactRootRef.current) { + reactRootRef.current.render(null); } return; } - const wasPlaceholder = container.querySelector('.example-driver-placeholder'); - if (wasPlaceholder) { - wasPlaceholder.remove(); + // Mount / update + if (!runner) { + runner = createRunner('example-driver-runner'); } - const root = document.createElement('div'); - root.className = 'example-driver-runner'; - container.appendChild(root); - const reactRoot = createRoot(root); - reactRootRef.current = reactRoot; - reactRoot.render(renderJsx); + runner.className = 'example-driver-runner'; + runner.style.height = ''; - requestAnimationFrame(() => { - const h = container.getBoundingClientRect().height; - if (h > 0) { - heightRef.current = h; + const root = ensureRoot(runner); + ensureResizeObserver(runner); + + mountedRef.current = true; + root.render(renderJsx); + + // Fallback: without ResizeObserver, record height after paint + if (typeof window === 'undefined' || typeof window.ResizeObserver !== 'function') { + requestAnimationFrame(() => { + if (mountedRef.current) { + recordHeight(runner); + } + }); + } + + return () => { + mountedRef.current = false; + }; + }, [containerRef, shouldRender, renderJsx, heightRef, getRunner, createRunner, ensureRoot, ensureResizeObserver, recordHeight]); + + useEffect(() => { + return () => { + if (resizeObserverRef.current) { + resizeObserverRef.current.disconnect(); + resizeObserverRef.current = null; } - }); - }, [shouldRender, renderJsx, containerRef, heightRef]); + if (reactRootRef.current) { + const root = reactRootRef.current; + reactRootRef.current = null; + // Defer unmount to avoid "unmount while React is rendering" warnings during React Testing Library cleanup. + setTimeout(() => { + try { + root.unmount(); + } catch (e) { + // ignore + } + }, 0); + } + runnerRef.current = null; + }; + }, []); }; export default useReactRoot; diff --git a/src/index.d.ts b/src/index.d.ts index 102138b..2c42b61 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -53,6 +53,19 @@ export interface LiveCodeProps { * 自定义上下文组件,用于包裹渲染的示例内容 */ contextComponent?: ComponentType<{ children?: ReactNode }>; + + /** + * 控制示例是否挂载: + * - 传入时:由该值决定挂载/卸载 + * - 不传:由是否进入视口决定挂载/卸载 + */ + mounted?: boolean; + + /** + * 是否启用“进入视口才挂载”的检测(仅 mounted 未传入时生效) + * 默认:true + */ + useInView?: boolean; } /** @@ -114,6 +127,17 @@ export interface ExampleItem { */ qrcodeUrl?: string; + /** + * 控制示例是否挂载(仅LiveCode生效) + */ + mounted?: boolean; + + /** + * 是否启用“进入视口才挂载”的检测(仅 mounted 未传入时生效) + * 默认:true + */ + useInView?: boolean; + /** * 自定义上下文组件 */ diff --git a/src/setupTests.js b/src/setupTests.js new file mode 100644 index 0000000..1f3e42f --- /dev/null +++ b/src/setupTests.js @@ -0,0 +1,39 @@ +import '@testing-library/jest-dom'; + +// Mock IntersectionObserver +class MockIntersectionObserver { + constructor(callback, options) { + this.callback = callback; + this.options = options; + this.elements = []; + } + + observe(element) { + this.elements.push(element); + } + + unobserve(element) { + this.elements = this.elements.filter(el => el !== element); + } + + disconnect() { + this.elements = []; + } + + // Helper to simulate intersection changes in tests + triggerIntersection(entry) { + this.callback([entry]); + } +} + +window.IntersectionObserver = MockIntersectionObserver; +window.HTMLElement.prototype.getBoundingClientRect = function () { + return { + width: this.offsetWidth || 0, + height: this.offsetHeight || 0, + top: 0, + left: 0, + bottom: this.offsetHeight || 0, + right: this.offsetWidth || 0, + }; +};