TypeScript-React-Starter 详解:从 create-react-app 脚手架到 Redux 状态管理的 TypeScript + React 完整实战

原创2026-10-13 17:07:021,397 阅读
文章标签:示例工程

TypeScript-React-Starter 详解:从 create-react-app 脚手架到 Redux 状态管理的 TypeScript + React 完整实战

本文以 README.md 这份 Quick Start 指南为主体,系统讲解如何在 React 项目中接入 TypeScript,并配套 TSLint 代码检查、Jest + Enzyme 组件测试与 Redux 状态管理。读完后你将能够从零搭建一个「类型安全、可测试、带全局状态」的 React 应用,并理解本仓库中 src/index.tsx、src/containers/Hello.tsx、src/reducers/index.tsx 等核心文件的真实实现。

需要先行说明的是:README 开篇就指出,该仓库所代表的"独立 TypeScript 脚手架"路线已经不再必要——Create React App、Next.js、Razzle 等工具如今都内置了 TypeScript 支持。因此 README 首先给出了现代化的起步方式:

# Creates an app called my-app
npx create-react-app my-app --typescript

cd my-app

# Adds the type definitions
npm install --save typescript @types/node @types/react @types/react-dom @types/jest

echo "Good to go :tada:"

这条命令用 --typescript 模板直接生成带类型支持的项目,再补齐 @types/node、@types/react、@types/react-dom、@types/jest 四个声明包即可开工。本仓库的价值在于:它展示了一个更早期的完整方案——用 react-scripts-ts 把 TypeScript「混入」标准 create-react-app 流水线,并额外演示了生产构建、测试、Redux 状态管理这些官方脚手架 README 一笔带过的部分。下文将按原文档的骨架逐节展开,并对照仓库源码逐处印证。

一、搭建基础:安装脚手架并创建 TypeScript 项目

1. 安装 create-react-app

原文假设你已经在用 Node.js 与 npm(如需先熟悉 React 基础可参考 React 官方 Hello World 文档)。create-react-app 是一个命令行脚手架工具,它会为 React 项目预设一套「官方推荐」的工具链与默认配置,这正是本教程选择它的原因。安装命令:

npm install -g create-react-app

2. 创建新项目

创建一个名为 my-app 的新项目:

create-react-app my-app --scripts-version=react-scripts-ts

这里的关键是 --scripts-version=react-scripts-ts:react-scripts-ts 是对标准 create-react-app 项目流水线的一组改造,作用就是把 TypeScript 混入构建流程。本仓库 package.json 中的 "react-scripts-ts": "1.0.6"(devDependencies)正是这一方案的落点,四个脚本全部委托给它:

"scripts": {
  "start": "react-scripts-ts start",
  "build": "react-scripts-ts build",
  "test": "react-scripts-ts test --env=jsdom",
  "eject": "react-scripts-ts eject"
}

注意 test 脚本显式指定了 --env=jsdom,这为后文的组件测试提供了浏览器 DOM 模拟环境。

3. 项目结构解析

按照 README,脚手架生成后的项目布局如下:

my-app/
├─ .gitignore
├─ images.d.ts
├─ node_modules/
├─ public/
├─ src/
│  └─ ...
├─ package.json
├─ tsconfig.json
├─ tsconfig.prod.json
├─ tsconfig.test.json
└─ tslint.json

各文件的职责:

  • tsconfig.json:TypeScript 的项目级编译选项。原文档还提到会附带 tsconfig.prod.json 与 tsconfig.test.json,用于分别微调生产构建与测试构建。从当前仓库看,只有 tsconfig.json 这一个文件,说明示例作者最终没有为生产/测试拆分配置。
  • tslint.json:代码检查器 TSLint 的规则配置。
  • package.json:依赖声明,以及测试、预览、部署等命令的快捷脚本。
  • public:静态资源目录,存放即将部署的 HTML 页面(即 public/index.html)与图片;除 index.html 外可随意删除其他文件。index.html 中有 <div id="root"></div>,正是 React 的挂载点。
  • src:TypeScript 与 CSS 源码目录。index.tsx 是入口文件,必须存在。
  • images.d.ts:告诉 TypeScript 某些图片文件类型可以被 import——create-react-app 的 Webpack 流水线支持这种用法,例如 src/App.tsx 中的 const logo = require('./logo.svg');。

