核心要点
构建有状态的现代应用程序是一项复杂的任务。随着状态的改变,应用程序变得不可预测且难以维护。这就是 Redux 的用武之地。Redux 是一个轻量级的库,用于处理状态。可以把它想象成一个状态机。
在本文中,我将通过构建一个工资处理引擎来深入探讨 Redux 的状态容器。该应用程序将存储工资单以及所有额外内容,例如奖金和股票期权。我将使用纯 JavaScript 和 TypeScript 进行类型检查来保持解决方案的简洁性。由于 Redux 非常易于测试,我还将使用 Jest 来验证应用程序。
在本教程中,我假设您对 JavaScript、Node 和 npm 有一定的了解。
首先,您可以使用 npm 初始化此应用程序:
npm init
当询问测试命令时,请继续使用 jest。这意味着 npm t 将启动 Jest 并运行所有单元测试。主文件将是 index.js,以保持其简洁性。您可以随意回答 npm init 的其余问题。
我将使用 TypeScript 进行类型检查并确定数据模型。这有助于概念化我们正在尝试构建的内容。
要开始使用 TypeScript:
npm i typescript --save-dev
我将把开发工作流程中的一部分依赖项放在 devDependencies 中。这清楚地表明哪些依赖项是为开发人员准备的,哪些依赖项将用于生产环境。准备好 TypeScript 后,在 package.json 中添加一个启动脚本:
"start": "tsc && node .bin/index.js"
在 src 文件夹下创建一个 index.ts 文件。这将源文件与项目的其余部分分开。如果您执行 npm start,则解决方案将无法执行。这是因为您需要配置 TypeScript。
创建一个包含以下配置的 tsconfig.json 文件:
{ "compilerOptions": { "strict": true, "lib": ["esnext", "dom"], "outDir": ".bin", "sourceMap": true }, "files": [ "src/index" ] }
我本可以将此配置放在 tsc 命令行参数中。例如,tsc src/index.ts --strict .... 但是将所有这些放在单独的文件中要清晰得多。请注意,package.json 中的启动脚本只需要一个 tsc 命令。
以下是一些合理的编译器选项,它们将为我们提供一个良好的起点,以及每个选项的含义:
因为我将使用 Jest 进行单元测试,所以我将继续添加它:
npm init
ts-jest 依赖项为测试框架添加了类型检查。一个需要注意的地方是在 package.json 中添加一个 jest 配置:
npm i typescript --save-dev
这使得测试框架能够拾取 TypeScript 文件并知道如何对其进行转换。一个不错的功能是,您在运行单元测试时可以进行类型检查。为了确保此项目已准备好,请创建一个 __tests__ 文件夹,其中包含一个 index.test.ts 文件。然后,进行健全性检查。例如:
"start": "tsc && node .bin/index.js"
现在执行 npm start 和 npm t 将不会出现任何错误。这告诉我们我们现在可以开始构建解决方案了。但在我们这样做之前,让我们将 Redux 添加到项目中:
{ "compilerOptions": { "strict": true, "lib": ["esnext", "dom"], "outDir": ".bin", "sourceMap": true }, "files": [ "src/index" ] }
此依赖项将用于生产环境。因此,无需使用 --save-dev 包含它。如果您检查您的 package.json,它将位于 dependencies 中。
工资单引擎将包含以下内容:工资、报销、奖金和股票期权。在 Redux 中,您不能直接更新状态。相反,会调度操作来通知存储任何新的更改。
因此,这留下了以下操作类型:
npm i jest ts-jest @types/jest @types/node --save-dev
PAY_DAY 操作类型可用于在发薪日发放支票并跟踪工资历史记录。这些操作类型在我们完善工资单引擎时指导其余的设计。它们捕获状态生命周期中的事件,例如设置基本工资金额。这些操作事件可以附加到任何内容,无论是点击事件还是数据更新。Redux 操作类型对于调度来自何处是抽象的。状态容器可以在客户端和/或服务器上运行。
使用类型理论,我将根据状态数据确定数据模型。对于每个工资单操作,例如操作类型和可选金额。金额是可选的,因为 PAY_DAY 不需要资金来处理工资单。我的意思是,它可以向客户收费,但现在先忽略它(也许在第二版中引入)。
例如,将其放在 src/index.ts 中:
"jest": { "preset": "ts-jest" }
对于工资单状态,我们需要一个用于基本工资、奖金等的属性。我们还将使用此状态来维护工资历史记录。
此 TypeScript 接口应该可以做到:
npm init
对于每个属性,请注意 TypeScript 使用冒号指定类型。例如,: number。这确定了类型契约,并为类型检查器增加了可预测性。使用具有显式类型声明的类型系统可以增强 Redux。这是因为 Redux 状态容器是为可预测的行为而构建的。
这个想法并不疯狂或激进。《学习 Redux》第 1 章(仅限 SitePoint Premium 会员)对此进行了很好的解释。
随着应用程序的改变,类型检查增加了额外的可预测性。随着应用程序的扩展,类型理论也有助于简化大型代码段的重构。
现在使用类型概念化引擎有助于创建以下操作函数:
npm i typescript --save-dev
好的一点是,如果您尝试执行 processBasePay('abc'),类型检查器会向您发出警告。破坏类型契约会降低状态容器的可预测性。我使用像 PayrollAction 这样的单个操作契约来使工资处理器更可预测。请注意,金额通过 ES6 属性简写在操作对象中设置。更传统的方法是 amount: amount,这比较冗长。箭头函数,例如 () => ({}),是编写返回对象文字的函数的一种简洁方法。
reducer 函数需要一个状态和一个操作参数。状态应该具有具有默认值的初始状态。那么,你能想象我们的初始状态可能是什么样子吗?我认为它需要从零开始,并带有一个空的工资历史记录列表。
例如:
"start": "tsc && node .bin/index.js"
类型检查器确保这些是属于此对象的正确值。有了初始状态,就开始创建 reducer 函数:
{ "compilerOptions": { "strict": true, "lib": ["esnext", "dom"], "outDir": ".bin", "sourceMap": true }, "files": [ "src/index" ] }
Redux reducer 具有一个模式,其中所有操作类型都由 switch 语句处理。但在遍历所有 switch case 之前,我将创建一个可重用的局部变量:
npm i jest ts-jest @types/jest @types/node --save-dev
请注意,如果您不改变全局状态,则可以改变局部变量。我使用 let 运算符来传达此变量将来会发生变化。改变全局状态(例如状态或操作参数)会导致 reducer 不纯。这种函数式范式至关重要,因为 reducer 函数必须保持纯净。《JavaScript 从新手到忍者》第 11 章(仅限 SitePoint Premium 会员)对此进行了解释。
开始 reducer 的 switch 语句以处理第一个用例:
"jest": { "preset": "ts-jest" }
我使用 ES6 rest 运算符来保持状态属性不变。例如,...state。您可以在新对象中的 rest 运算符之后覆盖任何属性。basePay 来自解构,这很像其他语言中的模式匹配。computeTotalPay 函数设置如下:
it('is true', () => { expect(true).toBe(true); });
请注意,您会扣除 stockOptions,因为这笔钱将用于购买公司股票。假设您想处理报销:
npm init
由于金额是可选的,请确保它具有默认值以减少故障。这就是 TypeScript 的优势所在,因为类型检查器会发现此陷阱并向您发出警告。类型系统知道某些事实,因此它可以做出合理的假设。假设您想处理奖金:
npm i typescript --save-dev
此模式使 reducer 可读,因为它只维护状态。您获取操作的金额,计算总工资,并创建一个新的对象文字。处理股票期权没有什么不同:
"start": "tsc && node .bin/index.js"
对于在发薪日处理工资单,它需要抹去奖金和报销。这两个属性不会在每个工资单中保留在状态中。并且,向工资历史记录中添加一个条目。基本工资和股票期权可以保留在状态中,因为它们不会经常更改。考虑到这一点,这就是 PAY_DAY 的处理方式:
{ "compilerOptions": { "strict": true, "lib": ["esnext", "dom"], "outDir": ".bin", "sourceMap": true }, "files": [ "src/index" ] }
在一个像 newPayHistory 这样的数组中,使用扩展运算符,它是 rest 的反义词。与收集对象中属性的 rest 不同,它会将项目展开。例如,[...payHistory]。尽管这两个运算符看起来很相似,但它们并不相同。仔细观察,因为这可能会出现在面试问题中。
对 payHistory 使用 pop() 不会改变状态。为什么?因为 slice() 返回一个全新的数组。JavaScript 中的数组是通过引用复制的。将数组分配给新变量不会更改底层对象。因此,在处理这些类型的对象时必须小心。
因为 lastPayHistory 有可能未定义,所以我使用穷人的空值合并来将其初始化为零。请注意 (o && o.property) || 0 模式用于合并。JavaScript 或甚至 TypeScript 的未来版本可能会有一种更优雅的方法来做到这一点。
每个 Redux reducer 都必须定义一个默认分支。为了确保状态不会变得未定义:
npm i jest ts-jest @types/jest @types/node --save-dev
编写纯函数的众多好处之一是它们易于测试。单元测试是指您必须期望可预测的行为的测试,您可以将所有测试作为构建的一部分自动化。在 __tests__/index.test.ts 中,取消虚拟测试并导入所有感兴趣的函数:
"jest": { "preset": "ts-jest" }
请注意,所有函数都设置为导出,因此您可以导入它们。对于基本工资,启动工资单引擎 reducer 并对其进行测试:
it('is true', () => { expect(true).toBe(true); });
Redux 将初始状态设置为未定义。因此,在 reducer 函数中提供默认值始终是一个好主意。处理报销怎么样?
npm i redux --save
处理奖金的模式与此相同:
const BASE_PAY = 'BASE_PAY'; const REIMBURSEMENT = 'REIMBURSEMENT'; const BONUS = 'BONUS'; const STOCK_OPTIONS = 'STOCK_OPTIONS'; const PAY_DAY = 'PAY_DAY';
对于股票期权:
interface PayrollAction { type: string; amount?: number; }
请注意,当 stockOptions 大于 totalPay 时,totalPay 必须保持不变。由于这家假设的公司是合乎道德的,它不想从员工那里拿钱。如果您运行此测试,请注意 totalPay 设置为 -10,因为 stockOptions 会被扣除。这就是我们测试代码的原因!让我们修复计算总工资的地方:
npm init
如果员工赚的钱不够买公司股票,请继续跳过扣除。另外,确保它将 stockOptions 重置为零:
npm i typescript --save-dev
该修复程序确定了 newStockOptions 中他们是否有足够的钱。有了这个,单元测试通过,代码健全且有意义。我们可以测试有足够的钱进行扣除的积极用例:
"start": "tsc && node .bin/index.js"
对于发薪日,请使用多个状态进行测试,并确保一次性交易不会持续存在:
{ "compilerOptions": { "strict": true, "lib": ["esnext", "dom"], "outDir": ".bin", "sourceMap": true }, "files": [ "src/index" ] }
请注意,我如何调整 oldState 以验证奖金并将报销重置为零。
reducer 中的默认分支怎么样?
npm i jest ts-jest @types/jest @types/node --save-dev
Redux 在开始时设置了一个像 INIT_ACTION 这样的操作类型。我们只关心我们的 reducer 是否设置了一些初始状态。
此时,您可能会开始怀疑 Redux 是否更像是一种设计模式。如果您回答它既是模式又是轻量级库,那么您是对的。在 index.ts 中,导入 Redux:
"jest": { "preset": "ts-jest" }
下一个代码示例可以围绕此 if 语句包装。这是一个权宜之计,因此单元测试不会泄漏到集成测试中:
it('is true', () => { expect(true).toBe(true); });
我不建议在实际项目中这样做。模块可以放在单独的文件中以隔离组件。这使其更易于阅读,并且不会泄漏问题。单元测试也受益于模块独立运行的事实。
使用 payrollEngineReducer 启动 Redux 存储:
npm i redux --save
每个 store.subscribe() 都返回一个后续的 unsubscribe() 函数,该函数可用于清理。它会在通过存储调度操作时取消订阅回调。在这里,我使用 store.getState() 将当前状态输出到控制台。
假设这位员工赚了 300,有 50 的报销,100 的奖金,以及 15 用于公司股票:
const BASE_PAY = 'BASE_PAY'; const REIMBURSEMENT = 'REIMBURSEMENT'; const BONUS = 'BONUS'; const STOCK_OPTIONS = 'STOCK_OPTIONS'; const PAY_DAY = 'PAY_DAY';
为了使其更有趣,再进行 50 的报销并处理另一张工资单:
interface PayrollAction { type: string; amount?: number; }
最后,运行另一张工资单并取消订阅 Redux 存储:
interface PayStubState { basePay: number; reimbursement: number; bonus: number; stockOptions: number; totalPay: number; payHistory: Array<PayHistoryState>; } interface PayHistoryState { totalPay: number; totalCompensation: number; }
最终结果如下所示:
export const processBasePay = (amount: number): PayrollAction => ({type: BASE_PAY, amount}); export const processReimbursement = (amount: number): PayrollAction => ({type: REIMBURSEMENT, amount}); export const processBonus = (amount: number): PayrollAction => ({type: BONUS, amount}); export const processStockOptions = (amount: number): PayrollAction => ({type: STOCK_OPTIONS, amount}); export const processPayDay = (): PayrollAction => ({type: PAY_DAY});
如所示,Redux 维护状态、改变状态并在一个简洁的小包中通知订阅者。可以将 Redux 想象成一个状态机,它是状态数据的真实来源。所有这些都采用了编码的最佳实践,例如健全的函数式范式。
Redux 为复杂的状态管理问题提供了一个简单的解决方案。它依赖于函数式范式来减少不可预测性。因为 reducer 是纯函数,所以单元测试非常容易。我决定使用 Jest,但是任何支持基本断言的测试框架都可以工作。
TypeScript 使用类型理论增加了额外的保护层。将类型检查与函数式编程结合起来,您将获得几乎不会中断的健全代码。最重要的是,TypeScript 在增加价值的同时不会妨碍工作。如果您注意到,一旦类型契约到位,几乎没有额外的编码。类型检查器会完成其余的工作。像任何好工具一样,TypeScript 在保持不可见的同时自动化编码纪律。TypeScript 吠叫声很大,但咬起来很轻。
如果您想试用此项目(我希望您这样做),您可以在 GitHub 上找到本文的源代码。
以上是深入研究Redux的详细内容。更多信息请关注PHP中文网其他相关文章!