{"_id":"@axiukk/componentlibrary","name":"@axiukk/componentlibrary","dist-tags":{"latest":"0.1.0"},"versions":{"0.1.0":{"name":"@axiukk/componentlibrary","private":false,"version":"0.1.0","type":"module","main":"dist/index.js","types":"dist/index.d.ts","module":"dist/index.js","dependencies":{"@fortawesome/free-brands-svg-icons":"^7.1.0","@fortawesome/free-regular-svg-icons":"^7.1.0","@fortawesome/free-solid-svg-icons":"^7.1.0","@fortawesome/react-fontawesome":"^3.1.1","axios":"^1.13.2","classnames":"^2.5.1","react-transition-group":"^4.4.5"},"devDependencies":{"@chromatic-com/storybook":"^4.1.3","@commitlint/cli":"^20.2.0","@commitlint/config-conventional":"^20.2.0","@eslint/js":"^9.39.1","@storybook/addon-a11y":"^10.1.3","@storybook/addon-docs":"^10.1.3","@storybook/addon-onboarding":"^10.1.3","@storybook/addon-vitest":"^10.1.3","@storybook/react-vite":"^10.1.3","@storybook/test":"^8.6.14","@testing-library/jest-dom":"^6.9.1","@testing-library/react":"^16.3.0","@types/node":"^24.10.1","@types/react":"^19.2.5","@types/react-dom":"^19.2.3","@types/react-transition-group":"^4.4.12","@types/testing-library__react":"^10.0.1","@vitejs/plugin-react":"^5.1.1","@vitest/browser-playwright":"^4.0.14","@vitest/coverage-v8":"^4.0.14","commitizen":"^4.3.1","cz-conventional-changelog":"^3.3.0","eslint":"^9.39.1","eslint-config-prettier":"^10.1.8","eslint-plugin-prettier":"^5.5.4","eslint-plugin-react-hooks":"^7.0.1","eslint-plugin-react-refresh":"^0.4.24","eslint-plugin-storybook":"^10.1.3","globals":"^16.5.0","husky":"^9.1.7","jsdom":"^27.2.0","lint-staged":"^16.2.7","node-sass":"^9.0.0","playwright":"^1.57.0","prettier":"^3.7.4","rimraf":"^6.1.2","sass":"^1.95.1","sass-embedded":"^1.93.3","storybook":"^10.1.3","typescript":"~5.9.3","typescript-eslint":"^8.46.4","vite":"^7.2.4","vitest":"^4.0.14"},"peerDependencies":{"react":">=18.0.0","react-dom":">=18.0.0"},"lint-staged":{"*.{js,jsx,ts,tsx,vue}":["eslint --fix","prettier --write"]},"config":{"commitizen":{"path":"./node_modules/cz-conventional-changelog"}},"scripts":{"dev":"vite","clean":"rimraf dist","build":"pnpm clean && pnpm build-ts && pnpm build-css","lint":"eslint .","preview":"vite preview","test":"vitest","storybook":"storybook dev -p 6006","build-storybook":"storybook build","build-ts":"tsc -p tsconfig.build.json","build-css":"sass ./src/styles/index.scss ./dist/index.css"},"_id":"@axiukk/componentlibrary@0.1.0","description":"## CSS","_integrity":"sha512-2xeRwjZK6FUVHXhlaGFMakj9albbfMJFBx3TgKN+DJ43ETL/a36QRKAxJU8cWXKwOVT5Kzc+jE6dQUxs9YGkJg==","_resolved":"C:\\Users\\Dell\\AppData\\Local\\Temp\\93e90864c7c6b1070a85886ba391d6d8\\axiukk-componentlibrary-0.1.0.tgz","_from":"file:axiukk-componentlibrary-0.1.0.tgz","_nodeVersion":"20.19.0","_npmVersion":"10.8.2","dist":{"integrity":"sha512-2xeRwjZK6FUVHXhlaGFMakj9albbfMJFBx3TgKN+DJ43ETL/a36QRKAxJU8cWXKwOVT5Kzc+jE6dQUxs9YGkJg==","shasum":"811b59cdc5169f05486ca6703f898368187c3d82","tarball":"https://registry.npmjs.org/@axiukk/componentlibrary/-/componentlibrary-0.1.0.tgz","fileCount":58,"unpackedSize":124749,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQDFaNV9FBZT9BGj9ygyvEcnuBFBWKjhoqhz2sVpVRTYYQIgCDyuT80Wt2yBXCh2UfUaUwR9sxUwqPTaLMq8WyIVkkI="}]},"_npmUser":{"name":"axiukk","email":"2931432685@qq.com"},"directories":{},"maintainers":[{"name":"axiukk","email":"2931432685@qq.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/componentlibrary_0.1.0_1765369979247_0.9875645177495391"},"_hasShrinkwrap":false}},"time":{"created":"2025-12-10T12:32:59.136Z","0.1.0":"2025-12-10T12:32:59.447Z","modified":"2025-12-10T12:32:59.780Z"},"maintainers":[{"name":"axiukk","email":"2931432685@qq.com"}],"description":"## CSS","readme":"# React + TypeScript + Vite\n\n## CSS\n\nSass 是 CSS 的增强版：\n\n- 可以用 **变量**：方便统一管理颜色、字体、间距等\n- 可以用 **嵌套**：CSS 层级关系更清晰\n- 可以用 **mixin 和函数**：复用样式或做计算\n- 支持 **模块化**：通过 `@use` 或 `@import` 分文件管理样式\n\n![image-20251130115641042](assets/image-20251130115641042.png)\n\n1️⃣ `_reboot.scss`\n\n- 作用：重置或统一浏览器默认样式\n- 解释：不同浏览器对 HTML 元素有默认样式（比如 `h1`、`p`、`button`），会导致界面显示不一致\n- `_reboot.scss` 会把这些默认样式统一\n- `_` 前缀说明：这是一个 **局部文件，不直接编译成 CSS**，只被其他 `.scss` 文件导入使用\n\n2️⃣ `_variables.scss`\n\n- 作用：定义全局样式变量\n- 解释：为了方便管理颜色、字体、间距等样式，可以把它们放在变量里\n- 例子：\n\n3️⃣ `index.scss`\n\n- 作用：汇总、引入所有子样式，并最终被项目引用\n- 解释：就像一个“总入口”，把 `_reboot.scss`、`_variables.scss` 等文件整合起来\n\n## 第一个组件Button\n\n![alt text](image.png)\n\n最终实现\n\n![image-20251130205704621](assets/image-20251130205704621.png)\n\n### 安装class Names\n\n根据条件自动拼 class Name\n\n```js\nnpm install classnames\n```\n\n用法：根据你给按钮的配置，决定要加哪些 CSS 类名\n\n```js\nclassNames(固定class, 可选class, {\n  \"class-名字\": 条件,\n  \"class-名字\": 条件,\n});\n```\n\n条件为 **true** → 加这个 class\n条件为 **false** → 不加\n\n最终效果：\n\n```js\n<button class=\"btn btn-primary btn-lg disabled\"></button>\n```\n\n### React Button 组件整体流程总结\n\n1️⃣ 明确需求和功能\n\n先想清楚按钮要实现什么功能：\n\n- 可以有不同类型（primary / default / danger / link）\n- 可以有不同尺寸（lg / sm）\n- 可以禁用（disabled）\n- 可以是普通按钮 `<button>` 或链接 `<a>`\n- 可以自定义样式（className）\n- 支持子内容（children）\n\n---\n\n2️⃣ 定义类型（TypeScript）\n\n用 TS 规定组件的 Props 类型，保证传参安全：\n\n```js\nexport type ButtonSize = 'lg' | 'sm'\nexport type ButtonType = 'primary' | 'default' | 'danger' | 'link'\n\ninterface BaseButtonProps {\n  className?: string\n  disabled?: boolean\n  size?: ButtonSize\n  btnType?: ButtonType\n  children: React.ReactNode\n  href?: string\n}\n```\n\n- **children** → 按钮内容\n- **btnType** → 按钮类型\n- **size** → 按钮大小\n- **disabled** → 是否禁用\n- **href** → 链接按钮地址\n- **className** → 用户自定义样式\n\n---\n\n3️⃣ 拼 className 样式\n\n用 `classnames` 根据条件拼接 class：\n\n```js\nconst classes = classNames(\"btn\", className, {\n  [`btn-${btnType}`]: btnType,\n  [`btn-${size}`]: size,\n  disabled: btnType === \"link\" && disabled,\n});\n```\n\n- `btn` → 基础样式\n- `btn-${btnType}` → 类型样式\n- `btn-${size}` → 尺寸样式\n- `disabled` → 链接禁用样式\n- 支持额外 `className`\n\n---\n\n4️⃣ 判断渲染哪种 HTML 标签+设置默认值\n\n```js\nif (btnType === \"link\" && href) {\n  // 链接按钮\n  return (\n    <a className={classes} href={href} {...restProps}>\n      {children}\n    </a>\n  );\n} else {\n  // 普通按钮\n  return (\n    <button className={classes} disabled={disabled} {...restProps}>\n      {children}\n    </button>\n  );\n}\n```\n\n- **`<a>`** → btnType=link + href\n- **`<button>`** → 其他情况\n- `{...restProps}` → 支持额外属性（onClick、target 等）\n\n测试发现这样定义会失效，所以直接传入默认值\n\n```js\n/*// 给 props 设置默认值\n// 当用户使用组件时，没有传某个 prop，就用这里设置的默认值\nButton.defaultProps = {\n  btnType: 'default',\n  size: 'sm',\n  disabled: false,\n}*/\n\n//对BaseButtonProps解构props\nexport const Button = ({\n  btnType='default',\n  className,\n  disabled=false,\n  size='sm',\n  children,\n  href,\n  ...restProps\n}: ButtonProps) => {\n```\n\n5️⃣ 完整组件框架\n\n```js\nimport React from 'react'\nimport classNames from 'classnames'\n\nexport const Button = ({\n  btnType='default',\n  className,\n  disabled=false,\n  size='sm',\n  children,\n  href,\n  ...restProps\n}: BaseButtonProps) => {\n  const classes = classNames('btn', className, {\n    [`btn-${btnType}`]: btnType,\n    [`btn-${size}`]: size,\n    'disabled': btnType === 'link' && disabled\n  })\n\n  if (btnType === 'link' && href) {\n    return (\n      <a className={classes} href={href} {...restProps}>\n        {children}\n      </a>\n    )\n  } else {\n    return (\n      <button className={classes} disabled={disabled} {...restProps}>\n        {children}\n      </button>\n    )\n  }\n}\n\n// 给 props 设置默认值\n// 当用户使用组件时，没有传某个 prop，就用这里设置的默认值\nButton.defaultProps = {\n  btnType: 'default',\n  size: 'sm',\n  disabled: false,\n}\n\nexport default Button\n```\n\n---\n\n6️⃣ 使用方法示例\n\n```js\n<Button>默认按钮</Button>\n<Button btnType=\"primary\">主按钮</Button>\n<Button btnType=\"danger\" size=\"lg\">大红按钮</Button>\n<Button btnType=\"link\" href=\"https://example.com\">链接按钮</Button>\n<Button btnType=\"link\" href=\"https://example.com\" disabled>禁用链接</Button>\n```\n\n---\n\n7️⃣ 总结流程思路\n\n1. **分析功能需求** → 类型、状态、特殊情况\n2. **定义 TS 类型** → 保证 props 安全\n3. **拼接 className** → 模块化、条件化样式\n4. **判断渲染标签** → `<button>` 或 `<a>`\n5. **透传剩余 props** → 支持 onClick、target 等\n6. **返回 JSX** → 子内容(children) 渲染\n7. **在项目里使用** → 根据 props 控制样式和行为\n\n### 继承原生属性\n\n方法一：联合类型（Union Type）\n\n```\ntype ButtonProps = ButtonHTMLProps | AnchorHTMLProps;\n```\n\n- 解释：这个 ButtonProps **可能是按钮属性，也可能是链接属性**\n- 缺点：访问属性时 TypeScript 可能不确定类型，需要做类型判断\n\n---\n\n方法二：交叉类型（Intersection Type） —— 更常用\n\n```\ntype ButtonProps = CustomProps & Partial<ButtonHTMLProps & AnchorHTMLProps>;\n```\n\n- `&` 表示 **交叉类型**（Intersection）\n- 意思：ButtonProps **同时包含**：\n  - 自定义属性（btnType、size、href 等）\n  - 原生按钮属性\n  - 原生链接属性（用 Partial 包裹表示可选）\n- 优点：使用 `...restProps` 时，TS 能智能提示所有属性，不需要额外判断\n\n> 总结：\n>\n> - 联合类型（`|`） = 多种可能，某一时刻只能是其中一种\n> - 交叉类型（`&`） = 多种类型叠加，同时拥有\n> - 组件里用交叉类型更合适，因为我们希望自定义属性和原生属性 **都能用**\n\n`Partial<T>` 是 TypeScript 的一个 **工具类型（Utility Type）**，作用是：\n\n> 把类型 `T` 里的 **所有属性都变为可选（optional）**\n\n交叉类型，包含 `<button>` 和 `<a>` 的所有原生属性\n\n如果不加 `Partial`：\n\n- 会报错，因为交叉类型里所有原生属性都是必填\n- 加 `Partial` 后，原生属性 **都变为可选**，你只传 `onClick` 或 `type` 就可以了\n\n## 测试\n\n安装vitest\n\n```js\nnpm install -D vitest\n```\n\n```js\n//package.json\n{\n  \"scripts\": {\n    \"test\": \"vitest\"\n  }\n}\n```\n\n安装testing-library\n\n```js\nnpm install --save-dev @testing-library/react @testing-library/jest-dom jsdom\n```\n\n配置enviroment，这是vite.config.ts\n\n```js\n/// <reference types=\"vitest/config\" />\nimport { defineConfig } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\n\n// https://vite.dev/config/\nexport default defineConfig({\n  plugins: [react()],\n  test: {\n    globals: true, // 可以直接使用 test/expect\n    environment: \"jsdom\", // 模拟浏览器环境\n  },\n});\n```\n\n安装jest-dom\n\n```js\nnpm install --save-dev @testing-library/jest-dom\n```\n\n## Menu导航组件\n\n![image-20251130205634188](assets/image-20251130205634188.png)\n\n分为Menu和MenuItem两个组件\n\n### context\n\n要将Menu的props传入MenuItem，使用hook\n\nContext\n\n- 是 React 自带的 API（`createContext` + `useContext`）。\n- 适合组件树中少量状态共享，例如：菜单的选中项、主题色、语言切换等。\n- 状态通常由父组件管理（这里的 `Menu`），然后通过 Context 传给子组件。\n- 使用简单，不需要额外依赖。\n\n```js\n//context属性种类\ninterface IMenuContext {\n    index: number;\n    onSelect: (index: number) => void;\n    mode?: MenuMode;\n}\n\n//context默认值\nexport const MenuContext = createContext<IMenuContext>({\n    index: 0,\n    onSelect: () => { },\n    mode: 'horizontal',\n})\n```\n\nprovider中实际传入的值\n\n```js\nconst passedContext: IMenuContext = {\n        index: currentActive,\n        onSelect: (index) => {\n            setCurrentActive(index)\n            onSelect(index)\n            alert(index)\n        },\n        mode,\n    }\n```\n\n包裹组件\n\n```js\nreturn (\n  //包裹\n  <MenuContext.Provider value={passedContext}>\n    <ul className={classes} style={style}>\n      {children}\n    </ul>\n  </MenuContext.Provider>\n  //包裹\n);\n```\n\n### 测试\n\n#### 发现active没有渲染到className中\n\nactive为异步渲染\n\n1、你点击 MenuItem：\n\n```\nconst thirdItem = wrapper.getByText('xyz')\nthirdItem.click()\n```\n\n2、MenuItem 内部会调用 `onSelect(index)`：\n\n```\nconst handleClick = () => {\n  if (!disabled) onSelect(index)\n}\n```\n\n3、Menu 组件里 `onSelect` 更新 state：\n\n```\nsetCurrentActive(index) // React 异步更新\n```\n\n4、React state 更新是 **异步的**，下一轮渲染（re-render）才会触发 MenuItem class 更新：\n\n```\nconst classes = classNames('menu-item', {\n  'active': currentActive === index\n})\n```\n\n- 所以在点击事件之后立即检查 `thirdItem.className`，可能还没更新 → 断言失败\n\n#### `waitFor`\n\n```js\nawait waitFor(() => {\n  expect(wrapper.getByText(\"xyz\")).toHaveClass(\"active\");\n});\n```\n\n- `waitFor` 会循环执行回调，直到：\n  1. 断言通过\n  2. 或者超时\n- 这样就能等待 **React 异步更新完成**，拿到最新的 DOM class\n\n#### beforeEach cleanup（）\n\n```js\nbeforeEach(() => {\n  // 每个测试用例运行前执行\n  wrapper = render(generateMenu(defaultProps));\n  //拿到被标记 data-testid=\"test-menu\" 的 DOM 元素(即<ul>)\n  menuElement = wrapper.getByTestId(\"test-menu\");\n  activeElement = wrapper.getByText(\"active\");\n  disabledElement = wrapper.getByText(\"disabled\");\n});\n```\n\n```js\ntest(\"vertical menu\", () => {\n  //清除wrapper渲染的defaultProps，里面也有test-menu所以查找到多个\n  cleanup();\n  const verticalWrapper = render(generateMenu(VerticalProps));\n  const verticalMenuElement = verticalWrapper.getByTestId(\"test-menu\");\n  expect(verticalMenuElement).toHaveClass(\"menu-vertical\"); // 检查默认类名\n});\n```\n\n### renderChildren\n\n1、**统一处理子元素**\n\n在复杂组件里，`children` 可能有各种类型：`MenuItem`、`SubMenu`、甚至其他 React 元素\n\n如果直接 `{children}` 渲染，可能出现非法元素或渲染错误\n\n`renderChildren` 可以：\n\n- 过滤掉不合法的元素\n- 给每个合法子元素自动加 `index`、`key` 等属性\n\n2、**方便扩展子菜单**\n\n当你增加 `SubMenu` 或动态生成菜单时，不用修改父组件核心渲染逻辑\n\n可以在 `renderChildren` 里做统一逻辑：\n\n- 添加 context\n- 注入 props\n- 做类型检查\n\n3、**保证组件健壮性**\n\n防止开发者传入错误的子元素\n\n比如 `<Menu>123</Menu>`，直接渲染就乱了，用 `renderChildren` 可以过滤掉\n\n```js\nconst renderChildren = () => {\n        //React.Children 是 React 提供的一个工具对象，专门用来操作组件的 children 属性\n        return React.Children.map(children, (child, index) => {\n            //child 是一个 React 元素，它的 props 类型是 MenuItemProps\n            const childElement = child as React.ReactElement<MenuItemProps>\n            //类型保护，判断childElement.type是否为函数组件\n            // type 可能是：\n            // HTML 标签（'div'、'ul'） → 没有 displayName\n            // React 组件（函数组件、class组件） → 有 displayName\n            if (typeof childElement.type === 'function') {\n                const type = childElement.type as { displayName?: string }\n                const displayName = type.displayName\n                if (displayName === 'MenuItem') {\n                    return child\n                } else {\n                    console.error('Menu children must be MenuItem')\n                    return null\n                }\n            } else {\n                console.error('Menu children must be function component')\n                return null\n            }\n        })\n    }\n```\n\n在menuItems中定义了displayName\n\n```js\nMenuItem.displayName = \"MenuItem\";\n```\n\n#### 测试（浏览器中）：\n\n```js\n<Menu mode=\"vertical\" defaultIndex={0} onSelect={(index) => console.log(index)}>\n  <MenuItem index={0}>首页</MenuItem>\n  <MenuItem index={1} disabled>\n    关于\n  </MenuItem>\n  <MenuItem index={2}>联系</MenuItem>\n  <li>123</li>\n  //输入不合法字符\n</Menu>\n```\n\n![image-20251201155803049](assets/image-20251201155803049.png)\n\n有报错\n\n#### 测试（文件中）：\n\nVitest 测试：默认console.log被拦截，需要 `spyOn` 才能捕获。\n\n`spy`（间谍）本质上就是“监听一个函数被调用的情况”，你可以检查：\n\n- 函数是否被调用\n- 调用次数\n- 调用参数\n\n```js\nconst spy = vi.spyOn(console, \"error\"); //监听console函数\n\nexpect(spy).toHaveBeenCalledWith(\"Menu children must be function component\");\nexpect(spy).toHaveBeenCalledWith(\"Menu children must be MenuItem\");\n//恢复原函数，也就是撤销 spy 的监听\nspy.mockRestore();\n```\n\n#### 自动添加index\n\n```js\nif (displayName === \"MenuItem\") {\n  //给menuitem自动添加index属性\n  const indexProp = childElement.props.index ?? index;\n  return React.cloneElement(childElement, { index: indexProp });\n}\n```\n\n### 添加 **下拉菜单（SubMenu）**\n\n修改样式css--很难\n\n#### 下拉菜单的open\n\n在menu组件的context中传入mode判断是垂直还是水平\n\n```js\n//传入context\nconst { index: currentActive, mode } = useContext(MenuContext)\n\n//处理hover\n    let timer: any\n    const handleMouse = (e: React.MouseEvent, toggle: boolean) => {\n        clearTimeout(timer)\n        e.stopPropagation()\n        timer = setTimeout(() => {\n            setMenuOpen(toggle)\n        }, 300)\n    }\n    //click\n    const clickEvents = mode === 'vertical' ? {\n        onClick: handleClick,\n    } : {}\n    //hover\n    const mouseEvents = mode !== 'vertical' ? {\n        onMouseEnter: (e: React.MouseEvent) => handleMouse(e, true),\n        onMouseLeave: (e: React.MouseEvent) => handleMouse(e, false),\n    } : {}\n```\n\n绑定事件\n\n```js\nreturn (\n  <li key={index} className=\"submenu-item\" {...mouseEvents}>\n    <div className=\"submenu-title\" {...clickEvents}>\n      {title}\n    </div>\n\n    <ul className={classes}>{renderChildren()}</ul>\n  </li>\n);\n```\n\n#### 区分MenuItem和subMenu中的MenuItem的共有index\n\n将index改成string类型\n\n```js\n// 顶层 MenuItem\n<MenuItem index=\"0\" />\n\n// SubMenu\n<SubMenu index=\"1\" title=\"子菜单\">\n    <MenuItem index=\"1-0\" />\n    <MenuItem index=\"1-1\" />\n</SubMenu>\n```\n\n#### 默认展开功能\n\n`defaultOpenSubMenus` 控制初始展开状态\n\n新增menu属性\n\n```js\ndefaultOpenSubMenus?: string[];\n```\n\n通过context传给subMenu\n\n```js\n    //排除未定义的defaultOpenSubMenus\n    const opendSubMenus=defaultOpenSubMenus as Array<string>\n    //如果是垂直菜单，且默认打开的子菜单包含当前子菜单索引，那么就设置为打开状态\n    const isOpend = (index&&mode==='vertical') ? opendSubMenus?.includes(index) : false\n    const [menuOpen, setMenuOpen] = useState(isOpend)\n```\n\n#### 测试\n\n先修改index为string导致的错误\n\n测试组件的展开（隐藏与显示）\n\n```js\n// 在测试环境里动态创建一段 CSS，用于控制 SubMenu 默认隐藏\nconst createStyleFile = () => {\n  const cssFile: string = `\n    .submenu{\n      display: none;\n    }\n    .menu-opened{\n      display: block;\n    }\n  `\n  const style = document.createElement('style')\n  style.innerHTML = cssFile\n  return style\n}\n```\n\n```js\nwrapper.container.appendChild(createStyleFile());\n```\n\n使用异步实现\n\n```js\ntest(\"horizontal submenu hover and click\", async () => {\n  expect(wrapper.queryByText(\"子项1\")).not.toBeVisible();\n  const dropdownElement = wrapper.getByText(\"下拉菜单\");\n  //模拟鼠标悬停事件\n  fireEvent.mouseEnter(dropdownElement);\n  await waitFor(() => {\n    expect(wrapper.getByText(\"子项1\")).toBeVisible();\n  });\n  //模拟点击事件\n  fireEvent.click(wrapper.getByText(\"子项1\"));\n  await waitFor(() => {\n    expect(defaultProps.onSelect).toHaveBeenCalledWith(\"4-0\");\n  });\n  //模拟鼠标移出事件\n  fireEvent.mouseLeave(dropdownElement);\n  await waitFor(() => {\n    expect(wrapper.getByText(\"子项1\")).not.toBeVisible();\n  });\n});\n```\n\n## Icon\n\n从fontawesome导入后加入library\n\n```js\nimport Icon from \"./components/Icon/icon\";\nimport { library } from \"@fortawesome/fontawesome-svg-core\";\nimport { fas } from \"@fortawesome/free-solid-svg-icons\";\nlibrary.add(fas);\n```\n\n使用后出现一个向下箭头\n\n```js\n<Icon icon=\"arrow-down\" theme=\"danger\" size=\"10x\" />\n```\n\n### 动态箭头\n\nrotate旋转180°\n\n```js\n&:hover {\n            .arrow-icon {\n                transform: rotate(180deg);\n            }\n        }\n```\n\n对于vertical垂直时取消hover效果，但是点击后箭头会翻转\n\n但是失败\n\n```js\n&.vertical {//垂直菜单没有hover效果\n            .arrow-icon {\n                transform: rotate(0deg) !important;\n            }\n        }\n\n&.vertical.menu-opened {//垂直菜单打开时箭头旋转180度\n            .arrow-icon {\n                transform: rotate(180deg) !important;\n            }\n        }\n```\n\n由于渲染时要renderChildren，所以将classes放在ul上\n\n但是这样就无法通过.vertical的className控制.arrow-icon\n\n所以又新建了一个submenuItemclasses传入vertical\n\n```js\n<li key={index} className={submenuItemclasses} {...mouseEvents}>\n  <div className=\"submenu-title\" {...clickEvents}>\n    {title}\n    <Icon icon=\"angle-down\" className=\"arrow-icon\" />\n  </div>\n\n  <ul className={classes}>{renderChildren()}</ul>\n</li>\n```\n\n### 下拉菜单栏动画\n\n```js\nimport { CSSTransition } from \"react-transition-group\";\n```\n\n```js\n.zoom-in-top-enter {\n    opacity: 0;\n    transform: scaleY(0);\n}\n\n.zoom-in-top-enter-active {\n    opacity: 1;\n    transform: scaleY(1);\n    transition: transform 300ms cubic-bezier(0.23,1,0.32,1) 100ms, opacity 300ms cubic-bezier(0.23,1,0.32,1);\n    transform-origin: center top;\n}\n\n.zoom-in-top-exit {\n    opacity: 1;\n}\n\n.zoom-in-top-exit-active {\n    opacity: 0;\n    transform: scaleY(0);\n    transition: transform 300ms cubic-bezier(0.23,1,0.32,1) 100ms, opacity 300ms cubic-bezier(0.23,1,0.32,1);\n    transform-origin: center top;\n}\n```\n\n#### 兼容问题\n\nReact 18+ 开始，`findDOMNode` 已经被移除， `react-transition-group` 内部还在偷偷用ReactDOM.findDOMNode(this)\n\n用 `nodeRef` 彻底绕开 findDOMNode，nodeRef`替代`findDOMNode\n\n```js\nconst nodeRef = useRef<HTMLUListElement>(null)\n\n<CSSTransition\n  in={open}\n  timeout={300}\n  classNames=\"zoom-in-top\"\n  unmountOnExit\n  nodeRef={nodeRef}        // ✅ 关键\n>\n  <ul ref={nodeRef} className=\"submenu\">//noderef\n    {renderChildren()}\n  </ul>\n</CSSTransition>\n```\n\nCSSTransition\n|\n|—— 内部调用 findDOMNode(this) ❌\n\n你 -> useRef() -> DOM 节点 -> nodeRef -> CSSTransition✅\n\n#### 只有显示动画没有离开特效\n\n离开时css中设置为display：none，`display` 是 **不可动画的属性**。\n\n添加unmountOnExit属性等 _离场动画播完_ 再把 DOM 从页面中删除\n\n```js\n<CSSTransition\n                in={menuOpen}\n                timeout={300}\n                classNames='zoom-in-top'\n                appear\n                nodeRef={nodeRef}\n                unmountOnExit //\n            >\n```\n\n在未点击时不渲染节点\n\n![image-20251202181126720](assets/image-20251202181126720.png)\n\n点击后才渲染\n\n![image-20251202181149726](assets/image-20251202181149726.png)\n\n现在可以注释掉display：none代码\n\n```js\n.submenu {\n        list-style: none;\n        padding-left: 0;\n        white-space: nowrap;\n        min-width: 100%;\n        //display: none;\n\n        >.menu-item {\n            padding: vars.$menu-item-padding-y vars.$menu-item-padding-x;\n            cursor: pointer;\n            transition: vars.$menu-transition;\n            color: vars.$body-color;\n            width: 100%;\n            margin-left: 10px;\n\n            &:hover,\n            &.active {\n                color: vars.$menu-item-active-color !important;\n            }\n        }\n\n        &.menu-opened {\n            //display: block;\n        }\n    }\n```\n\n## 封装成Transition组件\n\n优化：添加nodeRef，避免查找结点使用findDOMNode\n\n```js\nimport type { ReactNode } from \"react\";\nimport { useRef } from \"react\";\nimport { CSSTransition } from \"react-transition-group\";\nimport type { CSSTransitionProps } from \"react-transition-group/CSSTransition\";\n\ntype AnimationName = 'zoom-in-top' | 'zoom-in-left' | 'zoom-in-bottom' | 'zoom-in-right'\n\ntype TransitionProps = CSSTransitionProps & {\n    animation?: AnimationName,\n    children?: ReactNode,\n};\n\nconst Transition = ({\n    children,\n    classNames,\n    animation,\n    ...restProps\n}: TransitionProps) => {\n    //手动提供真实 DOM Ref，让库不再调用 findDOMNode\n    const nodeRef = useRef(null);\n    return (\n        <CSSTransition\n            nodeRef={nodeRef}\n            classNames={classNames ? classNames : animation}\n            {...restProps}\n        >\n            {children}\n        </CSSTransition>\n    )\n}\n\nexport default Transition\n```\n\n在subMenu中使用\n\n`CSSTransition` / `Transition` 需要一个 **明确的 DOM 引用** 来执行动画。\n\n`nodeRef` 告诉 `Transition`：“动画目标是这个 DOM 节点（ul）”，而不是去使用 `findDOMNode`。\n\n当 `menuOpen` 为 `true`，Transition 会对 `nodeRef.current` 的 `<ul>` 添加对应的 CSS 类（比如 `zoom-in-top-enter`），实现动画。\n\n```js\n//表示将来 nodeRef.current 会指向一个 <ul> DOM 元素。\nconst nodeRef = useRef<HTMLUListElement>(null)\n    return (\n        <li key={index} className={submenuItemclasses} {...mouseEvents}>\n            <div className='submenu-title' {...clickEvents}>\n                {title}\n                <Icon icon='angle-down' className='arrow-icon' />\n            </div>\n\n            <Transition\n                in={menuOpen}\n                timeout={300}\n                animation='zoom-in-top'\n                appear={true}\n                unmountOnExit={true}\n\t\t\t//明确node信息\n                nodeRef={nodeRef as unknown as React.Ref<undefined>}\n            >\n//当 <ul> 渲染到页面上后，React 会把 DOM 节点 赋值给 nodeRef.current\n                <ul ref={nodeRef} className={classes}>\n                    {renderChildren()}\n                </ul>\n            </Transition>\n        </li>\n    )\n```\n\n当 `<ul>` 渲染到页面上后，React 会把 **DOM 节点** 赋值给 `nodeRef.current`。\n\n此时 `nodeRef.current` 就不再是 `null`，而是对应的 `<ul>` DOM 元素。\n\n### 封装css\n\n在 Sass 里，`@mixin` 是 **可复用的样式模板**\n\n在`_mixins.scss` → 只放模板，在`_animation.scss` → 生成实际动画类\n\n```js\n@use './mixin' as m;\n\n@include m.zoom-animation('top', scaleY(0), scaleY(1), center top);\n@include m.zoom-animation('bottom', scaleY(0), scaleY(1), center bottom);\n@include m.zoom-animation('left', scaleX(0), scaleX(1), center left);\n@include m.zoom-animation('right', scaleX(0), scaleX(1), center right);\n```\n\n## Storybook\n\nStorybook 是一个 **组件开发环境**，可以：\n\n- 单独开发和调试 React/Vue/Angular 组件，不依赖整个应用\n- 给每个组件写“故事（stories）”，展示不同状态\n- 集成文档、测试和可访问性检查\n\n简单说，它就是给你组件做一个 **独立的展示和调试工具箱**。\n\n一个story记录了组件的一种渲染状态，同样类型的story放在一组，比如Button就是stories\n\n![image-20251202202110610](assets/image-20251202202110610.png)\n\n### 编写stories文件\n\n一定要启动自动文档doc\n\n```js\nimport { Button } from './button'\nimport { type Meta, type StoryObj } from '@storybook/react'\n\n//Meta<组件类型> 定义了组件的元数据，包括标题、组件类型等\nconst buttonMeta: Meta<typeof Button> = {\n    title: 'Button',\n    component: Button,\n    //启用自动文档\n    tags: ['autodocs']\n}\n\nexport default buttonMeta\n\n//StoryObj<组件类型> 定义了组件的故事对象，包括参数、渲染函数等\ntype Story = StoryObj<typeof Button>\n\nexport const Default: Story = {\n    name: 'Default Button',\n    args: {\n        children: 'Button',\n    },\n}\n```\n\n`Meta` 是组件档案， `Story` 是组件用法快照，`args` 就是 props\n\n#### 配置样式\n\npreview.js是 **Storybook 的“全局配置文件”**，给所有 stories 设置“公共规则 & 公共样式 & 公共装饰器”。\n\n/storybook/preview.js\n\n```js\nimport { library } from \"@fortawesome/fontawesome-svg-core\";\nimport { fas } from \"@fortawesome/free-solid-svg-icons\";\nlibrary.add(fas);\nimport \"../src/styles/index.scss\";\n```\n\n#### 模板\n\n```js\n//模板\nconst Template = (args: React.ComponentProps<typeof Button>) => (\n  <Button {...args} />\n)\n\nexport const Default: Story = {\n  render: Template,\n  args: {\n    children: 'Default Button',\n  },\n}\n```\n\n#### 子组件\n\n```js\nconst menuMeta: Meta<typeof Menu> = {\n    title: 'menu',\n    component: Menu,\n    //子组件信息\n    subcomponents: {\n      Item: MenuItem,\n      SubMenu,\n    },\n}\n```\n\n![image-20251203005920725](assets/image-20251203005920725.png)\n\n#### 在button.tsx中添加注释可以在doc中显示\n\n```js\n/**\n * 页面中最常用的按钮元素，适合于完成特定的交互，支持HTML button和a链接的所有属性\n */\n```\n\n![image-20251203005907735](assets/image-20251203005907735.png)\n\n### MDX\n\nMDX 是 **Markdown + JSX** 的组合，是一种可以在 Markdown 文档中直接写 React 组件的文件格式。简单来说，你可以把它当成一个“可以写组件的 Markdown”。\n\n`.mdx` 文件 = Markdown（文本、标题、列表、代码块） + 可以嵌入 React 组件。\n\n可以实现自定义doc\n\n## Input\n\n![image-20251203011623999](assets/image-20251203011623999.png)\n\ninput.tsx\n\n```js\n//omit忽略接口中的size属性，因为我们自己定义了size属性\nexport interface InputProps extends Omit<InputHTMLAttributes<HTMLElement>, 'size'>\n```\n\n### 测试中只有stories没有test\n\n在vite.config.ts中，project覆盖了原本的test配置，所以要在project中重新加上test路径\n\n```js\n/// <reference types=\"vitest/config\" />\nimport { defineConfig } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\n\n// https://vite.dev/config/\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { storybookTest } from \"@storybook/addon-vitest/vitest-plugin\";\nimport { playwright } from \"@vitest/browser-playwright\";\nconst dirname =\n  typeof __dirname !== \"undefined\"\n    ? __dirname\n    : path.dirname(fileURLToPath(import.meta.url));\n\n// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon\nexport default defineConfig({\n  plugins: [react()],\n  test: {\n    globals: true, //被覆盖\n    // 可以直接使用 test/expect\n    environment: \"jsdom\", // 模拟浏览器环境\n    projects: [\n      {\n        extends: true,\n        plugins: [\n          // The plugin will run tests for the stories defined in your Storybook config\n          // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest\n          storybookTest({\n            configDir: path.join(dirname, \".storybook\"), //只有对storybook的测试\n          }),\n        ],\n        test: {\n          name: \"storybook\",\n          browser: {\n            enabled: true,\n            headless: true,\n            provider: playwright({}),\n            instances: [\n              {\n                browser: \"chromium\",\n              },\n            ],\n          },\n          setupFiles: [\".storybook/vitest.setup.ts\"],\n        },\n      },\n      {\n        extends: true,\n        test: {\n          include: [\"tests/**/*.test.{ts,tsx}\", \"src/**/*.test.tsx\"], //加上test文件的测试\n          globals: true,\n          environment: \"jsdom\",\n        },\n      },\n    ],\n  },\n});\n```\n\n### menu测试修复\n\n由于在transition中把submenu组件改成先开始不渲染，在click/hover后才渲染，所以在测试中查看子项的都出错了\n\n把未渲染时对子组件的测试都删除即可，在渲染后添加\n\n## AutoComplete\n\n### 过滤筛选\n\n直接使用filter\n\n```js\ndata.filter((item) => item.includes(keyword));\n```\n\n但如果有很多数据，会导致浏览器内存爆炸、页面卡死等\n\n所以不在前端处理数据，而是让服务器筛选\n\nfetch实时请求\n\n```js\nfetch(`url?keyword=${keyword}`); //异步代码\n```\n\n输入触发查询，查询方式由 `fetchSuggestions` 抽象，当输入内容时，触发`handleChange`调用 `fetchSuggestions` 展示建议\n\n组件内部只负责：\n\n- 输入控制\n- 异步调用\n- 状态管理\n- UI 渲染\n\n```js\nimport { useState } from 'react';\nimport type { ChangeEvent } from 'react';\nimport type { InputProps } from '../Input/input';\nimport { Input } from '../Input/input';\n\nexport interface AutoCompleteProps extends Omit<InputProps, 'onSelect'> {\n    // 过滤筛选，fetch异步\n    fetchSuggestions: (str: string) => Promise<string[]>\n    onSelect?: (item: string) => void\n}\n\nexport const AutoComplete = ({\n    fetchSuggestions,\n    onSelect,\n    value = '',\n    ...restProps\n}: AutoCompleteProps) => {\n    const [inputValue, setInputValue] = useState(value);\n    const [suggestions, setSuggestions] = useState<string[]>([]);\n\n    console.log('suggestions', suggestions);\n    const handleChange = async (e: ChangeEvent<HTMLInputElement>) => {\n        const value = e.target.value;\n        setInputValue(value);\n        if (value) {\n            const results = await fetchSuggestions(value);\n            setSuggestions(results);\n        } else {\n            setSuggestions([]);\n        }\n    }\n\n    return (\n        <div className=\"auto-complete-wrapper\">\n            <Input\n                value={inputValue}\n                onChange={handleChange}\n                {...restProps}\n            />\n        </div>\n    )\n}\n```\n\n### 下拉菜单\n\n点击下拉菜单，会自动补全+清空下拉菜单+选择回调\n\n```js\nconst handleSelect = (item: string) => {\n        setInputValue(item);\n        setSuggestions([]);\n        // 触发选择回调,把选中的值传给父组件\n        onSelect?.(item);\n    }\n\n    // 生成下拉列表\n    const generateDropDown = () => {\n        return (\n            <ul>\n                {suggestions.map((item, index) => (\n                    <li key={index}\n                        onClick={() => handleSelect(item)}>\n                        {item}\n                    </li>\n                ))}\n            </ul>\n        )\n    }\n```\n\n#### 自定义菜单样式，添加renderOption属性\n\n```js\n//存在renderOption则使用renderOption渲染，否则直接渲染item\n    const renderTemplate = (item: string) => {\n        return renderOption ? renderOption(item) : item\n    }\n```\n\n只能定义菜单为string类型，所以将string改成对象类型\n\n由于不知道传入的参数类型，采用T泛型\n\n```js\ninterface DataSourceObject {\n    value: string\n}\nexport type DataSourceType<T = {}> = T & DataSourceObject\n```\n\n同时要更改AutoCojmpleteProps为泛型（即传入T）\n\n否则属性里的item还是为DataSourceObject而不是DataSourceType，因为T默认值为{}\n\n```js\nexport interface AutoCompleteProps<T={}> extends Omit<InputProps, 'onSelect'> {\n    // 过滤筛选，fetch异步\n    fetchSuggestions: (str: string) => Promise<DataSourceType<T>[]>\n    onSelect?: (item: DataSourceType<T>) => void\n    renderOption?: (item: DataSourceType<T>) => ReactNode\n}\n```\n\n在story中也要明确传入参数的类型\n\n```js\nconst lakersWithNumber = [\n    { value: 'bradley', number: 11 },\n    { value: 'pope', number: 1 },\n    { value: 'caruso', number: 4 },\n    { value: 'cook', number: 2 },\n    { value: 'cousins', number: 15 },\n    { value: 'james', number: 23 },\n    { value: 'AD', number: 3 },\n    { value: 'green', number: 14 },\n    { value: 'howard', number: 39 },\n    { value: 'kuzma', number: 0 },\n]\n\n//AutoComplete现在是泛型，必须传入T明确数据源的类型\nexport const Default: StoryObj<typeof AutoComplete<{ number: number }>> = {\n```\n\n对于普通的string，也要注意现在的返回值不能再是string而是Object类型\n\n```js\n//菜单为string类型\nexport const Default: StoryObj<typeof AutoComplete> = {\n    args: {\n        value: '',\n        //现在不能return string必须return Object类型\n        fetchSuggestions: async (str: string) => {\n            return lakers.filter(item => item.includes(str)).map(item => ({ value: item }))\n        },\n        onSelect: (item) => {\n            console.log(item);\n        },\n    }\n}\n```\n\n#### 异步逻辑\n\n返回promise对象\n\n```js\nfetchSuggestions: (str: string) => Promise<DataSourceType<T>[]>\n```\n\n```js\nconst handleChange = async (e: ChangeEvent<HTMLInputElement>) => {\n        const value = e.target.value;\n        setInputValue(value);\n        if (value) {\n            //await异步\n            const results = await fetchSuggestions(value);\n            setSuggestions(results);\n        } else {\n            setSuggestions([]);\n        }\n    }\n```\n\n添加loading的icon\n\n```js\nconst [loading, setLoading] = useState(false);\n```\n\n```js\nsetLoading(true);\nconst results = await fetchSuggestions(value);\nsetSuggestions(results);\nsetLoading(false);\n```\n\n```js\n{\n  loading && <Icon icon=\"spinner\" spin />;\n}\n```\n\n### 防抖节流\n\n#### `useEffect` + 防抖 Hook\n\n`useEffect` 是 React **函数组件**里的一个 **Hook**，作用是“在组件渲染后执行副作用操作”。\n\n**副作用（Side Effect）**：指组件渲染本身之外的操作，比如：\n\n- 数据请求（fetch API）\n- 订阅/取消订阅事件\n- 设置定时器/清理定时器\n- 操作 DOM（一般少用）\n\n```js\nuseEffect(() => {\n        const fetchData = async () => {\n            if (inputValue) {\n                setLoading(true);\n                const results = await fetchSuggestions(inputValue);\n                setSuggestions(results);\n                setLoading(false);\n            } else {\n                setSuggestions([]);\n            }\n        }\n\n        fetchData();\n    }, [inputValue]);\n\n    const handleChange = async (e: ChangeEvent<HTMLInputElement>) => {\n        const value = e.target.value;\n        setInputValue(value);\n    }\n```\n\n**handleChange → 只更新状态**\n\n**useEffect → 监听状态变化做异步副作用**\n\n#### useDebounce自定义hook\n\n```js\nimport { useState, useEffect } from 'react';\n\nconst useDebounce = (value: string, delay: number = 300) => {\n    const [debouncedValue, setDebouncedValue] = useState(value);\n\n    useEffect(() => {\n        const handler = setTimeout(() => {\n            setDebouncedValue(value);\n        }, delay);\n\n        // 清理函数，组件卸载时清除定时器\n        return () => {\n            clearTimeout(handler);\n        };\n    }, [value, delay]);\n\n    return debouncedValue;\n}\n\nexport default useDebounce;\n```\n\n这里的 `return` 并不是返回数据，而是**返回一个清理函数（cleanup function）**。\n\nReact 会在组件卸载或依赖变化时调用这个函数，常用于清理定时器、取消订阅等\n\n![image-20251204021622141](assets/image-20251204021622141.png)\n\n![image-20251204021736595](assets/image-20251204021736595.png)\n\n### 键盘事件\n\n上下箭头+回车+esc\n\n```js\n// 处理键盘事件\n    const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {\n        switch (e.key) {\n            case 'ArrowUp': // 上箭头\n                setHighlightIndex((prevIndex) => Math.max(prevIndex - 1, -1));\n                break;\n            case 'ArrowDown': // 下箭头\n                setHighlightIndex((prevIndex) => Math.min(prevIndex + 1, suggestions.length - 1));\n                break;\n            case 'Enter': // 回车键\n                if (highlightIndex >= 0 && highlightIndex < suggestions.length) {\n                    handleSelect(suggestions[highlightIndex]);\n                }\n                break;\n            case 'Escape': // 转义键\n                setSuggestions([]);\n                break;\n        }\n    }\n```\n\n```js\n<ul>\n  {suggestions.map((item, index) => {\n    // 高亮显示当前选中项\n    const itemClasses = classNames(\"suggestion-item\", {\n      \"item-highlighted\": index === highlightIndex,\n    });\n    return (\n      <li\n        key={index}\n        className={itemClasses}\n        onClick={() => handleSelect(item)}\n      >\n        {renderTemplate(item)}\n      </li>\n    );\n  })}\n</ul>\n```\n\n#### 修复在输入变化时，highlight还指向上一个位置\n\n```js\n//监听输入值变化\n    useEffect(() => {\n        //每次输入变化时，重置highlight位置\n        setHighlightIndex(-1);\n        const fetchData = async () => {\n            if (debouncedValue) {\n                setLoading(true);\n                const results = await fetchSuggestions(debouncedValue);\n                setSuggestions(results);\n                setLoading(false);\n            } else {\n                setSuggestions([]);\n            }\n        }\n```\n\n#### ⭐状态变更来源区分\n\n按 Enter 选中一项后，不要再触发一次查询（用选中项的 value 再请求一次 API）\n\n因为Enter后会改变input的值value，所以会触发useEffect监听\n\n1. Enter → `handleSelect(item)`\n2. `handleSelect` → `setInputValue(item.value)`\n3. `inputValue` 改变 → `useDebounce`\n4. `debouncedValue` 改变 → `useEffect` 重新调用 `fetchSuggestions`\n5. 再发一次请求 ×\n\n**增加一个 ref 标志位来阻断请求**\n\n`ref` 是一个“能在组件重新渲染时一直保持不变的普通变量容器”，\n修改它👉不会触发组件重新渲染。\n\n所以用ref代替state\n\n```js\nconst myRef = useRef(0);\n```\n\n得到的是一个对象：\n\n```js\n{\n  current: 0;\n}\n```\n\n真正的值在：myRef.current\n\n修改值也只改 .current\n\n```js\nmyRef.current = 100; // ✅\n```\n\n而这个操作：\n\n✅ 不会刷新页面\n✅ 不会重新渲染组件\n✅ 不会触发 useEffect\n\n```js\n//监听输入值变化\n    useEffect(() => {\n        setHighlightIndex(-1);\n        const fetchData = async () => {\n            if (debouncedValue && triggerSearch.current) {//添加开关\n                setLoading(true);\n                const results = await fetchSuggestions(debouncedValue);\n                setSuggestions(results);\n                setLoading(false);\n            } else {\n                setSuggestions([]);\n            }\n        }\n\n        fetchData();\n    }, [debouncedValue]);\n\n    const handleChange = async (e: ChangeEvent<HTMLInputElement>) => {\n        const value = e.target.value;\n        setInputValue(value);\n        triggerSearch.current = true;//handle触发fetch\n    }\n    const handleSelect = (item: DataSourceType<T>) => {\n        setInputValue(item.value);\n        setSuggestions([]);\n        // 触发选择回调,把选中的值传给父组件\n        onSelect?.(item);\n        triggerSearch.current = false;//select不触发fetch\n    }\n```\n\n#### 当用户点击 AutoComplete 组件外部区域时，自动关闭下拉菜单\n\n```js\n渲染 AutoComplete\n     ↓\ncomponentRef 绑定到最外层 div\n     ↓\nuseClickOutside 监听 document 点击\n     ↓\n点击发生 →\n     ↓\n判断：是否点在 componentRef 内？\n    ├─ 是 → 什么也不做\n    └─ 否 → 执行 handler\n                   ↓\n             setSuggestions([])\n                   ↓\n             下拉菜单关闭\n\n```\n\n```js\nconst componentRef = useRef < HTMLDivElement > null;\n```\n\ncomponentRef 是一个 引用对象 (RefObject<HTMLDivElement>)。\n\ncomponentRef.current 的类型是 HTMLDivElement | null，初始化时为 null。\n\n```js\nuseClickOutside(componentRef, () => {\n  setSuggestions([]);\n});\n```\n\n当点击时调用组件useClickOutside\n\n```js\nreturn (\n  //绑定在最外层元素上\n  <div className=\"auto-complete-wrapper\" ref={componentRef}>\n    <Input\n      value={inputValue}\n      onChange={handleChange}\n      onKeyDown={handleKeyDown}\n      {...restProps}\n    />\n    {loading && <Icon icon=\"spinner\" spin />}\n    {suggestions.length > 0 && generateDropDown()}\n  </div>\n);\n```\n\n组件useClickOutside定义\n\n```js\nimport { type RefObject, useEffect } from 'react';\n\n//ref：“点击外部”的目标元素的引用，handler：当点击发生在 ref 所指元素外部时执行的回调函数\nconst useClickOutside = (ref: RefObject<HTMLElement | null>, handler: () => void) => {\n    useEffect(() => {\n        //事件处理函数，鼠标点击事件\n        const listener=(event:MouseEvent)=>{\n            //如果ref.current存在且点击事件的目标元素不在ref.current内部\n            if(ref.current && !ref.current.contains(event.target as Node)){\n                handler();\n            }\n        }\n        // 监听全局点击事件\n        document.addEventListener('click', listener);\n        // 组件卸载时移除点击事件监听\n        return () => {\n            document.removeEventListener('click', listener);\n        }\n    }, [ref, handler]);\n}\n\nexport default useClickOutside;\n```\n\n### 添加动画\n\n```js\n<Transition\n                in={suggestions.length > 0 || loading} // 控制动画显示隐藏\n                animation=\"zoom-in-top\"\n                timeout={300}\n                unmountOnExit\n                nodeRef={dropdownRef as unknown as React.Ref<undefined>} // TS 类型转换\n            >\n```\n\n## UpLoad\n\n![image-20251204235226573](assets/image-20251204235226573.png)\n\nupload一个文件的生命周期：\n\n```js\nstart->点击按钮选择文件->beforeUpload(file)->onProgress(event.file)->onChange(file)->onSuccess(response,file)->点击删除按钮->onRemoved(file)\n    |\n onError(erorr,file)\n```\n\nbeforeUpload(file)上传前检查文件大小、类型是否符合\n\nonProgress(event.file)：文件上传进度\n\n另外还需要后端接口action属性\n\n### 前后端通信\n\n| 名称  | 类型     | 特点                                   |\n| ----- | -------- | -------------------------------------- |\n| XHR   | 原生 API | 低层级，回调方式，语法冗长             |\n| Ajax  | 技术概念 | 指异步请求页面更新，不是 API           |\n| Axios | 第三方库 | 基于 XHR + Promise，封装简洁，功能丰富 |\n\nAxios 提供了封装好的异步请求、统一拦截和易用 API，比原生 XHR 和 fetch 更方便、更安全、更易维护。\n\n#### 后端环境\n\n使用在线服务==JSONPlaceholder==、Mocky\n\n使用axois+JSONPlaceholder\n\nGET请求\n\n```js\nconst [title, setTitle] = useState(\"\");\nuseEffect(() => {\n  axios\n    .get(\"https://jsonplaceholder.typicode.com/posts/1\", {\n      headers: {\n        \"X-Requested-With\": \"XMLHttpRequest\",\n      },\n      responseType: \"json\",\n    })\n    .then((res) => {\n      console.log(res.data);\n      setTitle(res.data.title);\n    });\n}, []);\n```\n\n成功在请求头中添加'X-Requested-With': 'XMLHttpRequest'![image-20251205011334479](assets/image-20251205011334479.png)\n\nPOST请求\n\n```js\nconst postData = {\n  title: \"title\",\n  body: \"body\",\n};\n\nuseEffect(() => {\n  axios\n    .post(\"https://jsonplaceholder.typicode.com/posts\", postData)\n    .then((res) => {\n      console.log(res.data);\n      setTitle(res.data.title);\n    });\n}, []);\n```\n\n成功发送PSOT并返回201![image-20251205012300583](assets/image-20251205012300583.png)\n\nRequest Payload 显示 `postData`\n\n![image-20251205012413570](assets/image-20251205012413570.png)\n\n### 上传文件\n\n#### 1、表单上传（Form Submit）\n\n```js\n<div className=\"App\" style={{ marginTop: \"100px\", marginLeft: \"100px\" }}>\n  {/* 格式设置为multipart/form-data */}\n  <form\n    method=\"post\"\n    encType=\"multipart/form-data\"\n    action=\"https://jsonplaceholder.typicode.com/posts\"\n  >\n    <input type=\"file\" name=\"file\"></input>\n    <button type=\"submit\">提交</button>\n  </form>\n</div>\n```\n\n`<form>` 标签的 `method=\"post\"` + `encType=\"multipart/form-data\"` 表示 **以 POST 方式上传文件**。\n\n`<button type=\"submit\">提交</button>` 会触发浏览器默认表单提交，把文件和其他表单字段一起发送到 `action` 指定的 URL。\n\n特点\n\n- ✅ **不需要 JavaScript**，浏览器自动处理文件上传。\n- ❌ **页面会刷新**（默认行为），除非加上 `event.preventDefault()` 来阻止。\n- ❌ **无法在上传过程中显示进度或状态**，除非配合 JavaScript。\n\n#### 2、使用JavaScript上传\n\n```js\nconst handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {\n    // 取用户选择的第一个文件\n    const file = e.target.files?.[0]\n    if (file) {\n      //封装表单数据\n      const formData = new FormData()\n      formData.append('file', file)\n      axios.post('https://jsonplaceholder.typicode.com/posts', formData, {\n        headers: {\n          'Content-Type': 'multipart/form-data'\n        }\n      })\n        .then(res => {\n          console.log(res.data)\n          setTitle(res.data.title)\n        })\n    }\n  }\n```\n\n`const formData = new FormData()`\n\n- `FormData` 是浏览器提供的 **API，用于封装表单数据**，尤其适合上传文件。\n- 可以向里面追加文件、文本等，发送给服务器时会自动封装成 `multipart/form-data`。\n\n### 生命周期\n\n#### upLoadFiles（onProgress+onSuccess+onError）\n\n```js\n    const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n        const files = e.target.files\n        if (!files) {\n            return\n        }\n        upLoadFiles(files)\n        if (fileInput.current) {\n            fileInput.current.value = ''\n        }\n    }\n    const upLoadFiles = (files: FileList) => {\n        //把类数组对象 FileList 转换成真正的 Array<File>\n        Array.from(files).forEach(file => {\n            const formData = new FormData()\n            formData.append(file.name, file)\n            //并发上传\n            axios.post(action, formData, {\n                headers: {\n                    'Content-Type': 'multipart/form-data'\n                },\n                //实时监听文件上传进度，并把当前完成百分比通知给组件外部\n                onUploadProgress: (e) => {\n                    // 每次上传有进度变化就会执行\n                    let percentage = e.total ? Math.round((e.loaded * 100) / e.total) : 0\n                //防止与onSuccess冲突，只在进度不是100%时调用onProgress\n                    if(percentage<100) {\n                        if(onProgress) {\n                            onProgress(percentage, file)\n                        }\n                    }\n                }\n            }).then(res => {\n                console.log(res.data);\n                onSuccess?.(res.data, file)\n            }).catch(err => {\n                console.log(err);\n                onError?.(err, file)\n            })\n        })\n    }\n```\n\n- `onUploadProgress` 是 **Axios 提供的一个配置回调函数**，用于**在文件上传过程中实时获取“网络传输进度”**。\n\n浏览器每上传一部分数据，就会触发一次这个回调。\n\n- `ProgressEvent` 是浏览器内置类型\n\n当文件、网络数据上传 / 下载时：浏览器底层会不断创建 `ProgressEvent`，并把它丢给 `onUploadProgress`\n\n- 100% === 上传完成事件与成功回调**几乎同时触发**\n\n如果 **不加 `<100` 限制**：UI 会收到 2 次完成\n\n所以只把 1~99% 交给进度回调，100% 由 onSuccess 控制\n\n![image-20251205130447316](assets/image-20251205130447316.png)\n\n上传文件过大时会触发错误：\n\n请求体过大，服务器拒绝处理\n\n#### beforeUpload（file）\n\n`beforeUpload` 用来在 **文件真正上传之前** 对文件做 **检查或处理**：\n\n- **返回 `boolean`**：用来决定是否继续上传\n  - `true` → 继续上传\n  - `false` → 阻止上传\n- **返回 `Promise<File>`**：可以异步处理文件，比如压缩、转换格式等，然后再上传处理后的文件\n\n```js\nbeforeUpload?: (file: File) => boolean | Promise<File>\n```\n\n之前的上传文件逻辑用post封装\n\n```js\nconst upLoadFiles = (files: FileList) => {\n        //把类数组对象 FileList 转换成真正的 Array<File>\n        Array.from(files).forEach(file => {\n            if (beforeUpload) {\n                const result = beforeUpload(file)// ← 调用外部传入的函数\n                //异步完成后上传\n                if (result && result instanceof Promise) {\n                    result.then(res => {\n                        post(res)\n                    }).catch(err => {\n                        onError?.(err, file)\n                    })\n                } else if (result) {\n                    post(file)\n                }\n            }\n            //直接上传\n            else {\n                post(file)\n            }\n        })\n    }\n```\n\n#### onChange（file）\n\n`onChange` 是一个 **自定义回调**，用来通知父组件文件的最终状态。\n\n```js\n }).then(res => {\n            console.log(res.data);\n            onSuccess?.(res.data, file)\n            onChange?.(file)//上传成功后通知外部\n        }).catch(err => {\n            console.log(err);\n            onError?.(err, file)\n            onChange?.(file)//上传失败后也通知外部\n        })\n```\n\n#### 测试中没有成功 `alert('文件大小不能超过2MB')`\n\n```js\nconst checkFileSize = (file: File) => {\n    if (file.size > 1024 * 1024 * 2) {\n        console.log('文件大小为:', file.size);\n        alert('文件大小不能超过2MB');\n        return false;\n    } else {\n        console.log('此时return true');\n        return true;\n    }\n\n}\n```\n\n```js\nexport const boolBeforeUpload: Story = {\n    render: Template,\n    args: {\n        beforeUpload: checkFileSize,\n    }\n}\n```\n\n通过consolelog发现beforeUpload没有被传入\n\n```js\nconst Template = (args: any) => {\n    return (\n        <Upload\n            {...args}//没有传入参数\n            action='https://jsonplaceholder.typicode.com/posts'\n            onProgress={(percentage) => {\n                console.log(percentage);\n            }}\n            onSuccess={() => {\n                console.log('上传成功');\n            }}\n            onError={() => {\n                console.log('上传失败');\n            }}\n            onChange={() => {\n                console.log('文件改变');\n            }}\n        />\n    )\n}\n```\n\n问题是：没有在模板参数中传入args，导致只能传入写死的参数，beforeUpload没有被传入也就不会打印alert\n\n### ui显示\n\n#### 文件状态：显示加载进度、上传状态、删除按钮\n\n```js\nexport type UploadFileStatus = 'ready' | 'uploading' | 'success' | 'error'\nexport interface UploadFile {\n    uid: string\n    size: number\n    name: string\n    status?: UploadFileStatus\n    percent?: number\n    //原始文件\n    raw?: File\n    //上传成功后返回的数据\n    response?: any\n    //上传失败后返回的错误信息\n    error?: any\n}\n```\n\n使用state保存状态\n\n```js\nconst [fileList, setFileList] = useState<UploadFile[]>([])\n```\n\n在post中更新fileList\n\n```js\nlet _file: UploadFile = {\n            uid: Date.now().toString(),\n            status: 'ready',\n            size: file.size,\n            name: file.name,\n            percent: 0,\n            raw: file,\n        }\n        setFileList(prev => [...prev, _file])\n```\n\n监听fileList的变化并打印\n\n```js\nuseEffect(() => {\n  console.log(\"fileList 更新了:\", fileList);\n}, [fileList]);\n```\n\n成功打印fileList\n\n![image-20251205165623893](assets/image-20251205165623893.png)\n\n#### 更新进度条\n\n`setState` 可以接受两种参数\n\n在 React（无论是类组件还是函数组件）中，`setState` / `useState` 的 setter 有两种写法：\n\n1. **直接传值**\n\n   ```js\n   setFileList([file1, file2]);\n   ```\n\n   - React 会把这个值直接设置为新的状态。\n   - 问题：如果你连续多次调用，或者依赖旧的 state 计算新值，就可能出现异步问题。\n\n2. **传入函数（函数式更新）**\n\n   ```js\n   setFileList((prev) => [...prev, newFile]);\n   ```\n\n   - React 会把这个函数调用，传入 **最新的 state** 作为参数（这里是 `prev`）。\n   - 函数返回值会被用作新的 state。\n   - 优势：不管 state 更新是同步还是异步，函数总能拿到最新值，避免 race condition（竞争条件）。\n\n在进度条中更新percent和state\n\n```js\nif (percentage < 100) {\n  //找到文件uid，函数式更新\n  setFileList((prev) =>\n    prev.map((item) =>\n      item.uid === _file.uid\n        ? { ...item, percent: percentage, status: \"uploading\" }\n        : item,\n    ),\n  );\n  if (onProgress) {\n    onProgress(percentage, file);\n  }\n}\n```\n\n在success和error中更新state和//上传成功后返回的数据response?: any//上传失败后返回的错误信息error?: any\n\n```js\n}).then(res => {\n          //更新\n            setFileList(prev => prev.map(item => item.uid === _file.uid ? { ...item, status: 'success', response: res.data } : item))\n            onSuccess?.(res.data, file)\n            onChange?.(file)\n        }).catch(err => {\n          //更新\n            setFileList(prev => prev.map(item => item.uid === _file.uid ? { ...item, status: 'error', error: err } : item))\n            onError?.(err, file)\n            onChange?.(file)\n        })\n```\n\n#### 显示上传文件列表\n\n不同的文件类型return不同的ui\n\n![image-20251205181914805](assets/image-20251205181914805.png)\n\n#### defaultFileList+onRemove\n\n上传文件前的文件列表和删除键\n\n在uploadList.tsx中定义一个列表组件\n\n```js\nimport { type UploadFile } from './upload'\nimport { Icon } from '../Icon/icon'\n\ninterface UpLoadListProps {\n    fileList: UploadFile[]\n    onRemove: (file: UploadFile) => void\n}\n\nconst UpLoadlist = ({\n    fileList,\n    onRemove\n}: UpLoadListProps) => {\n    return (\n        <ul className=\"upload-list\">\n            {fileList.map(item => (\n                <li className='upload-list-item' key={item.uid}>\n                    <span className={`file-name file-name-${item.status}`}>\n                        {item.name}\n                        <Icon icon='file-alt' theme='secondary'></Icon>\n                    </span>\n                    <button onClick={() => onRemove(item)}>删除</button>\n                </li>\n            ))}\n        </ul>\n    )\n}\n\nexport default UpLoadlist\n```\n\n在stories中测试不同状态的组件\n\n```js\nconst defaultFileList = [\n  {\n    uid: \"1\",\n    size: 1024 * 1024,\n    name: \"file1.txt\",\n    status: \"success\",\n    percent: 100,\n    raw: new File([\"\"], \"file1.txt\"),\n    response: {\n      id: 1,\n      name: \"file1.txt\",\n    },\n  },\n  {\n    uid: \"2\",\n    size: 1024 * 1024,\n    name: \"file2.txt\",\n    status: \"error\",\n    percent: 50,\n    raw: new File([\"\"], \"file2.txt\"),\n    error: new Error(\"上传失败\"),\n  },\n  {\n    uid: \"3\",\n    size: 1024 * 1024,\n    name: \"file3.txt\",\n    status: \"uploading\",\n    percent: 75,\n    raw: new File([\"\"], \"file3.txt\"),\n  },\n];\n```\n\n![image-20251205183952066](assets/image-20251205183952066.png)\n\n缩小story中的宽度\n\n```js\nconst uploadMeta: Meta<typeof Upload> = {\n    title: 'Upload',\n    component: Upload,\n    // 上传组件的宽度\n    decorators: [\n    (Story) => (\n      <div style={{ width: 400 }}>\n        <Story />\n      </div>\n    ),\n  ],\n    //启用自动文档\n    tags: ['autodocs']\n}\n```\n\n![image-20251205191143003](assets/image-20251205191143003.png)\n\n#### 显示上传进度\n\n封装了Progress组件显示进度条\n\n```js\nimport { type ThemeProps } from '../Icon/icon'\n\nexport interface ProgressProps {\n    percent: number;\n    strokeHeight?: number;\n    showText?: boolean;\n    styles?: React.CSSProperties;\n    theme?: ThemeProps;\n}\n\nconst Progress = ({\n    percent,\n    strokeHeight = 15,\n    showText = true,\n    styles = {},\n    theme = 'primary',\n}: ProgressProps) => {\n    return (\n        <div className=\"progress-bar\" style={styles}>\n            {/* 灰色最外层 */}\n            <div className=\"progress-bar-outer\" style={{ height: `${strokeHeight}px` }}>\n                <div\n                    className={`progress-bar-inner color-${theme}`}\n                    style={{ width: `${percent}%` }}\n                >\n                    {showText && <span className=\"inner-text\">{`${percent}%`}</span>}\n                </div>\n            </div>\n        </div>\n    )\n}\n\nexport default Progress\n```\n\n![image-20251205204600956](assets/image-20251205204600956.png)\n\n### 自定义HTTP post请求\n\n```js\n//自定义HTTP post请求\n    headers?: { [key: string]: string }\n    name?: string\n    data?: { [key: string]: string }\n    withCredentials?: boolean\n```\n\n自定义header、name、post fromData、cookie\n\n`withCredentials` 的作用是控制跨域请求时是否带上 **浏览器的 Cookie、HTTP 认证信息**。\n\n```js\n            name='filename'\n            data={{\n                token: '123456',\n            }}\n            headers={{\n                'X-Powered-By': 'Bearer 123456',\n            }}\n```\n\n请求头中成功写入自定义属性\n\n![image-20251205211124109](assets/image-20251205211124109.png)\n\n![image-20251205211026847](assets/image-20251205211026847.png)\n\n### 自定义input属性\n\n添加multiple、accept实现上传多个文件、筛选文件格式\n\n这些都是input的原生属性，所以只需要实现动态即可\n\n```js\naccept='.png'\n            multiple={true}\n```\n\n只支持png文件上传、可上传多个文件\n\n![image-20251205212325876](assets/image-20251205212325876.png)\n\n### 拖拽上传\n\n首先将Button组件换成div，传入children，可以通过children控制样式（图标+文字）![image-20251207231247037](assets/image-20251207231247037.png)\n\n```js\n<div className=\"upload-component\">\n            <div className=\"upload-input\"\n                style={{ display: 'inline-block' }}\n                onClick={handleClick}>\n                {drag ?\n                    <Dragger onFile={(files)=>{upLoadFiles(files)}}>\n                        {children}\n                    </Dragger> :\n                    children\n                }\n```\n\n在dragger.tsx中\n\n拖拽用const [dragOver, setDragOver] = useState(false)实现，在离开和进来时改变状态\n\n文件使用onDrop绑定事件处理函数，onFile把拖进来的文件交给上传逻辑处理\n\n```js\nimport { useState } from 'react'\nimport classNames from 'classnames'\nimport type { ReactNode, DragEvent } from 'react'\n\ninterface DraggerProps {\n    onFile: (files: FileList) => void;\n    children?: ReactNode\n}\n\nconst Dragger = ({\n    onFile,\n    children,\n}: DraggerProps) => {\n    const [dragOver, setDragOver] = useState(false)\n\n    const classes = classNames('uploader-dragger', {\n        'is-dragover': dragOver\n    })\n\n    const handleDrop = (e: DragEvent<HTMLElement>) => {\n        e.preventDefault()\n        setDragOver(false)\n        onFile(e.dataTransfer.files)\n    }\n    const handleDrag = (e: DragEvent<HTMLElement>, over: boolean) => {\n        e.preventDefault()\n        setDragOver(over)\n    }\n    return (\n        <div\n            className={classes}\n            onDragOver={e => { handleDrag(e, true) }}\n            onDragLeave={e => { handleDrag(e, false) }}\n            onDrop={handleDrop}\n        >\n            {children}\n        </div>\n    )\n}\n\nexport default Dragger;\n```\n\n在测试文件中对children添加样式\n\n```js\nchildren={<div>\n                <Icon icon=\"upload\" size=\"5x\" theme=\"secondary\" />\n                <br />\n                <p>点击或者拖动到此区域进行上传</p>\n            </div>}\n            drag={true}\n```\n\n![image-20251207232027750](assets/image-20251207232027750.png)\n\n#### 抖动问题\n\n使用 `dragEnter / dragLeave` + 计数器\n\n利用**进入次数计数法**来防止误触发：\n\n```js\nconst handleDragEnter = (e: DragEvent<HTMLElement>) => {\n        e.preventDefault()\n        dragCounter.current += 1\n        setDragOver(true)\n    }\n\n    const handleDragLeave = (e: DragEvent<HTMLElement>) => {\n        e.preventDefault()\n        dragCounter.current -= 1\n\n        if (dragCounter.current === 0) {\n            setDragOver(false)\n        }\n    }\n```\n\n# 打包\n\nTS files.tsx-----tsc----->ES6 modules.jsx------->浏览器可执行的 JS\n\n1、创建入口文件\n\n将每个组件得引入集中在各个组件得index.tsx中，再由主页面得index.tsx集中导入\n\nmenu组件特殊，需要有子组件\n\n```js\nimport Menu,{type MenuProps} from \"./menu\";\nimport MenuItem,{type MenuItemProps} from \"./menuItem\";\nimport SubMenu,{type SubMenuProps} from \"./subMenu\";\n\n// 定义复合组件类型\nexport interface IMenuComponent extends React.FC<MenuProps> {\n  Item: React.FC<MenuItemProps>;\n  SubItem: React.FC<SubMenuProps>;\n}\n\nconst TransMenu = Menu as IMenuComponent;\nTransMenu.Item = MenuItem;\nTransMenu.SubItem = SubMenu;\n\nexport default TransMenu;\n```\n\n2、添加tsconfig.build.json配置（tsc）\n\n```js\n{\n  \"compilerOptions\": {\n    \"outDir\": \"dist\",\n    \"module\": \"esnext\",\n    \"target\": \"es5\",\n    \"declaration\": true,\n    \"jsx\": \"react\",\n    \"moduleResolution\":\"Node\",\n    \"allowSyntheticDefaultImports\": true,\n  },\n  \"include\": [\n    \"src\"\n  ],\n  \"exclude\": [\n    \"src/**/*.test.tsx\",\n    \"src/**/*.stories.tsx\",\n    \"src/setupTests.ts\",\n  ]\n}\n```\n\n用 **TypeScript 编译器** 按照指定的配置文件来构建项目\n\n```\n\"build-ts\": \"tsc -p tsconfig.build.json\"\n```\n\n命令：tsc -p tsconfig.build.json\n\n```js\npnpm run build-ts\n```\n\n成功打包在dist中\n\n3、.scss` 文件编译成 `.css\n\n4、 `rimraf`，这是一个在 Node.js 里用来**跨平台删除文件/目录**的工具，相当于 `rm -rf`。在组件库打包里，它通常用于**先清空 dist 目录**，再重新 build，避免旧文件残留。\n\n5、pnpm link\n\n①在组件库目录执行\n\n```js\npnpm link\n```\n\n这一步相当于：\n\n> 把你的组件库注册到 pnpm 的全局空间\n\n------\n\n② 到业务项目执行\n\n```js\npnpm link your-package-name\n```\n\n这样：your-package-name` 就是你组件库 `package.json` 里的 `name\n\n> 业务项目就会通过软链接使用你的**本地组件库源码**\n","readmeFilename":"README.md","_rev":"1-d627ab3aead89af21d27177161c9e093"}