tsconfig.json 的实际配置值得逐条对照理解:

{
  "compilerOptions": {
    "outDir": "build/dist",
    "module": "commonjs",
    "target": "es5",
    "lib": ["es6", "dom"],
    "sourceMap": true,
    "allowJs": true,
    "jsx": "react",
    "moduleResolution": "node",
    "rootDir": "src",
    "noImplicitReturns": true,
    "noImplicitThis": true,
    "noImplicitAny": true,
    "strictNullChecks": true
  },
  "exclude": ["node_modules", "build", "scripts", "acceptance-tests", "webpack", "jest", "src/setupTests.ts"],
  "types": ["typePatches"]
}

几个关键项:"jsx": "react" 开启经典 JSX 转换(对应 React.createElement 调用方式);"strictNullChecks": true 与 "noImplicitAny": true 是后文 document.getElementById('root') as HTMLElement 类型断言的根源——开启严格空检查后,getElementById 的返回类型是 HTMLElement | null,必须显式处理 null;"exclude" 中排除了 src/setupTests.ts,因为测试装配文件不参与应用编译。

二、初始化版本控制

README 强调:测试工具 Jest 期望项目里存在某种版本控制系统(Git 或 Mercurial),否则可能无法正常运行。因此新建项目后需要:

git init
git add .
git commit -m "Initial commit."

注意:如果你是从已有仓库克隆出来的项目(比如本仓库),则完全不需要执行以上步骤。

三、调整 TSLint 默认配置

原文档指出,react-scripts-ts 预置的 TSLint 配置"有些过于激进"(overzealous),需要手动放宽。具体修改是把 extends 清空、并将严重级别降为 warning:

 {
-  "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"],
+  "extends": [],
+  "defaultSeverity": "warning",
   "linterOptions": {
     "exclude": [
       "config/**/*.js",
       "node_modules/**/*.ts"
     ]
   }
 }

当前仓库的 tslint.json 恰好就是调整完成后的最终形态:

{
  "extends": [],
  "defaultSeverity": "warning",
  "linterOptions": {
    "exclude": [
      "config/**/*.js",
      "node_modules/**/*.ts"
    ]
  }
}

也就是说,这个 Starter 最终选择了「只排除构建产物与 node_modules、不继承任何推荐规则集、违规仅警告不报错」的宽松策略。TSLint 规则体系的深入配置超出了本 Starter 的范围,但你可以在此基础上按团队偏好自由实验。

四、运行、测试与生产构建

1. 开发模式运行

npm run start

这会执行 package.json 中的 start 脚本,启动一个开发服务器。保存文件即自动热重载页面,服务通常运行在 http://localhost:3000 并自动打开浏览器。这条命令的价值在于「缩短迭代回路」——快速预览每一次改动。

2. 运行测试

npm run test

该命令启动 Jest,对后缀为 .test.ts 或 .spec.ts 的所有文件运行测试。与 start 一样,Jest 具备监听能力,检测到文件变化会自动重跑。一个实用的工作方式是让 npm run start 与 npm run test 并排运行,一边预览一边跑测试。

3. 生产构建

npm run start 产出的是开发构建,并没有做优化。我们希望最终交付给用户的代码尽可能快且小,而压缩(minification)这类优化往往耗时更长——这类构建即「production build」:

npm run build

产物中的优化后 JS 与 CSS 分别位于 ./build/static/js 与 ./build/static/css。日常开发很少需要跑生产构建,但在测量应用最终体积等场景下很有用。

五、编写组件:Hello 组件的 TypeScript 写法

