{"id":24919741,"url":"https://github.com/deepfunc/ts-react-component-patterns","last_synced_at":"2025-08-28T05:13:33.301Z","repository":{"id":178500977,"uuid":"133590493","full_name":"deepfunc/ts-react-component-patterns","owner":"deepfunc","description":"With TypeScript 2.8+ ：更好的 React 组件开发模式","archived":false,"fork":false,"pushed_at":"2018-06-12T01:40:08.000Z","size":817,"stargazers_count":24,"open_issues_count":0,"forks_count":1,"subscribers_count":4,"default_branch":"master","last_synced_at":"2025-04-09T18:09:54.932Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/deepfunc.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2018-05-16T00:58:12.000Z","updated_at":"2025-01-10T08:52:39.000Z","dependencies_parsed_at":null,"dependency_job_id":"f462376b-0657-499c-8625-23bb48dc3387","html_url":"https://github.com/deepfunc/ts-react-component-patterns","commit_stats":null,"previous_names":["deepfunc/ts-react-component-patterns"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/deepfunc/ts-react-component-patterns","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/deepfunc%2Fts-react-component-patterns","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/deepfunc%2Fts-react-component-patterns/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/deepfunc%2Fts-react-component-patterns/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/deepfunc%2Fts-react-component-patterns/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/deepfunc","download_url":"https://codeload.github.com/deepfunc/ts-react-component-patterns/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/deepfunc%2Fts-react-component-patterns/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":272443876,"owners_count":24936029,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","status":"online","status_checked_at":"2025-08-28T02:00:10.768Z","response_time":74,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2025-02-02T10:37:19.783Z","updated_at":"2025-08-28T05:13:33.295Z","avatar_url":"https://github.com/deepfunc.png","language":"TypeScript","readme":"## With TypeScript 2.8+ ：更好的 React 组件开发模式\n\n近两年来一直在关注 React 开发，最近也开始全面应用 TypeScript 。国内有很多讲解 React 和 TypeScript 的教程，但如何将 TypeScript 更好地应用到 React 组件开发模式的文章却几乎没有（也可能是我没找到），特别是 TS 的一些新特性，如：条件类型、条件类型中的类型引用等。这些新特性如何应用到 React 组件开发？没办法只能去翻一些国外的文章，结合 TS 的官方文档慢慢摸索... 于是就有了想法把这个过程整理成文档。\n\n本文内容很长，希望你有个舒服的椅子，我们马上开始。\n\n\u003e 所有代码均使用 React 16.3、TypeScript 2.9 + strict mode 编写\n\n\n\n## 开始\n\n本文假设你已经对 React、TypeScript 有一定的了解。我不会讲到例如：webpack 打包、Babel 转码、TypeScript 编译选项这一类的问题，而将一切焦点放在如何将 TS 2.8+ 更好地应用到 React 组件设计模式中。\n\n首先，我们从无状态组件开始。\n\n\n\n\n\n## 无状态组件\n\n无状态组件就是没有 `state` 的，通常我们也叫做纯函数组件。用原生 JS 我们可以这样写一个按钮组件：\n\n```typescript\nimport React from 'react';\n\nconst Button = ({onClick: handleClick, children}) =\u003e (\n  \u003cbutton onClick={handleClick}\u003e{children}\u003c/button\u003e\n);\n```\n\n\n\n如果你把代码直接放到 `.tsx` 文件中，`tsc` 编译器马上会提示错误：有隐含的 any 类型，因为用了严格模式。我们必须明确的定义组件属性，修改一下：\n\n```typescript\nimport React, { MouseEvent, ReactNode } from 'react';\n\ninterface Props { \n onClick(e: MouseEvent\u003cHTMLElement\u003e): void;\n children?: ReactNode;\n};\n\nconst Button = ({ onClick: handleClick, children }: Props) =\u003e (\n  \u003cbutton onClick={handleClick}\u003e{children}\u003c/button\u003e\n);\n```\n\n\n\nOK，错误没有了！好像已经完事了？其实再花点心思可以做的更好。\n\nReact 中有个预定义的类型，`SFC` ：\n\n```typescript\ntype SFC\u003cP = {}\u003e = StatelessComponent\u003cP\u003e;\n```\n\n他是 `StatelessComponent` 的一个别名，而 `StatelessComponent` 声明了纯函数组件的一些预定义示例属性和静态属性，如：`children`、`defaultProps`、`displayName` 等，所以我们不需要自己写所有的东西！\n\n\n\n最后我们的代码是这样的：\n\n![](images/stateless.png)\n\n\n\n\n\n## 有状态的类组件\n\n接着我们来创建一个计数器按钮组件。首先我们定义初始状态：\n\n```typescript\nconst initialState = {count: 0};\n```\n\n\n\n然后，定义一个别名 `State` 并用 TS 推断出类型：\n\n```typescript\ntype State = Readonly\u003ctypeof initialState\u003e;\n```\n\n\u003e 知识点：这样做不用分开维护接口声明和实现代码，比较实用的技巧\n\n\n\n同时应该注意到，我们将所有的状态属性声明为  `readonly` 。然后我们需要明确定义 state 为组件的实例属性：\n\n```typescript\nreadonly state: State = initialState;\n```\n\n为什么要这样做？我们知道在 React 中我们不能直接改变 `State` 的属性值或者 `State` 本身：\n\n```typescript\nthis.state.count = 1; \nthis.state = {count: 1};\n```\n\n如果这样做在运行时将会抛出错误，但在编写代码时却不会。所以我们需要明确的声明 `readonly` ，这样 TS 会让我们知道如果执行了这种操作就会出错了：\n\n![](images/state-readonly.gif)\n\n\n\n下面是完整的代码：\n\n\u003e 这个组件不需要外部传递任何 `Props` ，所以泛型的第一个参数给的是不带任何属性的对象\n\n![](images/stateful.png)\n\n\n\n\n\n## 属性默认值\n\n让我们来扩展一下纯函数按钮组件，加上一个颜色属性：\n\n```typescript\ninterface Props {\n    onClick(e: MouseEvent\u003cHTMLElement\u003e): void;\n    color: string;\n}\n```\n\n如果想要定义属性默认值的话，我们知道可以通过 `Button.defaultProps = {...}` 做到。并且我们需要把这个属性声明为可选属性：（注意属性名后的 `?` ）\n\n```typescript\ninterface Props {\n    onClick(e: MouseEvent\u003cHTMLElement\u003e): void;\n    color?: string;\n}\n```\n\n\n\n那么组件现在看起来是这样的：\n\n```typescript\nconst Button: SFC\u003cProps\u003e = ({onClick: handleClick, color, children}) =\u003e (\n    \u003cbutton style={{color}} onClick={handleClick}\u003e{children}\u003c/button\u003e\n);\n```\n\n一切看起来好像都很简单，但是这里有一个“痛点”。注意我们使用了 TS 的严格模式，`color?: string` 这个可选属性的类型现在是联合类型 -- `string | undefined` 。\n\n这意味着什么？如果你要对这种属性进行一些操作，比如：`substr()` ，TS 编译器会直接报错，因为类型有可能是 `undefined` ，TS 并不知道属性默认值会由 `Component.defaultProps` 来创建。\n\n\n\n碰到这种情况我们一般用两种方式来解决：\n\n- 使用类型断言手动去除，添加 `!` 后缀，像这样：`color!.substr(...)` 。\n- 使用条件判断或者三元操作符让 TS 编译器知道这个属性不是 undefined，比如： `if (color) ...` 。\n\n\n\n以上的方式虽然可以工作但有种多此一举的感觉，毕竟默认值已经有了只是 TS 编译器“不知道”而已。下面来说一种可重用的方案：我们写一个 `withDefaultProps` 函数，利用 TS 2.8 的条件类型映射，可以很简单的完成：\n\n![](images/with-default-props.png)\n\n这里涉及到两个 type 定义，写在 `src/types/global.d.ts` 文件里面：\n\n```typescript\ndeclare type DiffPropertyNames\u003cT extends string | number | symbol, U\u003e =\n    { [P in T]: P extends U ? never: P }[T];\n\ndeclare type Omit\u003cT, K\u003e = Pick\u003cT, DiffPropertyNames\u003ckeyof T, K\u003e\u003e;\n```\n\n看一下 [TS 2.8 的新特性说明](http://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html) 关于 `Conditional Types` 的说明，就知道这两个 `type` 的原理了。\n\n\u003e 注意 TS 2.9 的新变化：`keyof T` 的类型是 `string | number | symbol ` 的结构子类型。\n\n\n\n现在我们可以利用 `withDefaultProps` 函数来写一个有属性默认值的组件了：\n\n![](images/button-with-default-props.png)\n\n现在使用这个组件时默认值属性已经发生作用，是可选的；并且在组件内部使用这些默认值属性不用再手动断言了，这些默认值属性就是必填属性！感觉还不错对吧 :smile:\n\n\u003e `withDefautProps` 函数同样可以应用在 `stateful` 有状态的类组件上。\n\n\n\n\n\n## 渲染回调模式\n\n有一种重用组件逻辑的设计方式是：把组件的  `children` 写成渲染回调函数或者暴露一个 `render` 函数属性出来。我们将用这种思路来做一个折叠面板的场景应用。\n\n首先我们先写一个 `Toggleable` 组件，完整的代码如下：\n\n![](images/render-callback.png)\n\n\n\n下面我们来逐段解释下这段代码，首先先看到组件的属性声明相关部分：\n\n```typescript\ntype Props = Partial\u003c{\n    children: RenderCallback;\n    render: RenderCallback;\n}\u003e;\n\ntype RenderCallback = (args: ToggleableRenderArgs) =\u003e React.ReactNode;\n\ntype ToggleableRenderArgs = {\n    show: boolean;\n    toggle: Toggleable['toggle'];\n}\n```\n\n我们需要同时支持 `children` 或 `render` 函数属性，所以这两个要声明为可选的属性。注意这里用了 `Partial` 映射类型，这样就不需要每个手动 `?` 操作符来声明可选了。\n\n为了保持 ***DRY*** 原则（Don't repeat yourself ），我们还声明了 `RenderCallback` 类型。\n\n最后，我们将这个回调函数的参数声明为一个独立的类型：`ToggleableRenderArgs` 。\n\n注意我们使用了 TS  的**查找类型**（*lookup types* ），这样 `toggle` 的类型将和类中定义的同名方法类型保持一致：\n\n```typescript\nprivate toggle = (event: MouseEvent\u003cHTMLElement\u003e) =\u003e {\n    this.setState(prevState =\u003e ({show: !prevState.show}));\n};\n```\n\n\u003e 同样是为了 DRY ，TS 非常给力！\n\n\n\n接下来是 State 相关的：\n\n```typescript\nconst initialState = {show: false};\ntype State = Readonly\u003ctypeof initialState\u003e;\n```\n\n这个没什么特别的，跟前面的例子一样。\n\n\n\n剩下的部分就是 渲染回调 设计模式了，代码很好理解：\n\n```typescript\nclass Toggleable extends Component\u003cProps, State\u003e {\n\n    // ...\n\n    render() {\n        const {children, render} = this.props;\n        const {show} = this.state;\n        const renderArgs = {show, toggle: this.toggle};\n\n        if (render) {\n            return render(renderArgs);\n        } else if (isFunction(children)) {\n            return children(renderArgs);\n        } else {\n            return null;\n        }\n    }\n\n    // ...\n}\n```\n\n\n\n现在我们可以将 children 作为一个渲染函数传递给 Toggleable 组件：\n\n![](images/render-callback-toggleable-children.png)\n\n或者将渲染函数传递给 render 属性：\n\n![](images/render-callback-toggleable-render.png)\n\n\n\n下面我们来完成折叠面板剩下的工作，先写一个 Panel 组件来重用 Toggleable 的逻辑：\n\n![](images/render-callback-panel.png)\n\n最后写一个 Collapse 组件来完成这个应用：\n\n![](images/render-callback-collapse.png)\n\n\n\n这里我们不谈样式的事情，运行起来看看，跟期待的效果是否一致？\n\n![](images/render-callback-collapse-demo.gif)\n\n\u003e 这种方式对于需要扩展渲染内容时非常有用：Toggleable 组件并不知道也不关心具体的渲染内容，但他控制着显示状态逻辑！\n\n\n\n\n\n## 组件注入模式\n\n为了使组件逻辑更具伸缩性，下面我们来说说组件注入模式。\n\n\n\n那么什么是组件注入模式呢？如果你用过 `React-Router` ，你已经使用过这种模式来定义路由了：\n\n```jsx\n\u003cRoute path=\"/example\" component={Example}/\u003e\n```\n\n\n\n不同于渲染回调模式，我们使用 `component` 属性“注入”一个组件。为了演示这个模式是如何工作的，我们将重构折叠面板这个场景，首先写一个可重用的 PanelItem 组件：\n\n```typescript\nimport { ToggleableComponentProps } from './Toggleable';\n\ntype PanelItemProps = { title: string };\n\nconst PanelItem: SFC\u003cPanelItemProps \u0026 ToggleableComponentProps\u003e = props =\u003e {\n    const {title, children, show, toggle} = props;\n\n    return (\n        \u003cdiv onClick={toggle}\u003e\n            \u003ch1\u003e{title}\u003c/h1\u003e\n            {show ? children : null}\n        \u003c/div\u003e\n    );\n};\n```\n\n\n\n然后重构 Toggleable 组件：加入新的 `component` 属性。对比先头的代码，我们需要做出如下变化：\n\n- `children` 属性类型更改为 function 或者 ReactNode（当使用 `component` 属性时）\n- `component` 属性将传递一个组件注入进去，这个注入组件的属性定义上需要有 `ToggleableComponentProps` （其实是原来的 `ToggleableRenderArgs` ，还记得吗？）\n- 还需要定义一个 ` props` 属性，这个属性将用来传递注入组件需要的属性值。我们会设置 `props` 可以拥有任意的属性，因为我们并不知道注入组件会有哪些属性，当然这样我们会丢失 TS 的严格类型检查...\n\n```typescript\nconst defaultInjectedProps = {props: {} as { [propName: string]: any }};\ntype DefaultInjectedProps = typeof defaultInjectedProps;\ntype Props = Partial\u003c{\n    children: RenderCallback | ReactNode;\n    render: RenderCallback;\n    component: ComponentType\u003cToggleableComponentProps\u003cany\u003e\u003e\n} \u0026 DefaultInjectedProps\u003e;\n```\n\n\n\n下一步我们把原来的 `ToggleableRenderArgs`  修改为 `ToggleableComponentProps` ，允许将注入组件需要的属性通过 `\u003cToggleable props={...}/\u003e ` 这样来传递：\n\n```typescript\ntype ToggleableComponentProps\u003cP extends object = object\u003e = {\n    show: boolean;\n    toggle: Toggleable['toggle'];\n} \u0026 P;\n```\n\n\n\n现在我们还需要重构一下 `render` 方法：\n\n```typescript\nrender() {\n    const {component: InjectedComponent, children, render, props} = this.props;\n    const {show} = this.state;\n    const renderProps = {show, toggle: this.toggle};\n\n    if (InjectedComponent) {\n        return (\n            \u003cInjectedComponent {...props} {...renderProps}\u003e\n                {children}\n            \u003c/InjectedComponent\u003e\n        );\n    }\n\n    if (render) {\n        return render(renderProps);\n    } else if (isFunction(children)) {\n        return children(renderProps);\n    } else {\n        return null;\n    }\n}\n```\n\n\n\n我们已经完成了整个 Toggleable 组件的修改，下面是完整的代码：\n\n![](images/inject-component-toggleable.png)\n\n\n\n最后我们写一个 `PanelViaInjection` 组件来应用组件注入模式：\n\n```typescript\nimport React, { SFC } from 'react';\nimport { Toggleable } from './Toggleable';\nimport { PanelItemProps, PanelItem } from './PanelItem';\n\nconst PanelViaInjection: SFC\u003cPanelItemProps\u003e = ({title, children}) =\u003e (\n    \u003cToggleable component={PanelItem} props={{title}}\u003e\n        {children}\n    \u003c/Toggleable\u003e\n);\n```\n\n\u003e 注意：`props` 属性没有类型安全检查，因为他被定义为了包含任意属性的可索引类型：\n\u003e `{ [propName: string]: any }`\n\n\n\n现在我们可以利用这种方式来重现折叠面板场景了：\n\n```typescript\nclass Collapse extends Component {\n\n    render() {\n        return (\n            \u003cdiv\u003e\n                \u003cPanelViaInjection title=\"标题一\"\u003e\u003cp\u003e内容1\u003c/p\u003e\u003c/PanelViaInjection\u003e\n                \u003cPanelViaInjection title=\"标题二\"\u003e\u003cp\u003e内容2\u003c/p\u003e\u003c/PanelViaInjection\u003e\n                \u003cPanelViaInjection title=\"标题三\"\u003e\u003cp\u003e内容3\u003c/p\u003e\u003c/PanelViaInjection\u003e\n            \u003c/div\u003e\n        );\n    }\n}\n```\n\n\n\n\n\n## 泛型组件\n\n在组件注入模式的例子中，`props` 属性丢失了类型安全检查，我们如何去修复这个问题呢？估计你已经猜出来了，我们可以把 Toggleable 组件重构为泛型组件！\n\n\n\n下来我们开始重构 Toggleable 组件。首先我们需要让 `props` 支持泛型：\n\n```typescript\ntype DefaultInjectedProps\u003cP extends object = object\u003e = { props: P };\nconst defaultInjectedProps: DefaultInjectedProps = {props: {}};\n                          \ntype Props\u003cP extends object = object\u003e = Partial\u003c{\n    children: RenderCallback | ReactNode;\n    render: RenderCallback;\n    component: ComponentType\u003cToggleableComponentProps\u003cP\u003e\u003e\n} \u0026 DefaultInjectedProps\u003cP\u003e\u003e;\n```\n\n\n\n然后让 Toggleable 的 class 也支持泛型：\n\n```typescript\nclass Toggleable\u003cT extends object = object\u003e extends Component\u003cProps\u003cT\u003e, State\u003e {}\n```\n\n看起来好像已经搞定了！如果你是用的 TS 2.9，可以直接这样用：\n\n```typescript\nconst PanelViaInjection: SFC\u003cPanelItemProps\u003e = ({title, children}) =\u003e (\n     \u003cToggleable\u003cPanelItemProps\u003e component={PanelItem} props={{title}}\u003e\n         {children}\n     \u003c/Toggleable\u003e\n);\n```\n\n\n\n但是如果 \u003c= TS 2.8 ...  JSX 里面不能直接应用泛型参数  :worried:  那么我们还有一步工作要做，加入一个静态方法 `ofType` ，用来进行构造函数的类型转换：\n\n```typescript\nstatic ofType\u003cT extends object\u003e() {\n    return Toggleable as Constructor\u003cToggleable\u003cT\u003e\u003e;\n}\n```\n\n这里用到一个 type：`Constructor`，依然定义在 `src/types/global.d.ts` 里面：\n\n```typescript\ndeclare type Constructor\u003cT = {}\u003e = { new(...args: any[]): T };\n```\n\n\n\n好了，我们完成了所有的工作，下面是 Toggleable 重构后的完整代码：\n\n\n\n现在我们来看看怎么使用这个泛型组件，重构下原来的 PanelViaInjection 组件：\n\n```typescript\nimport React, { SFC } from 'react';\nimport { Toggleable } from './Toggleable';\nimport { PanelItemProps, PanelItem } from './PanelItem';\n\nconst ToggleableOfPanelItem = Toggleable.ofType\u003cPanelItemProps\u003e();\n\nconst PanelViaInjection: SFC\u003cPanelItemProps\u003e = ({title, children}) =\u003e (\n    \u003cToggleableOfPanelItem component={PanelItem} props={{title}}\u003e\n        {children}\n    \u003c/ToggleableOfPanelItem\u003e\n);\n```\n\n所有的功能都能像原来的代码一样工作，并且现在 `props` 属性也支持 TS 类型检查了，很棒有木有！ :smiley:\n\n![](images/generic-toggleable-demo.gif)\n\n\n\n\n\n## 高阶组件\n\n最后我们来看下 HOC 。前面我们已经实现了 Toggleable 的渲染回调模式，那么很自然的我们可以衍生出一个 HOC 组件。\n\n\u003e 如果对 HOC 不熟悉的话，可以先看下 React 官方文档对于 [HOC](https://reactjs.org/docs/higher-order-components.html) 的说明。\n\n\n\n先来看看定义 HOC 我们需要做哪些工作：\n\n- `displayName` （方便在 devtools 里面进行调试）\n- `WrappedComponent ` （可以访问原始的组件 -- 有利于调试）\n- 引入 [hoist-non-react-statics](https://github.com/mridgway/hoist-non-react-statics) 包，将原始组件的静态方法全部“复制”到 HOC 组件上\n\n\n\n下面直接上代码 -- `withToggleable` 高阶组件：\n\n![](images/hoc-toggleable.png)\n\n\n\n现在我们来用 HOC 重写一个 Panel ：\n\n```typescript\nimport { PanelItem } from './PanelItem';\nimport withToggleable from './withToggleable';\n\nconst PanelViaHOC = withToggleable(PanelItem);\n```\n\n\n\n然后，又可以实现折叠面板了 :smile: \n\n```typescript\nclass Collapse extends Component {\n\n    render() {\n        return (\n            \u003cdiv\u003e\n                \u003cPanelViaHOC title=\"标题一\"\u003e\u003cp\u003e内容1\u003c/p\u003e\u003c/PanelViaHOC\u003e\n                \u003cPanelViaHOC title=\"标题二\"\u003e\u003cp\u003e内容2\u003c/p\u003e\u003c/PanelViaHOC\u003e\n            \u003c/div\u003e\n        );\n    }\n}\n```\n\n\n\n## 尾声\n\n感谢能坚持看完的朋友，你们真的很棒！\n\n如果觉得还不错请帮忙给个 :star:\n\n\n\n最后，感谢 Anders Hejlsberg 和所有的 TS 贡献者 :thumbsup:\n\n","funding_links":[],"categories":[],"sub_categories":[],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdeepfunc%2Fts-react-component-patterns","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fdeepfunc%2Fts-react-component-patterns","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdeepfunc%2Fts-react-component-patterns/lists"}