这是本教程的核心实战环节。我们先约定组件行为:

  • 写 <Hello name="Daniel" enthusiasmLevel={3} /> 时,组件应渲染出类似 <div>Hello Daniel!!!</div> 的内容;
  • 若不指定 enthusiasmLevel,默认显示 1 个感叹号;
  • 若 enthusiasmLevel 为 0 或负数,组件应抛出错误。

1. 无状态函数组件(SFC)

在 src/components/Hello.tsx 中编写(与当前仓库 src/components/Hello.tsx 的初始形态一致):

// src/components/Hello.tsx

import * as React from 'react';

export interface Props {
  name: string;
  enthusiasmLevel?: number;
}

function Hello({ name, enthusiasmLevel = 1 }: Props) {
  if (enthusiasmLevel <= 0) {
    throw new Error('You could be a little more enthusiastic. :D');
  }

  return (
    <div className="hello">
      <div className="greeting">
        Hello {name + getExclamationMarks(enthusiasmLevel)}
      </div>
    </div>
  );
}

export default Hello;

// helpers

function getExclamationMarks(numChars: number) {
  return Array(numChars + 1).join('!');
}

这里有几个 TypeScript 要点:

  1. Props 接口定义了组件接受的属性:name 是必填的 string;enthusiasmLevel 是可选的 number——名称后的 ? 即"可选"标记。
  2. SFC 写法:Hello 是一个接收 Props 对象、并对属性做「解构」的函数。解构时的 enthusiasmLevel = 1 是默认值,调用方不传就是 1。
  3. 写函数组件是 React 允许的主要组件写法之一(另一种是类组件)。如果写成类:
class Hello extends React.Component<Props, object> {
  render() {
    const { name, enthusiasmLevel = 1 } = this.props;

    if (enthusiasmLevel <= 0) {
      throw new Error('You could be a little more enthusiastic. :D');
    }

    return (
      <div className="hello">
        <div className="greeting">
          Hello {name + getExclamationMarks(enthusiasmLevel)}
        </div>
      </div>
    );
  }
}

类组件在组件实例需要持有状态或使用生命周期钩子时才有价值。本例并不需要状态,所以第二个泛型参数写成了 object,SFC 更合适——但知道如何写类组件同样重要。

注意类版本继承的是 React.Component<Props, object>。这里 TypeScript 特有的部分就是传给 React.Component 的两个类型实参:Props 是 this.props 的类型,object 是 this.state 的类型。组件状态稍后还会回来讲。

2. 在入口文件中渲染 Hello

现在打开 index.tsx,把渲染 <App /> 换成渲染 <Hello ... />。先在文件顶部导入:

import Hello from './components/Hello';

再修改 render 调用:

ReactDOM.render(
  <Hello name="TypeScript" enthusiasmLevel={10} />,
  document.getElementById('root') as HTMLElement
);

类型断言(type assertion)

上面这行里有个值得一提的写法:document.getElementById('root') as HTMLElement。这个语法叫类型断言(type assertion),有时也叫类型转换(cast)——它是你比类型检查器更了解实际类型时,告诉 TypeScript「这个表达式的真实类型是什么」的手段。

之所以必须断言,是因为 getElementById 的返回类型是 HTMLElement | null:找不到对应 id 的元素时它返回 null。我们假设它会成功找到,因此要用 as 语法"说服" TypeScript。TypeScript 还有一种尾部"感叹号"语法(!),它直接从表达式中剔除 null 和 undefined,所以也可以写成 document.getElementById('root')!——只是这里作者选择了更明确的 as HTMLElement 写法。

这个细节在当前仓库 src/index.tsx 中真实存在(document.getElementById('root') as HTMLElement),并且只有 tsconfig.json 中的 "strictNullChecks": true 才会让这行代码成为必要——类型系统与编译器配置在这里形成了一条完整的因果链。

有状态组件(Stateful Components)

前面说过 Hello 组件不需要状态。但如果我们希望组件能随用户交互而动态更新呢?这时状态就重要了。深入探讨 React 状态最佳实践超出本 Starter 的范围,但原文给出一个「有状态版」Hello 作为快速示范:渲染两个 <button>,用于增减 Hello 显示的感叹号数量。做法分三步:

  1. 为状态(即 this.state)定义类型;
  2. 在构造函数中基于传入的 props 初始化 this.state;
  3. 为两个按钮创建事件处理函数(onIncrement 与 onDecrement)。
// src/components/StatefulHello.tsx

import * as React from "react";

export interface Props {
  name: string;
  enthusiasmLevel?: number;
}

interface State {
  currentEnthusiasm: number;
}

class Hello extends React.Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { currentEnthusiasm: props.enthusiasmLevel || 1 };
  }

  onIncrement = () => this.updateEnthusiasm(this.state.currentEnthusiasm + 1);
  onDecrement = () => this.updateEnthusiasm(this.state.currentEnthusiasm - 1);

  render() {
    const { name } = this.props;

    if (this.state.currentEnthusiasm <= 0) {
      throw new Error('You could be a little more enthusiastic. :D');
    }

    return (
      <div className="hello">
        <div className="greeting">
          Hello {name + getExclamationMarks(this.state.currentEnthusiasm)}
        </div>
        <button onClick={this.onDecrement}>-</button>
        <button onClick={this.onIncrement}>+</button>
      </div>
    );
  }

  updateEnthusiasm(currentEnthusiasm: number) {
    this.setState({ currentEnthusiasm });
  }
}

export default Hello;

function getExclamationMarks(numChars: number) {
  return Array(numChars + 1).join('!');
}

注意三点:

  1. 与 Props 一样,状态也需要一个新类型:State。
  2. React 中更新状态要用 this.setState——除了构造函数里,不要直接赋值。setState 只接收需要更新的属性,组件会按需重新渲染。
  3. 事件处理函数使用了类属性初始化器 + 箭头函数(如 onIncrement = () => ...):
    • 声明为箭头函数可避免 this 被孤儿化调用的问题;
    • 把它们设为实例属性意味着只在构造时创建一次——常见错误是在 render 方法里初始化,那样每次 render 调用都会重新分配闭包。

原文说明:本 Starter 后面不会再使用这个有状态组件。有状态组件适合做「只专注展示内容」(而非处理核心应用状态)的组件;在某些场景下也可以用单一中心组件向下传递 setState 回调函数来管理整个应用状态,但对更大的应用而言,专用的状态管理器(如 Redux)往往更合适——这正是下一部分的主题。

六、添加样式

create-react-app 体系下给组件加样式很简单:在 src/components/Hello.css 创建 CSS 文件(当前仓库的 src/components/Hello.css 即此文件):

.hello {
  text-align: center;
  margin: 20px;
  font-size: 48px;
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}

.hello button {
  margin-left: 25px;
  margin-right: 25px;
  font-size: 40px;
  min-width: 50px;
}

create-react-app 底层的 Webpack 与各 loader 允许你直接 import 所需样式表,构建时任何被导入的 .css 文件都会被拼接进输出文件。因此在 src/components/Hello.tsx 顶部加一行:

import './Hello.css';

仓库中的 src/components/Hello.tsx 第二行正是 import './Hello.css';,与文档完全吻合。

七、使用 Jest 与 Enzyme 编写测试

先重申组件的三条行为假设:

  • 写 <Hello name="Daniel" enthusiasmLevel={3} /> 时,应渲染出类似 <div>Hello Daniel!!!</div> 的内容;
  • 不指定 enthusiasmLevel 时,默认显示 1 个感叹号;
  • enthusiasmLevel 为 0 或负数时应抛出错误。

1. 安装 Enzyme

npm install -D enzyme @types/enzyme enzyme-adapter-react-16 @types/enzyme-adapter-react-16 react-test-renderer

这里要区分 enzyme 与 @types/enzyme:前者是实际被执行的 JavaScript 代码包,后者是声明文件包(.d.ts),让 TypeScript 理解如何使用 Enzyme。@types 包的机制可参见 TypeScript 官方的 Declaration Files 文档。

另外 enzyme-adapter-react-16 与 react-test-renderer 是 enzyme 明确要求安装的依赖。

Enzyme 的定位:应用默认自带 jsdom 库来模拟 DOM、无需浏览器即可测试运行时行为;Enzyme 类似,但构建在 jsdom 之上,提供了更便捷的组件查询能力。

2. 配置 Enzyme 适配器

写第一个测试前,必须让 Enzyme 使用 React 16 适配器。创建 src/setupTests.ts——该文件在运行测试时会被自动加载:

import * as enzyme from 'enzyme';
import * as Adapter from 'enzyme-adapter-react-16';

enzyme.configure({ adapter: new Adapter() });

(tsconfig.json 的 exclude 列表中包含 src/setupTests.ts,正是因为它只在测试环境被 Jest 加载,不参与应用编译。)

3. 编写测试文件

在 Hello.tsx 旁边创建 src/components/Hello.test.tsx。当前仓库的 src/components/Hello.test.tsx 就是原文档所述测试的落点,五条用例逐条覆盖了三条行为假设:

// src/components/Hello.test.tsx

import * as React from 'react';
import * as enzyme from 'enzyme';
import Hello from './Hello';

it('renders the correct text when no enthusiasm level is given', () => {
  const hello = enzyme.shallow(<Hello name="Daniel" />);
  expect(hello.find('.greeting').text()).toEqual('Hello Daniel!');
});

it('renders the correct text with an explicit enthusiasm of 1', () => {
  const hello = enzyme.shallow(<Hello name="Daniel" enthusiasmLevel={1}/>);
  expect(hello.find('.greeting').text()).toEqual('Hello Daniel!');
});

it('renders the correct text with an explicit enthusiasm level of 5', () => {
  const hello = enzyme.shallow(<Hello name="Daniel" enthusiasmLevel={5} />);
  expect(hello.find('.greeting').text()).toEqual('Hello Daniel!!!!!');
});

it('throws when the enthusiasm level is 0', () => {
  expect(() => {
    enzyme.shallow(<Hello name="Daniel" enthusiasmLevel={0} />);
  }).toThrow();
});

it('throws when the enthusiasm level is negative', () => {
  expect(() => {
    enzyme.shallow(<Hello name="Daniel" enthusiasmLevel={-1} />);
  }).toThrow();
});

测试很基础,但足以理解套路:enzyme.shallow 做浅渲染,find('.greeting') 定位节点,text() 取出文本后与期望值 toEqual 断言;抛错场景则用 expect(...).toThrow() 包裹渲染调用。

八、添加状态管理:Redux 接入全链路

如果 React 只用来"取一次数据再展示",到这里项目就基本完成了。但若是交互更复杂的 App,就需要状态管理。

1. 状态管理的一般性讨论

React 本身是构建可组合视图的有用库,但不规定在应用内同步数据的任何特定方式:就组件而言,数据只通过你在各元素上写的 props 向下流动;其中一些 props 可能是以某种方式更新状态的函数,但"具体怎么更新"是开放问题。因此社区涌现了 Redux、MobX 等库:

  • Redux 依赖中心化、不可变的数据存储来同步数据,对数据的更新会触发应用重新渲染;状态通过发送显式的 action 消息、由称为 reducer 的函数处理,以不可变方式更新。由于这种显式性,往往更容易推断某个 action 对程序状态的影响。
  • MobX 依赖函数式响应式模式:状态被包装为 observable 并作为 props 传递,只需将状态标记为 observable 即可保证各观察者的完全同步。另一个加分项是该库本身就用 TypeScript 编写。

两者各有取舍。总体来看 Redux 使用更广泛,所以本教程聚焦 Redux,但鼓励你也去探索 MobX。下文学习曲线可能较陡,原文强烈建议先通过 Redux 官方文档熟悉基本概念。

2. 为 action 铺路

给应用加 Redux 之前,先要有能触发状态变化的"动作源"——可以是定时器,也可以是按钮等 UI 元素。本例为 Hello 组件增加两个按钮,控制热情值。

3. 安装 Redux

npm install -S redux react-redux @types/react-redux

注意这里不需要安装 @types/redux,因为 Redux 官方包自带声明文件(.d.ts)。当前 package.json 中对应的依赖版本为 redux: ^3.6.0、react-redux: ^5.0.2、@types/react-redux: ^4.4.36。

4. 定义应用状态形状

创建 src/types/index.tsx,存放整个程序可能用到的类型定义(当前仓库 src/types/index.tsx 内容一致):

// src/types/index.tsx

export interface StoreState {
    languageName: string;
    enthusiasmLevel: number;
}

意图是:languageName 表示本应用使用的编程语言(TypeScript 或 JavaScript),enthusiasmLevel 则会被按钮改变。注意 store 状态与组件 props 刻意设计得不同(一个叫 languageName、一个叫 name),等写到 container 时会明白这是为了演示 mapStateToProps 的"整形"职责。

5. 添加 actions

先在 src/constants/index.tsx 中创建一组消息类型(与仓库 src/constants/index.tsx 一致):

// src/constants/index.tsx

export const INCREMENT_ENTHUSIASM = 'INCREMENT_ENTHUSIASM';
export type INCREMENT_ENTHUSIASM = typeof INCREMENT_ENTHUSIASM;


export const DECREMENT_ENTHUSIASM = 'DECREMENT_ENTHUSIASM';
export type DECREMENT_ENTHUSIASM = typeof DECREMENT_ENTHUSIASM;

这个 const/type 组合模式让我们能以易于访问且可重构的方式使用 TypeScript 字符串字面量类型——重命名常量时,类型引用会随之更新,避免了魔法字符串散落各处。

然后在 src/actions/index.tsx 中创建 action 类型与生成函数(与仓库 src/actions/index.tsx 一致):

import * as constants from '../constants';

export interface IncrementEnthusiasm {
    type: constants.INCREMENT_ENTHUSIASM;
}

export interface DecrementEnthusiasm {
    type: constants.DECREMENT_ENTHUSIASM;
}

export type EnthusiasmAction = IncrementEnthusiasm | DecrementEnthusiasm;

export function incrementEnthusiasm(): IncrementEnthusiasm {
    return {
        type: constants.INCREMENT_ENTHUSIASM
    };
}

export function decrementEnthusiasm(): DecrementEnthusiasm {
    return {
        type: constants.DECREMENT_ENTHUSIASM
    };
}

这里定义了描述增量/减量 action 应长什么样的两个类型;用联合类型 EnthusiasmAction 表示"可能是增也可能是减";最后两个函数负责实际"制造" action,省去手写冗长对象字面量。显然这里有样板代码,熟练后可以看看 redux-actions 这类库。

6. 编写 reducer

Reducer 就是"通过创建应用状态的修改副本来产生变化、且没有副作用"的函数——即纯函数。本例的 reducer 放在 src/reducers/index.tsx(仓库文件 src/reducers/index.tsx 与原文档一致,仅多了一个 default 分支):

// src/reducers/index.tsx

import { EnthusiasmAction } from '../actions';
import { StoreState } from '../types/index';
import { INCREMENT_ENTHUSIASM, DECREMENT_ENTHUSIASM } from '../constants/index';

export function enthusiasm(state: StoreState, action: EnthusiasmAction): StoreState {
  switch (action.type) {
    case INCREMENT_ENTHUSIASM:
      return { ...state, enthusiasmLevel: state.enthusiasmLevel + 1 };
    case DECREMENT_ENTHUSIASM:
      return { ...state, enthusiasmLevel: Math.max(1, state.enthusiasmLevel - 1) };
    default:
      return state;
  }
}

职责明确:增量把热情值加 1,减量减 1,且永不跌破 1(Math.max(1, ...) 兜底)。

两个关键细节:

  1. 使用了对象展开(...state)创建状态的浅拷贝,同时替换 enthusiasmLevel;
  2. enthusiasmLevel 属性必须放在最后,否则会被旧状态里的同名属性覆盖。

由于 reducer 是纯函数,可以放心地传任意数据进去测试:对每个输入,只需断言它产出的新状态。建议为此写几个测试,用 Jest 的 toEqual 方法即可。

7. 制作 container(连接件)

写 Redux 时,除了组件(component)还会写容器(container):组件往往与数据无关、停留在展示层;容器则包裹组件,为它喂入显示与修改状态所需的数据。这个"展示组件/容器组件"的经典划分源自 Dan Abramov 的 Presentational and Container Components 一文。

第一步:让 Hello 组件接受回调。 给 Props 加两个可选回调属性 onIncrement 与 onDecrement(这正是当前 src/components/Hello.tsx 的最终形态):

export interface Props {
  name: string;
  enthusiasmLevel?: number;
  onIncrement?: () => void;
  onDecrement?: () => void;
}

然后把回调绑定到两个新按钮上:

function Hello({ name, enthusiasmLevel = 1, onIncrement, onDecrement }: Props) {
  if (enthusiasmLevel <= 0) {
    throw new Error('You could be a little more enthusiastic. :D');
  }

  return (
    <div className="hello">
      <div className="greeting">
        Hello {name + getExclamationMarks(enthusiasmLevel)}
      </div>
      <div>
        <button onClick={onDecrement}>-</button>
        <button onClick={onIncrement}>+</button>
      </div>
    </div>
  );
}

建议为「点击按钮会触发对应的 onIncrement/onDecrement」补写测试,借此练手组件测试。

第二步:用 connect 包裹成容器。 创建 src/containers/Hello.tsx(仓库文件 src/containers/Hello.tsx 即最终成品):

// src/containers/Hello.tsx

import Hello from '../components/Hello';
import * as actions from '../actions/';
import { StoreState } from '../types/index';
import { connect, Dispatch } from 'react-redux';

export function mapStateToProps({ enthusiasmLevel, languageName }: StoreState) {
  return {
    enthusiasmLevel,
    name: languageName,
  };
}

export function mapDispatchToProps(dispatch: Dispatch<actions.EnthusiasmAction>) {
  return {
    onIncrement: () => dispatch(actions.incrementEnthusiasm()),
    onDecrement: () => dispatch(actions.decrementEnthusiasm()),
  };
}

export default connect(mapStateToProps, mapDispatchToProps)(Hello);

connect 是这里的两把钥匙之一(另一把是原始 Hello 组件),它借助两个函数把组件变成容器:

  • mapStateToProps:把当前 store 中的数据"按摩"成组件所需 props 形状的一部分。回顾一下,store 状态有两个属性 languageName 与 enthusiasmLevel,而 Hello 期望的是 name 与 enthusiasmLevel——mapStateToProps 负责取数据并在必要时调整形状(这里的 name: languageName 就是整形动作,也解释了第五节中为何状态与 props 刻意不同名)。注意它只提供了 Hello 期望的 4 个属性中的 2 个;
  • mapDispatchToProps:接收一个 dispatcher 函数(可把 action 送入 store 触发更新),据此创建一对回调,在需要时调用 dispatcher。泛型 Dispatch<actions.EnthusiasmAction> 把可 dispatch 的 action 类型收紧为本应用的 EnthusiasmAction 联合类型。

最后调用 connect:它先接收 mapStateToProps 与 mapDispatchToProps,再返回一个可包裹组件的函数。一行代码 connect(mapStateToProps, mapDispatchToProps)(Hello) 即完成容器化。

第三步:创建 store 并接线。 回到 src/index.tsx。需要创建带初始状态的 store,并装配所有 reducer。当前 src/index.tsx 完整呈现了原文档所述的全部接线:

import { createStore } from 'redux';
import { enthusiasm } from './reducers/index';
import { StoreState } from './types/index';

const store = createStore<StoreState>(enthusiasm, {
  enthusiasmLevel: 1,
  languageName: 'TypeScript',
});

store 即应用全局状态的中心存储。泛型参数 <StoreState> 让 createStore 的状态类型与 src/types/index.tsx 中的定义对齐。

然后把对 ./src/components/Hello 的使用换成 ./src/containers/Hello,并用 react-redux 的 Provider 把 props 与容器接起来:

import Hello from './containers/Hello';
import { Provider } from 'react-redux';

ReactDOM.render(
  <Provider store={store}>
    <Hello />
  </Provider>,
  document.getElementById('root') as HTMLElement
);

注意此时 Hello 不再需要任何 props——connect 已经把应用状态适配为被包裹 Hello 组件所需的 props。数据流至此形成闭环:按钮点击 → mapDispatchToProps 生成的回调 → dispatch(action) → reducer enthusiasm 产出新状态 → mapStateToProps 重新映射 → 组件重渲染。

九、Eject:逃生通道

如果 create-react-app 的某些封装让你无法完成定制(例如想添加一个 Webpack 插件),可以随时选择退出、拿回全部配置选项:

npm run eject

重要提醒:eject 之前建议先提交所有工作。npm run eject 不可撤销——退出是永久性的,除非你能从执行 eject 之前的 commit 恢复。

十、后续学习方向

README 末尾给出的下一步建议:

  1. create-react-app 自带很多好东西,大部分都写在项目默认生成的 README.md 里(本仓库对应 README-CRA.md),值得一读;
  2. 想继续深入 Redux,看 Redux 官方文档;想探索 MobX,看 MobX 官方文档;
  3. 将来若打算 eject,需要补一些 Webpack 知识,可参考 TypeScript 官方的 React & Webpack 讲解;
  4. 某时你可能需要路由:在 Redux 项目中 react-router 可能是最流行的选择,常与 react-router-redux 配合使用。

总结:从这份 Starter 中能带走什么

结合原文档与仓库源码,可以把整个技术栈的要点压缩成一张"文件—职责"对照:

文件 职责
src/index.tsx 应用入口:createStore<StoreState> 建库、Provider 注入、挂载到 #root
src/components/Hello.tsx 展示组件:Props 接口约束属性,SFC 解构 + 默认值,CSS 按模块导入
src/containers/Hello.tsx 容器:connect + mapStateToProps/mapDispatchToProps 完成状态与 actions 的接线
src/constants/index.tsx const/type 模式定义字符串字面量类型的 action type
src/actions/index.tsx action 接口、联合类型 EnthusiasmAction 与 action creator
src/reducers/index.tsx 纯函数 reducer,对象展开做不可变更新,Math.max 守住下界
src/types/index.tsx 全局 StoreState 类型,贯穿 store、reducer 与 container
src/components/Hello.test.tsx Jest + Enzyme 浅渲染测试,覆盖默认值、显式值与抛错三条规则
tsconfig.json / tslint.json 严格空检查、经典 JSX、宽松 lint 策略等工程化配置

这套方案诞生于 create-react-app 尚未原生支持 TypeScript 的年代,如今新项目可直接用 npx create-react-app my-app --typescript 起步;但 README 所沉淀的组件类型建模(Props/State 接口、类型断言)、Redux 四件套(constants → actions → reducers → container)以及 Jest + Enzyme 的测试手法,仍然适用于今天的 React + TypeScript 项目,而本仓库中每个文件都是可直接对照阅读的完整实现样本。

登录后查看全文
TypeScript-React-Starter