React-设计模式实用指南-全-

React 设计模式实用指南(全)

原文:zh.annas-archive.org/md5/44C916494039D4C1655C3E1D660CD940

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

框架和库来来去去。设计模式通常会持续更长时间。在这本书中,我们将学习 React Native 和与该生态系统相关的设计模式。当涉及到 React 时,关于设计模式的基本知识散布在各个地方。有时它被埋在专有的代码库中。这本书将把它带给你。我称它们为思想模式:通过真实的工作示例来解释的实用设计模式。在这本书中,我们使用 React Native,但你也可以成功地在 React 的 Web 开发中使用大多数这些模式,甚至是其他框架,比如 Angular 或 Vue。希望你能利用这些知识来构建深思熟虑且易于维护的代码库。祝你好运!

这本书适合谁

业余程序员和热情的人非常欢迎阅读这本书,但请注意,这可能比初级编程书籍更具挑战性。

我假设你有一些 JavaScript 编程经验,并且对终端窗口并不陌生。理想情况下,你应该是一名开发人员(初级/中级/高级),这样你就会有广阔的视野,并且可以立即将知识应用到你的工作中。不需要有开发移动应用程序的经验。

为了充分利用这本书,

花点时间,不要着急。你不需要在一周内读完这本书。

随着你的开发者职业的进步,回到这本书。你将专注于完全不同的事情,这样你将能够充分利用这本书。

玩一下我准备的例子。每个都是一个独立的应用程序,所以你可以在我们进行的过程中玩耍和改进代码。这旨在作为一个游乐场,这样你不仅可以从例子中学习,还可以创建它们的扩展。当你构建时,你将理解在每个部分引入的变化。如果你只是读这本书,你肯定会错过这个视角。

下载示例代码文件

你可以从www.packt.com的账户中下载这本书的示例代码文件。如果你在其他地方购买了这本书,你可以访问www.packt.com/support并注册,文件将直接发送到你的邮箱。

你可以按照以下步骤下载代码文件:

  1. www.packt.com上登录或注册。

  2. 选择“支持”选项卡。

  3. 点击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

文件下载后,请确保使用最新版本的解压缩或提取文件夹:

  • WinRAR/7-Zip 适用于 Windows

  • Zipeg/iZip/UnRarX 适用于 Mac

  • 7-Zip/PeaZip 适用于 Linux

该书的代码包也托管在 GitHub 上,网址为github.com/Ajdija/hands-on-design-patterns-with-react-native。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有来自丰富图书和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。去看看吧!

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/9781788994460_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。例如:"将下载的WebStorm-10*.dmg磁盘映像文件挂载为系统中的另一个磁盘。"

代码块设置如下:

export default function() {
    return React.createElement(
        Text,
  {style: {marginTop: 30}},
  'Example Text!'
  ); }

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

export default **function** App() {
  return (
      <View style={styles.container}>
  ...
      </View>
  ); }

任何命令行输入或输出都将按以下方式编写:

yarn test -- --coverage

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。例如:"您现在可以点击 详细 按钮导航到 任务详情 屏幕。"

警告或重要说明会以这种方式出现。提示和技巧会以这种方式出现。

第一章:React 组件模式

开发 Android 和 iOS 从未像现在这样简单。React Native 改变了我们开发新应用并向最终用户提供价值的速度。了解这项技术将使你在市场上拥有巨大优势。我是 Matt,很高兴向你展示我在 React Native 生态系统中学到的最佳实践。通过本书,我们将通过示例探索设计模式。仅在本章中,我们将创建超过 10 个小应用程序。在本书的后面,我们将使用我逐渐向你介绍的模式创建更复杂的应用程序。

在本章中,我们将探讨同样适用于 React Native 世界的 React 模式。你需要理解的最关键的模式是无状态和有状态组件。了解如何使用这些模式将使你成为一个更好的 React Native 开发者,并赋予你在每个 React Native 应用程序中使用的标准模式。

在组件方面,使它们尽可能可重用并遵循众所周知的程序员原则——不要重复自己DRY)是至关重要的。展示性组件和容器组件就是为了做到这一点。我们将通过几个示例来深入了解它们,学习如何将功能分割成可重用的部分。

更准确地说,在本章中,我们将研究以下主题:

  • 无状态和有状态组件,使用简短然后更复杂的示例

  • 如何创建可重用且易于配置的展示性组件

  • 容器组件及其在功能封装中的作用

  • 何时组合组件以及如何创建高阶组件HOCs

是时候采取行动了。如果你想跟着学习并尝试示例,请立即为 React Native 开发准备好你的环境。本书中的大部分代码示例都可以在模拟器或真实移动设备上运行和显示。现在,确保你可以在手机或模拟器上启动Hello World示例。

代码示例已经提交到 GitHub 上的 Git 存储库中,可以在github.com/Ajdija/hands-on-design-patterns-with-react-native找到。

请按照readme.md中的说明设置您的计算机并启动我们的第一个示例。Hello World示例可以在以下目录中找到src/Chapter_1_React_component_patterns/Example_1_Hello_World

无状态和有状态组件

首先,让我们看看为我们创建的第一个无状态组件。它是由Create React Native AppCRNA)自动生成的,用于我们的Hello World应用程序。这个组件是使用 ECMAScript 2015(ES6)中引入的类语法自动生成的。这样的组件通常被称为类组件

// src/ Chapter 1/ Example 1_Hello World/ App.js

**export default class** App extends React.Component {
 render() {
 return (
        <View style={styles.container}>
 <Text>Hands-On Design Patterns with React Native</Text>
 <Text>Chapter 1: React Component Patterns</Text>
 <Text style={styles.text}>You are ready to start the journey. 
          Fun fact is, this text is rendered by class component called 
          App. Check App.js if you want to look it up.</Text>
 </View>  );
  }
}

类组件可用于创建有状态组件。

本书提供的代码示例使用具有 Stage 3 功能类字段声明的 ECMAScript 2018 语法。 Babel 是支持这样的代码的转换器,相关插件由 CRNA 工具箱预先配置。如果您决定不使用 CRNA,则可能需要自行配置 Babel。

然而,在这种情况下,类组件是不必要的。我们可以安全地使用无状态组件,因为它更简单。让我们看看如何声明无状态组件。最常见的方法是使用 ES6 箭头语法。这样的组件称为功能组件。查看以下代码,看看我们重写的组件是什么样子的:

const App = () => (
    <View style={styles.container}>  <Text>Hands-On Design Patterns with React Native</Text>  <Text>Chapter 1: React Component Patterns</Text>  <Text style={styles.text}>You are ready to start the journey. Fun 
      fact is, this text is rendered by Functional Component called 
      App. Check App.js if you want to look it up.</Text>  </View>  );
export default App;

如果您不喜欢箭头语法,您也可以使用常规的function语法:

// src/ Chapter 1/ Example_2_Functional_Components/ App.js

export default **function** App() {
  return (
      <View style={styles.container}>
  ...
      </View>
  ); }

首先弹出的第一个问题是:为什么它是无状态的?答案很简单:它不包含任何内部状态。这意味着我们没有在其中存储任何私有数据。组件需要渲染自身的一切都来自外部世界,而组件并不关心。

在这个小例子中,我们实际上从未将任何外部数据传递给组件。现在让我们来做这件事。为此,我们将创建另一个名为HelloText的组件,它消耗一个属性:要显示的文本。将文本传递给这样一个组件的通常约定是将文本放在开放和关闭标签之间,例如<HelloText>传递的示例文本</HelloText>。因此,在我们的功能组件中检索这样的属性,我们将需要使用一个名为children的特殊键:

// src/ Chapter 1/ Example_3_Functional_Components_with_props/ App.js

const HelloText = ({children, ...otherProps}) => (
    <Text {...otherProps}>{children}**</Text>** ); const App = () => (
    <View style={styles.container}>
 <HelloText>  Hands-On Design Patterns with React Native
        </HelloText>
 <HelloText>Chapter 1: React Component Patterns</HelloText>
 <HelloText style={styles.text}>
  You are ready to start the journey. Fun fact is, this text
            is rendered by Functional Component called HelloText.
            Check App.js if you want to look it up.
        </HelloText>
 </View> ); export default App;

使用children属性使我们的HelloText组件更加强大。属性是一种非常灵活的机制。使用属性,您可以发送任何有效的 JavaScript 类型。在这种情况下,我们只发送了文本,但您也可以发送其他组件。

现在是时候为我们的组件添加一些活力了。我们将使其展开第三个文本块,但只有在按下章节或标题文本后才会展开。为了实现这个功能,我们需要存储一个状态,记住组件是展开还是折叠的。

您需要做的是:

  1. 将组件更改为类语法。

  2. 利用 React 库的状态对象。我们必须在类构造函数中初始化状态,并默认使文本折叠。

  3. 在组件的render函数中添加条件渲染。

  4. 添加按下处理程序,当我们点击标题或章节文本时将改变状态。

解决方案如下所示:

// src/ Chapter 1/ Example_4_Stateful_expandable_component/ App.js export default class App extends React.Component {
    constructor() {
        super();
  this.state = {
            // default state on first render
  expanded: **false**
  }
    }

    expandOrCollapse() {
        // toggle expanded: true becomes false, false becomes true
  this.setState({expanded: !this.state.expanded})**;**
  }

    render = () => (
        <View style={styles.container}>
 <HelloText onPress={() => this.expandOrCollapse()}>
  Hands-On Design Patterns with React Native
            </HelloText>
 <HelloText onPress={() => this.expandOrCollapse()}>
  Chapter 1: React Component Patterns
            </HelloText>
  {
                this.state.expanded &&
                <HelloText style={styles.text}>
  You can expand and collapse this text by clicking
                    the Title or Chapter text. Bonus: Check Chapter 4
                    to learn how to animate expanding andcollapsing.
                </HelloText>
  }
        </View>
  );
}

恭喜——我们已经创建了我们的第一个无状态和有状态组件!

注意显示组件的&&运算符。如果运算符左侧的布尔值为true,那么右侧的组件将被显示。整个表达式需要用大括号括起来。我们将在第三章中探索更多功能,样式模式。

现在是时候创建一些更具挑战性的东西:任务列表。请重新开始并准备好您的代码。清理App.js,使其只包括App类组件:

  1. 构造函数应该在其状态中初始化任务列表。在我的示例中,任务列表将是一个字符串数组。

  2. 迭代任务以为每个任务创建Text组件。这应该发生在App组件的render函数中。请注意,您可以使用map函数简化迭代,而不是使用常规的for循环。这应该成为第二天性,因为它已经成为几乎每个 JS 项目的标准。

我的解决方案如下所示:

// src/ Chapter 1/ Example 5_Task_list/ App.js export default class App extends React.Component {
  constructor() {
    super();
    // Set the initial state, tasks is an array of strings
  this.state = {
 tasks: ['123', '456']
 }
  }

  render = () => (
      <View style={styles.container}>
  {
          this.state.tasks
  .map((task, index) => (
 <Text key={index} style={styles.text}>{task}</Text>
  ))
        }
      </View>
  );
}

使用map进行迭代是一个很好的功能,但整个组件看起来还不像一个任务列表。别担心,您将学会如何在第三章中为组件添加样式,样式模式

无状态组件的优势是什么?

也许只使用有状态的类组件并开发整个应用程序似乎很诱人。为什么我们要费心使用无状态的函数组件呢?答案是性能。无状态的函数组件可以更快地渲染。这样做的原因之一是因为无状态的函数组件不需要一些生命周期钩子。

什么是生命周期钩子?React 组件有生命周期。这意味着它们有不同的阶段,如挂载、卸载和更新。您可以挂钩每个阶段甚至子阶段。请查看官方 React 文档以查看可用生命周期方法的完整列表:reactjs.org/docs/state-and-lifecycle.html。这些对于触发从 API 获取数据或更新视图非常有用。

请注意,如果您使用的是 React v16 或更高版本,功能组件不会在 React 库内部被包装成类组件。

React 16 中的功能组件与类组件不走相同的代码路径,不像在之前的版本中它们被转换为类并且会有相同的代码路径。类组件有额外的检查和创建实例的开销,而简单函数没有。尽管这些是微优化,不应该在真实应用中产生巨大差异,除非你的类组件过于复杂。- Dominic Gannaway,Facebook React 核心团队的工程师

功能组件更快,但在大多数情况下被扩展React.PureComponent的类组件性能更好:

“但要明确的是,当 props 浅相等时,它们不会像 PureComponent 那样退出渲染。”- Dan Abramov,Redux 和 Create React App 的共同作者,Facebook React 核心团队的工程师

功能组件不仅更简洁,而且通常也是纯函数。我们将在《第九章》中进一步探讨这个概念,函数式编程模式的元素。纯函数提供了许多好处,如可预测的 UI 和轻松跟踪用户行为。应用程序可以以某种方式实现来记录用户操作。这些数据有助于调试和在测试中重现错误。我们将在本书的后面深入探讨这个话题。

组件组合

如果您学习过任何面向对象OO)语言,您可能已经广泛使用了继承。在 JavaScript 中,这个概念有点不同。JavaScript 继承是基于原型的,因此我们称之为原型继承。功能不是复制到对象本身,而是从对象的原型继承,甚至可能通过原型树中的其他原型继承。我们称之为原型链

然而,在 React 中,使用继承并不是很常见。由于组件,我们可以采用另一种称为组件组合的模式。我们将创建一个新的父组件,该组件将使用其子组件使自己更具体或更强大,而不是创建一个新类并从基类继承。让我们看一个例子:

// src/ Chapter 1/ Example_6_Component_composition_red_text/ App.js

const WarningText = ({style, ...otherProps}) => (
    <**Text** style={[style, {color: 'orange'}]} {...otherProps} /> );   export default class App extends React.Component {
    render = () => (
        <**View** style={styles.container}>
 <**Text** style={styles.text}>Normal text</**Text**>
 <**WarningText** style={styles.text}>Warning</**WarningText**>
 </**View**>  ); }

App组件由三个组件构建:ViewTextWarningText。这是一个完美的例子,说明一个组件如何通过组合来重用其他组件的功能。

WarningText组件使用组合来强制Text组件中的橙色文本颜色。它使通用的Text组件更具体。现在,我们可以在应用程序的任何地方重用WarningText。如果我们的应用程序设计师决定更改警告文本,我们可以快速适应一个地方的新设计。

注意隐式传递了一个名为 children 的特殊 prop。它代表组件的子元素。在Example 6_ Component composition *-* red text中,我们首先将警告文本作为子元素传递给WarningText组件,然后使用扩展运算符将其传递给Text组件,WarningText封装了它。

组合应用程序布局

假设我们必须为我们的应用程序创建一个欢迎屏幕。它应该分为三个部分 - 头部,主要内容和页脚。我们希望对已登录和匿名用户都有一致的边距和样式。但是,头部和页脚内容将不同。我们的下一个任务是创建一个支持这些要求的组件。

让我们创建一个欢迎屏幕,它将使用一个通用组件来封装应用程序布局。

按照以下逐步指南操作:

  1. 创建AppLayout组件,强制一些样式。它应该接受三个 props:headerMainContentFooter
const AppLayout = ({Header, MainContent, Footer}) => (
    // **These three props can be any component that we pass.**
    // You can think of it as a function that
    // can accept any kind of parameter passed to it.
    <View style={styles.container}>
        <View style={styles.layoutHeader}>{Header}</View>
        <View style={styles.layoutContent}>{MainContent}</View>
        <View style={styles.layoutFooter}>{Footer}</View>
    </View>
);
  1. 现在是时候为标题、页脚和内容创建占位符了。我们创建了三个组件:WelcomeHeaderWelcomeContentWelcomeFooter。如果你愿意,你可以将它们扩展为比一个微不足道的文本更复杂的组件:
const WelcomeHeader = () => <View><Text>Header</Text></View>;
const WelcomeContent = () => <View><Text>Content</Text></View>;
const WelcomeFooter = () => <View><Text>Footer</Text></View>;
  1. 我们应该将AppLayout与我们的占位符组件连接起来。创建WelcomeScreen组件,它将占位符组件(来自步骤 2)作为 props 传递给AppLayout
const WelcomeScreen = () => (
    <AppLayout
        Header={<WelcomeHeader />}
 MainContent={<WelcomeContent />}
 Footer={<WelcomeFooter />}
    />
);
  1. 最后一步将是为我们的应用程序创建根组件并添加一些样式:
// src/ Chapter 1/ Example_7_App_layout_and_Welcome_screen/ App.js

// root component
export default class App extends React.Component {
 render = () => <WelcomeScreen />; }

// styles
const styles = StyleSheet.create({
 container: {
         flex: 1,
  marginTop: 20
    },
 layoutHeader: {
 width: '100%',
 height: 100,
 backgroundColor: 'powderblue'
    },
 layoutContent: {
 flex: 1,
 width: '100%',
 backgroundColor: 'skyblue'
    },
 layoutFooter: {
 width: '100%',
 height: 100,
 backgroundColor: 'steelblue'
    }
});

请注意使用StyleSheet.create({...})。这将创建一个表示我们应用程序样式的样式对象。在这种情况下,我们创建了四种不同的样式(containerlayoutHeaderlayoutContentlayoutFooter),可以在我们定义的标记中使用。我们以前使用诸如widthheightbackgroundColor之类的键来自定义样式,这些都是微不足道的。然而,在这个例子中,我们还使用了来自术语flexbox 模式flex。我们将在第三章中详细解释这种方法,样式模式,我们主要关注StyleSheet模式。

这很不错。我们为我们的应用程序制作了一个微不足道的布局,然后创建了欢迎屏幕。

组件继承怎么样?

“在 Facebook,我们在成千上万的组件中使用 React,并且我们没有发现任何我们建议创建组件继承层次结构的用例。”- React 官方文档(reactjs.org/docs/composition-vs-inheritance.html

我还没有遇到过必须放弃组件组合而选择继承的情况。Facebook 的开发人员也没有(根据前面的引用)。因此,我强烈建议你习惯于组合。

在高级模式上测试组件

在创建可靠和稳定的应用程序时,测试是非常重要的。首先,让我们看看你需要编写的最常见的三种测试类型:

  • 琐碎的单元测试:我不明白,但它是否工作或根本不工作?通常,检查组件是否渲染或函数是否无错误运行的测试被称为琐碎的单元测试。如果你手动进行这些测试,你会称这些测试为冒烟测试。这些测试非常重要。不管你喜不喜欢,你都应该编写琐碎的测试,至少要知道每个功能某种程度上是否工作。

  • 单元测试:代码是否按照我的预期工作?它是否在所有的代码分支中工作?分支指的是代码中的分支位置,例如,if 语句将代码分支到不同的代码路径,这类似于 switch-case 语句。单元测试是指测试单个代码单元。在应用程序的关键特性中,单元测试应该覆盖整个函数代码(原则上:对于关键特性,代码覆盖率达到 100%)。

  • 快照测试:测试之前和实际版本是否产生相同的结果被称为快照测试。快照测试只是创建文本输出,但一旦输出被证明是正确的(通过开发人员评估和代码审查),它可能会作为比较工具。尽量多使用快照测试。这些测试应该提交到你的代码库并经过审查过程。Jest 中的这个新功能为开发人员节省了大量时间:

  • 图像快照测试:在 Jest 中,快照测试比较文本(JSON 到 JSON),但是你可能会在移动设备上遇到快照测试的引用,这意味着比较图像和图像。这是一个更高级的话题,但是大型网站通常会使用。拍摄这样的屏幕截图很可能需要构建整个应用程序,而不仅仅是一个单独的组件。构建整个应用程序是耗时的,因此一些公司只在计划发布时运行这种类型的测试,例如在发布候选版本构建上。这种策略可以自动化遵循持续集成持续交付原则。

由于我们在本书中使用 CRNA 工具箱,你想要检查的测试解决方案是 Jest(facebook.github.io/jest/)。

如果你来自 React web 开发背景,请注意。React Native,顾名思义,是在本地环境中运行的,因此有许多组件,比如 react-native-video 包,可能需要特殊的测试解决方案。在许多情况下,你需要模拟(创建占位符/模仿行为)这些包。

点击facebook.github.io/jest/docs/en/tutorial-react-native.html#mock-native-modules-using-jestmock了解更多信息。

我们将在第十章中解决其中一些问题,管理依赖

通常有一些测试指标,比如代码覆盖率(测试覆盖的行数)、报告的错误数量和注册的错误数量。

尽管非常有价值,但这些指标可能会产生一个错误的信念,即应用程序经过了充分测试。

在涉及测试模式时,有一些完全错误的做法需要提及:

  • 仅依赖单元测试:单元测试意味着仅测试单独的代码片段,例如,通过向函数传递参数并检查输出来测试。这很好,可以避免很多错误,但无论你有多高的代码覆盖率,你可能会在集成经过充分测试的组件时遇到问题。我喜欢用的一个现实例子是两扇门放得太靠近,导致它们不断开合。

  • 过分依赖代码覆盖率:不要过分强调自己或其他开发人员达到 100%或 90%的代码覆盖率。如果你有能力做到,那很好,但通常这会导致开发人员编写价值较低的测试。有时,向函数发送不同的整数值是至关重要的;例如,在测试除法时,仅发送两个正整数是不够的。你还需要检查当除以零时会发生什么。覆盖率无法告诉你这一点。

  • 不追踪测试指标如何影响错误数量:如果你只依赖于一些指标,无论是代码覆盖率还是其他任何指标,请重新评估这些指标是否反映了真相,例如,指标的增加是否导致了更少的错误。举个例子,我听过许多不同公司的开发人员说,代码覆盖率超过 80%并没有对他们有太大帮助。

如果你是产品所有者,并且已经查看了上面的不追踪测试指标如何影响错误数量,请与项目的技术负责人或资深开发人员进行咨询。可能会有一些特定因素影响这个过程,例如,开发进度转向更可重复的代码。请不要过快下结论。

快照测试可扩展组件

这一次,我们将展示快照测试的一个棘手部分。

让我们从创建我们的第一个快照测试开始。转到Chapter_1/Example 4_Stateful_expandable_component并在命令行中运行yarn test。您应该会看到一个测试通过。这是什么样的测试?这是一个位于App.test.js文件中的微不足道的单元测试。

是时候创建我们的第一个快照测试了。将expect(rendered).toBeTruthy();替换为expect(rendered).toMatchSnapshot();。它应该是这样的:

it('renders', () => {
  const rendered = renderer.create(<App />).toJSON();
  expect(rendered).toMatchSnapshot(); });

完成后,重新运行yarn test。将创建一个名为__snapshots__的新目录,其中包含App.test.js.snap文件。查看其内容。这是您的第一个快照。

是时候测试应用的覆盖率了。您可以使用以下命令来完成:

yarn test -- --coverage

它产生了一些令人担忧的东西:

File |  % Stmts | % Branch |  % Funcs |  % Lines | Uncovered Line #s
All files|    66.67 |       50 |       50 |    66.67
App.js   |    66.67 |       50 |       50 |    66.67 | 18,23,26

我们有一个组件有一个分支(if),进行快照测试后,覆盖率甚至没有接近 100%。出了什么问题?

显然,依赖状态的分支存在问题,但是否会占据超过 30%的代码行数?让我们看看完整的报告。打开./coverage/lcov-report/App.js.html文件:

覆盖率报告文件。您可以看到代码未被覆盖,测试标记为红色。

现在,你看到了问题所在。答案很简单——快照测试不测试属性函数。为什么?首先,这没有太多意义。我们为什么要将一个函数转换为 JSON,这有什么帮助呢?其次,告诉我如何序列化这个函数。我应该将函数代码作为文本返回,还是以其他方式计算输出?

以此例为教训,快照测试并不足够

测试驱动开发方法

您经常会听到测试驱动开发TDD)方法,基本上意味着先编写测试。为了简化这个过程,让我们总结为以下三个步骤:

  1. 编写测试并观察它们失败。

  2. 实现功能直到看到测试通过。

  3. 重构为最佳实践(可选)。

我必须承认,我真的很喜欢这种方法。然而,事实是大多数开发人员会赞美这种方法,但几乎没有人会使用它。这通常是因为它很耗时,而且很难预测即将测试的东西是什么样子。

更进一步,你会发现测试类型之一是针对 TDD 的。快照测试只能在组件实现后创建,因为它们依赖于其结构。这也是为什么快照测试更多是对你的测试的一种补充,而不是替代品的另一个原因。

这种方法在长期运行的大型应用程序中效果最好,其中一组技术架构师规划要使用的接口和模式。这最有可能出现在后端项目中,你会对所有类和模式如何相互连接有一个大致的了解。然后,你只需拿出接口并编写测试。接下来,你跟进实现。如果你想在 React Native 中创建接口,你需要支持 TypeScript。

有人认为 TDD 在小型项目中很棒,你可能很快就会在 Stack Overflow 上找到这样的讨论。不要误会我的意思;我很高兴有些人很开心。然而,小型项目往往非常不稳定,很可能经常变化。如果你正在构建一个最小可行产品(MVP),它与 TDD 并不很搭配。你最好依赖于你使用的库经过了充分测试,并及时使用快照测试来交付项目。

总结一下:放弃 TDD 不应该意味着写更少的测试。

表现组件

现在是时候学习如何使组件可重用了。为了实现这个目标,我们将利用我们手中最好的工具:表现组件模式。它将组件与逻辑解耦,并使它们更加灵活。

表现组件是一个模式名称,如果以后你决定使用 Redux 库,你会经常听到。例如,在 Dan Abramov 的 Redux 课程中,表现组件被大量使用。

我喜欢解释,表现组件模式是网站的世界。很长一段时间以来,每个网站都有三个主要的组成部分:CSS、HTML 和 JavaScript。然而,React 引入了一种有点不同的方法,即基于 JavaScript 自动生成 HTML。HTML 变成了虚拟的。因此,你可能听说过虚拟文档对象模型虚拟 DOM)。这种关注点的分离——HTML(视图)、CSS(样式)和 JavaScript(逻辑,有时称为控制器)——应该在我们的 JavaScript 世界中保持不变。因此,在我们的 JavaScript 世界中,使用表现组件来模仿 HTML,使用容器组件来处理逻辑。

以与 React Native 应用程序相同的方式解决这个问题。你编写的标记应该与它所消耗的逻辑分离。

让我们看看这个问题。你还记得Example 4_Stateful expandable component吗?它已经有一个呈现层组件了:

const HelloText = ({children, ...otherProps}) => (
    <Text {...otherProps}>{children}</Text> ); 

这个组件不引入任何逻辑,只包含标记,在这种情况下非常简短。任何有用的逻辑都隐藏在 props 中并传递,因为这个组件不需要使用它。在更复杂的例子中,你可能需要解构 props 并将它们传递给正确的组件;例如,当使用上面的展开运算符时,所有未解构的 props 都被传递了。

但是,与其专注于这个简单的例子,不如开始重构App组件。首先,我们将把标记移到单独的呈现层组件中:

// src/ Chapter_1_React_component_patterns/
// Example_9_Refactoring_to_presentational_component/ App.js
// Text has been replaced with "..." to save space.

**export const** HelloBox = ({ isExpanded, expandOrCollapse }) => (
    <View style={styles.container}>
 <HelloText onPress={() => expandOrCollapse()}>...</HelloText>
 <HelloText onPress={() => expandOrCollapse()}>...</HelloText>
  {
            isExpanded &&
            <HelloText style={styles.text}>...</HelloText>
  }
    </View> );

现在,我们需要用以下内容替换App组件中的render函数:

render = () => (
    **<HelloBox**
  isExpanded={this.state.expanded}
        expandOrCollapse={this.expandOrCollapse}
    **/>** );

然而,如果你现在运行代码,你会在HelloText的按键事件上遇到一个错误。这是由于 JavaScript 处理this关键字的方式。在这次重构中,我们将expandOrCollapse函数传递给另一个对象,在那里,this指的是一个完全不同的对象。因此,它无法访问状态。

有几种解决这个问题的方法,其中一种是使用箭头函数。我将坚持性能最佳的方法。关键是在你的构造函数中添加以下行:

this.expandOrCollapse = this.expandOrCollapse.bind(this); 

搞定了;应用程序已经完全可用,就像以前一样。我们已经将一个组件重构为两个——一个是呈现层的,一个负责逻辑的。很好。

假设我们只对两个组件进行了浅层单元测试。

我们能否识别出this关键字的问题?

也许不记得了。这个简单的陷阱可能会在大型项目中让你陷入困境,到时候你会太忙碌而无法重新思考每一个组件。小心并记住集成测试

解耦样式

在前面的例子中,你可能已经注意到样式与呈现层组件紧密耦合。为什么紧密?因为我们通过style={styles.container}明确地包含它们,但styles对象是不可配置的。我们无法用 props 替换任何样式部分,这使我们与现有的实现紧密耦合。在某些情况下,这是期望的行为,但在其他情况下则不是。

如果您对样式工作感兴趣,我们将深入研究涉及它们的模式,在第三章中,样式模式。您还将了解来自 CSS 的 flexbox 模式和许多其他约定。

如果您尝试将代码拆分为单独的文件,您将遇到这个问题。我们该如何解决这个问题?

让样式成为可选属性。如果未提供样式,则我们可以回退到默认值:

// src/ Chapter_1/ Example_10_Decoupling_styles/ App.js

export const HelloBox = ({
    isExpanded,
  expandOrCollapse,
  containerStyles,
  expandedTextStyles
}) => (
    <View style={containerStyles || styles.container}>
 <HelloText onPress={() => expandOrCollapse()}>...</HelloText>
 <HelloText onPress={() => expandOrCollapse()}>...</HelloText>
  {
            isExpanded &&
            <HelloText style={expandedTextStyles || styles.text}>
                ...
            </HelloText>
  }
    </View> );

注意使用||运算符。在上面的例子(expandedTextStyles || styles.text)中,它首先检查expandedTextStyles是否已定义,如果是,则返回该值。如果expandedTextStyles未定义,则返回我们硬编码的默认样式对象styles.text

现在,如果我们希望,在某些地方,我们可以通过传递相应的 props 来覆盖我们的样式:

render = () => (
    <HelloBox   isExpanded={this.state.expanded}
        expandOrCollapse={this.expandOrCollapse}
        expandedTextStyles={{ color: 'red' }}
    /> );

这就是我们如何分割标记、样式和逻辑。请记住尽可能经常使用表现性组件,以使您的功能在许多屏幕/视图上真正可重用。

如果您来自后端背景,您可能会迅速假设它就像MVC 模式ModelViewController。它不一定是 1:1 的关系,但一般来说,您可以简化为以下内容:

  • View:这是一个表现性组件。

  • Model:这是数据表示,对我们来说,它是在有状态组件中构建的状态,或者使用所谓的存储和 reducers(查看第五章,存储模式,了解有关 Redux 是什么以及如何使用它的更多细节)。

  • Controller:这是一个负责应用程序逻辑的容器组件,包括事件处理程序和服务。它应该是精简的,并从相应的文件中导入逻辑。

容器组件

容器组件模式是很久以前引入的,并在 React 社区中由 Dan Abramov 推广。到目前为止,当我们将 App 组件的内容重构为表现性组件时,我们已经创建了一个容器组件。事实证明,App组件成为了一个容器组件——它包含了HelloBox组件并实现了必要的逻辑。我们从这种方法中获得了什么?我们获得了以下内容:

  • 我们可以以不同的方式实现展开和折叠,并重用HelloBox组件的标记

  • HelloBox不包含逻辑

  • 容器组件封装了逻辑,并将其隐藏在其他组件中

我强烈建议阅读 Dan Abramov 在 medium 上的文章。查看medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0获取更多信息。当涉及到依赖注入模式时,容器组件是非常有用的工具。查看第十章,管理依赖项,以了解更多信息。

HOC

HOC是一种模式,用于增强组件的附加属性或功能,例如,如果您想使组件可扩展。我们可以使用 HOC 模式,而不是像之前那样只创建一个有状态的容器。让我们将我们的有状态容器组件重构为 HOC,并将其命名为makeExpandable

// src/ Chapter_1/ Example_12_Higher_order_component_makeExpandable/ App.js
 const makeExpandable = (ComponentToEnrich) => (
    class HelloBoxContainer extends React.Component {
        constructor() {
            super();
  this.state = {
                // default state on first render
  expanded: false
  };
  this.expandOrCollapse = this.expandOrCollapse.bind(this);
  }

        expandOrCollapse() {
            // toggle expanded: true becomes false, false becomes true
  this.setState({expanded: !this.state.expanded});
  }

        render = () => (
            <**ComponentToEnrich**
  isExpanded={this.state.expanded}
                expandOrCollapse={this.expandOrCollapse}
            />
  );
  }
);

makeExpandable组件接受ComponentToEnrich。因此,我们可以创建一个根组件(App)如下:

export default makeExpandable(HelloBox);

酷,不是吗?现在,让我们创建一些其他组件,并用我们的 HOC 来丰富它。这将是一个显示文本隐藏或显示的小按钮。如果用户按下按钮,它应该显示或隐藏一个小的彩色框。对于这个任务,你可以使用以下样式:

box: {
    width: 100,
  height: 100,
  backgroundColor: 'powderblue', }

将它们放在StyleSheet.create({ ... })中。我的解决方案非常简单:

// src/ Chapter_1/
// Example_13_Higher_order_component_show_hide_button/ App.js

export const SomeSection = ({
    isExpanded,
  expandOrCollapse,
  containerStyles,
  boxStyle
}) => (
    <View style={containerStyles || styles.container}>
 <Button
            onPress={expandOrCollapse}
 title={isExpanded ? "Hide" : "Show"}
 color="#841584"
        />
        {isExpanded && <View style={boxStyle || styles.box} />}
    </View> );

export default makeExpandable(SomeSection);

在前面的示例中,SomeSection组件被makeExpandable HOC 包装,并接收isExpandedexpandOrCollapse属性。

太棒了!我们刚刚制作了一个可重用的 HOC,它运行得非常完美。

现在,我将向您展示一个相当不为人知但有时很有用的技术,可以使您的 HOC 更加灵活。想象一下,您将增强一个对属性命名要求严格的组件,就像以下示例中一样:

export const SomeSection = ({
    showHideBox,
  isVisible,
  containerStyles,
  boxStyle
}) => {...};

不幸的是,我们的 HOC,makeExpandable,传递了错误的属性名称。让我们来修复一下:

// src/ Chapter_1/ Example_14_Flexible_prop_names_in_HOC/ App.js
render = () => {
  const props = {
    [propNames && propNames.isExpanded || 'isExpanded']: this.state.expanded,
  [propNames && propNames.expandOrCollapse || 'expandOrCollapse']: this.expandOrCollapse
  };
  return <ComponentToEnrich {...props} /> }; 

这是一个棘手的例子。它提供了重命名由 HOC 传递的属性的能力。要重命名它,我们需要将一个名为propNames的配置对象传递给 HOC。如果传递了这样的对象,并且它包含某个键,那么我们将覆盖该名称。如果该键不存在,则我们将回退到默认的属性名称,例如isExpanded

注意对象内部的[]的使用。它允许您在对象中动态命名键。在这个例子中,键是根据propNames的存在动态选择的。

为了使一切正常工作,我们还需要在makeExpandable HOC 中接受可选参数propNames

const makeExpandable = (ComponentToEnrich, propNames) => (
    ...
)

太棒了!现在我们的 HOC 在处理 prop 名称时更加灵活了!我们可以将其与前面提到的严格的SomeSection组件一起使用:

export default makeExpandable(SomeSection, {
    isExpanded: 'isVisible',
  expandOrCollapse: **'showHideBox'** }); 

render函数内创建变量时要注意性能影响。它会减慢你的应用程序。有时,模式可能会牺牲一点性能,有时则不会。明智地使用它们。你也可以将内联的propNames变量作为两个 props。

确保查看下一节以获得更清晰和解耦的方法。

HOC 组合

创建 HOC 的主要原因是能够组合它们提供的功能。

再次从上一节的问题来看。如果我们可以将工作委托给另一个 HOC 呢?例如,有一个名为mapPropNames的 mapper HOC,你可以像这样与我们之前的 HOC 组合:

makeExpandable(mapPropNames(SomeSection)); 

这是mapPropNames的实现:

// src/ Chapter_1/ Example_15_HOC_Composition/ App.js

const mapPropNames = (Component) => (props) => (
    <Component
        {...props}
  isVisible={props.isExpanded}
 showHideBox={props.expandOrCollapse}
    />  );

很好,很快,不是吗?这是一个常见的模式,也在处理作为 JSON 发送的后端数据时使用。它可以将数据格式适应到前端层的表示。正如你所看到的,我们在处理 HOC 时也可以采用这个好主意!

如果你来自面向对象的背景,请注意 HOC 模式与装饰器模式非常相似。然而,装饰器还依赖继承,并且需要实现它装饰的接口。

请查看en.wikipedia.org/wiki/Decorator_pattern以获取示例。

你也可以组合装饰器。它的工作方式类似。

有用的 HOC 示例

你需要一个快速的记录器来显示应用程序的行为吗?或者你正在准备一个实时演示,想在屏幕上显示一些动态信息?来吧:

// src/ Chapter_1/ Example_16_Useful_HOCs/ App.js

const logPropChanges = Component => props => {
    console.log('[Actual props]:', props)**;**
  return <Component {...props} />; };
// Use: makeExpandable(logPropChanges(mapPropNames(SomeSection))); 

好的。现在,假设你正在等待一些数据加载。这里就是加载动画:

// src/ Chapter_1/ Example_16_Useful_HOCs/ App.js

import {ActivityIndicator} from 'react-native';
const withSpinner = Component => props => (
    props.shouldSpin
        ? <View style={styles.container}>
 <Text>Your fav spinner f.in. on data load.</Text>
 <**ActivityIndicator** size="large" color="#0000ff" />
 </View>  : <Component {...props} /> );
// Needs a HOC that provides prop shouldSpin (true/false)

你可能想要求用户为你的应用打五星。你需要一个模态框来做这个:

const withModalOpener = Component => props => (
    // Replace with your favourite Modal box implementation
  <Component {...props} openModal={() => console.log('opening...')} /> );

有时,模态框也应该足够智能,以维持它们的可见性。

// src/ Chapter_1/ Example_16_Useful_HOCs/ App.js

const withModalOpener = OriginalComponent => (
    class ModalExample extends React.Component {
        // Check this shorter way to init state
        state = {
 modalVisible: true,
        }**;**    setModalVisible(visible) {
            this.setState({modalVisible: visible})**;**
  }

        render() {
            return (
                // Replace with your favourite Modal box implementation
  <View style={styles.container}>
 <OriginalComponent  {...this.props}
                        openModal={() => this.setModalVisible(true)}
                        closeModal={() =>
                     this.setModalVisible(false)}
                    />
 <**Modal**  animationType="slide"
  visible={this.state.modalVisible}
                        onRequestClose={() => {
                            alert('Modal has been closed.');
  }}>
 <View style={styles.container}>
 <Text>Example modal!</Text>   <TouchableHighlight  onPress={() => {
                                    this.setModalVisible(false);
  }}>
 <Text style={{fontSize: 30}}>
  Hide Modal
                                </Text>
 </TouchableHighlight> </View> </**Modal**> </View>  );
  }
    }
); 

在这个例子中,我们用Modal来丰富组件。Modal可以使用名为openModalcloseModal的 props 打开或关闭。关于模态框是打开还是关闭的信息存储在 HOC 的私有状态中,在这个例子中不会暴露给原始组件。很好的分离,对吧?这个 HOC 也是可重用的。

现在是你的作业时间:我们如何使Modal在盒子显示的同时打开?你不能改变SomeComponent

总结

在这一章中,你已经学会了如何在 React Native 环境中使用 React 创建基本组件。现在,你应该对无状态和有状态组件感到相当舒适。此外,你还学会了关于展示性和容器性组件。你知道这些模式用于解耦标记和逻辑。你还学会了如何通过使用高阶组件来增强组件功能。希望你也已经在 Git 存储库中玩过我为你收集的可运行示例。

在第二章 视图模式 中,我们将更多关注标记。你还将学习一些可以使用的标签。

第二章:查看模式

一个非常苛刻的技能是第一次写好视图代码。这需要经验,并且在某个时候几乎变得自动化。因此,从一开始就做对是至关重要的。在本章中,我们将探讨最佳实践,并深入研究您在上一章中已经使用的 React JSX 模式。我们还将专注于更广泛的内置组件范围,其中包括输入和表单。最后,我将向您展示一个名为 linter 的好工具,它对于任何新的前端项目都是必不可少的。

在本章中,您将学习以下内容:

  • 编写简洁的 JSX

  • 使用常见的 React Native 内置组件

  • 使用TextInput创建简单的表单

  • 区分受控和不受控输入

  • 创建错误边界

  • 从代码库中消除混合物

  • 设置一个代码风格指南的 linter

技术要求

在本章中,您将了解各种模式,以及它们的代码片段。但是,要运行它们,您将需要 Create React Native App 包。我已经将每个示例分成一个独立的应用程序,您可以在手机或模拟器上启动。

要跟着本章的示例,您将需要以下内容:

按照 GitHub 页面上的安装和运行说明开始。

JSX 简介

到目前为止,我们一直在使用 JSX,但是它是什么意思呢?JSX 代表 JavaScript 扩展。它怎么能是一个扩展呢?

您可能知道,ECMAScript 也是 JavaScript 的一个扩展(有点)。ECMAScript 被转译成 JavaScript。这意味着什么?这意味着它只是将 ECMAScript 代码转换为有效的 JavaScript 代码。JavaScript 缺少我们从 ECMAScript 中喜欢的许多功能,例如箭头函数、类和解构运算符。

JSX 的工作方式也是一样的。JSX 被转译成 JavaScript,它的主要特点是根据您编写的标记创建 React 元素。

我们能只使用 JavaScript 吗?是的。值得吗?很可能不值得。

让我们看看这个实例。这是 JSX ECMAScript:

export default () => <Text style={{marginTop: 30}}>Example Text!</Text>

现在,将这与纯 JavaScript 进行比较:

export default function() {
    return React.createElement(
        Text,
  {style: {marginTop: 30}},
  'Example Text!'
  ); }

毫无疑问,第一个代码片段更容易阅读和理解。

Babel 将 JSX 转译为 JavaScript。查看这个交互式工具,以便您可以玩耍并查看更复杂示例中的输出:goo.gl/RjMXKC

JSX 标准技巧

在我们继续之前,我想向您展示在编写 JSX 标记时的最佳实践。这将使您在接下来的示例中更容易理解。

让我们从简单的规则开始:

  • 如果您的组件内没有子元素,请使用自闭合标签:
// good
<Button onPress={handlePress} />

// bad
<Button onPress={handlePress}></Button> 
  • 如果需要根据某些条件显示组件,则使用&&运算符:
// bad
function HelloComponent(props) {   if (isSomeCondition) {
return <p>Hello!</p>;   }
return null;  }

// bad
const HelloComponent = () => {
  return isSomeCondition ? <p>Hello!</p> : null
};

// ok (probably it will require some logic before return)
const HelloComponent = () => { return isSomeCondition && <p>Hello!</p> };

// almost good (isSomeCondition can be passed using props)
const HelloComponent = () => isSomeCondition && <p>Hello!</p>;

// best: use above solution but within encapsulating component
// this way HelloComponent is not tightly tied to isSomeCondition

const HelloComponent = () => <p>Hello!</p>;
const SomeComponent = () => (
    // <== here some component JSX ...
   isSomeCondition && <HelloComponent />
    // <== the rest of encapsulating component markup here
);

前述做法仅适用于其他选项为null的情况。如果 false 情况也是一个组件,您可以使用*b ? x : y*运算符,甚至是简单的if-else方法,但是它应符合您项目的最佳实践。

  • 如果使用*b ? x : y*运算符,则可能会发现大括号({})很有用:
const SomeComponent = (props) => (
<View>
 <Text>{props.isLoggedIn ? 'Log In' : 'Log Out'}</Text>
 </View> );
  • 您还可以使用大括号({})来解构 props 对象:
const SomeComponent = ({ isLoggedIn, ...otherProps }) => (
<View>
 <Text>{isLoggedIn ? 'Log In' : 'Log Out'}</Text>
 </View> );
  • 如果要将isLoggedIn传递为true,只需写入 prop 名称即可:
// recommended const OtherComponent = () => (
    <SomeComponent isLoggedIn />
);

// not recommended
const OtherComponent = () => (
    <SomeComponent isLoggedIn={true} />
);
  • 在某些情况下,您可能希望传递所有其他 props。在这种情况下,您可以使用展开运算符:
const SomeButton = ({ type , ...other }) => {
const className = type === "blue" ? "BlueButton" : "GrayButton";
  return <button className={className} {...other} />; }; 

初学者命名指南

命名可能听起来微不足道,但在 React 中有一些标准做法,您应该遵守。这些做法可能因项目而异,但请记住,您至少应该尊重这里提到的做法。在其他情况下,请检查您项目的样式指南,可能还有您的 linter 配置。

伟大的 React 样式指南之一来自 Airbnb,可以在github.com/airbnb/javascript/tree/master/react#naming上查看。

组件名称应以大写字母开头,除非它是 HOC。使用组件名称作为文件名。文件名应为 UpperCamelCase(有关 CamelCase 的更多信息,请参见en.wikipedia.org/wiki/Camel_case):

// bad
someSection.js
// good
SomeSection.js or SomeSection.jsx
// Current Airbnb style guide recommends .jsx extension though.

以下是有关导入组件的规则:

// bad
import App from './App/App';

// bad
import App from './App/index';

// good
import App from './App';

如果是 HOC,请使用小写字母的小驼峰命名法开始其名称,例如makeExpandable

Airbnb 还建议您注意内部组件的名称。我们需要指定displayName属性,如下所示:

// Excerpt from
// https://github.com/airbnb/javascript/tree/master/react#naming
// bad
export default function withFoo(WrappedComponent) {
 return function WithFoo(props) {
 return <WrappedComponent {...props} foo />;
  }
}

// good
export default function withFoo(WrappedComponent) {
  function WithFoo(props) {
 return <WrappedComponent {...props} foo />;
  }

  const wrappedComponentName = WrappedComponent.displayName
 || WrappedComponent.name
 || 'Component';

  WithFoo.displayName = `withFoo(${wrappedComponentName})`;
 return WithFoo;
}

这是一个有效的观点,因为在某些工具中,您可能会从看到正确的组件名称中受益。遵循此模式是可选的,并由团队决定。

可以创建一个 HOC 来处理displayName prop。这样的 HOC 可以在我们在第一章中创建的 HOC 之上重复使用,React 组件模式

在定义新的 props 时,请避免使用曾经表示其他含义的常见 props。一个例子可能是我们用来将样式传递给组件的 style prop。

请查看以下链接,了解应避免使用哪些 props:

不要太害怕。迟早会感觉更自然。

使用 PropTypes 进行类型检查

React 带有对基本类型检查的支持。它不需要您升级到 TypeScript 或其他更高级的解决方案。要立即实现类型检查,您可以使用prop-types库。

让我们为Chapter 1/Example 12中的HelloBox组件提供类型定义:

import PropTypes from 'prop-types';

// ...  HelloBox.propTypes = {
 isExpanded: PropTypes.bool.isRequired,
  expandOrCollapse: PropTypes.func.isRequired,
  containerStyles: PropTypes.object,
  expandedTextStyles: PropTypes.object }; 

这样,我们强制isExpanded为布尔类型(truefalse),并且expandOrCollapse为函数。我们还让 React 知道两个可选的样式 props(containerStylesexpandedTextStyles)。如果未提供样式,我们将简单地返回默认样式。

在标记中还有一个很好的功能可以避免显式的if——默认 props。看一下:

HelloBox.defaultProps = {
    containerStyles: styles.container,
  expandedTextStyles: styles.text }; 

太棒了!现在,如果containerStylesexpandedTextStyles为 null,那么它们将获得相应的默认值。但是,如果您现在运行应用程序,您会注意到一个小警告:

Warning: Failed prop type: Invalid prop `containerStyles` of type `number` supplied to `HelloBox`, expected `object`.

你现在可能感到恐慌,但这是正确的。这是 React Native 团队做出的一个很好的优化,你可能不知道。它缓存样式表,只是发送缓存的 ID。以下行返回了表示传递的styles对象的样式表的数字和 ID:

styles.container

因此,我们需要调整我们的类型定义:

HelloBox.propTypes = {
    isExpanded: PropTypes.bool.isRequired,
  expandOrCollapse: PropTypes.func.isRequired,
  containerStyles: PropTypes.oneOfType([
 PropTypes.object,
        PropTypes.number
 ])**,**
  expandedTextStyles: PropTypes.oneOfType([
 PropTypes.object,
        PropTypes.number
 ])
};

现在,您可以在组件标记中删除显式的if语句。它应该看起来更或多如下所示:

export const HelloBox = ({
    isExpanded,
  expandOrCollapse,
  containerStyles,
  expandedTextStyles
}) => (
    <View style={containerStyles}>
 <HelloText onPress={() => expandOrCollapse()}>...</HelloText>
 <HelloText onPress={() => expandOrCollapse()}>...</HelloText>
  {
            isExpanded &&
            <HelloText style={expandedTextStyles}>
                ...
            </HelloText>
  }
    </View> );

干得好!我们已经为我们的组件定义了默认属性和类型检查。请查看src/chapter 2目录中的完整工作Example 2以获取更多详细信息。

请注意,从现在开始,所有的代码示例都将被拆分成几个模块化的源文件。所有文件将放在各自示例的./src目录下。

例如,Example 2的组织方式如下:

  • src

  • HelloBox.js

  • HelloText.js

  • makeExpandable.js

  • App.js

这个结构将随着应用程序的发展而发展。在第十章中,管理依赖关系,您将学习如何在拥有一百万行代码的大型项目中组织文件。

您需要了解的内置组件

React Native 正在快速发展并经常变化。我已经选择了一系列组件的精选列表,这些组件可能会在 API 中长期存在。我们将花一些时间学习它们,这样我们以后在这本书中就能更快地进行下去。任何进一步的示例都将依赖于这些组件,并假设您知道这些组件的用途。

ScrollView 组件

到目前为止,我们知道了三个组件:ViewTextStyleSheet。现在,想象一种情况,我们在应用程序中有很多行要显示——比如我脑海中浮现出的信息表。显然,这将是一个很长的表,但屏幕很小,所以我们将使其可滚动——上下滚动,就像在浏览器中一样。这在概念上可能看起来微不足道,但实现起来并不容易,这就是为什么 React Native 提供了ScrollView组件。

让我们看看这个问题是如何发生的。从Chapter 2文件夹中查看Example 3_ No ScrollView problem来开始。

在这里,我们有一个典型的TaskList组件,它将每个任务转换为一个Task组件。TaskText的形式显示其名称和描述。这是一个非常简单的机制,但是一旦任务数量庞大,比如 20 个或更多个任务,它就会填满整个屏幕,突然间你意识到你无法像在浏览器窗口中那样滚动:

// Chapter 2 / Example 3 / src / TaskList.js
export const TaskList = ({tasks, containerStyles}) => (
    <View style={containerStyles}>
  {tasks.map(task => // problems if task list is huge
            <ExpandableTask
  key={task.name + task.description}
                name={task.name}
                description={task.description}
            />
  )}
    </View> );

为了解决这个问题并使内容可滚动,将View替换为ScrollView。您还需要将style属性重命名为contentContainerStyle。请参见完整示例,如下所示:

// Chapter 2 / Example 4 / src / TaskList.js import React from 'react'; import Task from './Task'; import PropTypes from 'prop-types'; import {StyleSheet, Text, ScrollView, View} from 'react-native'; import makeExpandable from './makeExpandable';   const ExpandableTask = makeExpandable(Task);   export const TaskList = ({tasks, containerStyles}) => (
     <**ScrollView** contentContainerStyle={containerStyles}>
  {tasks.map(task =>
             <ExpandableTask key={task.name + task.description}
                name={task.name}
                description={task.description}
             />
  )}
 </**ScrollView**> );   const styles = StyleSheet.create({
 container: {
 backgroundColor: '#fff'     }
});   TaskList.propTypes = {
 tasks: PropTypes.arrayOf(PropTypes.shape({
 name: PropTypes.string.isRequired,
 description: PropTypes.string.isRequired
  })),
  containerStyles: PropTypes.oneOfType([
         PropTypes.object,
  PropTypes.number
     ])
};   TaskList.defaultProps = {
 tasks: [],
 containerStyles: styles.container };   export default TaskList;  

我还包括了PropTypes定义,这样您就可以练习我们在上一节中学到的内容。

注意在Task组件上使用key属性(key={task.name + task.description})。这在渲染集合时是必需的,以便 React 可以区分元素的属性更改,并在可能的情况下避免不必要的重绘组件。

图像组件

你经常会使用的下一个组件是Image组件。让我们用 React 标志扩展我们的任务列表。在每个任务之后,我们将展示 React 标志的.png 图片:

// Chapter 2_View patterns/ Example 5/src /Task.js // ...
**<Image** // styles just to make it smaller in the example  style={{width: 100, height: 100}}
 source={require("./**react.png**")}
**/>**
// ... 

请注意,目前并非所有图像类型都受支持。例如,SVG 图像将需要一个单独的库来工作。

您可以在官方文档中查看Image组件消耗的 props:facebook.github.io/react-native/docs/image。您会在这里找到有用的 props,比如loadingIndicatorSource——这是在加载大源图像时显示的图像。

文本输入组件

我们将在下一节经常使用这个组件。总体思路是能够从智能手机键盘传递数据。TextInput用于登录和注册表单以及用户需要向应用程序发送文本数据的许多其他地方。

让我们扩展第一章中的HelloWorld示例,“React 组件模式”,以接受一个名字:

// Chapter 2 / Example 6 / src / TextInputExample.js
export default class TextInputExample extends React.Component {
    state = {
        name: null
  };    render = () => (
        <View style={styles.container}>
  {this.state.name && (
                <Text style={styles.text}>
  Hello {this.state.name}
                </Text>
  )}
            <Text>Hands-On Design Patterns with React Native</Text>
 <Text>Chapter 2: View Patterns</Text>
 <Text style={styles.text}>
  Enter your name below and see what happens.
            </Text>
 <TextInput  style={styles.input}
 onChangeText={name => this.setState({name})}
            **/>**
 </View>  ); }
// ... styles skipped for clarity in a book, check source files.

如果用户在TextInput组件中输入文本,那么我们会在简短的问候语中显示输入的文本。条件渲染使用state来检查名字是否已经定义。当用户输入时,onChangeText事件处理程序被调用,并且我们传递的函数会用新的名字更新状态。

有时,本地键盘可能会与您的View组件重叠,并隐藏重要信息。如果您的应用程序出现这种情况,请熟悉KeyboardAvoidingView组件。

查看facebook.github.io/react-native/docs/keyboardavoidingview.html获取更多信息。

按钮组件

Button是一个常见的组件,你会发现自己在任何类型的应用程序中使用它。让我们用上下按钮构建一个小的like计数器:

// Chapter 2 / Example 7 / src / LikeCounter.js
class LikeCounter extends React.Component {
    state = {
        likeCount: 0
  }
    // like/unlike function to increase/decrease like count in state
    like = () => this.setState({likeCount: this.state.likeCount + 1})
    unlike = () => this.setState({likeCount: this.state.likeCount - 1})

    render = () => (
        <View style={styles.container}>
 <Button  onPress={this.unlike}
                title="Unlike"
  />
 <Text style={styles.text}>{this.state.likeCount}</Text>
 <Button  onPress={this.like}
                title="Like"
  />
 </View>  ); }
// Styles omitted for clarity

对这个概念的进一步修改可以实现对评论的点赞/踩或者对评论的星级评价系统。

Button组件非常有限,习惯于 Web 开发的人可能会感到惊讶。例如,您不能以 Web 方式设置文本,例如<Button>Like</Button>,也不能传递样式属性。如果您需要为按钮设置样式,请使用TouchableXXXX。查看下一节以获取TouchableOpacity的示例。

不透明的触摸

当按钮需要自定义外观时,很快似乎需要更好的替代方案。这就是TouchableOpacity发挥作用的地方。当内部内容需要变得可触摸时,它可以满足任何目的。因此,我们将制作自己的按钮并根据需要进行样式设置:

class LikeCounter extends React.Component {
    state = {
        likeCount: 0
  }
    like = () => this.setState({likeCount: this.state.likeCount + 1})
    unlike = () => this.setState({likeCount: this.state.likeCount - 1})

    render = () => (
        <View style={styles.container}>
 <TouchableOpacity  style={styles.button}
 onPress={this.unlike}
            **>**
 <Text>Unlike</Text>
 **</TouchableOpacity>** <Text style={styles.text}>{this.state.likeCount}</Text>
 <TouchableOpacity  style={styles.button}
 onPress={this.like}
            **>**
 <Text>Like</Text>
 **</TouchableOpacity>** </View>  ); }

以下是一些示例样式。我们将在第三章中深入探讨样式模式:

const styles = StyleSheet.create({
    container: {
        flexDirection: 'row',
  paddingTop: 20,
  paddingLeft: 20
  },   button: {
 alignItems: 'center', // horizontally centered
  justifyContent: 'center', // vertically centered
  backgroundColor: '#DDDDDD',
  padding: 20
  }**,**
  text: {
        fontSize: 45
  }
}); 

按钮的内容在垂直和水平方向上都居中。我们有一个自定义的灰色背景颜色和按钮内的填充。填充是从子元素到组件边框的空间。

现在我们知道了这些简单的组件,我们准备进一步探索如何构建表单以及如何处理更复杂的用例。

构建表单

在本节中,我们将探讨如何处理用户的文本输入。从所谓的表单中收集输入的传统方式分为两种主要方式:受控和不受控。在本机环境中,这意味着要么在 React Native 端处理任何按键(受控输入),要么让其在本机系统级别上处理并根据需要在 React 中收集数据(不受控输入)。

如果您来自 Web 开发背景,请注意,写作本书时,没有表单组件,我也看不到它的出现。对于引用和您可以使用它们的方式也有限制。例如,您不能要求TextInput的引用获取其当前值。请查看以下两个小节以获取更多详细信息。您也可以使用自定义库,但我不会在这里讨论这样的解决方案,因为这些解决方案往往经常变化。

受控输入

受控输入是在 JavaScript 端处理所有用户输入的输入,很可能是在 React 状态或其他状态替代品中(有关更多信息,请参见第五章 Store Patterns)。这意味着,当用户输入时,按键在本地系统级别和 JavaScript 级别都被记住。当然,这可能是低效的,不应该在复杂的 UI 中使用,这在移动世界中似乎是罕见的。

还记得本章前面的带有你的名字的 hello world示例吗?这是受控输入的一个完美例子。让我们再看一遍:

// Chapter 2_ View patterns/Example 6/src/TextInputExample.js

export default class TextInputExample extends React.Component {
    state = {
 name: null
  }**;**    render = () => (
        <View style={styles.container}>
  {this.state.name && (
                <Text style={styles.text}>
  Hello {this.state.name}
                </Text>
  )}  ...  <TextInput  style={styles.input}
                onChangeText={name => this.setState({name})}
            />
 </View>  ); }

我们监听文本的每一次改变(onChangeText),然后立即更新组件状态(this.setState({name}))。状态成为唯一的真相来源。我们不需要请求本地组件的值。我们只关心状态中的内容。因此,我们使用状态来显示新的Hello消息,以及输入的文本。

让我们看看在一个更复杂的例子中它是如何工作的。我们的任务是创建一个登录表单,其中包括登录TextInput、密码TextInput和一个显示文本为“登录”的Button组件。当用户按下按钮时,它应该将信息记录到我们的调试控制台。在一个真实的应用程序中,你会将登录详情传递给服务器进行验证,然后登录用户。你将在第五章 Store Patterns中学习如何做到这一点,当我们讨论副作用时:

// Chapter 2 / Example 9 / src / LoginForm.js

export default class LoginForm extends React.Component {
    // Initial state for our components
  state = {
        login: this.props.initLogin || '', // remembered login or ''
  password: ''
  };
  // Submit handler when the Login button is pressed
  submit = () => {
        console.log(this.state.login);
  console.log(this.state.password);
  };    render() {
        return (
            <View style={styles.container}>
 <View> <TextInput  style={styles.input}
                        placeholder={'Login'}
                        onChangeText={login => this.setState({login})}
                    />
 </View> <View> <TextInput  style={styles.input}
                        placeholder={'Password'}
                        onChangeText={
                            password => this.setState({password})
                        }
                        secureTextEntry={true} // hide password
  />
 </View> <View> <Button  onPress={this.submit}
                        title="Login"
  />
 </View> </View>  );
  }
}

请注意这里的三个重要事项:

  • 它提供了传递记住的登录文本的能力。完整的功能需要在物理设备内存中记住登录信息,因此我为了清晰起见省略了这一点。

  • TextInputsecureTextEntry属性可以将密码隐藏在点后面。

  • 在按钮组件上设置onPress处理程序,以便它可以对收集到的数据进行操作。在这个简单的例子中,我们只是将日志记录到调试控制台。

不受控输入

React Native 中的不受控输入并不是在 Web 开发中的真实情况。事实上,TextInput不能完全不受控制。你需要以某种方式监听数值的变化:

  • onChangeText在文本输入改变时触发

  • onSubmitEditing在按下文本输入的提交按钮时触发

另外,TextInput本身就是一个受控组件。进一步查看解释。很久以前,它曾经有一个叫做controlled的属性,允许您指定一个布尔值,但是这已经改变了。当时的文档指定了以下内容:

"如果您真的希望它表现得像一个受控组件,您可以将其设置为 true,但是您可能会看到闪烁、丢失的按键和/或输入延迟,这取决于您如何处理 onChange 事件。"

我意识到 React Native 团队在解决这些问题上付出了很多努力,并且他们修复了TextInput。然而,TextInput在某种程度上变成了受控输入。例如,TextInput上的选择由 React Native 在componentDidUpdate函数中进行管理。

"选择也是一个受控属性。如果本地值与 JS 值不匹配,则更新为 JS 值。"

除非您指定onChangeTextvalue属性,否则您的组件似乎不会有任何额外的开销。

事实上,您仍然可以使用引用。查看以下示例,了解如何使用 React 的最新 API:

// Chapter 2 / Example 10 / App.js

export default class App extends React.Component {
    constructor(props) {
        super(props);    this.inputRef = React.createRef()**;**
  }

    render = () => (
        <TextInput style={{height:50}} ref={ref => this.inputRef = ref} **/>**
  );    componentDidMount() {
        this.inputRef.focus()**;**
  }
}

然而,有一些限制。您不能要求输入值的引用。可悲的是,我觉得这种情况不太可能改变。如果你从另一个角度来看,这种情况更自然。你可能只需要受控组件。目前,非受控组件的好处在于性能,并没有太大的不同。因此,我怀疑你在 React Native 中是否需要非受控组件。我甚至无法想出一个需要大量非受控组件的用例,因为性能问题。

我能做的最接近让组件独立的是使用onSubmitEditingonEndEditing。这样的回调可以像onChangeText属性一样使用。它们直到用户按下本机键盘上的提交/返回按钮才会触发。不幸的是,您可能可以想象到当用户按下预期的按钮而不是按下登录按钮时的情况。在这种情况下,状态不会更新为最新数据,因为本机键盘仍然打开。这样的细微差别可能导致不正确的数据提交和关键错误。要小心。

如果您正在使用 React 开发网站,请不要因为这一部分而感到沮丧。refs 对于棕地网站非常有用,对于那些无法将现有部分重写为 React 的人也很有用。如果这是您的情况,请还要查看 React v16 的门户 API[https://reactjs.org/docs/portals.html](https://reactjs.org/docs/portals.html)。

错误边界介绍

这是 React 版本 16 中带来的一个被忽视的功能。正如您应该已经知道的,JavaScript 可能会抛出错误。这样的错误不应该破坏您的应用程序,特别是如果它来自金融部门。JavaScript 的常规命令式解决方案是try-catch块:

try {
    // helloWorld function can potentially throw error
    helloWorld(); } catch (error) {
    // If helloWorld throws error
    // we catch it and handle gracefully
    // ... }

这种方法在 JSX 中很难使用。因此,React 团队为 React 视图开发了一种替代解决方案。它被称为“错误边界”。任何类组件都可以成为ErrorBoundary组件,只要它实现了componentDidCatch函数:

class AppErrorBoundary extends React.Component {
    state = { hasError: false };    componentDidCatch() {
        this.setState({ hasError: true });
  }

    render = () => (
        this.state.hasError
  ? <Text>Something went wrong.</Text>
  : this.props.children
  )
}

export default () => (
    <AppErrorBoundary>  <LoginForm /> </AppErrorBoundary**>** )

如果您跟随这些示例,您可能会看到一个带有错误的红色屏幕。这是开发模式下的默认行为。您将不得不关闭屏幕才能看到应用程序的外观:错误边界将按预期工作。如果切换到发布模式,错误屏幕将不会出现。

LoginForm现在被包装在ErrorBoundary中。它捕获渲染LoginForm时发生的任何错误。如果捕获到Error,我们会显示一个简短的消息,说明“出了点问题”。我们可以从错误对象中获取真正的错误消息。但是,与最终用户分享它并不是一个好的做法。相反,将其发送到您的分析服务器:

// Chapter 2_View patterns/Example 11/ App.js
...
**componentDidCatch**(error) {
    this.setState({
        hasError: true,
  errorMsg: error
    }); }

render = () => (
    this.state.hasError
  ? (
            <View>
 <Text>Something went wrong.</Text>
 <Text>{this.state.errorMsg.toString()}**</Text>**
 </View>  )
        : this.props.children )
...

错误边界如何捕获错误

错误边界似乎是用来捕获阻止渲染成功完成的运行时错误的。因此,它们非常特定于 React,并且是使用类组件的特殊生命周期钩子来实现的。

错误边界不会捕获以下错误:

  • 事件处理程序

  • 异步代码(例如,setTimeoutrequestAnimationFrame回调)

  • 服务器端渲染

  • 错误边界本身抛出的错误(而不是其子组件)

让我们进一步讨论之前提到的错误边界的限制:

  • 事件处理程序:这个限制是由于事件处理程序的异步性质。回调是由外部函数调用的,并且事件对象作为参数传递给回调。我们对此没有任何控制,也不知道何时会发生。代码被执行,永远不会进入 catch 子句。提示:这也以同样的方式影响try-catch

  • 异步代码:大多数异步代码不会与错误边界一起工作。这个规则的例外是异步渲染函数,这将在未来的 React 版本中推出。

  • 服务器端渲染:这通常涉及服务器端渲染的网站。这些网站是在服务器上计算并发送到浏览器的。由于这个原因,用户可以立即看到网站的内容。大多数情况下,这样的服务器响应会被缓存和重复使用。

  • 错误边界本身抛出的错误:您无法捕获发生在同一类组件内部的错误。因此,错误边界应该包含尽可能少的逻辑。我总是建议为它们使用单独的组件。

理解错误边界

错误边界可以以许多不同的方式放置,每种方法都有其自己的好处。选择适合您用例的方法。有关想法,请跳转到下一节。在这里,我们将演示应用程序根据错误边界的放置方式而表现出的行为。

这个第一个例子在LikeCounter组件周围使用了两个错误边界。如果其中一个LikeCounter组件崩溃,另一个仍然会显示出来:

...
    <AppErrorBoundary>  <LikeCounter /> </AppErrorBoundary**>** <AppErrorBoundary>  <LikeCounter /> </AppErrorBoundary**>** **...** 

这第二个例子在两个LikeCounter组件周围使用了一个ErrorBoundary。如果一个崩溃,另一个也将被ErrorBoundary替换:

...
    <AppErrorBoundary>  <LikeCounter /> <LikeCounter /> </AppErrorBoundary**>** **...**

何时使用错误边界

ErrorBoundary绝对是一个很好的模式。它将try-catch的概念转化为声明性的 JSX。我第一次看到它时,立刻想到将整个应用程序包装在一个边界中。这没问题,但这不是唯一的用例。

考虑错误边界的以下用例:

  • 小部件:如果给定一些不正确的数据,您的小部件可能会遇到问题。在最坏的情况下,如果它无法处理数据,它可能会抛出错误。鉴于这个小部件对于应用程序的其余部分并不是至关重要的,您希望其余的应用程序仍然可用。您的分析代码应该收集错误并保存至少一个堆栈跟踪,以便开发人员可以修复它。

  • 模态框:保护应用程序的其余部分免受错误模态框的影响。这些通常用于显示一些数据和简短的消息。您不希望模态框炸毁您的应用程序。这样的错误应该被认为是非常罕见的,但“宁愿安全也不要后悔”。

  • 功能容器的边界:假设您的应用程序被划分为由容器组件表示的主要功能。例如,让我们以 Facebook Messenger 这样的消息应用为例。您可以向侧边栏、我的故事栏、页脚、开始新消息按钮和消息历史记录列表视图添加错误边界。这将确保,如果一个功能出现故障,其他功能仍有机会正常工作。

现在我们知道了所有的优点,让我们讨论一下缺点:混合。

为什么混合是反模式

使用混合模式,您可以将某种行为与您的 React 组件混合在一起。您可以免费注入一种行为,并且可以在不同的组件中重用相同的混合。这一切听起来都很棒,但实际上并不是这样——您很容易找到关于为什么的文章。在这里,我想通过示例向您展示这种反模式。

混合示例

与其大声喊叫混合是有害的,不如创建一个正在使用它们的组件,并查看问题所在。混合已经被弃用,因此第一步是找到一种使用它们的方法。事实证明,它们仍然以一种传统的方式创建 React 类组件。以前,除了 ES6 类之外,还有一个特殊的函数叫做createReactClass。在一个重大版本发布中,该函数从 React 库中删除,并且现在可以在一个名为'create-react-class'的单独库中使用:

// Chapter 2_View patterns/Example 12/App.js
...
import createReactClass from **'create-react-class'**;

const LoggerMixin = {
    componentDidMount: function() { // uses lifecycle method to log
        console.log('Component has been rendered successfully!');
  }
};   export default createReactClass({
    mixins: [LoggerMixin]**,**   render: function() {
        return (
            <View>
 <Text>Some text in a component with mixin.</Text>
 </View>  );
  }
});

在这里,我们创建了LoggerMixin,它负责记录必要的信息。在这个简单的例子中,它只是关于已呈现的组件的信息,但它可以很容易地进一步扩展。

在这个例子中,我们使用了componentDidMount,这是组件生命周期钩子之一。这些也可以在 ES6 类中使用。请查看官方文档以了解其他方法的见解:reactjs.org/docs/react-component.html#the-component-lifecycle

如果您需要更多的记录器,可以使用逗号将它们混合到单个组件中:

...
mixins: [LoggerMixin, LoggerMixin2],
...

这是一本关于模式的书,因此在这里停下来看一下createReactClass函数。

为什么它已经被弃用?答案实际上非常简单。React 团队更喜欢显式 API 而不是隐式 API。CreateReactClass函数是另一个隐式抽象,它会隐藏实现细节。与其添加一个新函数,不如使用标准方式:ES6 类。ES6 类也有自己的缺点,但这是另一个完全不同的话题。此外,您可以在其他基于 ECMAScript 构建的语言中使用类,例如 TypeScript。这是一个巨大的优势,特别是在现今 TypeScript 变得流行的时代。

要了解更多关于这种思维过程的信息,我建议您观看 Sebastian Markbåge 的一次精彩演讲,名为Minimal API Surface Area。它最初是在 2014 年的 JSConf EU 上发布的,可以在www.youtube.com/watch?v=4anAwXYqLG8找到。

使用 HOC 代替

我相信您可以轻松地将前面的用例转换为 HOC。让我们一起做这个,然后我们将讨论为什么 HOC 更好:

// Chapter 2_View patterns/ Example 13/ App.js
const withLogger = (ComponentToEnrich, logText) =>
    class WithLogger extends React.Component {
        componentDidMount = () => console.log(
            logText || 'Component has been rendered successfully!'
  );    render = () => <ComponentToEnrich {...this.props} />;
  };   const App = () => (
    <View style={styles.container}>
 <Text>Some text in a component with mixin.</Text>
 </View> );   export default withLogger(withLogger(App), 'Some other log msg');

您立即注意到的第一件事是 HOC 可以堆叠在一起。HOC 实际上是相互组合的。这样更加灵活,并且可以保护您免受在使用 Mixins 时可能发生的名称冲突。React 开发人员提到handleChange函数是一个问题示例:

"不能保证两个特定的 mixin 可以一起使用。例如,如果FluxListenerMixin定义了handleChange(),而WindowSizeMixin也定义了handleChange(),那么您不能将它们一起使用。您也不能在自己的组件上定义一个具有这个名称的方法。

如果您控制 mixin 代码,这并不是什么大问题。当出现冲突时,您可以在其中一个 mixin 上重命名该方法。但是,这很棘手,因为一些组件或其他 mixin 可能已经直接调用了这个方法,您需要找到并修复这些调用。"

- Dan Abramov 的官方 React 博客文章(reactjs.org/blog/2016/07/13/mixins-considered-harmful.html).

此外,混入可能会导致添加更多状态。从前面的例子来看,HOCs 可能会做同样的事情,但实际上不应该。这是我在 React 生态系统中遇到的问题。它给您很大的权力,您可能没有意识到您开始使用的模式是如此一般。对我来说,有状态的组件应该很少,有状态的 HOCs 也应该很少。在本书中,我将教您如何避免使用状态对象,而是更倾向于一种更好的解决方案,尽可能地将状态与组件解耦。我们将在第五章中进一步了解这一点,存储模式

代码检查工具和代码样式指南

在本节中,我们将看一下完全不同的一组模式,即如何构建代码的模式。多年来,已经有数十种样式的方法,一般规则是:人越多,越多种偏好的方式。

因此,设置项目的关键点选择您的样式指南,以及您定义的一套明确的规则。这将为您节省大量时间,因为它消除了任何潜在的讨论。

在高级集成开发环境的时代,可以在几秒钟内快速重新格式化整个代码库。如果您需要允许对代码样式进行小的未来更改,这将非常方便。

添加代码检查工具以创建 React Native 应用

按照以下步骤配置您自己的代码检查工具:

  1. 打开终端并导航到项目目录。cd命令用于更改目录将非常方便。

  2. 列出目录中的文件,并确保您位于根目录,并且可以看到package.json文件。

  3. 使用yarn add命令添加以下软件包。新添加的软件包将自动添加到package.json中。--dev将其安装在package.json的开发依赖项中:

yarn add --dev eslint eslint-config-airbnb eslint-plugin-import eslint-plugin-react eslint-plugin-jsx-a11y babel-eslint

ESLint 是我们将使用的代码检查工具,通过运行上述命令,您将已经将其安装在项目的node_modules目录中。

  1. 现在,我们准备为您的项目定义一个新的脚本。请编辑package.json,并在scripts部分下添加以下行:
"scripts": {
...
 "lint": "./node_modules/eslint/bin/eslint.js src"
...
}

前面的命令运行 ESLint 并向其传递一个参数。这个参数是将包含要进行代码检查的文件的目录的名称。如果你不打算跟着这本书一起学习,我们使用src目录来存储源 JavaScript 文件。

  1. 下一步是指定代码风格,更准确地说,是实现您的代码风格的代码检查器配置。在本例中,我们将使用一个众所周知的 Airbnb 样式指南。但是,我们还将对其进行调整,以符合我的首选风格。

首先,通过运行以下命令创建您的代码检查器配置:

./node_modules/eslint/bin/eslint.js --init
  1. 接下来将出现一个特殊提示。选择以下选项:
How would you like to configure ESLint? Use a popular style guide
Which style guide do you want to follow? Airbnb
Do you use React? Yes
What format do you want your config file to be in? JSON
  1. 将为您创建一个名为.eslintrc.json的配置文件。打开文件并添加以下规则。在下一节中,我将解释这些选择。现在,请使用给定的一组规则:
{
  "rules": {
    "react/jsx-filename-extension": [1, { "extensions": [".js"] }],
  "comma-dangle": ["error", "never"],
    "no-use-before-define": ["error", { "variables": false }],
  "indent": ["error", 4],
  "react/jsx-indent": ["error", 4],
    "react/jsx-indent-props": ["error", 4]
  },
  "parser": "babel-eslint", // usage with babel transpiler
  "extends": "airbnb" }
  1. 现在,您可以通过使用以下命令运行代码检查器:
yarn run lint 

完整的设置在第二章 _ 视图模式文件夹下的示例 14中提供。

Airbnb React 样式指南规则

Airbnb React 样式指南定义了数十个经过深思熟虑的规则。这是一个很好的资源,也是您下一个 React 项目的基础。我强烈建议您深入研究。您可以在github.com/airbnb/javascript/tree/master/react找到 Airbnb React 样式指南。

但是,每个人都应该找到自己的风格。我的风格只是从 Airbnb 中调整了一些东西。

  • comma-dangle:Airbnb 建议您在数组多行元素、列表或对象多行键值列表的末尾留下一个逗号。这不是我习惯的。我更喜欢 JSON 样式,它不会留下尾随逗号:
// My preference
const hero = {
  firstName: 'Dana',
  lastName: 'Scully'
};

const heroes = [
  'Batman',
  'Superman'
];

// Airbnb style guide
const hero = {
  firstName: 'Dana',
  lastName: 'Scully',
};

const heroes = [
  'Batman',
  'Superman',
];
  • react/jsx-filename-extension:在我看来,这个规则应该在样式指南中进行更改。它试图说服您在使用 JSX 的文件中使用.jsx扩展名。我不同意这一点。我想引用 Dan Abramov 在这个问题上的评论:

“.js 和.jsx 文件之间的区别在 Babel 之前是有用的,但现在已经不那么有用了。

还有其他语法扩展(例如 Flow)。如果使用 Flow 的 JS 文件应该如何命名?.flow.js?那使用 Flow 的 JSX 文件呢?.flow.jsx?还有其他一些实验性语法呢?.flow.stage-1.jsx?

大多数编辑器都是可配置的,因此您可以告诉它们在.js 文件中使用 JSX 语法方案。由于 JSX(或 Flow)是 JS 的严格超集,我认为这不是问题。

  • no-use-before-define:这是一个聪明的规则。它防止您使用稍后定义的变量和函数,尽管 JavaScript 的提升机制允许您这样做。但是,我喜欢将我的 StyleSheets 放在每个组件文件的底部。因此,我放宽了这个规则,允许在定义之前使用变量。

当我将片段复制到这本书中时,我也更喜欢使用四个空格的缩进来提高清晰度。

修复错误

由于我们已经设置了 linter,我们可以在以前的项目中尝试它。

如果您想跟着这个例子,只需从第二章中复制Example 9_Controlled TextInputView Patterns,并在复制的项目中设置一个 linter。之后,执行以下命令,该命令在源目录上执行您的 linter 脚本。

我在Example 9_ Controlled TextInputLoginForm.js上尝试了它。不幸的是,它列出了一些错误:

$ yarn run lint
yarn run v1.5.1 $ ./node_modules/eslint/bin/eslint.js src

/Users/mateuszgrzesiukiewicz/Work/reactnativebook/src/Chapter 2: View patterns/Example 14: Linter/src/LoginForm.js
2:8 error    A space is required after '{' object-curly-spacing
2:44 error    A space is required before '}' object-curly-spacing
7:27 error    'initLogin' is missing in props validation    react/prop-types
12:9 warning  Unexpected console statement                  no-console
13:9 warning  Unexpected console statement                  no-console
22:37 error    Curly braces are unnecessary here             react/jsx-curly-brace-presence
23:62 error    A space is required after '{' object-curly-spacing
23:68 error    A space is required before '}' object-curly-spacing
29:37 error    Curly braces are unnecessary here             react/jsx-curly-brace-presence
31:55 error    A space is required after '{' object-curly-spacing
31:64 error    A space is required before '}' object-curly-spacing
33:25 error    Value must be omitted for boolean attributes  react/jsx-boolean-value
49:20 error    Unexpected trailing comma                     comma-dangle

 13 problems (11 errors, 2 warnings)
10 errors, 0 warnings potentially fixable with the `--fix` option.

13 个问题!幸运的是,ESLint 可以尝试自动修复它们。让我们试试。执行以下操作:

$ yarn run lint -- --fix

很好 - 我们将问题减少到了只有三个:

7:27 error 'initLogin' is missing in props validation react/prop-types
12:9 warning Unexpected console statement no-console
13:9 warning Unexpected console statement no-console

我们可以跳过最后两个。这些警告是相关的,但控制台对于这本书来说很方便:它提供了一个打印信息的简单方法。在生产中不要使用console.log。然而,'initLogin'在 props 验证 react/prop-types 中丢失是一个有效的错误,我们需要修复它:

LoginForm.propTypes = {
    initLogin: PropTypes.string
};

LoginForm现在已经验证了它的 props。这将修复 linter 错误。要检查这一点,请重新运行 linter。看起来我们又遇到了另一个问题!正确的链接是:

error: propType "initLogin" is not required, but has no corresponding defaultProp declaration react/require-default-props

这是真的 - 如果未提供initLogin,我们应该定义默认的 props:

LoginForm.defaultProps = {
    initLogin: '' };

从现在开始,如果我们没有明确提供initLogin,它将被分配一个默认值,即一个空字符串。重新运行 linter。它现在会显示一个新的错误:

error 'prop-types' should be listed in the project's dependencies. Run 'npm i -S prop-types' to add it import/no-extraneous-dependencies

至少这是一个简单的问题。它正确地建议您明确维护prop-types依赖关系。

通过在控制台中运行以下命令添加prop-types依赖项:

yarn add prop-types

重新运行 linter。太好了!最终,没有错误了。干得好。

总结

在本章中,我们学习了以后在本书中会非常有用的视图模式。现在我们知道如何编写简洁的 JSX 和类型检查组件。我们还可以组合来自 React Native 库的常见内置组件。当需要时,我们可以编写简单表单的标记并知道如何处理输入。我们比较了受控和不受控输入,并深入了解了TextInput的工作原理。如果出现错误,我们的错误边界将处理这个问题。

最后,我们确保了我们有一个严格的风格指南,告诉我们如何编写 React Native 代码,并且我们通过使用 ESLint 来强制执行这些规则。

在下一章中,我们将致力于为我们学到的组件进行样式设置。由此,我们的应用程序将看起来漂亮而专业。

第三章:样式模式

现在是为我们的应用程序添加一些外观的时候了。在本章中,我们将探索独特的样式解决方案和机制。React Native StyleSheet 可能类似于 Web 层叠样式表(CSS);然而,原生应用程序的样式是不同的。语法上的相似之处很快就结束了,您应该花一些时间来学习样式的基础知识。在本书的后面,我们将使用一个提供现成样式的外部库。对于您来说,了解如何自己制作这样的组件至关重要,特别是如果您计划在 React Native 团队中专业工作,他们提供定制设计。

在本章中,我们将涵盖以下主题:

  • 在 React Native 环境中为组件设置样式

  • 处理有限的样式继承

  • 使用密度无关像素

  • 使用 Flexbox 定位元素

  • 处理长文本问题

  • 使用 Animated 库制作动画

  • 使用每秒帧数(FPS)指标来测量应用程序的速度

技术要求

与前几章一样,我已经将每个示例分成一个独立的应用程序,您可以在手机或模拟器上启动。要做这些示例,您将需要以下内容:

React Native 样式的工作原理”

“React 的核心前提是 UI 只是数据投影到不同形式的数据中。相同的输入产生相同的输出。一个简单的纯函数。”

您将在本书的后面学习纯函数。查看以下示例以了解基础知识:

// Code example from React readme. Comments added for clarity.

// JavaScript pure function
// for a given input always returns the same output
function NameBox(name) {
    return { fontWeight: 'bold', labelContent: name };  }

// Example with input
'Sebastian Markbåge' ->
{ fontWeight: 'bold', labelContent: 'Sebastian Markbåge' };

回到更实际的例子,让我们看看在 React Native 中如何实现前提。

“使用 React Native,您不需要使用特殊的语言或语法来定义样式。您只需使用 JavaScript 为应用程序设置样式。所有核心组件都接受一个名为style的属性。样式名称和值通常与 Web 上的 CSS 工作方式相匹配,只是名称使用驼峰式命名,例如 backgroundColor 而不是 background-color。

样式属性可以是一个普通的 JavaScript 对象。(...) 您还可以传递一个样式数组 - 数组中的最后一个样式具有优先权,因此您可以使用它来继承样式。

随着组件复杂性的增加,通常更清晰的做法是使用 StyleSheet.create 在一个地方定义多个样式。

总之,我们有三种定义组件样式的方式:

  • 使用样式属性并传递一个包含键值对的对象,表示样式。

  • 使用样式属性并传递一个对象数组。每个对象应包含表示样式的键值对。数组中的最后一个样式具有优先权。可以使用这种机制来继承样式或像阴影函数和变量一样阴影它们。

  • 使用 StyleSheet 组件及其 create 函数来创建样式。

在下面的示例中,您可以找到定义样式的三种方式:

// src/ Chapter_3/ Example_1_three_ways_to_define_styles/ App.js

export default () => (
    <View>
 <Text style={{ color: 'green' }}>inline object green</Text>
 <Text style={styles.green}>styles.green green</Text>
 <Text style={[styles.green, styles.bigred]}>
  [styles.green, styles.bigred] // big red
        </Text>
 <Text style={[styles.bigred, styles.green]}>
  [styles.bigred, styles.green] // big green
        </Text>
 </View> );   const styles = StyleSheet.create({
    green: {
        color: 'green'
  },
  bigred: {
        color: 'red',
  fontSize: 35
  }
});

注意使用对象数组的用例。您可以结合先前学到的技巧来实现条件样式:

<View>
 <Text  style={[
            styles.linkStyle,
  this.props.isActive && styles.activeLink
        ]}
    >
  Some link
    </Text> </View> 

另外,让我们讨论一下为什么我们使用StyleSheet组件而不是内联样式:

  • 代码质量:

  • 通过将样式从渲染函数中移出,可以使代码更容易理解。

  • 给样式命名是向渲染函数中的低级组件添加含义的好方法。

  • 性能:

  • 将样式对象转换为样式表,可以通过 ID 引用它,而不是每次都创建一个新的样式对象。

  • 它还允许您通过桥只发送样式一次。所有后续使用都将引用一个 ID(尚未实现)。

  • React Native 官方文档

facebook.github.io/react-native/docs/stylesheet.html.

在质量和可重用性方面,StyleSheet 将样式和组件标记分离。甚至可以将这些样式提取到一个单独的文件中。此外,正如文档中所述,它可以使您的标记更容易理解。您可以看到一个有意义的名称,比如styles.activeLink,而不是一个庞大的样式对象。

如果您低估了应用程序中的解耦性,那么请尝试将代码基础扩展到超过 5,000 行。您可能会发现一些紧密耦合的代码需要一些技巧才能重用。不良实践会滚雪球,使代码基础非常难以维护。在后端系统中,它通常与单片结构相辅相成。拯救的惊人主意是微服务。在en.wikipedia.org/wiki/Microservices了解更多。

令人惊讶的样式继承

当我们开始使用样式时,理解 React Native 样式不像网站的 CSS 是至关重要的。特别是在继承方面。

父组件的样式不会被继承,除非它是一个Text组件。如果是Text组件,它只会从父组件继承,只有父组件是另一个Text组件时才会继承:

// src/ Chapter_3/ Example_2_Inheritance_of_Text_component/ App.js

export default () => (
    <View style={styles.container}>
 <Text style={styles.green}>
  some green text
            <Text style={styles.big}>
  some big green text
            </Text>
 </Text> </View> );   const styles = StyleSheet.create({
    container: {
        marginTop: 40
    },
    green: {
        color: **'green'**
  },
  big: {
        fontSize: **35**
  }
});

如果您运行此代码,您会看到显示的文本是绿色的,后面的部分也很大。具有大样式的Text从父Text组件继承了绿色。还请注意,整个文本都呈现在具有 40 dp 的顶部边距的View组件内,这是密度无关像素。跳转到学习无单位尺寸部分以了解更多。

有限继承的解决方法

想象一种情况,您希望在整个应用程序中重用相同的字体。鉴于前面提到的继承限制,您将如何做到这一点?

解决方案是我们已经学到的一个机制:组件组合。让我们创建一个满足我们要求的组件:

// src/ Chapter_3/ Example_3/ src/ AppText.js

const AppText = ({ children, ...props }) => (
    <Text style={styles.appText} {...props}>
  {children}
    </Text> );  // ... propTypes and defaultProps omitted for clarity   const styles = StyleSheet.create({
    appText: {
        fontFamily: **'Verdana'**
  }
});   export default AppText;

AppText组件只是包装了Text组件并指定了它的样式。在这个简单的例子中,它只是fontFamily

请注意,style对象中的fontFamily键接受字符串值,并且在平台之间可能不同(在 Android 上接受一些,在 iOS 上接受一些)。为了保持一致性,您可能需要使用自定义字体。设置相当简单,但需要一些时间,因此超出了本书的设计模式主题。要了解更多,请访问docs.expo.io/versions/latest/guides/using-custom-fonts

考虑如何编辑AppText以支持自定义样式,以便可以覆盖指定的键。

在这种情况下,样式对象覆盖是最好的解决方案吗?也许不是;您创建此组件是为了统一样式,而不是允许覆盖。但是,您可能会说需要创建另一个组件,比如HeaderText或类似的东西。您需要一种重用现有样式并仍然放大文本的方法。幸运的是,您仍然可以在这里使用Text继承:

// src / Chapter 3 / Example 3 / App.js
export default () => (
    <View style={styles.container}>
 **<AppText>**  some text, Verdana font
            <Text style={styles.big}**>**
  some big text, Verdana font
            </Text>  **</AppText>** <Text style={styles.big}>
  some normal big text
        </Text>
 </View> );

因此,HeaderText将非常容易实现。请查看以下代码:

// src / Chapter 3 / Example 3 / src / HeaderText.js
const HeaderText = ({ children, ...props }) => (
    <**AppText**>
 <Text style={styles.headerText} {...props}>
  {children}
        </Text>
 </**AppText**> );
// ...
const styles = StyleSheet.create({
    headerText: {
        fontSize: 30
  }
});

学习无单位的尺寸。

在这一部分,我们将学习 React Native 应用程序在屏幕上的尺寸。

"设置组件尺寸的最简单方法是在样式中添加固定的宽度和高度。在 React Native 中,所有尺寸都是无单位的,表示密度无关的像素。"

  • React Native 官方文档

facebook.github.io/react-native/docs/height-and-width.html

与 CSS 不同,对于样式属性如marginbottomtopleftrightheightwidth,您必须以 dp 或百分比提供值。

文档到此结束。但是在处理屏幕时,您还需要了解以下关键字:

  • 像素:这些是屏幕上可以控制的最小单元。每个像素通常由三个子像素组成:红色、绿色和蓝色。这些颜色通常被称为 RGB。

  • 尺寸:这是屏幕或窗口的宽度和高度。

  • 分辨率:这是每个维度上可以显示的像素数。

  • DPI/PPI:这是每英寸可以放置的点/像素数。

  • 点数:这是 iOS 上的一个抽象度量。

  • 密度无关的像素:这是 Android 上的一个抽象度量。

如果您想检查这些概念在 Java 中是如何实现的,请查看:

github.com/facebook/react-native/blob/master/ReactAndroid/src/main/java/com/facebook/react/uimanager/LayoutShadowNode.java

为了计算这些值,我们将需要widthheightscale。您可以从Dimensions对象中获取这些信息:

// src/ Chapter 3/ Example 4/ App.js

export default () => {
    const { height, width } = Dimensions.get('window');
  return (
        <View style={{ marginTop: 40 }}>
 <Text>Width: {width}, Height: {height}</Text>
 <View  style={{
                    width: width / 4,
  height: height / 3**,**
  backgroundColor: 'steelblue'
  }}
            />
 <View style={styles.powderblue} />
 </View>  ); };   const styles = StyleSheet.create({
    powderBlueBox: {
        width: Dimensions.get('window').width / 2,
  height: Dimensions.get('window').height / 5,
  backgroundColor: 'powderblue'
  }
});

然而,这段代码有问题。你能看出来为什么吗?如果你旋转设备,它就不会更新。

如果尺寸发生变化,我们需要强制重新渲染。我们可以通过注册自己的监听器使用Dimensions.addEventListener来检测尺寸变化。然后我们需要在这个监听器中强制重新渲染。通常人们使用state来这样做。React 检查state的变化并在发生变化时重新渲染:

// src/ Chapter_3/ Example_5_Listening_on_dimensions_change/ App.js

export default class LogDimensionChanges extends React.Component {
    state = { window: Dimensions.get('window') };
  componentWillMount() {
        // This lifecycle hook runs before component
        // is render for the first time
        Dimensions.addEventListener('change', this.handler)**;**
  }
    componentWillUnmount() {
        // This lifecycle hook runs after unmount
        // that is when component is removed
        // It is important to remove listener to prevent memory leaks
  Dimensions.removeEventListener('change', this.handler)**;**
  }
    handler = dims => this.setState(dims);    render() {
        const { width, height } = this.state.window**;**
  return (
            ...  <View  style={{
                        width: width / 4,
  height: height / 3,
  backgroundColor: 'steelblue'
  }}
                />
 <View style={styles.powderBlueBox} />
 ...  );
  }
}

const styles = StyleSheet.create({
    powderBlueBox: {
        width: Dimensions.get('window').width / 2,
  height: Dimensions.get('window').height / 5,
  backgroundColor: 'powderblue'
  }
});

在结果中,我们有一个适应尺寸变化的工作View。这是通过使用我们使用 React 生命周期方法(componentWillMountcomponentWillUnmount)注册的自定义事件监听器完成的。然而,另一个使用StyleSheetView没有适应。它无法访问this.state。StyleSheet 通常是静态的,以提供优化,例如只通过桥一次发送样式到本机。

如果我们仍然希望我们的StyleSheet样式适应?我们可以做以下之一:

  • 放弃 StyleSheet 并创建一个返回表示样式的对象的自定义函数,并将它们作为内联样式传递。如果这是目标,它将提供类似的解耦:
dynamicStyles(newWidth, newHeight) {
    return {
        // calculate styles using passed newWidth, newHeight
    }
}
...
render = () => (
<View
    style={
        this.dynamicStyles(this.state.window.width, this.state.window.height)
    }
>
...
</View>
)
  • 使用styles来覆盖标记中的语法:
<View
  style={[
        styles.powderBlueBox,
  {
            width: this.state.window.width / 2,
  height: this.state.window.height / 5 }
    ]}
/>
  • 使用StyleSheet.flatten来覆盖标记外的styles
const powderBlueBox = StyleSheet.flatten([
    styles.powderBlueBox, {
        width: this.state.window.width / 4,
  height: this.state.window.height / 5
  }
]);   return (
    ...  <View style={powderBlueBox} />
 ... );

与内联样式一样,要注意性能影响。当涉及到样式缓存时,你将失去优化。很可能,在每次重新渲染时,styles将被重新计算并再次通过桥发送。

绝对和相对定位

这一部分是关于定位事物的基础知识。在 React Native 中,默认情况下一切都是relative的。这意味着如果我把View嵌套到另一个具有marginTop: 40View中,这个定位也会影响我的嵌套View

在 React Native 中,我们也可以将定位改为absolute。然后位置将根据父级的固定像素数计算。在 StyleSheet 中使用top/bottom + left/right键。记住,其他视图不会考虑这个位置。如果你想让视图重叠,这很方便:

三个框重叠在一起,因为它们是绝对定位的。

查看以下代码,以查看前面三个重叠框的示例:

// src/ Chapter 3/ Example_6/ App.js

export default () => (
    <View>
 <View style={[styles.box]}>
 <Text style={styles.text}>B1</Text>
 </View> <View style={[styles.box, {
            left: 80,
  top: 80**,**
  backgroundColor: 'steelblue'
  }]}
        >
 <Text style={styles.text}>B2</Text>
 </View> <View style={[styles.box, {
            left: 120,
  top: 120**,**
  backgroundColor: 'powderblue'
  }]}
        >
 <Text style={styles.text}>B3</Text>
 </View> </View> );   const styles = StyleSheet.create({
    box: {
        position: 'absolute'**,**
  top: 40,
  left: 40**,**
  width: 100,
  height: 100,
  backgroundColor: 'red'
  },
  text: {
        color: '#ffffff',
  fontSize: 80
  }
});

组件根据它们在标记中的顺序进行渲染,所以B3覆盖B2B2覆盖B1

如果需要将一些组件放在顶部,请使用zIndex属性。

查看文档以获取更详细的解释:facebook.github.io/react-native/docs/layout-props.html#zindex

由于我们有三个absolute盒子,让我们看看如果将B2更改为relative会发生什么:

<View style={[styles.box, {
    position: 'relative'**,**
  backgroundColor: 'steelblue' }]}
>
 <Text style={styles.text}>B2</Text> </View>

突然B1消失了:

B2盒子现在相对于其父View。因此,其位置从父位置的左上角开始(因为我们没有填充或边距)。B1B2盒子大小相同;B2覆盖了B1的所有内容。如果我们使用{ width: 50, height: 50 }稍微缩小B2,我们将看到B1在下面。我还将B2的文本字体大小更改为40以便清晰。查看src/Chapter 3/Example 7目录中的App.js。结果如下:

现在我们已经了解了绝对定位和相对定位,是时候学习一个称为 Flexbox 的伟大模式了。

使用弹性盒模型

这是我在样式方面学到的最伟大的模式之一。弹性盒模型Flexbox)可以使您的盒子变得灵活。

让我们看一个小例子。目标是将您的盒子拉伸以填满屏幕的整个宽度:

// src/ Chapter_3/ Example_8/ App.js
export default () => (
    <View style={{ flex: 1 }}>
 <View  style={{ backgroundColor: 'powderblue', height: 50 }}
        />
 </View> );

以下是前述代码的结果:

由于我们使用了 flex: 1 样式,框延伸到整个屏幕宽度

这并不太花哨,但您不需要使用Dimensions。显然这只是一个开始。

您已经知道默认情况下视图是相对于彼此的,因此如果要制作一些条纹,只需将三个div堆叠在一起即可:

// src/ Chapter_3/ Example_8/ App.js

export default () => (
    <View style={{ flex: 1 }}>
 <**View**  style={{ backgroundColor: 'powderblue', height: 50 }}
        />
 <**View**  style={{ backgroundColor: 'skyblue', height: 50 }}
        />
 <**View**  style={{ backgroundColor: 'steelblue', height: 50 }}
        />
 </View> );  

查看以下屏幕截图,看到三个盒子横跨整个屏幕的宽度:

三个盒子依次排列,每个盒子都使用从父 View 组件继承的 flex: 1 进行拉伸

现在,让我们使用这个相当简单的概念来创建头部、主要内容和页脚组件。为了实现这一点,让我们拉伸中间的View

<View
  style={{ backgroundColor: 'skyblue', flex: 1 }}
/>

现在中间的View延伸以填充所有可用空间,为头部View留下 50 dp,为页脚View留下另外 50 dp。

现在是时候向我们分割的屏幕添加一些有用的内容了。

在接下来的章节中,我将尝试使用示例来解释 Flexbox。但请也查看 Flexbox Froggy 游戏,以了解不同情景下的 flexbox。它提供了一个交互式编辑器,你的目标是将青蛙移动到相应的叶子上github.com/thomaspark/flexboxfroggy/

使用 Flexbox 定位项目

第一个重要的关键是flexDirection。我们可以将其设置为rowrow-reversecolumncolumn-reverse。Flex 方向使内容沿着该方向流动。在 React Native 中,默认情况下,flex 方向设置为column。这就是为什么在前面的示例中,框以列的形式显示的原因。

让我们使用flexDirection在页脚中显示三个小部分:主页搜索关于

// src / Chapter 3 / Example 9 / App.js
...
<View
  style={{
        backgroundColor: 'steelblue',
  height: 70,
  flexDirection: **'row'**
  }}
>
 <View><Text style={{ fontSize: 40 }}>Home</Text></View>
 <View><Text style={{ fontSize: 40 }}>Search</Text></View>
 <View><Text style={{ fontSize: 40 }}>About</Text></View> </View>
...

好的,现在我们的页脚中有三个单独的文本。我们将学习如何在第七章中切换屏幕的方法,导航模式

我们的页脚看起来几乎没问题:

三个单独的页脚文本

现在是学习如何在 x 轴上均匀分布视图的时候了。如果flexDirection设置为rowrow-reverse,我们可以使用justifyContentjustifyContent接受flex-startflex-endcenterspace-betweenspace-aroundspace-evenly值。我们稍后会使用它们。现在,让我们使用space-between。它将拉伸主页视图,搜索视图和关于视图,以在它们之间留下均匀的空间:

...
    style={{
        backgroundColor: 'steelblue',
  height: 70,
  justifyContent: 'space-between'**,**
  flexDirection: **'row'**
  }}
...

结果如下:

页脚中的三个文本现在用均匀的空格分隔开来

虽然与 flexbox 无关,但我们可以添加一些填充使其更美观:

paddingLeft: 10, paddingRight: 10

这样文本更容易阅读:

右边和左边的填充从屏幕边缘添加空间

如果我们还想垂直定位怎么办?有一个叫做alignItems的关键。它接受flex-startflex-endcenterstretchbaseline值。

现在让我们把页脚的高度提高:100 个密度无关像素。此外,我们希望文本在垂直方向上居中:

// src / Chapter 3 / Example 10 / App.js
...
    style={{
        backgroundColor: 'steelblue',
  height: 100,
  alignItems: 'center'**,**
  justifyContent: 'space-between',
  flexDirection: 'row',
  paddingLeft: 10,
  paddingRight: 10
  }}
...

查看结果:

页脚中的文本现在垂直居中

样式化 flex 项

当我们构建应用程序时,您可能很快意识到样式有点丑陋。调色板是一个完全的灾难。除非您是设计师,我建议您搜索调色板生成器。我已经将颜色更改为更可接受的:白色,黑色和蓝色。

此外,我已经添加了边距和填充。标题和内容之间通过边框很好地分隔开来。让我们看看在 iPhone 8 和 iPhone X 上的效果如何:

在颜色更改后,iPhone 8 和 iPhone X 模拟器上的完整应用程序外观

有些人可能不了解样式的基础知识,所以让我们快速解释一下边距和填充是什么。边距用于在元素周围创建空间。这个空间是从元素的边框创建的。如果您只想在某个地方应用空间,您可以选择顶部、底部、左侧或右侧。填充非常类似,但它不是在外部创建空间,而是在内部创建空间。空间是从边框内部创建的。查看元素检查器以直观地理解这一点。我已经检查了我们应用程序的标题,以了解样式是如何工作的:

Header box 的边距和填充

在上一张截图中,填充用绿色标记,边距用橙色标记。组件空间是浅蓝色的。有关样式中指定的确切值,请查看图像的右侧部分。

要打开元素检查器,请摇动您的设备,当菜单打开时,选择切换元素检查器。如果您正在使用模拟器,您可以通过从模拟器菜单中选择硬件/摇动手势来模拟摇动。

以下是我用来创建header的样式:

header: {
    height: 45,
  borderBottomColor: '#000000',
  borderBottomWidth: 1,
  paddingLeft: 10,
  paddingRight: 10,
  marginBottom: 10 },
// All the other styles are available in
// src/ Chapter_3/ Example_11/ App.js

接下来,让我们使页脚更具重复使用性。如果在某个时候,我们不需要“关于”链接,而是需要“通知”链接呢?这个词真的很长。它不适合我们的设计。虽然现在是一个问题,但如果我们计划添加翻译,我们也会在那里遇到这个问题。

大多数应用程序使用图标来解决这些问题。让我们试试:

  1. 安装图标包:
yarn add @expo/vector-icons
  1. 更改页脚标记:
// src/ Chapter_3/ Example_11/ App.js
<View style={styles.footer}>
 <Ionicons name="md-home" size={32} color="white" />
 <Ionicons name="md-search" size={32} color="white" />
 <Ionicons name="md-notifications" size={32} color="white" /> </View> 

新增的图标可以在以下截图中观察到:

应用程序的页脚现在由图标组成

页脚现在是可重复使用的,并支持任何语言。如果您支持他们的语言,请检查其他国家的图标含义。

样式内容

我们已经使用方向行定位了页脚。现在是定位主要内容和列的时候了。在之前的章节中,我们创建了一个任务列表。现在是将其与我们的设计整合的时候了。

TaskList组件添加到内容框中。我还添加了ScrollView组件,以便在任务占用太多空间无法全部显示时使内容可滚动:

import data from './tasks.json';

// ... header
<**ScrollView** style={styles.content}>
 <**TaskList** tasks={data.tasks} /> </**ScrollView**>
// ... footer

我的任务模拟在 JSON 文件中呈现如下。在本书的后面,我们将学习如何从后端服务器获取任务以及如何将这样的逻辑与标记分离:

{
  "tasks": [
    {
      "name": "Task 1",
  "description": "Task 1 description...",
  "likes": 239
  },
 //... more comma separated tasks here
  ]
}

有了模拟,我们可以实现TaskList视图:

const TaskList = ({ tasks }) => (
    <View>
  {tasks.map(task => (
            <View key={task.name}>
 <Text>{task.name}</Text>
 <Text>{task.description}</Text>
 <LikeCounter likes={task.likes} />
 </View>  ))}
    </View> );
// separate component for each task is not created for book clarity 

LikeCounter是从Chapter 2 / Example 8 / src复制并调整以接受点赞作为 props(替换默认的零)。请注意,它也使用了 Flexbox,并且flexDirection设置为行。

现在,我们准备样式内容。这是我们的起点:

iPhone 8 和 iPhone X 模拟器的当前外观

我们想重新组织每个任务的内容。点赞取消点赞小部件应该显示在任务的右侧,并且应该使用图标。任务名称应该比描述稍大,并且应该适合任务宽度的 70%。右侧的点赞/取消点赞小部件应该用细灰色边框分隔。边框也应该分隔任务。在必要的地方添加漂亮的填充和边距:

iPhone 8 和 iPhone X 模拟器的期望外观

好的,我们如何开始?我们需要将事情分解成可以分别实现的小块。创建以下内容:

  • 具有任务容器样式和顶部边框样式的任务View

  • 两个内部Views - 一个用于名称和描述,另一个用于点赞计数器。这些应该以行的形式显示。

  • 名称和描述View内应该有两个Views:一个用于名称,一个用于描述。添加样式使名称的fontSize更大。

  • 点赞计数器View容器应该在左边定义边框。容器内应该有两个Views:一个用于点赞数量,另一个用于点赞/取消点赞图标。这些Views应该使用列作为默认方向。

  • 具有点赞/取消点赞图标的View应该具有行方向的 flexbox 样式。

有了这个,使用alignItemsjustifyContent来垂直或水平定位元素。请从检查器中查看辅助图像:

已实现组件的检查器视图。作为实现的提示。

橙色高亮表示View边距,绿色高亮表示View填充。

尝试自己实现这个。完整的解决方案可以在src/ Chapter_3/ Example_12/ src/文件夹中的App.jsTaskList.jsLikeCounter.js文件中找到。

解决文本溢出问题

最常见的问题之一是文本溢出。解决这个问题最简单的方法是换行,但有时不可能。例如:

  • 按钮文本

  • 需要显示的大数字(例如,点赞数)

  • 不应该被分解的长单词

问题是:我们如何解决这个问题?有很多解决方案。让我们看看其中一些。

缩小字体

这在 iOS 上是可能的。

<Text
  style={styles.text}
    numberOfLines={1}
    **adjustsFontSizeToFit** >
  {this.state.likeCount}
</Text>

但是,在我们的情况下,结果是完全灾难性的。即使我们在这个缩放解决方案上付出了一些工作,布局仍然感觉非常不一致:

使用 iOS 的 adjustsFontSizeToFit 属性进行自动字体调整正如本书前面所示,您可以使用Dimensions而不是依赖adjustsFontSizeToFit。基于Dimensions,您可以创建一个缩放函数来计算fontSize

截断文本

另一种方法被称为截断。根据文本长度,您可以在某个位置截断它,并用三个点...代替。然而,这种方法对我们的用例不好。我们处理的是点赞数,我们想知道数字是多少:

<Text style={styles.text}>
  {
        this.state.likeCount.toString().length > 4
  ? `${this.state.likeCount.toString().substring(0, 4)}**...`**
  : this.state.likeCount
  }
</Text>

观察以下截断的点赞数:

截断的数字是没有意义的,这个解决方案只适用于文本

使用千位分隔符社交媒体表示法

您知道 kilo 表示 1,000。社交媒体设计师将这个想法推广到了网络和移动设备。每当一个数字大于 1,000 时,他们用 K 替换最后的 3 位数字。例如 20K 表示 20,000。

微不足道的实现:

const likes = this.state.likeCount.toString();
...
<Text style={styles.text}>
  {
        likes.length > 3
  ? `${likes.substring(0, likes.length - 3)}**K`**
  : likes   }
</Text>

然而,一个数字如9,876,543,210将再次溢出。但 9,876,543K 仍然太长。让我们用一个简单的递归函数来解决这个问题:

// src / Chapter 3 / Example 12 / src / LikeCounter.js

kiloText = (nr, nrK = 0) => (nr.length > 3
  ? this.kiloText(nr.substring(0, nr.length - 3), nrK + 1)
    : nr + Array(nrK).fill('K').join(''))

该算法的工作原理如下:

该函数接受一个字符串格式的数字和一个可选参数,指示原始数字已经剥离了多少千。

它检查是否可以再减去一千,如果可以,就返回自身的结果,其中数字减去三个数字,千位数增加一。

如果数字长度小于四,计算文本:取数字并附加相应数量的 K 作为后缀。我们使用一个巧妙的技巧来计算 K:创建一个大小等于 K 数量的数组,用 K 字符串填充每个元素,并将所有元素连接成一个长字符串。现在 JSX 简单多了:

<Text style={styles.text}>
  {this.kiloText(likes)}
</Text> 

检查结果如下。长数字现在使用千位符号显示:

现在使用千(K)符号显示大的点赞数

可以肯定地说,点赞数不会超过 9,000,000,000。如果需要支持更大的数字,请尝试使用MB字母。

React Native 动画

当我们构建应用程序时,我们需要关注用户体验UX)。其中一部分是使我们的屏幕更加生动并提供对操作的即时反馈的动画。如果你自己玩过我们的应用程序,你会发现当你点击喜欢/不喜欢图标时,它会有一个小闪烁效果。这种效果是由TouchableOpacity自带的。现在是时候学习如何在我们自己的应用程序中实现这样的功能了。

什么是动画?

当我第一次阅读 Animated 库的文档时,我吓了一跳。有很多新词汇需要你适应。与其直接深入其中,不如先了解动画到底是什么。

动画是组件样式随时间的变化。

记住:你需要一个样式属性,它的起始值和结束值。动画是当这个值随着时间从起始值到结束值时所看到的。你可以组合许多属性,可能同时对许多组件进行动画处理。

存储随时间变化的变量的常见和推荐方法是组件状态。React Native Animated 提供了一个特殊的类,以非常高效的方式实现了这个功能:Animated.Value。例如:

state = {
    fadeIn: new Animated.Value(0)
}

随时间改变属性

在 React Native 中,有三种主要的创建动画的方式:

  • Animated.timing(): 以毫秒为单位的时间和期望的结束值,并将它们映射到你的Animated.Value

  • Animated.decay(): 从初始速度开始,然后慢慢衰减。

  • Animated.spring(): 提供了一个简单的弹簧物理模型。

让我们看看它是如何运作的。我们的目标是在应用程序启动时淡入应用程序。为了实现淡入效果,我们将从 0 到 1 操纵不透明度。动画应该持续两秒:

显示随时间推移不透明度动画进度的图像序列

Animated.timing需要两个参数:要操作的变量和配置对象。在配置对象中,您需要指定toValue键,以告诉函数在毫秒的持续时间后您的变量应该是什么结束值 - 在我们的情况下是 2,000。我选择了两秒只是为了让动画更容易看到。随意尝试:

// src/ Chapter_3/ Example_13/ src/ App.js
class App extends React.Component {
    state = {
        fadeIn: new Animated.Value(0)
    }

    componentDidMount() {
        this.fadeInApp();
  }

    fadeInApp() {
        Animated.timing(
 this.state.fadeIn,
  {
 toValue: 1,
  duration: 2000,
  easing: Easing.linear
  }
 ).start()**;**
  }

    render = () => (
        <**Animated.View**
  style={[
                styles.appContainer,
  { opacity: this.state.fadeIn }
            ]}
        >
 ... // rest of render removed for clarity  </**Animated.View**>  )
}

我们还引入了一个新组件:Animated.View。它使我们通常的View组件支持动画。

React Native Animated 提供了可动画化的组件:Animated.ImageAnimated.ScrollViewAnimated.TextAnimated.View,但您也可以使用createAnimatedComponent()函数定义自己的组件。

此外,在配置对象中,我们指定了easing。缓动是动画应该如何进行的方式。如果它应该随时间线性改变值,那么使用Easing.linear。然而线性并不自然。查看下一节以了解更多关于缓动函数的信息。

学习动画需要时间。您可以创建无数不同的场景,应该自己尝试 API。特别是当涉及到Animated.decayAnimated.spring时。我在书中没有涵盖它们,因为它不是一个非常大的模式,它只是您需要学习的另一个 API。在接下来的章节中,我们将专注于如何链接动画,然后如何使它们性能良好。

想想如何使用Animated.decay创建一个可拖动的框。您还需要一个PanResponder组件。在触摸事件释放时,它应该保持在相同方向上的速度,并在飞行一段距离后慢慢停止。

第二个练习可能是实现一个带有按钮的红色正方形框。在按下按钮时,正方形框应该通过另外 15 个独立像素来扩展其宽度和高度。所有这些都应该通过弹簧动画完成,因此宽度应该略微超过 15,然后再回到 15。就像弹簧一样。

如果这两个练习听起来很困难,请继续下一节。一旦您了解了缓动函数,它们应该会变得更容易。

缓动函数

动画是随时间的变化。这种变化可以以多种方式应用。确定随时间变化的新值的函数称为缓动函数。

为什么我们使用缓动函数而不是线性缓动?我喜欢的常见例子是抽屉的打开。当您在现实世界中打开抽屉时,这是一个线性过程吗?也许不是。

现在让我们看看常见的缓动函数。有几种。选择适合您应用程序的那个:

许多不同的缓动函数,以及每个函数随时间变化的可视化。

在图表上,灰色线表示起始值和结束值。黑线表示值随时间的变化。最终,黑线达到了上方的灰色线。正如您所见,一些缓动函数会低于起始值或超过结束值。这些可能对突出重要操作很有用。

想看更多缓动函数?查看easings.net/

大多数这些函数可以使用 RN Easing 模块实现。

回到 React Native 缓动。我为您准备了一个应用程序,让您玩转缓动函数。您可以在src/ Chapter_3/ Example_14/ App.js找到源代码:

缓动函数游乐场应用

当您点击按钮时,您将看到一个框从左到右移动,使用相应的缓动函数。

至于动画,我是通过操纵框的marginLeft来实现的。动画从marginLeft设置为 20 开始,并应用缓动函数在 2 秒内达到 300:

// src/ Chapter_3/ Example_14/ App.js
// ...
animate(easing) {
    this.easeValue.setValue(20);
  Animated.timing(
        this.easeValue,
  {
            toValue: 300,
  duration: 2000,
  easing
        }
    ).start(); }

onPress = easingName => this.animate(Easing[easingName.toLowerCase()]);
// ... 

调度事件

现在我们知道如何创建动画,现在让我们谈谈如何安排它们。

最简单的方法是延迟动画调度:

  • Animated.delay(): 在给定的延迟后开始动画。如果您需要延迟对用户操作的响应,这很有用。但通常情况下并不需要。

让我们谈谈我们想要安排的事件数组。应该分派多个事件。如果我们需要所有事件同时发生,这也很简单:

  • Animated.parallel(): 同时开始多个动画。但如果我们需要按顺序进行呢?这就是序列的用处。

  • Animated.sequence(): 按顺序开始动画,等待每个动画完成后再开始下一个。还有一个并行的变体,称为 stagger。

  • Animated.stagger(): 按顺序和并行启动动画,但具有连续的延迟。

练习时间:用彩色框填满屏幕。行应该以交错的方式一个接一个地出现在屏幕上:

显示随时间变化的交错动画的图像

完整的实现可在src/ Chapter_3/ Example_15/ App.js中找到。让我们看一下关键片段:

// ...
getFadeInAnimation = animatedVal =>
    Animated.timing(animatedVal, { toValue: 1, duration: 5000 });   componentDidMount() {
    const animations = Boxes.map(box =>
        this.getFadeInAnimation(this.state[box]));
  Animated.stagger(10, animations).start(); }
// ...

第一个函数只是一个辅助函数。它生成一个定时动画。我们使用这个辅助函数来生成所有的动画,并将它们收集在animations变量中。辅助函数期望animatedVal,它将被缓慢到 1。在我的实现中,我为每个框创建了一个单独的Animated.Value。最后,我将生成的动画数组传递给stagger并立即开始。

很不错的动画,对吧?现在,让我们谈谈性能。

测量 FPS

网站和移动应用程序很少使用动画。大多数情况下,这是对用户行为的响应,往往是缓慢的。如果您曾经玩过动态电脑游戏,您可能还记得这是一个不同的世界。是的,当我们深入研究动画时,有一件事来自电脑游戏,您应该记住:FPS

每秒帧数 - 屏幕上的所有内容都以光学幻觉的形式出现在运动中,这是由于以一致的速度快速更改帧而创建的。60 FPS 意味着每秒 60 帧,这意味着您每 16.67 毫秒看到一个新帧。JavaScript 需要在这么短的时间内传递该帧,否则帧将被丢弃。如果是这样,您的 FPS 指标将低于 60。

React Native 以其在大多数应用程序中的惊人性能而闻名:60 FPS。但是,当我们开始使用大量动画时,我们很快就会降低性能。在本节中,我想向您展示如何测量应用程序的 FPS。

让我们检查一下我们之前的动画表现如何:

显示随时间变化的交错动画的图像

我们将测量这个动画。在模拟器上,我得到48 FPS,动画已经进行了一半。接近完成时,FPS 降至18。当所有动画完成时,FPS 恢复到正常的 60。我还在我的真实手机(iPhone 7 plus)上进行了检查,结果类似。

这只是开发环境中 FPS 下降的一个例子。然而,您应该在真实的生产版本中测试您的应用程序。在facebook.github.io/react-native/docs/performance.html了解更多。

如何测量 FPS

现在是时候学习如何检查 FPS 了。有两种主要方法:

  • 使用工具,比如 Perf Monitor。它提供了这个功能。它还允许您测量本机环境。

  • 编写自定义 JavaScript 代码来测量 FPS。这只会测量 JS 线程的性能。

使用Create React Native App 的性能监视器就像摇动您的设备并选择“显示 Perf Monitor”选项一样简单:

显示性能监视器。数字 60 和 45 代表 FPS 测量的最新值

在 JavaScript 中实现自己的解决方案应该依赖于所需的 60FPS 意味着每 16.67ms(1000ms/60)有一帧。我为您创建了一个简单的示例:

// src / Chapter 3 / Example 16 / App.js
constructor() {
    // ...   let FPScounter = 0;
 setInterval(() => FPScounter++, 16)**;**
  setInterval(() => {
        this.setState({ fps: FPScounter });
  FPScounter = 0;
  }, 1000); }  // ... render = () => (
    // ...  <Text>FPS: {this.state.fps}</Text>
 // ...  );
// makes sure these measures are only done in dev environment
// and never leak to the production app!
// Beware: This example is not really very accurate and performant
// I have made it to illustrate the idea

由于本书致力于教授设计模式,我希望您也能检查您的解决方案是否具有高性能。

总结

在本章中,您学会了如何为 React Native 应用程序设置样式。我们介绍了许多不同的元素定位方式,您还学会了我们的设计如何在真实设备上呈现。最后,我们制作了一些动画,并根据 FPS 进行了测量。

到目前为止,我们知道如何使用 React 组件创建可重用的代码,以及如何对它们进行样式设置。我们使用本地 JSON 文件中存储的有限数据进行了工作。现在是时候让我们的应用程序变得更加复杂,并讨论影响大型应用程序的不同场景。在下一章中,您将学习 Flux,这是一种架构模式。

第四章:Flux 架构

如果你之前使用过 React,你可能已经听说过 Flux。如果没有,不用担心。Flux 是用于构建 React 用户界面的一种架构模式。我们将从 React 使用的单向数据流模式开始,然后进入 Flux。Flux 的每一个部分都很重要,我强烈建议你在这一章节花一些时间。你至少应该明白如何分离代码以及如何使用 Flux 将应用程序分割成部分。这些相互连接的小服务负责现代移动应用程序所需的一切。

单向数据流模式

在我们深入了解 Flux 架构之前,让我们先看看这种模式的历史背景。我希望你能理解为什么要引入它。

当我看到 Facebook 的开发人员谈论 Flux 架构时,我有一种直觉,他们从 模型-视图-控制器 (MVC) 模式转向了 Flux。MVC 模式是将业务模型与视图标记和编码逻辑解耦。逻辑由一个称为控制器的函数封装,并将工作委托给服务。因此,我们说我们的目标是精简控制器。

然而,在像 Facebook 这样的大规模应用中,看起来这种模式还不够。因为它允许双向数据流,很快就变得难以理解,甚至更难追踪。一个事件引起的变化可能会循环回来,并在整个应用程序中产生级联效应。想象一下,如果你必须在这样的架构中找到一个 bug。

React 的单向数据绑定

React 对上述问题的解决方案始于单向数据绑定。这意味着视图层由组件维护,只有组件才能更新视图。组件的 render 函数计算出结果的原生代码,并显示给最终用户。如果视图层需要响应用户的操作,它只能分发由组件处理的事件。它不能直接改变 stateprops

让我们看一下下面的图表,它说明了这个概念:

App块代表了原生视图层的状态。在图中,组件被简化为:属性、状态、render函数和事件监听器。一旦属性或状态发生变化,观察者就会调用render函数来更新原生视图。一旦用户执行操作,相应的事件就会被分派,然后被事件监听器捕获。

在双向数据绑定模式中,App层不需要分派事件。它可以直接修改组件的状态。我们也可以用事件监听器来模拟这一点。其中一个例子就是受控输入,我们在第二章中学习过,视图模式

事件问题

"伴随着巨大的自由而来的是巨大的责任。"

你可能已经听过这句话。这种情绪适用于我们分派和处理的事件。让我们讨论一些问题。

首先,要监听事件,您需要创建一个事件监听器。何时应该创建它?通常情况下,我们在具有标记的组件中创建事件监听器,并使用onClick={this.someEventListener}进行注册。如果这个事件需要导致完全不同的组件发生变化呢?在这种情况下,我们需要将监听器提升到组件树中的某个容器中。

当我们这样做时,我们注意到我们将越来越多的组件紧密耦合,将越来越多的监听器传递到属性链中。如果可能的话,这是我们想要避免的噩梦。

因此,Flux 引入了 Dispatcher 的概念。Dispatcher 将事件发送到所有注册的组件。这样,每个组件都可以对与其相关的事件做出反应,而忽略与其无关的事件。我们将在本章后面讨论这个概念。

绑定的进一步问题

仅使用单向数据绑定是不够的,正如你所看到的。我们很快就会陷入模拟双向数据绑定的陷阱,或者遇到前面部分提到的事件问题。

一切都归结为一个问题:我们能处理吗?对于大规模应用程序,答案通常是不行。我们需要一个可预测的模型,保证我们能够迅速找出发生了什么以及为什么。如果事件在我们的应用程序中随处发生,开发人员显然将不得不花费大量时间找出具体是什么导致了检测到的错误。

我们如何缩小这个问题?答案是限制。我们需要对事件流施加一些限制。这就是 Flux 架构发挥作用的地方。

Flux 简介

Flux 架构对组件之间的通信创建了一些限制。其主要原则是普遍的动作。应用程序视图层通过向分发器发送动作对象来响应用户动作。分发器的作用是将每个动作发送到订阅的存储。您可以拥有许多存储,每个存储都可以根据用户的动作做出不同的反应。

例如,想象一下你正在构建一个基于购物车的应用程序。用户可以点击屏幕将一些项目添加到购物车中,随后相应的动作被分发,您的购物车存储对此做出反应。此外,分析存储可能会跟踪用户已将此类项目添加到购物车中。两者都对同一动作对象做出反应,并根据需要使用信息。最终,视图层会根据新状态进行更新。

替换 MVC

为了增强 MVC 架构,让我们回顾一下它的外观:

动作由各自的控制器处理,这些控制器可以访问模型(数据表示)。视图通常与模型耦合,并根据需要对其进行更新。

当我第一次阅读这个架构时,我很难理解它。如果你还没有亲自使用过它,让我给你一些建议:

  • 动作:将其视为用户的动作,例如按钮点击、滚动和导航更改。

  • 控制器:这是负责处理动作并显示适当的本机视图的部分。

  • 模型:这是一个保存信息的数据结构,与视图分离。视图需要模型来根据设计进行视觉显示。

  • 视图:这是最终用户所看到的内容。视图描述了所有的标记代码,以后可以进行样式化。视图有时与样式耦合在一起,被称为一个整体。

随着应用程序的增长,小型架构迟早会变成以下的样子:

在这个图表中,我试图通过在模型结构中创建缩进来显示一些模型依赖于其他模型。视图也是类似的情况。这不应被视为不好。一般来说,这种架构在某种程度上是有效的。问题出现在当您发现错误时,却无法确定错误出现的位置和原因。更准确地说,您失去了对信息流的控制。您会发现自己处于一个同时发生许多事情的位置,以至于您无法轻易预测是什么导致了失败,也无法理解为什么会发生。有时,甚至很难重现错误或验证它是否实际上是一个错误。

从图表中可以看出,模型-视图通信存在问题:它是双向的。这是软件多年来一直在做的事情。一些聪明的人意识到,在客户端环境中,我们可以承担单向数据流。这将有效地使架构可预测。如果我们的控制器只有一系列输入数据,然后应该提供视图的新状态,那将会更清晰。单元测试可以提供一系列数据,比如输入,并对输出进行断言。同样,跟踪服务可以记录任何错误并保存输入数据系列。

让我们来看一下 Flux 提出的数据流:

所有操作都通过分发器进行,并且然后发送到注册的存储回调。最终,存储内容被映射到视图。

随着时间的推移,这可能变得复杂,就像下图所示的那样:

您可能会有各种不同的存储库,这些存储库在不同的视图或视图部分中使用。我们的视图组合成用户看到的最终视图。如果发生了变化,另一个操作将被分派到存储库中。这些存储库计算新状态并刷新视图。

这样就简单多了。我们现在可以跟踪操作,并查看哪个操作导致了存储中不需要的更改。

以示例说明 Flux

在我们深入研究 Flux 之前,让我们使用 Flux 架构创建一个简单的应用程序。为此,我们将使用 Facebook 提供的 Flux 库。该库包括我们需要的所有组件,以便根据新的 Flux 流使应用程序正常运行。安装 Flux 和immutable库。随着我们对 Flux 的了解越来越多,immutable也对进一步的优势至关重要:

yarn add flux immutable

我们在 Flux 中构建的应用程序是一个 Tasks 应用程序。我们已经创建的应用程序需要一些调整。首先要做的是创建Dispatcher,Tasks 存储和任务操作。

Flux 包提供了我们架构的基础。例如,让我们为我们的 Tasks 应用程序实例化Dispatcher

// src / Chapter 4_ Flux patterns / Example 1 / src / data / AppDispatcher.js
import { Dispatcher } from 'flux';   export default new Dispatcher(); 

Dispatcher将用于调度操作,但我们需要首先创建操作。我将遵循文档建议,首先创建操作类型:

// src / Chapter 4_ Flux patterns / Example 1 / src / data / TasksActionTypes.js
**const** ActionTypes = {
 ADD_TASK: 'ADD_TASK' }**;**   export default ActionTypes; 

现在我们已经创建了类型,接下来应该跟进操作创建者本身,如下所示:

// src / Chapter 4_ Flux patterns / Example 1 / src / data / TaskActions.js
import TasksActionTypes from './TasksActionTypes'; import AppDispatcher from './AppDispatcher';   const Actions = {
    addTask(task) {
 AppDispatcher.dispatch({
 type: TasksActionTypes.ADD_TASK,
  task
 });
  }
};   export default Actions; 

到目前为止,我们有了操作和调度它们的工具。缺失的部分是Store,它将对操作做出反应。让我们创建TodoStore

// src / Chapter 4_ Flux patterns / Example 1 / src / data / TaskStore.js
import Immutable from 'immutable'; import { ReduceStore } from 'flux/utils'; import TasksActionTypes from './TasksActionTypes'; import AppDispatcher from './AppDispatcher';   class TaskStore extends ReduceStore {
    constructor() {
        super(AppDispatcher)**;**
  }

    getInitialState() {
        return Immutable.List([]);
  }

    reduce(state, action) {
 switch (action.type) {
 case TasksActionTypes.ADD_TASK:
 return state; // <= placeholder, to be replaced!!!   default:
 return state;
  }
 }
}

export default new TaskStore(); 

要创建存储,我们从flux/utils导入ReduceStore。存储类应该扩展以提供必要的 API 方法。我们将在以后的部分中介绍这些。就目前而言,您应该已经注意到您需要在构造函数中使用superDispatcher传递给上层类。

另外,让我们为ADD_TASK实现reduce情况。相同的流程可以调整到您想要创建的任何其他操作类型:

reduce(state, action) {
    switch (action.type) {
    case TasksActionTypes.ADD_TASK:
        if (!action.task.name) {
            return state;
  }
        return state.push({
            name: action.task.name,
  description: action.task.description,
  likes: 0
  });
  default:
        return state;
  }
}

现在我们已经拥有了 Flux 架构的所有要素(ActionDispatcherStoreView),我们可以将它们全部连接起来。为此,flux/utils 提供了一个方便的容器工厂方法。请注意,我将重用我们以前任务应用程序的视图。为了清晰起见,我已经删除了喜欢的计数器:

// src / Chapter 4 / Example 1 / src / App.js
import { Container } from 'flux/utils'; import TaskStore from './data/TaskStore'; import AppView from './views/AppView';   const getStores = () => [TaskStore]; const getState = () => ({ tasks: TaskStore.getState() })**;**   export default Container.createFunctional(AppView, getStores, getState);

如果您没有从头开始阅读本书,请注意我们在这里使用容器组件。这种模式非常重要,需要理解,我们在第一章中已经介绍过了,React 组件模式。在那里,您可以学习如何从头开始创建容器组件。

我们的应用程序现在配备了 Flux 架构工具。我们需要做的最后一件事是重构以遵循我们的新原则。

为此,这是我们的任务:

  1. 初始化存储与任务,而不是直接将 JSON 数据传递给视图。

  2. 创建一个添加任务表单,当提交时会调度一个ADD_TASK操作。

第一个相当简单:

// src / Chapter 4_ Flux patterns / Example 1 / src / data / TaskStore.js
import data from './tasks.json';

class TaskStore extends ReduceStore {
// ...
    getInitialState() {
 return Immutable.List([...data.tasks]);
  }
// ...

第二个要求我们使用Input组件。让我们创建一个负责整个功能的单独文件。在这个文件中,我们将为名称和描述创建状态,一个handleSubmit函数,该函数会调度ADD_TASK操作,以及一个包含表单视图标记的render函数:

// src / Chapter 4_ Flux patterns / Example 1 / src / views / AddTaskForm.js

export const INITIAL_ADD_TASK_FORM_STATE = {
    name: '',
  description: '' };   class AddTaskForm extends React.Component {
    constructor(props) {
        super(props);
  this.handleSubmit.bind(this);
  }

    state = INITIAL_ADD_TASK_FORM_STATE**;**    handleSubmit = () => {
 TaskActions.addTask({
 name: this.state.name,
  description: this.state.description
  });
  this.setState(INITIAL_ADD_TASK_FORM_STATE);
  }**;**    render = () => (
        <View style={styles.container}>
 <**TextInput**  style={styles.input}
                placeholder="Name"
  onChangeText={name => this.setState({ name })}
                value={this.state.name}
            />
 <**TextInput**  style={styles.input}
                placeholder="Description"
  onChangeText={d => this.setState({ description: d })}
                value={this.state.description}
            />
 <**Button**  title="Add task"
  onPress={() => this.handleSubmit()}
            />
 </View>  ); }

// ... styles

完全功能的应用程序将如下所示:

现在我们已经创建了遵循 Flux 架构的第一个应用程序,是时候深入了解 API 了。

详细的 Flux 图

让我们以更正式的方式来看 Flux 架构。这里有一个简化架构的小图表:

官方文档中的 Flux 图:https://github.com/facebook/flux

在前面的图表中,每个部分都有自己在循环链中的目的:

  • 调度程序:应用程序中发生的一切都由它来管理。它管理动作并将它们提供给注册的回调函数。所有动作都需要通过调度程序。调度程序必须公开registerunregister方法来注册/注销回调,并必须公开dispatch方法来分发动作。

  • 存储:应用程序由多个在调度程序中注册回调的存储组成。每个存储需要公开一个接受Dispatcher参数的constructor方法。构造函数负责使用给定的调度程序注册此存储实例。

  • React 视图:这个主题在上一章中已经涵盖过了。如果你没有从头开始阅读这本书,请看一下。

  • 操作创建者:这些将数据组合成一个动作对象,然后交付给调度程序。这个过程可能涉及数据获取和其他手段来获取必要的数据。操作创建者可能会导致副作用。我们将在下一节中涵盖这个主题。操作创建者必须在最后返回一个普通的动作对象。

您可以在以下链接下找到每个部分的完整 API 参考:

facebook.github.io/flux/.

什么是副作用?

副作用是在被调用函数之外发生的应用程序状态更改——确切地说,除了其返回值之外的任何状态更改。

这里有一些副作用的例子:

  • 修改全局变量

  • 修改父作用域链中的变量

  • 写入屏幕

  • 写入文件

  • 任何网络请求,例如,AJAX 请求

这部分关于副作用的内容旨在让你为下一章做好准备,在那里我们将在 Redux 的上下文中讨论纯函数。此外,我们将在《第九章》《函数式编程模式》中进一步推进这些想法,您将学习如何从函数式编程实践中受益,例如可变和不可变对象,高阶函数和单子。

为什么要识别副作用?

副作用操纵的是不属于函数属性的状态。因此,当我们孤立地看待函数时,很难评估函数对应用程序是否有任何负面影响。这不仅在单元测试中成立;在进行数学证明时也很麻烦。一些必须安全的大型应用程序可以努力构建一个经得起考验的数学模型。这样的应用程序使用超出本书材料的数学工具进行验证。

副作用,当被隔离时,可以作为我们应用程序的数据提供者。它们可以在最佳时机“注入”流程,从那时起,数据就被视为变量。从一个无副作用的函数到另一个。这样的无副作用函数链更容易调试,并且在某些情况下可以重播。通过重播,我指的是传递完全相同的输入数据来评估输出,并查看是否符合业务标准。

让我们从 MVC 和 Flux 的角度来看这个概念的实际面。

在 MVC 中处理副作用

如果我们遵循经典的 MVC 架构,我们将按照以下关注点的分离工作:模型、视图和控制器。此外,视图可能会暴露直接更新模型的函数。如果发生这种情况,可能会触发副作用。

有几个地方可以放置副作用:

  • 控制器初始化

  • 控制器相关服务(这项服务是一个解耦的专业逻辑部分)

  • 视图,使用作为回调暴露的控制器相关服务

  • 在某些情况下,对模型进行更新(服务器-客户端双向模型)

我相信你甚至可以想出更多。

这种自由是以巨大的代价为代价的。我们可以有几乎无限数量的与副作用交织在一起的路径,如下所示:

  • 副作用 => 控制器 => 模型 => 视图

  • 控制器 => 副作用 => 模型 => 视图

  • 控制器 => 视图 => 模型 => 副作用

这会破坏我们以无副作用的方式对整个应用程序进行推理的能力。

MVC 通常如何处理这个问题?答案很简单——大部分时间这种架构并不关心它。只要我们能通过单元测试断言应用程序按预期工作,我们就会很满意。

但后来 Facebook 出现了,并声称我们可以在前端做得更好。由于前端的特殊性质,我们可以更有条理地组织和规定流程,而不会有显著的性能损失。

在 Flux 中处理副作用

在 Flux 中,我们仍然保留选择触发副作用的自由,但我们必须尊重单向流。

Flux 中可能的副作用示例包括以下内容:

  • 在用户点击时下载数据,然后将其发送给分发器

  • 分发器在发送数据给注册的回调之前下载数据

  • 存储开始同步副作用以保留更新所需的数据

一个好主意是强制副作用只发生在 Flux 架构中的一个地方。我们可以只在操作触发时执行副作用。例如,当用户点击触发SHOW_MORE操作时,我们首先下载数据,然后将完整对象发送给分发器。因此,分发器或任何存储都不需要执行副作用。这个好主意在Redux Thunk中被使用。我们将在下一章中学习 Redux 和 Redux Thunk。

了解本书中更高级材料的关键在于副作用。现在我们已经了解了副作用,让我们继续阅读本章摘要。

摘要

总之,Flux 对于大型应用程序来说是一个非常好的发明。它解决了经典 MVC 模式难以解决的问题。事件是单向的,这使得通信更加可预测。您的应用程序的领域可以很容易地映射到存储,然后由领域专家维护。

所有这些都得益于一个经过深思熟虑的模式,包括一个分发器、存储和操作。在本章中,我们使用了flux-utils,这是 Facebook 的官方库,制作了基于 Flux 的小应用程序。

连接了所有这些部分后,我们准备深入研究一个特定的方面——存储。有一些模式可以让你将存储放在另一个层次上。其中一个是 Redux 库。我们将在下一章中探讨 Redux 提供的不同功能。

问题

  1. 为什么 Facebook 放弃了经典的 MVC 架构?

答:Facebook 在处理 Facebook 所需的大规模时,发现了 MVC 存在的问题。在前端应用程序中,视图和模型紧密耦合。双向数据流使情况变得更糟:很难调试数据在模型和视图之间的转换以及哪些部分负责最终状态。

  1. Flux 架构的主要优势是什么?

答:观看在进一步阅读部分提到的视频Hacker Way: Rethinking Web App Development at Facebook,或查看替换 MVC部分。

  1. 你能画出 Flux 架构的图吗?你能详细地用 Web API 绘制并连接到你的图表吗?

答:查看详细的 flux 图部分。

  1. 调度程序的作用是什么?

答:如果需要再次查看完整的解释,请查看Flux 介绍详细的 flux 图

  1. 你能举四个副作用的例子吗?

答:查看Flux 介绍

  1. Flux 架构中如何解耦副作用?

答:查看在 Flux 中处理副作用部分。

进一步阅读

第五章:存储模式

围绕 JavaScript 虚拟存储构建的模式包含了决定应用程序中显示什么的一切所需内容。在我看来,这是理解 Flux 的最重要的部分,因此,我专门为存储模式撰写了一个特别的章节,以便通过许多示例并比较替代方案。由于 React Native 应用程序通常需要离线工作,我们还将学习如何将我们的 JavaScript 存储转换为用户移动设备上的持久存储。这将在用户体验方面将我们的应用程序提升到一个新的水平。

在本章中,您将学到以下内容:

  • 如何将 Redux 集成到您的 Flux 架构中

  • Redux 与经典 Flux 的不同之处以及新方法的好处

  • Redux 的核心原则

  • 如何创建一个将成为唯一真相来源的存储

  • 效果模式和副作用是什么

使用 Redux 存储

我花了一段时间才弄清楚如何向您宣传 Redux。您很可能期望它是一种在 Flux 中使用的存储实现。这是正确的;但是,Redux 不仅仅是这样。Redux 是一段精彩的代码,是一个很棒的工具。这个工具可以在许多不同的项目中以许多不同的方式使用。在这本书中,我致力于教会您如何在 React 和 Redux 中思考。

这个介绍受到了 Cheng Lou 在 React Conf 2017 上发表的有用演讲Taming the Meta Language的启发。

goo.gl/2SkWAj观看。

Redux 应用程序的最小示例

在我向您展示 Redux 架构之前,让我们看看它的实际运行情况。了解 Redux API 的外观至关重要。一旦我们在 Redux 中开发了最简单的 hello world 应用程序,我们将进行更高级的概述。

我们将构建的 hello world 应用程序是一个计数器应用程序,只有两个按钮(增加和减少)和一个显示当前计数的文本。

在我们深入之前,让我们使用以下命令安装两个软件包:

yarn add redux react-redux

好的,首先,让我们创建一些基本的 Flux 部分,这些部分我们已经知道,但这次使用 Redux API:

  • ActionTypes
// Chapter 5 / Example 1 / src / flux / AppActionTypes.js

const ActionTypes = {
    INC_COUNTER: 'INC_COUNTER',
  DEC_COUNTER: 'DEC_COUNTER' };   export default ActionTypes; 
  • Store
// Chapter 5 / Example 1 / src / flux / AppStore.js

import { combineReducers, createStore } from 'redux'; import counterReducer from '../reducers/counterReducer';   const rootReducer = combineReducers({
    count: counterReducer            // reducer created later on });   const store = createStore(rootReducer);   export default store; 

注意两个新词——ReducerrootReducerrootReducer将所有其他 reducer 组合成一个。Reducer负责根据已发生的操作生成状态的新版本。如果当前操作与特定的Reducer不相关,Reducer 也可以返回旧版本的状态。

  • CounterReducer
// Chapter 5 / Example 1 / src / reducers / counterReducer.js

import types from '../flux/AppActionTypes';   const counterReducer = (state = 0, action) => {
    switch (action.type) {
    case types.INC_COUNTER:
        return state + 1;
  case types.DEC_COUNTER:
        return state - 1;
  default:
        return state;
  }
};   export default counterReducer; 
  • Dispatcher
// Chapter 5 / Example 1 / src / flux / AppDispatcher.js
import store from './AppStore';   export default store.dispatch;  

很好,我们已经有了所有的 Flux 组件,所以现在可以继续实际的实现了。

让我们先从简单的事情开始,视图。它应该显示两个Button和一个Text组件。在按钮按下时,计数器应该增加或减少,如下所示:

// Chapter 5 / Example 1 / src / views / CounterView.js

const CounterView = ({ inc, dec, count }) => (
    <View style={styles.panel}>
 <Button title="-" onPress={dec} />
 <Text>{count}</Text>
 <Button title="+" onPress={inc} />
 </View> );   const styles = StyleSheet.create({
    panel: {
        // Check chapter 3: "Style patterns" to learn more on styling
        flex: 1,
  marginTop: 40,
  flexDirection: 'row'
  }, });   export default CounterView;

现在是时候向视图提供必要的依赖项了:incdeccounter属性。前两个非常简单:

// Chapter 5 / Example 1 / src / Counter.js const increaseAction = () => dispatch({ type: types.INC_COUNTER }); const decreaseAction = () => dispatch({ type: types.DEC_COUNTER });

现在我们将它们传递给视图。在这里,将使用许多特定的 Redux API 组件。Provider用于提供store以连接调用。这是可选的 - 如果您真的想手动执行此操作,可以直接将store传递给connect。我强烈建议使用Provider.Connect来创建一个围绕分发和状态的 facade。在状态更改的情况下,组件将自动重新渲染。

Facade 是另一种完全不同的模式。它是一种结构设计模式,用于与复杂的 API 进行交互。如果典型用户对所有功能都不感兴趣,提供一个带有一些默认设置的函数对用户来说非常方便。这样的函数被称为facade函数,并且也在 API 中公开。最终用户可以更快地使用它,而无需进行复杂和优化项目所需的深入挖掘。

在下面的片段中检查如何使用ProviderConnect

// Chapter 5 / Example 1 / src / Counter.js
... import { Provider, connect } from 'react-redux'; ...    const mapStateToProps = state => ({
    count: state.count,
  inc: increaseAction,
  dec: decreaseAction });   const CounterContainer = connect(mapStateToProps)(CounterView);   const CounterApp = () => (
    <Provider store={store}><CounterContainer /></Provider> );   export default CounterApp; 

就是这样。我们已经完成了第一个 Redux 应用程序。

Redux 如何适配 Flux

我们执行的步骤创建了一个Counter应用程序,涉及连接 Flux 组件。让我们看看我们使用的图表:

首先,我们有Actions被分发。然后运行根Reducer函数,并且每个 reducer 确定是否需要更改状态。根Reducer返回一个新版本的State,并且状态传递给View根。connect函数确定是否应重新渲染特定视图。

请注意,前面的图表遵循 Flux 架构。实际的 Redux 实现,正如您在计数器示例中所看到的,有些不同。分发器由 Store API 封装并作为store函数公开。

转向 Redux

Redux 不仅可以做简单的状态管理。它也以在具有庞大状态对象和许多业务模型的应用程序中表现出色而闻名。也就是说,让我们将我们的任务应用程序重构为 Redux。

Tasks应用程序是在前几章中开发的。如果你直接跳到这一章,请看一下位于 GitHub 存储库中的src / Chapter 4 / Example 1_ Todo app with Flux的应用程序。

重构步骤将类似。用 Redux 的部分替换现有的 Flux 部分:

  • ActionTypes:实际的实现是可以的:
const ActionTypes = {
    ADD_TASK: 'ADD_TASK' };   export default ActionTypes;
  • TaskStore.js: 重命名为AppStore.js。现在,store只有一个实例。

此外,我们需要将reduce函数移动到一个单独的 reducer 文件中。剩下的部分应该转换为新的语法:

// Chapter 5 / Example 2 / src / data / AppStore.js  const rootReducer = combineReducers({ tasks: taskReducer}); const store = createStore(rootReducer); export default store;
  • AppDispatcher.js:调度程序现在是存储的一部分。
// Chapter 5 / Example 2 / src / data / AppDispatcher.js import store from './AppStore';  export default store;
// ATTENTION: To stay consistent with Flux API
// and previous implementation, I return store.
// Store contains dispatch function that is expected. 
  • taskReducer.js:这是一个我们需要创建的新文件。然而,它的内容是从之前的reduce函数中复制过来的:
// Chapter 5 / Example 2 / src / reducers / taskReducer.js
...
import data from '../data/tasks.json';

const taskReducer = (state = Immutable.List([...data.tasks]), action) => {
    switch (action.type) {
    case TasksActionTypes.ADD_TASK:
        if (!action.task.name) {
            return state;
  }
        return state.push({
            name: action.task.name,
  description: action.task.description,
  likes: 0
  });
  default:
        return state;
  }
};   export default taskReducer;

最后一个必需的步骤是更改应用程序容器,如下所示:

// Chapter 5 / Example 2 / src / App.js   const mapStateToProps = state => ({ tasks: state.tasks }); const AppContainer = connect(mapStateToProps)(AppView); const TasksApp = () => (
    <Provider store={store}><AppContainer /></Provider> );   export default TasksApp; 

到目前为止,一切顺利。它有效。但这里有一些事情我们跳过了。我会向你展示我们可以做得更好的地方,但首先,让我们学习一些 Redux 的原则。

Redux 作为一种模式

当 Redux 做得好时,它提供了出色的功能,比如时间旅行热重载。时间旅行允许我们根据操作日志看到应用程序随时间的变化。另一方面,热重载允许我们在不重新加载应用程序的情况下替换代码的部分。

在本节中,我们将学习 Redux 的核心原则和一些常见的推荐方法。

请努力阅读 Redux 文档。这是一个很好的免费资源,可以学习如何在 React 和 Redux 中思考。它还将帮助你将 Redux 的使用扩展到 React 生态系统之外,并且可以在以下网址找到:

redux.js.org/introduction/examples. 

Redux 的核心原则

单一数据源:整个应用程序的状态存储在单个存储中的对象树中。理想情况下,应该有一个单一的 Redux 存储,可以指导视图渲染整个应用程序。这意味着你应该将所有的状态远离类组件,直接放在 Redux 存储中。这将简化我们在测试中恢复视图的方法,或者当我们进行时间旅行时。

对于一些开发人员来说,有一个单一的存储位置感觉不自然,很可能是因为多年来在后端,我们已经学会了它会导致单片架构。然而,在应用环境中并非如此。不会期望应用窗口在垂直方向上扩展以处理大量用户的负载。也不应该在单个设备上同时被数百名用户使用。

状态是只读的:改变状态的唯一方法是发出一个动作——描述发生了什么的对象。我们必须有一个单一的流来影响我们的存储。存储是我们应用状态的表示,不应该被随机代码改变。相反,任何有兴趣改变状态的代码都应该提交一份被称为动作对象签名文件。这个动作对象代表了一个已知的在我们库中注册的动作,称为动作类型。Reducer 是决定状态变化的逻辑。具有单一流的不可变状态更容易维护和监督。确定是否有变化以及何时发生变化更快。我们可以轻松地创建一个审计数据库。特别是在银行等敏感行业,这是一个巨大的优势。

通过纯函数进行更改:为了指定状态树如何通过操作进行转换,您需要编写纯净的 reducer。这是一个我们还没有讨论过的概念。Reducer 需要是纯函数。纯函数保证没有外部情况会影响函数的结果。简而言之,reducer 不能执行 I/O 代码、受时间限制的代码,或者依赖于可变作用域数据的代码。

纯函数是满足两个要求的函数:

  • 给定相同的输入参数,它返回相同的输出

  • 函数执行不会引起任何副作用

一个很好的例子是常见的数学函数。例如,给定 1 和 3 的加法函数总是返回 4。

这为什么有益并且应该被视为原则可能并不明显。想象一种情况,一个 bug 在开发阶段无意中被引入到你的项目中。或者更糟糕的是,它泄漏到生产环境,并在用户的某个会话期间炸毁了一个关键应用。很可能你有一些错误跟踪,你可以得到异常和堆栈跟踪,显示了一个漫长而模糊的路径通过被压缩的代码。然而,你需要修复它,所以你尝试在你的本地机器上重现完全相同的情况,最终花了连续三天的时间才意识到问题是一些无聊的竞争条件。想象一下,相反,你有一个单一的动作流(没有未跟踪条件的随机交换),你跟踪和记录。此外,你的整个应用依赖于只能根据动作流改变的状态。在失败的情况下,你需要存储的只是动作跟踪,以便回放情况。瞧,我刚刚为你节省了一两天的时间。

当我用类似的例子学习 Redux 时,我仍然很难理解为什么纯函数在这里如此重要。在 Chrome 的 Redux 标签中进行时间旅行的玩耍让我更清楚地看到了实际情况。当你来回进行操作时,一些有状态的组件(即依赖内部状态而不是 Redux 状态的组件)将不会跟随。这是一个巨大的问题,因为它破坏了你的时间旅行,使一些部分处于未来状态。

转向单一真相来源

现在是练习的时候了。我们的新目标是重构 Tasks 应用程序,使其具有一个单一的真相来源的存储。

为了做到这一点,我们需要寻找依赖组件状态而不是 Redux 存储的地方。到目前为止,我们有三个视图:

  • AppView.js:这个组件相当简单,分为头部、底部和主要内容。

这是一个呈现组件,不持有状态。它的 props 由AppContainer提供,后者已经使用了 Redux 存储。AppView将主要内容委托给以下两个子视图。

  • TaskList.js:这是一个呈现组件,负责在一个简单可滚动的列表中显示待办任务。它的 props 是由AppViewAppContainer中转发的。

  • AddTaskForm.js:这是一个容器组件,基于TextInput组件。这个部分使用了内部状态。如果可能的话,我们应该重构这个部分。

如果你曾经读过关于 React 和 Redux 的内容,你可能会发现这个例子与你在网页上找到的内容非常相似,但实际上并不是。如果你在阅读本书的前几章时,可能会有一种直觉;如果没有,我强烈建议你回到“第二章 > 构建表单 > 不受控输入”。

我们的目标是以某种方式将状态从AddTaskForm移动到 Redux 存储中。这就是问题开始的地方。你可能已经注意到TextInput是 React-Native API 的一部分,我们无法改变它。但TextInput是一个有状态的组件。这是在构建 React Native 应用时,你应该意识到的关于 Redux 的第一件事——有些部分需要有状态,你无法绕过它。

幸运的是,TextInput的有状态部分只管理焦点。你几乎不太可能需要在 Redux 存储中存储关于它的信息。所有其他状态都属于我们的AddTaskForm组件,我们可以解决这个问题。让我们马上做。

在惯用的 Redux 中,你的状态应该被规范化,类似于数据库。在 SQL 数据库中有已知的规范化技术,通常是基于实体之间的 ID 引用。你可以通过使用 Normalizr 库在 Redux 存储中采用这种方法。

首先,我们将重建AddTaskForm组件。它需要分派一个新的动作,这将触发一个新的减速器,并改变 Redux 存储中的一个新键(我们将在后面开发后面的部分):

// Chapter 5 / Example 3 / src / views / AddTaskForm.js
class AddTaskForm extends React.Component {
    // ...
    handleSubmit = () => {
        if (this.props.taskForm.name) {
            TaskActions.addTask({
                name: this.props.taskForm.name,
  description: this.props.taskForm.description
  });
  this.nameInput.clear();
  this.descriptionInput.clear()**;**
  }
    };    render = () => (
        <View style={styles.container}>
 <TextInput  style={styles.input}
                placeholder="Name"
  ref={(input) => { this.nameInput = input; }}
                onChangeText={
 name => TaskActions.taskFormChange({
 name,
  description: this.props.taskForm.description
  })
 }
                value={this.props.taskForm.name}
            />
 <TextInput  style={styles.input}
                placeholder="Description"
  ref={(input) => { this.descriptionInput = input; }}
 onChangeText={
 desc => TaskActions.taskFormChange({
 name: this.props.taskForm.name,
  description: desc
 })
 }
 value={this.props.taskForm.description}
            />
 <Button  title="Add task"
  onPress={() => this.handleSubmit()}
            />
 </View>  ); }

最困难的部分已经过去了。现在是时候创建一个全新的taskFormReducer,如下所示:

// Chapter 5 / Example 3 / src / reducers / taskFormReducer.js export const INITIAL_ADD_TASK_FORM_STATE = {
    name: '',
  description: '' };   const taskFormReducer = (
    state = INITIAL_ADD_TASK_FORM_STATE,
  action
) => {
    switch (action.type) {
    case TasksActionTypes.TASK_FORM_CHANGE:
        return action.newFormState;
  default:
        return state;
  }
};   export default taskFormReducer; 

接下来,向TasksActionTypes添加一个新的动作类型,如下所示:

// Chapter 5 / Example 3 / src / data / TasksActionTypes.js
const ActionTypes = {
    ADD_TASK: 'ADD_TASK',
  TASK_FORM_CHANGE: 'TASK_FORM_CHANGE' };

然后,添加动作本身,如下所示:

// Chapter 5 / Example 3 / src / data / TaskActions.js
const Actions = {
    // ...   taskFormChange(newFormState) {
        AppDispatcher.dispatch({
            type: TasksActionTypes.TASK_FORM_CHANGE,
  newFormState
        });
  }
};

接下来,在AppStore中注册一个新的减速器,如下所示:

// Chapter 5 / Example 3 / src / data / AppStore.js
const rootReducer = combineReducers({
    tasks: taskReducer,
  taskForm: taskFormReducer });

最后,我们需要传递新的状态:

// Chapter 5 / Example 3 / src / App.js
const mapStateToProps = state => ({
    tasks: state.tasks,
  taskForm: state.taskForm }); 

我们将其传递到组件树上的AppView,如下所示:

// Chapter 5 / Example 3 / src / views / AppView.js
const AppView = props => (
        // ...  <AddTaskForm taskForm={props.taskForm} />
 // ...  );

最后,我们连接了所有的部分。享受你的集中式单一真相源 Redux 存储。

或者,看一下redux-form库。在写这本书的时候,它是 Redux 中构建表单的行业标准。该库可以在redux-form.com找到。

使用 MobX 创建一个替代方案

在没有强大替代方案的情况下依赖 Redux 是愚蠢的。MobX 就是这样的替代方案之一,它是一个状态管理库,对变化没有那么多意见。MobX 尽可能少地提供样板文件。与 Redux 相比,这是一个巨大的优势,因为 Redux 非常显式,需要大量的样板文件。

在这里,我必须停下来提醒您,React 生态系统倾向于显式性,即构建应用程序而没有太多隐藏的机制。您控制流程,并且可以看到应用程序完成 Flux 的整个周期所需的所有位。毫不奇怪,主流开发人员更喜欢 Redux。有趣的是,Facebook Open Source 支持 MobX 项目。

MobX 更加隐式,可以隐藏一些围绕 Observables 构建的逻辑,并提供整洁的注释,以快速增强您的具有状态的组件与 MobX 流。

一些开发人员可能会发现这是一个更好的方法,最有可能是那些来自面向对象背景并习惯于这些事情的人。我发现 MobX 是一个更容易开始并开发原型或概念验证应用程序的库。然而,由于逻辑被隐藏在我身后,我担心一些开发人员永远不会查看底层。这可能会导致性能不佳,以后很难修复。

让我们看看它在实际操作中的感觉。

转向 MobX

在本节中,我们将重构 Tasks 应用程序,以使用 MobX 而不是 vanilla Flux。

任务应用程序是在前几章中开发的。如果您直接跳转到本章,请查看位于 GitHub 存储库中的src / Chapter 4 / Example 1_ Todo app with Flux位置的应用程序。

在我们深入之前,使用以下命令安装这两个软件包:

yarn add mobx mobx-react

好的,首先,让我们清理不需要的部分:

  • AppDispatcher.js:MobX 在幕后使用可观察对象进行分发。

  • TaskActions.js:操作现在将驻留在TaskStore中并在其状态上工作。在 MobX 中,您很可能最终会有许多存储,因此这不是一个大问题-我们将相关的东西放在一起。

  • TasksActionTypes.js:没有必要定义这个。MobX 会在内部处理它。

正如您所看到的,在我们开始之前,我们已经去掉了很多开销。这是库的粉丝们提到的 MobX 最大的优势之一。

是时候以 MobX 方式重建存储了。这将需要一些新的关键字,因此请仔细阅读以下代码片段:

// Chapter 5 / Example 4 / src / data / TaskStore.js
import { configure, observable, action } from 'mobx'; import data from './tasks.json';   // don't allow state modifications outside actions configure({ enforceActions: true });   export class TaskStore {
    @observable tasks = [...data.tasks]; // default state    @action addTask(task) {
        this.tasks.push({
            name: task.name,
  description: task.description,
  likes: 0
  });
  }
}

const observableTaskStore = new TaskStore(); export default observableTaskStore; 

正如您所看到的,有三个新关键字我从 MobX 库中导入:

  • configure:这用于设置我们的存储,以便只能通过操作来强制执行变化。

  • observable:这用于丰富属性,使其可以被观察到。如果您对流或可观察对象有一些 JavaScript 背景,它实际上是由这些包装的。

  • action:这就像任何其他操作一样,但是以装饰器的方式使用。

最后,我们创建了一个存储的实例,并将其作为默认导出传递。

现在我们需要将新的存储暴露给视图。为此,我们将使用 MobX Provider,这是 Redux 中找到的类似实用程序:

// Chapter 5 / Example 4 / src / App.js
// ... import { Provider as MobXProvider  } from 'mobx-react/native'; // ... const App = () => (
    <MobXProvider store={TaskStore}>
 <AppView /> </MobXProvider> ); export default App; 

前面片段的最后一部分涉及重构后代视图。

AppView组件向下提供任务到TaskList组件。现在让我们从新创建的存储中消耗任务:

// Chapter 5 / Example 4 / src / views / AppView.js

import { inject, observer } from 'mobx-react/native'; 
@inject('store') @observer class AppView extends React.Component {
 render = () => (
     // ...
     <AddTaskForm />  <TaskList tasks={this.props.store.tasks} />
     // ...   ); }

让我们对AddTaskForm做类似的事情,但是不是使用tasks,而是使用addTask函数:

// Chapter 5 / Example 4 / src / views / AddTaskForm.js
// ...

@inject('store') @observer class AddTaskForm extends React.Component {
    // ...   handleSubmit = () => {
        this.props.store.addTask({
            name: this.state.name,
  description: this.state.description
  });
 // ...   };
    // ...  }

就是这样!我们的应用程序再次完全可用。

使用注释与 PropTypes

如果您跟着做,您可能会感到有点迷茫,因为您的 linter 可能开始抱怨PropTypes不足或缺失。让我们来解决这个问题。

对于AppView,我们缺少对tasks存储的PropTypes验证。当类被标注为@observer时,这有点棘手-您需要为wrappedComponent编写PropTypes,如下所示:

AppView.wrappedComponent.propTypes = {
    store: PropTypes.shape({
        tasks: PropTypes.arrayOf(PropTypes.shape({
            name: PropTypes.string.isRequired,
  description: PropTypes.string.isRequired,
  likes: PropTypes.number.isRequired
  })).isRequired
    }).isRequired
}; 

对于AddTaskForm,我们缺少对addTask存储操作的PropTypes验证。让我们现在来解决这个问题:

AddTaskForm.wrappedComponent.propTypes = {
    store: PropTypes.shape({
        addTask: PropTypes.func.isRequired
  }).isRequired
};

就是这样,linter 的投诉都消失了。

比较 Redux 和 MobX

有一天,我在想如何比较这两者,接下来的想法浮现在脑海中。

这一部分受到了 Preethi Kasireddy 在 React Conf 2017 的演讲的很大影响。请花半个小时观看一下。您可以在www.youtube.com/watch?v=76FRrbY18Bs找到这个演讲。

MobX 就像汽车的道路系统。你创建了一张路线图,让人们开车。有些人会造成事故,有些人会小心驾驶。有些道路可能限制为单向,以限制交通,甚至以某种方式塑造,以便更容易推理汽车流量,就像在曼哈顿一样。另一方面,Redux 就像一辆火车。一次只能有一列火车在轨道上行驶。如果有几列火车同时行驶,前面的火车被阻挡,其他火车就会在后面等待,就像在地铁站一样。有时火车需要把人们送到大陆的另一边,这也是可能的。所有这些火车流量都由一个(分布式)机构管理,规划移动并对火车流量施加限制。

记住这个例子,让我们更加技术性地看看这些库:

  • Redux 使用普通对象,而 MobX 将对象包装成可观察对象。

你可能期待我再次提到一些魔法——不会。残酷的事实是,MobX 是有代价的。它需要包装可观察数据,并为每个对象或集合的每个成员增加一些负担。很容易查看有多少数据:使用console.log来查看您的可观察集合。

  • Redux 手动跟踪更新,而 MobX 自动跟踪更新。

  • Redux 状态是只读的,并且可以通过分派操作进行更改,而 MobX 状态可以随时更改,有时只能使用存储 API 公开的操作来更改。此外,在 MobX 中,不需要操作。您可以直接更改状态。

  • 在 Redux 中,状态通常是规范化的,或者至少建议这样做。在 MobX 中,您的状态是非规范化的,并且计算值是嵌套的。

  • 无状态和有状态组件:这里可能看起来很困难。在前面的信息框中链接的讲座中,Preethi Kasireddy 提到 MobX 只能与智能组件一起使用。在某种程度上,这是正确的,但这与 Redux 没有区别。两者都支持展示组件,因为它们与状态管理库完全解耦!

  • 学习曲线——这是非常主观的标准。有些人会发现 Redux 更容易,而其他人会发现 MobX 更容易。普遍的看法是 MobX 更容易学习。我是这方面的例外。

  • Redux 需要更多的样板文件。更加明确,这是非常直接的,但如果您不在乎,也有一些库可以解决这个问题。我不会在这里提供参考资料,因为我建议您进行教育性的使用。

  • Redux 更容易调试。这自然而然地带来了单一流程和消息的轻松重放。这就是 Redux 的亮点。MobX 在这方面更加老派,有点难以预测,甚至对经验丰富的用户来说也不那么明显。

  • 当涉及可扩展性时,Redux 胜出。MobX 可能会在大型项目中提出一些可维护性问题,特别是在有很多连接和大型领域的项目中。

  • MobX 在小型、时间受限的项目中更加简洁,发光。如果你参加黑客马拉松,考虑使用 MobX。在大型、长期项目中,你需要在 MobX 的自由基础上采用更有见地的方法。

  • MobX 遵循 Flux 架构,并且不像 Redux 那样对其进行修改。Redux 倾向于一个全局存储(尽管可以与多个一起使用!),而 MobX 在存储的数量上非常灵活,其示例通常展示了与 Flux 早期思想类似的思维方式。

在使用 Redux 时,您需要学习如何处理不同的情况以及如何构建结构。特别是在处理副作用时,您需要学习 Redux Thunk,可能还有 Redux Saga,这将在下一章中介绍。在 MobX 中,所有这些都在幕后神奇地处理,使用响应式流。在这方面,MobX 是有见地的,但却减轻了你的一个责任。

在 React Native 中使用系统存储

那些来自原生环境的人习惯于持久存储,比如数据库或文件。到目前为止,每当我们的应用重新启动时,它都会丢失状态。我们可以使用系统存储来解决这个问题。

为此,我们将使用 React Native 附带的AsyncStorage API:

“在 iOS 上,AsyncStorage 由存储小值的序列化字典和存储大值的单独文件的本机代码支持。在 Android 上,AsyncStorage 将根据可用的情况使用 RocksDB 或基于 SQLite。”

  • 来自 React Native 官方文档,可以在以下网址找到:

facebook.github.io/react-native/docs/asyncstorage.html

AsyncStorage API 非常容易使用。首先,让我们保存数据:

import { AsyncStorage } from 'react-native';  try { await AsyncStorage.setItem('@MyStore:key', 'value');
} catch (error) { // Error saving data } 

接下来,这是我们如何检索保存的值:

try { const  value = await AsyncStorage.getItem('@MyStore:key'); } catch (error) { // Error retrieving data } 

然而,文档建议我们在AsyncStorage中使用一些抽象:

“建议您在 AsyncStorage 上使用一个抽象,而不是直接使用 AsyncStorage,因为它在全局范围内运行。”

  • 可以在 React Native 官方文档中找到:

facebook.github.io/react-native/docs/asyncstorage.html

因此,让我们遵循标准库redux-persist。存储的主题很大,超出了这本书的范围,所以我不想深入探讨这个问题。

让我们使用以下命令安装该库:

yarn add redux-persist redux-persist-transform-immutable

第一步是通过新的持久性中间件增强我们的AppStore定义,如下所示:

// Chapter 5 / Example 5 / src / data / AppStore.js
// ... import { persistStore, persistReducer } from 'redux-persist';
import immutableTransform from 'redux-persist-transform-immutable'; import storage from 'redux-persist/lib/storage';   const persistConfig = {
    transforms: [immutableTransform()],
 key: 'root',
  storage }**;**   const rootReducer = combineReducers({
    // ...  }); const persistedReducer = persistReducer(persistConfig, rootReducer)
const store = createStore(persistedReducer); export const persistor = persistStore(store)**;** export default store; 

配置完成后,我们需要使用PersistGate加载状态。如果有自定义组件,可以将其提供给加载属性:

// Chapter 5 / Example 5 / src / App.js
import store, { persistor } from './data/AppStore'; // ... const TasksApp = () => (
    <Provider store={store}>
 <PersistGate loading={null} persistor={persistor}>
 <AppContainer /> </PersistGate> </Provider> ); 

看哪!每当重新启动应用程序时,状态将从持久存储加载,并且您将看到上次应用程序启动时的所有任务。

效果模式

在处理外部数据时,您需要处理外部因素,如网络或磁盘。这些因素会影响您的代码,因此它需要是异步的。此外,您应该努力将其与可预测的部分解耦,因为网络是不可预测的,可能会失败。我们称这样的事情为副作用,您已经学到了一些关于它们的知识。

为了理解这一点,我想介绍一个大词:效果。

“我们产生纯粹的 JavaScript 对象[...]。我们称这些对象为效果。效果就是一个包含一些信息的对象,由中间件解释。您可以将效果视为中间件执行某些操作的指令(例如,调用某些异步函数,向存储分发操作等)。”

  • 可以在 Redux Saga 官方文档中找到:

redux-saga.js.org/docs/basics/DeclarativeEffects.html

如果在立即范围之外使用这些效果,就会引起所谓的副作用,因此得名。最常见的情况是对外部范围变量的改变。

没有副作用是程序正确性的数学证明的关键。我们将在第九章中深入探讨这个话题,函数式编程模式的要素

处理副作用

在第四章 Flux 架构中,您学会了副作用是什么,以及您可以遵循哪些策略来将其与视图和存储解耦。在使用 Redux 时,您应该坚持这些策略。然而,已经开发了一些很棒的库来解决 Redux 的问题。您将在接下来的章节中了解更多,这些章节专门讨论这个问题:

"我们正在混合两个对人类思维来说非常难以理解的概念:突变和异步性。我称它们为 Mentos 和 Coke。它们分开时都很棒,但一起就会变成一团糟。像 React 这样的库试图通过在视图层中移除异步性和直接 DOM 操作来解决这个问题。然而,管理数据状态留给了你。这就是 Redux 介入的地方。"

  • 官方 Redux 文档

摘要

在这一章中,我们讨论了存储在我们架构中的重要性。您学会了如何塑造您的应用程序,以满足不同的业务需求,从使用状态和全局状态的混合方法来处理非常脆弱的需求,到允许时间旅行和 UI 重建的复杂需求。

我们不仅关注了 Redux 这一主流解决方案,还探讨了 MobX 库的完全不同的方法。我们发现它在许多领域都非常出色,比如快速原型设计和小型项目,现在您知道在何时以及在哪些项目中选择 MobX 而不是 Redux 是明智的。

进一步阅读

  • Redux 官方文档:

redux.js.org/. 这是文档中特别有用的部分:

redux.js.org/faq.

  • Redux 时间旅行和热重载介绍 由 Dan Abramov 在 React Europe 上:

www.youtube.com/watch?v=xsSnOQynTHs.

  • Dan Abramov 在 Egghead 上的课程:

egghead.io/instructors/dan-abramov.

  • Redux GitHub 页面上有已关闭的问题。这包含了大量有用的讨论:

github.com/reduxjs/redux/issues?q=is%3Aissue+is%3Aclosed.

  • Netflix JavaScript Talks: RxJS + Redux + React = Amazing!

www.youtube.com/watch?v=AslncyG8whg.

  • Airbnb 如何使用 React Native:

www.youtube.com/watch?v=8qCociUB6aQ

这不仅仅是关于存储模式,而是说明了如何思考像 Airbnb 这样的大型生产应用程序。

  • 您可能需要 Redux:

www.youtube.com/watch?v=2iPE5l3cl_s&feature=youtu.be&t=2h7m28s.

  • 最后但并非最不重要的是,Redux 作者为您带来的一个非常重要的话题:

您可能不需要 Redux

medium.com/@dan_abramov/you-might-not-need-redux-be46360cf367.

第六章:数据传输模式

在本章中,我们将学习如何在 React Native 应用程序中发送和接收数据。首先,我们将使我们的应用程序更加动态,并且依赖于后端服务器。您将了解到 Thunk 模式,它非常适合 Flux。然后,我们将深入研究一个更高级的库,redux-saga,它基于效果模式。这两种解决方案都将使我们的应用程序能够与服务器无缝交换数据。我还会给您一点关于更高级通信模式的介绍,比如HATEOASGraphQL。尽管这两种模式对于 React Native 开发人员来说很少是关键的,但如果有一天这些模式在 React Native 世界中变得流行,您会发现理解起来更容易。

在本章中,您将学习如何做以下事情:

  • 创建一个假的 API

  • 从后端获取数据并将其存储在应用程序中

  • 设计动作创建者并将获取逻辑与容器解耦

  • 使用 Redux Thunk 来有条件地分发动作

  • 编写自己的迭代器和生成器

  • 从大量依赖于生成器的 saga 中受益

准备工作

为了在不依赖外部来源的情况下测试各种 API,我们将创建我们自己的本地 API。您不需要了解任何后端语言,也不需要知道如何公开 API。在本章中,我们将使用一个特殊的库,该库根据我们提供的 JSON 文件构建 API。

到目前为止,我们已经制作了一个漂亮的应用程序来显示任务。现在,我们不再加载本地数据文件,而是使用我们自己的 REST API。克隆任务应用程序以开始。(我将使用第五章中示例二的代码目录,存储模式。)

表述性状态转移REST)是对 Web 服务施加约束的一组规则。其中一个关键要求是无状态性,这保证了服务器不会存储客户端的数据,而是仅依赖于请求数据。这应该足以向客户端发送回复。

为了创建一个假的 API,我们将使用json-server库。该库需要一个 JSON 文件;大多数示例都将其称为db.json。根据该文件,该库创建一个静态 API,以响应请求发送数据。

让我们从使用以下命令安装库开始:

yarn global add json-server

如果您喜欢避免使用global,请记住在以下脚本中提供node_modules/json-server/bin的相对路径。

库的 JSON 文件应该如下所示:

{
  "tasks": [
    // task objects separated by comma 
  ]
}

幸运的是,我们的tasks.json文件符合这个要求。我们现在可以启动我们的本地服务器。打开package.json并添加一个名为server的新脚本,如下所示:

// src / Chapter 6 / Example 1 / package.jsonn
// ...
"scripts": {
  // ...   "server": "json-server --watch ./src/data/tasks.json" },
// ...

现在可以输入yarn run server来启动它。数据将在http://localhost:3000/tasks上公开。只需使用浏览器访问 URL 以检查是否有效。正确设置的服务器应该打印出以下数据:

[
  {
    "name": "Task 1",
    "description": "Task 1 description",
    "likes": 239
  },
  // ... other task objects
]

我们现在可以进一步学习如何使用端点。

使用内置函数获取数据

首先,让我们从一些相当基础的东西开始。React Native 实现了 Fetch API,这在现在是用于进行 REST API 调用的标准。

重构为活动指示器

目前,在taskReducer.js文件中从文件加载了默认的任务列表。老实说,从文件或 API 加载都可能耗时。最好最初将任务列表设置为空数组,并通过旋转器或文本消息向用户提供反馈,告知他们数据正在加载。我们将在使用 Fetch API 时实现这一点。

首先,从 reducer 中的文件中删除数据导入。将声明更改为以下内容:

(state = Immutable.List([...data.tasks]), action) => {
    // ...
}

并用此片段中的代码替换它:

(state = Immutable.List([]), action) => {
    // ...
}

从文件加载数据也是一种副作用,并且应该遵循与数据获取类似的限制模式。不要被我们以前用于同步加载数据的实现所愚弄。这个快捷方式只是为了集中在特定的学习材料上。

启动应用程序以查看空列表。现在让我们添加一个加载指示器,如下所示:

import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
// ...
const TaskList = ({ tasks, isLoading }) => (
    <View>
  {isLoading
            ? <ActivityIndicator size="large" color="#0000ff" />
  : tasks.map((task, index) => (
                // ...   ))
        }
    </View> ); 

在某些情况下,如果加载时间很长,你需要处理一个更复杂的情况:数据正在加载,但用户可能同时添加任务。在以前的实现中,直到从服务器检索到数据之前,任务才会显示出来。这个简单的解决方法是,如果我们有任何任务,无论isLoading属性如何,都始终显示任务,这意味着期望有其他一些数据:

// src / Chapter 6 / Example 2 / src / views / TaskList.js
const TaskList = ({ tasks, isLoading }) => (
    <View>
  {isLoading && <ActivityIndicator size="large" color="#0000ff" />}
 {tasks.map((task, index) => (
            // ...   ))}
    </View> );

由于我们有一个根据isLoading属性显示的加载指示器,我们需要考虑我们的获取过程可能产生的其他状态。

处理错误情况

在大多数情况下,Fetch 将需要三种状态:

  • 开始:开始获取,应导致isLoadingtrue

  • 成功:成功获取数据

  • 错误:Fetch 无法检索数据;应显示适当的错误消息

我们需要处理的最后一个状态是错误。在用户体验指南方面,有几种方法可以处理这个问题:

  • 在列表中显示错误消息 - 这为那些关心表中数据的人提供了一个清晰的消息。它可能包括一个可点击的链接或一个重试按钮。您可以将此方法与后续的方法混合使用。

  • 在失败时显示浮动通知 - 这在一个角落显示有关错误的消息。消息可能在几秒钟后消失。

  • 显示错误模态 - 这会阻止用户通知他们有关错误;它可能包含重试和解除等操作。

我想在这里采取的方法是第一种。这种方法相当容易实现 - 我们需要添加一个error属性,并根据它显示消息:

const TaskList = ({
    tasks, isLoading, hasError, errorMsg
}) => (
    <View>
  {hasError &&
            <View><Text>{errorMsg}</Text></View>}
        {hasError && isLoading &&
            <View><Text>Fetching again...</Text></View>}
        {isLoading && <ActivityIndicator size="large" color="#0000ff" />}
        {tasks.map((task, index) => (
            // ...   ))}
    </View> );
// ... TaskList.defaultProps = {
    errorMsg: 'Error has occurred while fetching tasks.' };

天真的有状态组件获取

现在,让我们获取一些数据并使我们的标记完全可用。首先,我们将遵循 React 初学者的方法:在一个有状态组件中使用fetch。在我们的情况下,它将是App.js

// src / Chapter 6 / Example 2 / src / App.js
class TasksFetchWrapper extends React.Component {
    constructor(props) {
        super(props);
        // Default state of the component
  this.state = {
            isLoading: true,
  hasError: false,
  errorMsg: '',
  tasks: props.tasks
  };
  }

    componentDidMount() {
        // Start fetch and on completion set state to either data or
        // error
        return fetch('http://localhost2:3000/tasks')
            .then(response => response.json())
            .then((responseJSON) => {
                this.setState({
                    isLoading: false,
  tasks: Immutable.List(responseJSON)
                });
  })
            .catch((error) => {
                this.setState({
                    isLoading: false,
  hasError: true,
  errorMsg: error.message
  });
  });
  }

    render = () => (
        <AppView
  tasks={this.state.tasks}
            isLoading={this.state.isLoading}
            hasError={this.state.hasError}
            errorMsg={this.state.errorMsg}
        />
  ); }
  // State from redux passed to wrapper. const mapStateToProps = state => ({ tasks: state.tasks }); const AppContainer = connect(mapStateToProps)(TasksFetchWrapper);

这种方法有一些缺点。首先,它不遵循 Fetch API 文档。让我们阅读这个关键的引用:

“从 fetch 返回的 Promise 不会在 HTTP 错误状态下拒绝,即使响应是 HTTP 404 或 500。相反,它将正常解析(ok 状态设置为 false),并且只有在网络故障或任何阻止请求完成时才会拒绝。”

  • 可用的 Fetch API 文档:

developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch

如您所见,前面的实现缺乏 HTTP 错误处理。

第二个问题是状态重复,我们维护了一个 Redux 状态,但然后将任务复制到本地组件状态,并甚至用已获取的内容覆盖它。我们可能更关心我们已经在任务中的内容,通过连接两个数组,并找到一种避免再次存储任务的方法。

此外,如果 Redux 状态发生变化,那么先前的组件将完全忽略更新。这太糟糕了,让我们找到一种解决方法。

Thunk 模式和 Redux Thunk

在这一部分,我们将学习Thunk 模式以及如何在Redux Thunk库中使用它。首先,我们需要重构上一节中的天真和有缺陷的实现,改为使用 Redux。

将状态提升到 Redux

不要依赖组件状态,让我们将其提升到 Redux 存储中。注意我们在这里使用的Immutable.Map。此外,ADD_TASK动作现在使用Immutable.jsupdate函数:

// src / Chapter 6 / Example 3 / src / reducers / taskReducer.js

const taskReducer = (state = Immutable.Map({
    entities: Immutable.List([])**,**
  isLoading: false,
  hasError: false,
  errorMsg: **''** }), action) => {
    switch (action.type) {
    case TasksActionTypes.ADD_TASK:
        if (!action.task.name) {
            return state;
  }
        return state.update('entities', entities => entities.push({
            name: action.task.name,
  description: action.task.description,
  likes: 0
  }));
  default:
        return state;
  }
}; 

由于我们已经改变了减速器,我们需要修复有状态的组件。它不应该有自己的状态,而是通过动作委托给 Redux 存储。然而,我们将稍后实现这些动作:

// src / Chapter 6 / Example 3 / src / App.js
class TasksFetchWrapper extends React.Component {
    componentDidMount() {
        TaskActions.fetchStart();
  return fetch('http://localhost:3000/tasks')
            .then(response => response.json())
            .then((responseJSON) => {
                TaskActions.fetchComplete(Immutable.List(responseJSON));
  })
            .catch((error) => TaskActions.fetchError(error));
  }

    render = () => <AppView tasks={this.props.tasks} />; }

将获取逻辑移动到单独的服务是明智的。这将使其他组件在需要触发获取时共享相同的功能。这是你的作业。

你可以将动作分派到构造函数而不是依赖于componentDidMount。然而,这可能会引发重构为函数组件的诱惑。这将是一场灾难,因为你将在每次重新渲染时开始获取。此外,componentDidMount对我们来说更安全,因为在动作的上下文中,如果有任何可能减慢应用程序的计算,我们可以 100%确定用户已经看到ActivityIndicator

现在,转到动作的实现。你应该能够自己编写它们。如果遇到任何问题,请参阅src / Chapter 6 / Example 3 / src / data / TaskActions.js。现在我们将专注于扩展减速器。这是相当多的工作,因为我们需要处理所有三种动作类型:FETCH_STARTFETCH_COMPLETEFETCH_ERROR,如下所示:

// src / Chapter 6 / Example 3 / src / reducers / taskReducer.js
const taskReducer = (state = Immutable.Map({
    // ...  }), action) => {
    switch (action.type) {
    case TasksActionTypes.ADD_TASK: {
        // ...   }
    case TasksActionTypes.TASK_FETCH_START: {
        return state.update('isLoading', () => true);
  }
    case TasksActionTypes.TASK_FETCH_COMPLETE: {
        const noLoading = state.update('isLoading', () => false);
  return noLoading.update('entities', entities => (
            // For every task we update the state
            // Homework: do this in bulk
            action.tasks.reduce((acc, task) => acc.push({
                name: task.name,
  description: task.description,
  likes: 0
  }), entities)
        ));
  }
    case TasksActionTypes.TASK_FETCH_ERROR: {
        const noLoading = state.update('isLoading', () => false);
  const errorState = noLoading.update('hasError', () => true);
  return errorState.update('errorMsg', () => action.error.message);
  }
    default: {
        return state;
  }
    }
};

基本上就是这样。最后,你还需要更新视图以使用新的结构Immutable.Map,如下所示:

// src / Chapter 6 / Example 3 / src / views / AppView.js
// ...
<TaskList
  tasks={props.tasks.get('entities')}
    isLoading={props.tasks.get('isLoading')}
    hasError={props.tasks.get('hasError')}
    errorMsg={props.tasks.get('errorMsg')}
/>
// ... 

这段代码需要进行一些改进。我现在不会触及它们,因为那些是高级主题,涉及更一般的 JavaScript 函数式编程概念。你将在第八章中学习有关镜头和选择器的内容,JavaScript 和 ECMAScript 模式

重构为 Redux 的好处

可能很难看到先前重构的好处。其中一些重构可能在几天后才会显现出来。例如,需要在特定事件上重新获取任务。此事件发生在应用程序的完全不同部分,并且与任务列表无关。在天真的实现中,您需要处理更新过程并保持一切更新。您还需要向另一个组件公开fetch函数。这将紧密耦合这两者。灾难。相反,正如您所看到的,您可能更喜欢将获取逻辑复制到第二个独立的组件中。再次,您最终会出现代码重复。因此,您将创建一个由这两个组件共享的父服务。不幸的是,获取与状态紧密耦合,因此您还将状态移动到服务中。然后,您将进行一些技巧,例如使用闭包在服务中存储数据。正如您所看到的,这是这些问题的一个平稳解决方案。

当使用 Redux 存储时,您只有一个通过 reducer 更新的集中状态。获取是使用精心设计的操作将数据发送到 reducer。获取可以在一个单独的服务中执行,该服务由需要获取任务的组件共享。现在,我们将介绍一个使所有这些事情更清洁的库。

使用 Redux Thunk

在经典的 Redux 中,没有中间件,您无法调度不是纯对象的东西。使用 Redux Thunk,您可以通过调度函数延迟调度:

"Redux Thunk 中间件允许您编写返回函数而不是操作的操作创建者。thunk 可以用于延迟操作的调度,或者仅在满足某些条件时进行调度。内部函数接收存储方法dispatchgetState作为参数。"

  • Redux Thunk 官方文档,网址:

github.com/reduxjs/redux-thunk

例如,您可以调度一个函数。这样的函数有两个参数:dispatchgetState。这个函数尚未到达 Redux reducer。它只延迟了老式的 Redux 调度,直到进行必要的检查,例如基于当前状态的检查。一旦我们准备好调度,我们就使用作为function参数提供的dispatch函数:

function incrementIfOdd() {
  return (dispatch, getState) => {
    const { counter } = getState();

    if (counter % 2 === 0) {
      return;
    }

    dispatch(increment());
  };
}

dispatch(incrementIfOdd())

在前一节中,我指出fetch调用可以是一个单独的函数。如果你还没有做作业,这里是一个重构的例子:

const fetchTasks = () => {
    TaskActions.fetchStart();
  return fetch('http://localhost:3000/tasks')
        .then(response => response.json())
        .then((responseJSON) => {
            TaskActions.fetchComplete(Immutable.List(responseJSON));
  })
        .catch(error => TaskActions.fetchError(error)); };   class TasksFetchWrapper extends React.Component {
 componentDidMount = () => this.props.fetchTasks();
  render = () => <AppView tasks={this.props.tasks} />; }

const mapStateToProps = state => ({ tasks: state.tasks }); const mapDispatchToProps = dispatch => ({ fetchTasks }); const AppContainer = connect(mapStateToProps, mapDispatchToProps)(TasksFetchWrapper);

然而,我们所谓的ActionCreatorsdispatch紧密耦合,因此不仅创建动作,还有dispatch。让我们通过移除 dispatch 来放松它们的责任:

// Before  const Actions = {
addTask(task) {
        AppDispatcher.dispatch({
type: TasksActionTypes.ADD_TASK,
  task
        });
  },
  fetchStart() {
        AppDispatcher.dispatch({
type: TasksActionTypes.TASK_FETCH_START
  });
  },
 // ...
}; 
// After
const ActionCreators = {
 addTask: task => ({
type: TasksActionTypes.ADD_TASK,
  task
   }),
  fetchStart: () => ({
type: TasksActionTypes.TASK_FETCH_START
  }),
 // ...
}; 

现在,我们需要确保将前面的动作分发到相关的位置。可以通过以下方式实现:

const ActionTriggers = {
 addTask: dispatch => task => dispatch(ActionCreators.addTask(task)),
  fetchStart: dispatch => () => dispatch(ActionCreators.fetchStart()),
  fetchComplete: dispatch =>
        tasks => dispatch(ActionCreators.fetchComplete(tasks)),
  fetchError: dispatch =>
        error => dispatch(ActionCreators.fetchError(error))
};

对于有编程经验的人来说,这一步可能看起来有点像我们在重复自己。我们在重复函数参数,唯一得到的是调用分发。我们可以用函数模式来解决这个问题。这些改进将作为《第八章》JavaScript 和 ECMAScript 模式的一部分进行。

另外,请注意,在这本书中,我没有写很多测试。一旦你养成了写测试的习惯,你就会很快欣赏到这种易于测试的代码。

完成这些后,我们现在可以调整我们的容器组件,如下所示:

// src / Chapter 6 / Example 4 / src / App.js export const fetchTasks = (dispatch) => {
    TaskActions.fetchStart(dispatch)();
  return fetch('http://localhost:3000/tasks')
        .then(response => response.json())
        .then(responseJSON =>
            TaskActions.fetchComplete(dispatch)(Immutable.List(responseJSON)))
        .catch(TaskActions.fetchError(dispatch)); };
// ... const mapDispatchToProps = dispatch => ({
fetchTasks: () => fetchTasks(dispatch),
  addTask: TaskActions.addTask(dispatch)
});  

好的,这是一个很好的重构,但 Redux Thunk 在哪里?这是一个非常好的问题。我故意延长了这个例子。在许多 React 和 React Native 项目中,我看到了对 Redux Thunk 和其他库的过度使用。我不希望你成为另一个不理解 Redux Thunk 目的并滥用其功能的开发人员。

Redux Thunk 主要让你有条件地决定是否要分发。通过 Thunk 函数访问dispatch并不是什么特别的事情。主要的好处是第二个参数getState。这让你可以访问当前状态,并根据那里的值做出决定。

这样强大的工具可能会导致你创建不纯的 reducer。怎么会呢?你会创建一个setter reducer,它的工作方式类似于类中的 set 函数。这样的 reducer 只会被调用来设置值;然而,值将在 Thunk 函数中计算,使用getState函数。这完全是反模式,可能会导致严重的竞争条件破坏。

现在我们知道了危险,让我们继续讨论 Thunk 的真正用途。想象一种情况,您希望有条件地做出决定。如何访问状态以进行if语句?一旦我们在 Redux 中使用connect()函数,这就变得复杂起来。我们传递给connectmapDispatchToProps函数无法访问状态。但我们需要它,这就是 Redux Thunk 的一个有效用法。

以下是需要知道的:如果我们不能使用 Redux Thunk,我们如何制作一个逃生舱?我们可以将部分状态传递给render函数,然后使用预期的状态调用原始函数。if语句可以在 JSX 中使用常规的if。然而,这可能会导致严重的性能问题。

现在是时候在我们的情况下使用 Redux Thunk 了。您可能已经注意到我们的数据集不包含 ID。如果我们两次获取任务,这将是一个巨大的问题,因为我们没有机制告诉哪些任务已经添加,哪些已经存在于我们的 UI 中。当前的方法是添加所有获取到的任务,这将导致任务重复。我们破碎架构的第一个预防机制是在isLoadingtrue时停止获取。

现实生活中的情况要么使用 ID,要么在获取时刷新所有任务。如果是这样,ADD_TASK需要保证后端服务器中的更改。

在渐进式 Web 应用程序时代,我们需要进一步强调这个问题。考虑一种情况,即在添加新任务之前失去连接。如果您的 UI 在本地添加任务并安排后端更新,一旦网络连接解决,您可能会遇到竞争条件:这意味着任务在ADD_TASK更新在后端系统中传播之前被刷新。结果,您最终会得到一个任务列表,其中不包含添加的任务,直到您从后端重新获取所有任务。这可能是非常误导人的,不应该发生在任何金融机构中。

让我们实现这种天真的预防机制来说明 Redux Thunk 的能力。首先,使用以下命令安装库:

yarn add redux-thunk

然后,我们需要将thunk中间件应用到 Redux 中,如下所示:

// src / Chapter 6 / Example 4 / src / data / AppStore.js
import { combineReducers, createStore, applyMiddleware } from 'redux'; import thunk from 'redux-thunk';  // ... const store = createStore(rootReducer, applyMiddleware(thunk));  

从现在开始,我们可以调度函数。现在让我们修复我们的fetch函数,以避免多次请求:

// src / Chapter 6 / Example 5 / src / App.js
export const fetchTasks = (dispatch, getState) => {
    if (!getState().tasks.isLoading) {
        // ...   }
    return null; };
// ... const mapDispatchToProps = dispatch => ({
    fetchTasks: () => dispatch(fetchTasks),
 // ...
});

正如您所看到的,这是一个非常简单的用例。请明智地使用 Redux Thunk,不要滥用它给您带来的力量。

理解 Thunk 模式

Thunk 是另一种模式,不特定于 React 或 Redux。实际上,在许多复杂的解决方案中,如编译器,它被广泛使用。

Thunk 是一种延迟评估的模式,直到无法避免为止。解释这一点的初学者示例之一是简单的加法。示例如下:

// immediate calculation, x equals 3
let x = 1 + 2;

// delayed calculation until function call, x is a thunk
let x = () => 1 + 2;

一些更复杂的用法,例如在函数式语言中,可能会在整个语言中依赖于这种模式。因此,计算只有在最终应用层需要它们时才执行。通常情况下,不会进行提前计算,因为这样的优化是开发人员的责任。

传说模式和 Redux Saga

到目前为止,我们可以使用fetch执行简单的 API 调用,并且知道如何组织我们的代码以实现可重用性。然而,在某些领域,如果我们的应用程序需要,我们可以做得更好。在深入研究 Redux Saga 之前,我想介绍两种新模式:迭代器和生成器。

“处理集合中的每个项目是一个非常常见的操作。JavaScript 提供了许多迭代集合的方法,从简单的 for 循环到 map 和 filter。迭代器和生成器直接将迭代的概念引入核心语言,并提供了自定义 for...of 循环行为的机制。”

  • MDN web 文档上的 JavaScript 指南:

developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Iterators_and_Generators.

迭代器模式简介

顾名思义,迭代器允许您遍历集合。为了能够这样做,集合需要实现一个可迭代接口。在 JavaScript 中,没有接口,因此迭代器只是实现了一个函数。

当对象知道如何一次从集合中访问项目,并在该序列内跟踪其当前位置时,该对象就是一个迭代器。在 JavaScript 中,迭代器是一个提供 next 方法的对象,该方法返回序列中的下一个项目。此方法返回一个具有两个属性的对象:done 和 value。

  • MDN web 文档上的 JavaScript 指南

developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Iterators_and_Generators

以下是 MDN web 文档中此类函数的示例:

function createArrayIterator(array) {
    var nextIndex = 0;    return {
        next: function() {
            return nextIndex < array.length ?
                {value: array[nextIndex++], done: false} :
                {done: true};
  }
    }; }

生成器模式

生成器类似于迭代器;然而,在这里,你会在函数内部精心设计的断点上进行迭代。生成器返回一个迭代器。返回的迭代器在提到的断点上进行迭代,并且每次从函数中返回一些值。

为了表示该函数是一个生成器,我们使用特殊的*****符号,例如,function* idGenerator()。请在以下代码片段中找到一个示例生成器函数。生成器使用yield关键字来返回当前迭代步骤的值。如果调用了它的next()函数,迭代器将在下一行恢复,就像这样:

function* numberGenerator(numMax) {
    for (let i = 0; i < numMax; i += 1) {
        yield console.log(i);
  }
}
const threeNumsIterator = numberGenerator(3); // logs 0 threeNumsIterator.next(); // logs 1 threeNumsIterator.next(); // logs 2 threeNumsIterator.next(); // logs nothing, the returned object contains a key 'done' set to true
threeNumsIterator.next(); 

首先,我们创建一个generator函数。Generator函数期望一个参数。根据提供的参数,生成器知道何时停止生成新的数字。在函数之后,我们创建一个示例数字迭代器并迭代其值。

Redux Saga

Redux Saga 在很大程度上依赖于生成器模式。由于这种方法,我们可以将副作用完全解耦到行为就像是一个独立线程的 sagas 中。这是方便的,并且从长远来看,相对于 Thunk 函数提供了一些优势。其中一些依赖于可组合性,sagas 易于测试,并提供更清晰的流程来执行异步代码。现在这些可能听起来不清楚,所以让我们深入了解一下。

本书并不涉及太多关于 React、Redux 和 React Native 的测试。这个主题会让本书变得很长,我认为它值得有一本单独的书。然而,我会强调测试代码的重要性。这个信息框是为了提醒你在 Redux Sagas 中进行测试。在互联网的不同地方(GitHub、论坛、Stack Overflow)我一遍又一遍地看到这个提到:sagas 比 Thunks 更容易测试。你可以自己验证一下,你不会后悔的。

首先,完成安装库和应用中间件的初学者步骤。这些步骤可以在官方的 Redux Saga README 文件中找到,网址为redux-saga.js.org/

现在是时候创建第一个 saga 并将其添加到我们的rootSaga中了。还记得获取任务的情况吗?它们可以从许多地方(许多解耦的小部件或功能)请求。saga 的方法与我们之前的解决方案类似,所以让我们看看它如何在以下示例中实现:

// src / Chapter 6 / Example 6 / src / sagas / fetchTasks.js
function* fetchTasks() {
    const tasks = yield call(ApiFetch, 'tasks');
  if (tasks.error) {
        yield put(ActionCreators.fetchError(tasks.error));
  } else {
        const json = yield call([tasks.response, 'json']);
  yield put(ActionCreators.fetchComplete(Immutable.List(json)));
  }
}

// whereas ApiFetch is our own util function
// you will want to make a separate file for it
// and take care of environmental variables to determine right endpoint
const ApiFetch = path => fetch(`http://localhost:3000/${path}`)
    .then(response => ({ response }))
    .catch(error => ({ error }));

我们的fetchTasks saga 非常简单:首先,它获取任务,然后检查是否发生了错误,然后要么分派一个带有获取的数据附加的错误事件,要么分派一个成功事件。

我们如何触发fetchTasks saga?为了说服你 saga 的强大之处,让我们更进一步。假设我们的代码库是解耦的,一些功能几乎同时请求任务。我们如何防止触发多个获取任务的作业?Redux Saga 库为此提供了现成的解决方案:throttle函数。

"throttle(ms, pattern, saga, ...args) 在与模式匹配的存储器上分派一个动作,然后在生成任务后,它仍然接受传入的动作到底层缓冲区,最多保留 1 个(最近的一个),但同时在 ms 毫秒内不生成新任务(因此它的名称是 - throttle)。其目的是在处理任务时忽略一定时间内的传入动作。"

  • 官方 Redux Saga 文档:

redux-saga.js.org/docs/api/.

我们的用例非常简单:

// src / Chapter 6 / Example 6 / src / sagas / fetchTasks.js
function* watchLastFetchTasks() {
    yield throttle(2000, TasksActionTypes.TASK_FETCH_START, fetchTasks); }

fetchTasks函数将在TASK_FETCH_START事件上执行。在两秒内,相同的事件不会导致另一个fetchTasks函数的执行。

就是这样。最后的几件事之一是将前面的 saga 添加到rootSaga中。这不是一个非常有趣的部分,但是如果你感兴趣,我建议你在代码存储库中查看完整的示例,该示例可在github.com/Ajdija/hands-on-design-patterns-with-react-native上找到。

Redux Saga 的好处

在更复杂的应用程序中,具有明确定义的例程,Redux Saga 比 Redux Thunk 更出色。一旦你遇到需要取消、重新运行或回复流程的一部分,就不会立即明显地知道如何使用 Thunk 或纯 Redux 来完成这些操作。使用可组合的 saga 和良好维护的迭代器,你可以轻松地完成这些操作。即使官方文档也提供了这些问题的解决方案。(有关参考,请参阅本章末尾的进一步阅读部分。)

这样一个强大库的阴暗面在于它在旧应用程序中的使用可能会出现问题。这些应用程序可能以基于 promise 或 Thunk 的方式编写功能,可能需要进行重大重构才能与在新应用程序中找到的与 sagas 的使用方式相匹配。例如,从 Thunk 函数调用 saga 并不容易,也不能像在 sagas 中等待分发的函数那样等待 promise。可能有很好的方法来连接这两个世界,但真的值得吗?

摘要

在这一章中,我们重点关注了网络模式和随之而来的副作用。我们经历了简单的模式,然后使用了市场上可用的工具。您已经了解了 Thunk 模式,以及迭代器和生成器模式。这三种模式在您未来的编程生涯中都将非常有用,无论是在 React Native 中还是其他地方。

至于 React 生态系统,您已经了解了 Redux Thunk 和 Redux Saga 库的基础知识。它们都解决了大规模应用程序所面临的一些挑战。明智地使用它们,并牢记我在本章中提出的所有警告。

现在我们知道如何显示数据,样式化数据和获取数据,我们已经准备好学习一些应用程序构建模式。特别是在下一章中,您将学习导航模式。在 React Native 中,有很多解决这些问题的解决方案,我很乐意教您如何选择与您项目需求匹配的解决方案。

进一步阅读

  • 编写测试-Redux 官方文档:

redux.js.org/recipes/writing-tests.

  • 实现撤销历史-Redux 官方文档:

redux.js.org/recipes/implementing-undo-history.

  • 服务器渲染-Redux 官方文档:

redux.js.org/recipes/server-rendering.

  • 规范化状态-Redux 官方文档:

redux.js.org/recipes/structuring-reducers/normalizing-state-shape.

这在网络模式的背景下非常重要。从后端系统获取的一些数据将需要进行规范化处理。

  • 异步操作-Redux 官方文档:

redux.js.org/advanced/async-actions.

  • Redux Saga 食谱-Redux Saga 官方文档:

redux-saga.js.org/docs/recipes/

这个资源特别有价值,因为它提供了使用 saga 进行节流、去抖动和撤销的食谱。

  • Redux Saga 通道-Redux Saga 官方文档:

“到目前为止,我们已经使用‘take’和‘put’效果与 Redux Store 进行通信。通道将这些效果泛化为与外部事件源或 Sagas 之间进行通信。它们还可以用于从 Store 中排队特定的操作。”

  • Redux Saga 官方文档:

redux-saga.js.org/docs/advanced/Channels.html

  • 关于 Thunk、saga、抽象和可重用性的惯用 redux 思想:

blog.isquaredsoftware.com/2017/01/idiomatic-redux-thoughts-on-thunks-sagas-abstraction-and-reusability/

  • 资源库:React Redux 链接/Redux 副作用:

github.com/markerikson/react-redux-links/blob/master/redux-side-effects.md

  • 关于 Saga 的 Saga:

“术语‘saga’通常在 CQRS 讨论中用来指代协调和路由有界上下文和聚合之间的消息的一段代码。然而,[...]我们更倾向于使用术语‘过程管理器’来指代这种类型的代码构件。”

关于 Saga 的 Saga-Microsoft 文档:

docs.microsoft.com/en-us/previous-versions/msp-n-p/jj591569(v=pandp.10)

  • GraphQL-另一种处理副作用的方法。GraphQL 是一个用于 API 的查询语言,既可以用于前端,也可以用于后端。在这里了解更多:

graphql.org/learn/

  • Redux Observable-Thunk 和 saga 的竞争对手。介绍了响应式编程模式:

github.com/redux-observable/redux-observable

还请查看 RxJS,这是 JavaScript 的响应式编程库:

github.com/reactivex/rxjs

  • 表述性状态转移:

en.wikipedia.org/wiki/Representational_state_transfer

  • HATEOAS(REST 架构的一个组件):

https://en.wikipedia.org/wiki/HATEOAS

第七章:导航模式

几乎每个应用程序的关键部分是导航。直到今天,这个话题仍然让许多 React Native 开发人员头疼。让我们看看有哪些可用的库,以及哪一个适合您的项目。本章从可用库的分解开始。然后,我们将介绍一个新项目并进行操作。我们将一次专注于一个库。完成后,我将带您了解所使用的模式以及这些模式意味着什么,同时您编写导航代码。记得在您的计算机和手机上尝试这些代码。

在本章中,您将了解以下内容:

  • 为什么 React Native 有许多替代路由库?

  • 导航库面临的挑战是什么?

  • 本地导航和 JavaScript 导航有什么区别?

  • 如何使用选项卡导航、抽屉导航和堆栈导航。

  • 本地解决方案的基础知识:您将首次弹出创建 React Native 应用程序。

React Native 导航替代方案

通常,如果您是初学者,并尝试在 Google 上搜索React Native 导航,您最终会头疼。可用的替代方案数量很多。这是有几个原因的:

  • 一些早期的库不再得到维护,因为维护者已经退出

  • 一些资源充足的公司开始了一个库,然后将员工的重点转移到其他事情上

  • 一些解决方案被证明效率低下,或者实施了更好的解决方案

  • 不同方法有架构上的原因,导致需要维护不同的解决方案

我们将在这里专注于最后一点,因为了解哪个库适合您的需求至关重要。我们将讨论解决方案,以便在本章结束时,您将知道为您的项目选择哪个库。

设计师的导航模式

在我们深入了解库的世界之前,我想向您展示在应用程序中设计导航的不同方式。通常,这是项目设计师的工作;然而,一旦您了解了权衡,添加代码模式层将会更容易。

移动应用程序由屏幕和过渡组成。总的来说,这些可以用以下图表表示:

这是一个代表任务应用程序屏幕的示例图表

前面图表的主要要点如下:

  • 每个应用程序都包括顶层屏幕(主页项目搜索

  • 从顶层屏幕,您可以向前导航并深入树中(项目 => 项目任务列表

  • 有时,您会向后过渡(任务 => 项目任务列表

有了这个想法,让我们看看将帮助我们进行这些转换的组件。

导航到顶层屏幕

通常使用以下三种替代方案之一导航到顶层屏幕:

  • 经典底部导航,就像我们已经实现的那样。这通常使用图标或图标和文本的组合。根据所做的选择,这允许我们放置两到五个链接。这在平板设计上通常是避免的:

经典底部导航的一个例子

  • 导航抽屉,从屏幕侧边打开。其中包含一个链接列表,可能超过五个。这可能是复杂的,并且可以在顶部包括用户配置文件。这往往是通过位于一个上角的汉堡图标打开的:

抽屉导航的一个例子

  • 标签,放置在屏幕顶部,成对出现,至少如此。标签的数量可以超过四个,在这种情况下,标签可以水平滚动。这不仅用于顶层导航,还用于同一深度屏幕之间的任何导航。

在图表的不同级别之间导航

一旦到达一定级别,有时我们想进一步探索特定区域。在任务应用程序的情况下,这意味着选择一个项目或在项目内选择特定任务。

通常,为了在图表中向下导航,我们使用以下方法:

  • 容器,包括列表、卡片、图像列表和图像卡片

  • 简单按钮、文本链接或图标

然而,为了回到图表的上方,通常我们使用以下方法:

  • 返回图标,如箭头,通常位于左上角或左下角

  • 按钮或链接,文本如返回|取消|重新开始

  • 在编辑/创建屏幕的相关部分放置的交叉图标

对于你们中的一些人来说,这些知识是自然而然的;然而,我遇到了一些明显混淆了这些概念的提案或早期设计草案,最终严重影响了用户体验。尝试是好的,但只能在使用标准和众所周知的模式的受控环境中进行,这些模式对大多数用户来说是自然的。

对于设计实验,您应该实施 A/B 测试。这需要能够在生产中为不同的用户子集运行应用程序的不同版本。借助分析,您可以随后评估 A 或 B 哪个选择更好。最终,所有用户都可以迁移到获胜的方案。

在图的同一级别上导航

在更复杂的应用程序中,除了顶层导航之外,您还需要在相同深度的不同屏幕之间进行水平过渡。

要在同一级别的屏幕之间进行过渡,您可以使用以下方法:

  • 选项卡,类似于顶层导航部分讨论的内容

  • 屏幕滑动(字面上在屏幕之间滑动)

  • 在容器中滑动(例如,查看任务描述、连接任务或任务评论)可以与选项卡连接

  • 左右箭头,或指示您在级别内位置的点

同样,您也可以用这些来处理数据集合。然而,数据集合提供更多自由,可以使用列表或不受限制的容器,利用顶部/底部滑动。

牢记设计师们如何解决导航问题,现在让我们讨论如何使其性能良好以及如何维护导航图。

开发者的导航模式

说实话,一切都归结于这一点——JavaScript 实现是否足够好?如果是,让我们为自己的利益使用它(即,跟踪、JavaScript 中的控制、日志等)。随着时间的推移,看起来 React Native 社区设法创建了一个稳定的东西,称为 React Navigation:

“React Navigation 完全由 React 组件组成,并且状态在 JavaScript 中管理,与应用程序的其余部分在同一线程上。这在许多方面使 React Navigation 变得很棒,但这也意味着您的应用逻辑与 React Navigation 竞争 CPU 时间——每帧可用的 JavaScript 执行时间有限。”

  • React Navigation 官方文档,可在以下网址找到:

reactnavigation.org/docs/en/limitations.html

然而,正如前面的引用所讨论的,这与您的应用程序竞争 CPU 周期。这意味着它在一定程度上耗尽资源并减慢应用程序的速度。

JavaScript 导航的优点如下:

  • 您可以使用 JavaScript 代码调整和扩展解决方案。

  • 当前的实现对于中小型应用程序来说性能足够好。

  • 状态在 JavaScript 中管理,并且很容易与 Redux 等状态管理库集成。

  • API 与本机 API 解耦。这意味着如果 React Native 最终超越 Android 和 iOS,API 将保持不变,并且一旦由库维护者实施,这将使您能够为另一个平台使用相同的解决方案。

  • 易学。

  • 适合初学者。

JavaScript 导航的缺点如下:

  • 在性能方面实施起来非常困难。

  • 对于大型应用程序来说可能仍然太慢。

  • 一些动画与本机动画略有不同。

  • 某些手势或动画可能与本机的完全不同(例如,如果本机系统更改了默认设置,或者由于历史更改而不一致)。

  • 很难与本机代码集成。

  • 根据当前文档,路由应该是静态的。

  • 某些解决方案,如果您曾经创建过本机导航,可能不可用(例如,与本机生命周期的连接)。

  • 有限的国际支持(例如,截至 2018 年 7 月,某些 JavaScript 导航库不支持从右到左,包括 React Navigation)。

另一方面,让我们看看本机导航。

本机导航的优点如下:

  • 本机导航可以通过系统库进行优化,例如,容器化导航堆栈

  • 本机导航优于 JavaScript 导航

  • 它利用了每个系统的独特能力

  • 能够利用本机生命周期并通过动画连接到它

  • 大多数实现都集成了状态管理库

本机导航的缺点如下:

  • 有时它违背了 React Native 的初衷-它使导航在系统之间分歧,而不是统一。

  • 很难在各个平台上提供一致的 API,或者根本不一致。

  • 单一真相不再成立-我们的状态泄漏到在特定平台内部管理状态的本机代码。这会破坏时间旅行。

  • 问题状态同步 - 所选择的库要么根本不承诺立即状态同步,要么实现了不同的锁定,这会使应用程序变慢,通常会破坏其目的。

一些专家认为 NavigatorIOS 库的开发人员(截至 2018 年 7 月,仍在官方 React Native 文档中提到)在开发工作上做得很好,但它的未来是不确定的。

  • 它需要使用本地系统的工具和配置。

  • 它旨在针对有经验的开发人员。

在选择其中一个之前,你需要考虑所有这些并做出正确的权衡。但在我们深入代码之前,请专注于下一节。

重构你的应用程序

没有人喜欢庞大的单片代码库,所有功能都交织在一起。随着应用程序的增长,我们可以做些什么来防止这种情况发生?确保明智地定位代码文件,并且有一种标准化的做法。

一旦超过 10,000 行,会让你头痛的单片代码库的一个例子是:

一个目录结构的例子,对于大型项目来说并不够好

想象一下有 1,200 个减速器的目录。你可能会使用搜索。相信我,这在有 1,200 个减速器的情况下也会变得困难。

相反,更好的做法是按功能对代码进行分组。由此,我们将清楚地了解在调查应用程序的某个独立部分时要查看的文件范围:

一个对于中大型项目可能有好处的目录结构的例子

要查看这种新结构的实际效果,请查看第七章中src文件夹中的Example 1的代码文件,导航模式

如果你曾经使用过微服务,可以将其想象成你希望你的功能在前端代码库中成为简单的微服务。一个屏幕可能会要求它们通过发送数据来运行,并期望特定的输出。

在某些架构中,每个这样的实体也会创建自己的 Flux 存储。这对于大型项目来说是一个很好的关注点分离。

React Navigation

浏览器内置了导航解决方案,React Native 需要有自己的解决方案,这其中是有原因的:

“在 Web 浏览器中,您可以使用锚点()标签链接到不同的页面。当用户点击链接时,URL 将被推送到浏览器历史堆栈中。当用户按下返回按钮时,浏览器将从历史堆栈的顶部弹出项目,因此活动页面现在是先前访问的页面。React Native 没有像 Web 浏览器那样内置全局历史堆栈的概念 - 这就是 React Navigation 进入故事的地方。”

  • React Navigation 官方文档,可在以下网址找到:

reactnavigation.org/docs/en/hello-react-navigation.html

总之,我们的移动导航不仅可以像在浏览器中看到的那样处理,而且可以按照我们喜欢的任何自定义方式处理。这要归功于历史原因,因为一些屏幕更改通常与特定操作系统的用户所认可的特定动画相关联。因此,尽可能地遵循它们以使其类似于原生感觉是明智的。

使用 React Navigation

让我们通过以下命令安装 React Navigation 库开始我们的旅程:

yarn add react-navigation

一旦库安装完成,让我们尝试最简单的路径,使用一个类似于浏览器中看到的堆栈导航系统。

对于那些不知道或忘记堆栈是什么的人,堆栈这个名字来源于现实生活中一组物品堆叠在一起的类比。物品可以被推到堆栈中(放在顶部),或者从堆栈中弹出(从顶部取出)。

一个特殊的结构,进一步推动这个想法,类似于一个水平堆栈,可以从底部和顶部访问。这样的结构被称为队列;然而,在本书中我们不会使用队列。

在上一节中,我对我们的文件结构进行了重构。作为重构的一部分,我创建了一个新文件,名为TaskListScreen,它由我们代码库中的特性组成:

// src / Chapter 7 / Example 2 / src / screens / TaskListScreen.js export const TaskListScreen = () => (
    <View>
 **<AddTaskContainer />    // Please note slight refactor** **<TaskListContainer />   // to two separate containers** </View> );   export default withGeneralLayout(TaskListScreen);

withGeneralLayout HOC 也是重构的一部分,它所做的就是用头部和底部栏包装屏幕。这样包装的 TaskList 组件准备好被称为 Screen 并直接提供给 React Navigation 设置:

// src / Chapter 7 / Example 2 / src / screens / index.js

export default createStackNavigator({
    TaskList: {
        screen: TaskListScrn,
  path: 'project/task/list', // later on: 'project/:projectId/task/list'
  navigationOptions: { header: null }
    },
  ProjectList: {
        screen: () => <View><Text>Under construction.</Text></View>,
  path: 'project/:projectId'
  },
 // ...
}, {
    initialRouteName: 'TaskList',
  initialRouteParams: {}
}); 

在这里,我们使用一个期望两个对象的 createStackNavigator 函数:

  • 代表应该由这个StackNavigator处理的所有屏幕的对象。每个屏幕都应该指定一个代表该屏幕和路径的组件。您还可以使用navigationOptions来自定义您的屏幕。在我们的情况下,我们不想要默认的标题栏。

  • 代表导航器本身的设置对象。您可能想要定义初始路由名称及其参数。

做完这些,我们已经完成了导航的 hello world - 我们有一个屏幕在工作。

使用 React Navigation 的多个屏幕

现在是时候向我们的StackNavigator添加一个任务屏幕了。使用你新学到的语法,为任务详情创建一个占位符屏幕。以下是我的实现:

// src / Chapter 7 / Example 3 / src / screens / index.js
// ...
Task: {
    screen: () => <View><Text>Under construction.</Text></View>,
  path: 'project/task/:taskId',
  navigationOptions: ({ navigation }) => ({
        title: `Task ${navigation.state.params.taskId} details`  })
},
// ...

这一次,我还传递了navigationOptions,因为我想使用具有特定标题的默认导航器顶部栏:

新任务屏幕可能的样子

要导航到任务详情,我们需要一个单独的链接或按钮,可以带我们到那里。让我们在我们的目录结构的顶部创建一个可重用的链接,如下所示:

// src / Chapter 7 / Example 3 / src / components / NavigateButton.js
// ...
export const NavigateButton = ({
    navigation, to, data, text
}) => (
    <Button
  onPress={() => navigation.navigate(to, data)}
        title={text}
    /> );
// ...
export default withNavigation(NavigateButton);

前面片段的最后一行使用了withNavigation HOC,这是 React Navigation 的一部分。这个 HOC 为NavigateButton提供了导航属性。Todatatext需要手动传递给组件:

// src / Chapter 7 / Example 3 / src / features / tasks / views / TaskList.js
// ...
<View style={styles.taskText}>
 <Text style={styles.taskName}>
  {task.name}
    </Text>
 <Text>{task.description}</Text> </View> <View style={styles.taskActions}>
 <NavigateButton  data={{ taskId: task.id }}
 to="Task"
  text="Details" **/>** </View>
// ... 

就是这样!让我们看看以下的结果。如果你觉得设计需要一点润色,可以使用第三章 样式模式中学到的技巧:

每个任务行现在都显示了一个详情链接

现在您可以点击详情按钮导航到任务详情屏幕。

标签导航

由于我们已经放置了底部图标控件,使它们工作将非常简单。这是标签导航的一个经典示例:

// src / Chapter 7 / Example 4 / src / screens / index.js
export default createBottomTabNavigator(
    {
        Home: createStackNavigator({
            TaskList: {
                // ...
            },
            // ...
        }, {
            // ...
        }),
  Search: () => (
            <View>
 <Text>Search placeholder. Under construction.</Text>
 </View>  ),
  Notifications: () => (
            <View>
 <Text>Notifications placeholder. Under construction.</Text>
 </View>  )
    },
  {
        initialRouteName: 'Home',
  initialRouteParams: {}
    }
); 

请注意使用缩写创建屏幕的用法。我直接传递组件,而不是使用对象:

默认情况下,React Navigation 会为我们创建一个底部栏

要禁用标题栏,我们需要传递适当的属性,如下所示:

// src / Chapter 7 / Example 4 / src / screens / index.js
// ...
{
    initialRouteName: 'Home',
  initialRouteParams: {},
  navigationOptions: () => ({
 tabBarVisible: false
  })
}
// ...

现在,我们需要让我们的图标响应用户的触摸。首先,创建一个NavigateIcon组件,你可以在你的应用程序中重用。查看存储库以获取完整的代码示例,但这里提供了一个示例:

// src / Chapter 7 / Example 4 / src / components / NavigateIcon.js export const NavigateIcon = ({
    navigation, to, data, ...iconProps
}) => (
    <Ionicons
  {...iconProps}
        onPress={() => navigation.navigate(to, data)}
    /> ); // ... export default withNavigation(NavigateIcon); 

NavigateIcon相当简单地替换现有的图标,如下所示:

// src / Chapter 7 / Example 4 / src / layout / views / GeneralAppView.js
import NavIonicons from '../../components/NavigateIcon';
<View style={styles.footer}>
 <NavIonicons  to**="Home"**
 // ...   />
 <NavIonicons  to**="Search"**
        // ...   />
 <NavIonicons  to**="Notifications"**
        // ...   /> </View>

最后要注意的是一般布局。SearchNotifications屏幕应该显示我们的自定义底部导航。由于我们学到的 HOC 模式,这 surprisingly 容易:

// src / Chapter 7 / Example 4 / src / screens / index.js
// ...
Search: withGeneralLayout(() => (
    <View>
 <Text>Search placeholder. Under construction.</Text>
 </View> )), Notifications: withGeneralLayout(() => (
    <View>
 <Text>Notifications placeholder. Under construction.</Text>
 </View> )) // ...

结果显示在以下截图中:

搜索屏幕及其占位符。

请通过向withGeneralLayout HOC 添加配置对象来修复标题名称。

抽屉导航

现在是时候实现抽屉导航,以便用户访问不常用的屏幕,如下所示:

// src / Chapter 7 / Example 5 / src / screens / index.js
// ...
export default createDrawerNavigator({
    Home: TabNavigation,
  Profile: withGeneralLayout(() => (
        <View>
 <Text>Profile placeholder. Under construction.</Text>
 </View>  )),
  Settings: withGeneralLayout(() => (
        <View>
 <Text>Settings placeholder. Under construction.</Text>
 </View>  ))
}); 

由于我们的默认抽屉已准备就绪,让我们添加一个图标来显示它。汉堡图标是最受欢迎的,通常放置在标题的一个角落:

// src / Chapter 7 / Example 5 / src / layout / views / MenuView.js
const Hamburger = props => (<Ionicons
  onPress={() => props.navigation.toggleDrawer()}
    name="md-menu"
  size={32}
    color="black" />); // ...   const MenuView = withNavigation(Hamburger); 

现在,只需将其放在GeneralAppView组件的标题部分并适当地进行样式设置:

// src / Chapter 7 / Example 5 / src / layout / views / GeneralAppView.js
<View style={styles.header}>
 // ...  <View style={styles.headerMenuIcon}>
 <MenuView /> </View> </View> 

就是这样,我们的抽屉功能完全可用。您的抽屉可能看起来像这样:

在 iPhone X 模拟器上打开抽屉菜单。

您可以通过单击右上角的汉堡图标来打开抽屉。

重复数据的问题

任务列表组件在成功挂载时获取显示列表所需的数据。然而,没有实现防止数据重复的机制。本书不旨在为常见问题提供解决方案。然而,让我们考虑一些您可以实施的解决方案:

  • 更改 API 并依赖于唯一的任务标识符(如 ID、UUID 或 GUID)。确保只允许唯一的标识符。

  • 每次请求都清除数据。这很好;然而,在我们的情况下,我们会丢失未保存的(与 API 相关的)任务。

  • 保持状态,并且只请求一次。这只适用于我们简单的用例。在更复杂的应用程序中,您将需要更频繁地更新数据。

好的,牢记这一点,让我们最终深入基于本地导航解决方案的库。

React Native Navigation

在本节中,我们将使用本地解决方案进行导航。React Native Navigation 是 Android 和 iOS 本地导航的包装器。

我们的目标是重新创建我们在上一节中实现的内容,但使用 React Navigation。

关于设置的几句话

在本节中,您可能会面临的最大挑战之一是设置库。请遵循最新的安装说明。花点时间——如果您不熟悉工具和生态系统,可能需要超过 8 小时。

按照以下链接中的安装说明进行安装:github.com/wix/react-native-navigation

本书使用 React Native Navigation 第 2 版的 API。要使用相同的代码示例,您也需要安装第 2 版。

您可能还需要要么退出 Create React Native App,要么使用react-native init引导另一个项目并将关键文件复制到那里。如果您在这个过程中遇到困难,请尝试使用src/Chapter 7/Example 6/(只是 React Native)或src/Chapter 7/Example 7/(整个 React Native Navigation 设置)中的代码。我使用了react-native init并将所有重要的东西都复制到那里。

在实现可工作的设置过程中,肯定会出现错误。不要沮丧;在 StackOverflow 上搜索任何错误或在 React Native 和 React Native Navigation 的 GitHub 问题中搜索。

React Native Navigation 的基础知识

第一个重大变化是缺少AppRegistryregisterComponent的调用。相反,我们将使用Navigation.setRoot(...)来完成工作。只有在确定应用程序成功启动时,才应调用setRoot函数,如下所示:

// src / Chapter 7 / Example 7 / src / screens / index.js
import { Navigation } from 'react-native-navigation';
// ...
export default () => Navigation.events().registerAppLaunchedListener(() => {
    Navigation.setRoot({
        // ...
    });
});

然后,我们的根/入口文件将只调用 React Native Navigation 函数:

import start from './src/screens/index';   export default start();

好的。更有趣的部分是我们放入setRoot函数的内容。基本上,我们在这里有一个选择:堆栈导航或标签导航。根据我们之前的应用程序,顶层应用将是标签导航(抽屉导航在 React Native Navigation 中是解耦的)。

在撰写本书时,使用默认内置的底部栏是保留先前功能的唯一选项。一旦库作者发布 RNN 的第 2 版并修复Navigation.mergeOptions(...),您就可以实现自定义底部栏。

首先,让我们移除默认的顶部栏并自定义底部栏:

// src / Chapter 7 / Example 7 / src / screens / index.js
// ...
Navigation.setRoot({
    root: {
        bottomTabs: {
            children: [
            ],
  options: {
                topBar: {
                    visible: false**,**
  drawBehind: true,
  animate: false
  },
  bottomTabs: {   animate: true
  }   }
        }
    }
});

完成了这一点,我们准备定义标签。在 React Native Navigation 中要做的第一件事是注册屏幕:

// src / Chapter 7 / Example 7 / src / screens / index.js
// ...
Navigation.registerComponent(
    'HDPRN.TabNavigation.TaskList',
  () => TaskStackNavigator, store, Provider
); Navigation.registerComponent(
    'HDPRN.TabNavigation.SearchScreen',
  () => SearchScreen, store, Provider
); Navigation.registerComponent(
    'HDPRN.TabNavigation.NotificationsScreen',
  () => NotificationsScreen, store, Provider
); 

当我们注册了所有基本的三个屏幕后,我们可以按照以下方式进行标签定义:

// src / Chapter 7 / Example 7 / src / screens / index.js
// ...
children: [
    {
        stack: {
            id: 'HDPRN.TabNavigation.TaskListStack',
            // TODO: Check below, let's handle this separately
        }
    },
  {
        component: {
            id: 'HDPRN.TabNavigation.**SearchScreen**',
  name: 'SearchScreen',
  options: {
                bottomTab: {
                    text: 'Search',
                    // Check sources if you want to know
                    // how to get this icon variable
  icon: search 
                }
            }
        }
    },
 // Notifications config object omitted: similar as for Search
]

我们定义了三个单独的标签 - TasksSearchNotifications。关于Tasks,这是另一个导航器。Stack导航器可以配置如下:

stack: {
    id: 'HDPRN.TabNavigation.TaskListStack',
  children: [{
        component: {
            id: 'HDPRN.TabNavigation.**TaskList**',
  name: 'HDPRN.TabNavigation.TaskList',
  }
    }],
  options: {
        bottomTab: {
            text: 'Tasks',
  icon: home
        }
    }
}

在上面的片段中,bottomTab选项设置了底部栏中的文本和图标:

使用 React Native Navigation 的任务选项卡

进一步调查

我将把如何实现导航元素(如抽屉或任务详情屏幕)的调查留给那些勇敢的人。在撰写本文时,React Native Navigation v2 相当不稳定,我选择不再发布来自该库的任何片段。对于大多数读者来说,这应该足够让他们对整体感觉有所了解。

总结

在这一章中,我们最终扩展了我们的应用程序,比以前有更多的视图。您已经学会了移动应用程序中不同的导航方法。在 React Native 世界中,要么是原生导航,要么是 JavaScript 导航,或者两者的混合。除了学习导航本身,我们还使用了包括StackNavigationTabNavigationDrawerNavigation在内的组件。

这是我们第一次将 Create React Native App 弹出,并从原生导航库中安装了原生代码。我们开始深入研究 React Native。现在是时候退后一步,更新我们的 JavaScript 知识了。我们将学习不仅在 React Native 中有益的模式,而且在整个 JavaScript 中都有益的模式。

进一步阅读

  • React Navigation 常见错误-来自官方文档,可在以下链接找到:

reactnavigation.org/docs/en/common-mistakes.html

  • Charles Mangwa 的《在 React Native 中导航的千种方式》:

www.youtube.com/watch?v=d11dGHVVahk.

  • React Navigation 的导航游乐场:

expo.io/@react-navigation/NavigationPlayground

  • Expo 关于导航的文档:

docs.expo.io/versions/v29.0.0/guides/routing-and-navigation

  • 标签的 Material Design:

material.io/design/components/tabs.html#placement

  • 在 Awesome React Native 存储库中关于导航的部分:

github.com/jondot/awesome-react-native#navigation

第八章:JavaScript 和 ECMAScript 模式

在本章中,我们将回到 JavaScript 语言的核心。这里的一些模式可以在许多不同的语言中重复使用,例如 Java、C++ 和 Python。用这样强大的东西填充您的工具箱是至关重要的。这一次,我们将在 JavaScript 中实现众所周知的设计模式,并看看我们如何从中受益,特别是在 React Native 环境中。作为一个小补充,我们将学习一个名为 Ramda 的新库,它以其出色的功能而闻名,可以帮助我们编写更短、更简洁的代码。您还将了解函数式编程的基础知识,这将是下一章的主题。

在本章中,您将学习以下内容:

  • 选择器模式

  • 柯里化模式

  • Ramda 库

  • 函数式编程基础

JavaScript 和函数式编程

函数式编程基本上意味着以某种方式使用函数来编写逻辑代码。大多数语言允许函数变得非常复杂和难以理解。然而,函数式编程对函数施加了约束,以便能够组合它们并在数学上证明它们的行为。

其中一个约束是规范与外部世界的通信(例如,副作用,如数据获取)。有人断言,无论我们用相同的参数调用函数多少次,它都会返回完全相同的值。所有这些约束都将给我们带来一定的好处。您已经可以列举一些这些好处,比如时间旅行,它使用纯减速器。

在本章中,我们将学习一些有用的函数,这将使我们更容易进入第九章,函数式编程模式的要素。我们还将更详细地阐述确切的约束及其好处。

ES6 的 map、filter 和 reduce

本节旨在刷新我们对 mapfilterreduce 函数的了解。

通常,常见的语言函数需要非常高的性能,这是一个超出本书范围的话题。避免重新实现语言中已有的功能。本章中的一些示例仅用于学习目的。

reduce 很可能经常被忽视,因此我们将重点关注它。通常,reduce(顾名思义)用于将集合的大小减小到更小的集合,甚至是单个变量。

以下是 reduce 函数的声明:

reduce(callback, [initialValue])

回调函数接受四个参数:previousValuecurrentValueindexarray

为了快速提醒一下reduce函数的工作原理,让我们看下面的例子:

const sumArrayElements = arr => arr.reduce((acc, elem) => acc+elem, 0);
console.log(sumArrayElements([5,15,20])); // 40

reduce在集合上进行迭代。在每一步,它调用函数来处理它所在的元素迭代器。然后它记住函数的输出并传递给下一个元素。这个记住的输出是第一个函数参数;在前面的例子中,它是累加器(acc)变量。它记住了先前运行函数的结果,应用reducer函数并传递到下一步。这与 Redux 库在状态上的操作非常相似。

reduce函数的第二个参数是累加器的初始值;在前面的例子中,我们从零开始。

让我们提高难度,使用reduce来实现一个average函数:

const numbers = [1, 2, 5, 7, 13]; const average = numbers.reduce(
    (accumulator, currNumber, indexOfElProcessed, arrayWeWorkOn) => {
        // Sum all numbers so far
        const newAcc = accumulator + currNumber;
  if (indexOfElProcessed === arrayWeWorkOn.length - 1) {
            // if this is the last item, return average
            return newAcc / arrayWeWorkOn.length;
  }
        // if not the last item, pass sum
        return newAcc;
  },
  0 ); // average equals 5.6

在这个例子中,我们在if语句中做了一个小技巧。如果元素是数组中的最后一个元素,那么我们想要计算average而不是sum

使用 reduce 重新实现 filter 和 map

现在是一个小挑战的时候了。你知道吗,你可以使用reduce来实现mapfilter两个函数吗?

在我们开始之前,让我们快速回顾一下filter函数的工作原理:

过滤函数在集合上的工作原理

假设我们有一个task集合,想要筛选出type等于1的任务,如下所示:

const onlyType1 = task => task.type === 1

使用标准的 filter 函数,你只需要写下面的代码:

tasks.filter(onlyType1)

但是现在,想象一下如果没有filter函数,到目前为止,你的工具箱中只有reduce

你可以这样做:

tasks.reduce((acc,t) => onlyType1(t) ? [...acc, t] :acc, [])

技巧是将累加器变成一个集合。前一个值始终是一个集合,从空数组开始。一步一步地,我们要么将任务添加到累加器中,要么如果任务未能通过筛选,则简单地返回累加器。

那么如何实现map函数呢?map只是通过应用传递给它的映射函数,将每个元素转换为一个新元素:

映射函数在集合上的工作原理

让我们使用reduce来做,如下所示:

const someFunc = x => x+1; const tab = [1, 5, 9, 13]; tab.reduce((acc, elem) => [...acc, someFunc(elem)], []);
// result: [2, 6, 10, 14]

在这个例子中,我们只是再次将每个项目收集到相同的集合中,但在将其添加到数组之前,我们对其应用了一个映射函数。在这个例子中,映射函数被定义为someFunc

在数组中计算项目数量

我们的下一个例子是关于计算数组中的项数。假设你有一个房屋物品的数组。你需要计算你拥有的每种物品的数量。使用reduce函数,预期的结果是一个具有物品作为键和特定物品计数作为值的对象,如下所示:

const items = ['fork', 'laptop', 'fork', 'chair', 'bed', 'knife', 'chair']; items.reduce((acc, elem) => ({ ...acc, [elem]: (acc[elem] || 0) + 1 }), {});
// {fork: 2, laptop: 1, chair: 2, bed: 1, knife: 1} 

这很棘手:(acc[elem] || 0)部分意味着我们要么取acc[elem]的值,如果它被定义了,要么取0。这样,我们就可以检查其种类的第一个元素。另外,{ [elem]: something }是用来定义一个以存储在elem变量中的名称为键的语法。

前面的例子在你处理来自外部 API 的序列化数据时很有帮助。有时你需要对其进行转换以进行缓存,以避免不必要的重新渲染。

下一个例子介绍了一个新词—展开。当我们展开一个集合时,意味着它是一个嵌套在集合中的集合,我们希望将其变平。

例如,一个集合,比如[[1, 2, 3], [4, 5, 6], [7, 8, 9]]在展开后变成了[1, 2, 3, 4, 5, 6, 7, 8, 9]。这是通过以下方式完成的:

const numCollections = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]; numCollections.reduce((acc, collection) => [...acc, ...collection], []);
// result:[1, 2, 3, 4, 5, 6, 7, 8, 9] 

这个例子对于理解我们将在第九章,函数式编程模式的元素中使用的更复杂的例子中的展开是至关重要的。

迭代器模式

在前面的部分中,我们遍历了许多不同的集合,甚至是嵌套的集合。现在,是时候更多地了解迭代器模式了。如果你打算使用 Redux Saga 库,这种模式尤其闪耀。

如果你直接跳到这一章,我强烈建议你阅读介绍迭代器模式的部分第六章,数据传输模式。该章还涵盖了 Redux Saga 库和生成器。

总结一下,在 JavaScript 中,迭代器是一个知道如何逐个遍历集合项的对象。它必须公开next()函数,该函数返回集合的下一个项。集合可以是任何想要的东西。它甚至可以是一个无限集合,比如斐波那契数列,就像这里看到的那样:

class FibonacciIterator {
    constructor() {
        this.n1 = 1;
  this.n2 = 1;
  }
    next() {
        var current = this.n2;
  this.n2 = this.n1;
  this.n1 = this.n1 + current;
  return current;
  }
}

在你使用这个之前,你需要创建一个类的实例:

const fibNums = new FibonacciIterator(); fibNums.next(); // 1 fibNums.next(); // 1 fibNums.next(); // 2 fibNums.next(); // 3 fibNums.next(); // 5 

这可能很快变得无聊,因为它看起来像一个学术例子。但它并不是。它很有用,可以向你展示我们将使用闭包和Symbol迭代器重新创建的算法。

定义一个自定义迭代器

快速回顾一下 JavaScript 中的符号:CallingSymbol()返回一个唯一的符号值。符号值应该被视为一个 ID,例如,作为一个在对象中用作键的 ID。

要为集合定义一个迭代器,您需要指定特殊的键Symbol.iterator。如果定义了这样一个符号,我们说这个集合是可迭代的。看下面的例子:

// Array is iterable by default,
// we don't need to create a custom iterator,
// just use the one that is present.
const alpha = ['a','b','c']; const it = alpha[Symbol.iterator]()**;**   it.next(); //{ value: 'a', done: false } it.next(); //{ value: 'b', done: false } it.next(); //{ value: 'c', done: false } it.next(); //{ value: undefined, done: true }

现在让我们为斐波那契数列创建一个自定义的iterator。斐波那契数列的特点是每个数字都是前两个数字的和(序列的开头是 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, ...):

const fib = {
    [Symbol.iterator]() {
        let n1 = 1;
  let n2 = 1;    return {
            next() {
                const current = n2;
  n2 = n1;
  n1 += current;
  return { value: current, done: false };
  },    return(val) { // this part handles loop break
                // Fibonacci sequence stopped.
  return { value: val, done: true };
  }
        };
  }
};

为了轻松遍历可迭代的集合,我们可以使用方便的for...of循环:

for (const num of fib) {
    console.log(num);
    if (num > 70) break; // We do not want to iterate forever
}

使用生成器作为迭代器的工厂

我们还需要知道如何使用生成器(例如,对于 Redux Saga),所以我们应该熟练地编写它们。事实证明它们可以像迭代器的工厂一样工作,我们已经学会了如何使用迭代器。

关于生成器的快速回顾——它们是带有*yield操作符的函数,比如,function* minGenExample() { yield "a"; }。这样的函数执行直到遇到yield关键字。然后,函数返回yield值。函数可以有多个yield,在第一次调用时返回Generator。这样的生成器是可迭代的。看下面的例子:

const a = function* gen() { yield "a"; }; console.log(a.prototype)
// Generator {}

现在我们可以利用这个知识重新实现斐波那契数列作为一个生成器:

function* fib() {
    let n1 = 1;
  let n2 = 1;
  while (true) {
        const current = n2;
  n2 = n1;
  n1 += current;    yield current;   }
}
// Pay attention to invocation of fib to get Generator
for (const num of fib()) {
    console.log(num);
  if (num > 70) break; }

就是这样。我们使用生成器函数语法来简化自己的事情。生成器函数就像迭代器的工厂。一旦调用,它将为您提供一个新的生成器,您可以像任何其他集合一样对其进行迭代。

处理斐波那契数的代码可以简化。我能写的最简洁的方式如下:

function* fib() {

  let n1 = 1, n2 = 1;

  while (true) {

    yield n1;

    [n1, n2] = [n2, n1 + n2];

  }

}

使用生成器调用 API 以获取任务详情

我们已经尝试过生成器,并成功使用它们获取了任务。现在,我们将重复这个过程,但目标略有不同:获取单个任务的数据。为了实现这一点,我对代码进行了一些修改,并准备了代码的部分,让你只关注生成器:

// src/Chapter 8/Example 1/src/features/tasks/sagas/fetchTask.js
// ^ fully functional example with TaskDetails page
export **function*** fetchTask(action) {
    const task = yield call(apiFetch, `tasks/${action.payload.taskId}`);
  if (task.error) {
        yield put(ActionCreators.fetchTaskError(task.error));
  } else {
        const json = yield call([task.response, 'json']);
  yield put(ActionCreators.fetchTaskComplete(json));
  }
}

这个生成器首先处理 API 调用。端点是使用分派的动作的有效负载计算出来的。为了方便起见,使用了字符串模板。然后,根据结果,我们要么分派成功动作,要么分派错误动作:

这是任务详细信息屏幕的示例。请随意处理样式。

请注意生成器中的许多yield。每次yield都会停止函数执行。在我们的例子中,执行会在完成的call效果上恢复。然后,我们可以继续,知道调用的结果。

但为什么我们要停下来呢?有没有这样的用例?首先,它比简单的承诺和异步/等待更强大(在下一节中将会更多介绍)。其次,停下来等待某些事情发生是很方便的。例如,想象一下,我们想等到创建三个任务后才显示祝贺消息,就像这样:

function* watchFirstThreeTasksCreation() {
    for (let i = 0; i < 3; i++) {
        const action = yield take(TasksActionTypes.ADD_TASK)
    }
    yield put({type: 'SHOW__THREE_TASKS_CONGRATULATION'})
}

这个例子仅用于游乐场目的。请注意任务创建计数器在生成器函数内。因此,它不会保存在任何后端系统中。在应用程序刷新时,计数器将重置。如果您为应用程序构建任何奖励系统,请考虑这些问题。

生成器的替代方案

JavaScript 中多年来一直存在的一个流行的替代方案是承诺。承诺使用了与生成器非常相似的概念。语法糖允许您等待承诺。如果您想要这种语法糖,那么您的函数需要是async的。您看到了任何相似之处吗?是的,我愿意说承诺是生成器的一个不太强大的变体。

如果您使用承诺,请看一下名为for await of的新循环。您可能会发现它很方便。另一个值得检查的功能是异步迭代器

选择器

在上一节中,我们再次处理了异步数据。这些数据已被推送到应用程序的 Redux 存储中。我们在mapStateToProps函数中多次访问它,例如,在任务列表容器中:

const mapStateToProps = state => ({
    tasks: state.tasks.get('entities'),
  isLoading: state.tasks.get('isLoading'),
  hasError: state.tasks.get('hasError'),
  errorMsg: state.tasks.get('errorMsg')
});

这个看起来并不是很丑陋,但对于任务详细页面来说,它已经有些失控了。考虑以下内容:

// On this page we don't know if tasks are already fetched
const mapStateToProps = (state, ownProps) => ({
    task: state.tasks
  ? state.tasks
  .get('entities')
            .find(task => task.id === ownProps.taskId)
        : null }); 

我们进行了许多检查和转换。这个流程在每次重新渲染时都会发生。如果数据没有改变,我们是否可以记住计算结果?是的,我们可以——缓存选择器来拯救我们。

从 Redux 存储中选择

面对现实吧,到目前为止我们还没有对访问存储进行任何抽象。这意味着每个mapStateToProps函数都是单独访问它的。如果存储结构发生变化,所有mapStateToProps函数都可能受到影响。第一步是分离关注点,并提供选择器,而不是直接对象访问:

// src/Chapter 8/Example 1/src/features/
//                         ./tasks/containers/TaskListContainer.js
const mapStateToProps = state => ({
    tasks: tasksEntitiesSelector(state),
  isLoading: tasksIsLoadingSelector(state),
  hasError: tasksHasErrorSelector(state),
  errorMsg: tasksErrorMsgSelector(state)
}); 

实现与之前完全相同,唯一的例外是我们可以在许多地方重用代码:

// src/Chapter 8/Example 2/src/features/
//                      ./tasks/state/selectors/tasks.js  export const tasksSelector = state => state.tasks;   export const tasksEntitiesSelector = state =>
 (tasksSelector(state) ? tasksSelector(state).get('entities') : null);   export const tasksIsLoadingSelector = state =>
 (tasksSelector(state) ? tasksSelector(state).get('isLoading') : null);   export const tasksHasErrorSelector = state =>
 (tasksSelector(state) ? tasksSelector(state).get('hasError') : null);   export const tasksErrorMsgSelector = state =>
 (tasksSelector(state) ? tasksSelector(state).get('errorMsg') : null);

// PS: I have refactored the rest of the app to selectors too. 

即使在这个小例子中,我们在每个其他选择器中两次访问tasksSelector。如果tasksSelector很昂贵,那将会非常低效。然而,现在我们将通过缓存选择器来保护自己免受这种情况的影响。

缓存选择器

为了缓存选择器,我们将使用memoization函数。这样的函数一旦函数的输入引用发生变化就会重新计算值。为了节省时间,我们将使用一个流行的库来为我们实现这个 memoization 函数。这个库叫做reselect。在reselect中,引用变化是通过强相等性(===)来检查的,但如果需要,你可以更改相等函数。使用以下命令添加这个库:

yarn add reselect

有了这个,我们就准备好缓存了:

// src/Chapter 8/Example 2/src/features/
//                                ./tasks/state/selectors/tasks.js
import { createSelector } from 'reselect';   export const tasksSelector = state => state.tasks;   export const tasksEntitiesSelector = createSelector(
    tasksSelector,
  tasks => (tasks ? tasks.get('entities') : null)
); 
// ... rest of the selectors in similar fashion

从 Ramda 库学习函数

映射,过滤,减少,迭代器,生成器和选择器。不算太多,对吧?不要太害怕,你能用只有 10 个单词的英语说话吗?不行?好吧,那么我们可以继续学习一些新的单词,这些单词将使我们在 JavaScript 编程中更加流利。

组合函数

高阶组件(HOCs)最广告的特性之一是它们的可组合性。例如,withLoggerwithAnalyticswithRouter HOCs,我们可以以以下方式组合它们:

**withLogger**(**withAnalytics**(**withRouter**(SomeComponent))) 

Ramda 库将可组合性提升到了一个新的水平。不幸的是,我发现许多开发人员几乎不理解它。让我们看一个等价的例子:

R.compose(withLogger,withAnalytics, withRouter)(SomeComponent)

大多数人对 Ramda compose的难点在于理解它的工作原理。它通常从右到左应用函数,这意味着它首先评估withRouter,然后将结果转发给withAnalytics,依此类推。函数的最重要的一点是,只有第一个函数(withRouter)可以有多个参数。每个后续函数都需要在前一个函数的结果上操作。

Ramda compose函数从右到左组合函数。要从左到右组合函数,可以使用 Ramda pipe函数。

这个例子对你的 React 或 React Native 代码库的重要性在于,你不需要reselect或任何其他库来组合事物。你可以自己做。这在使用reselect库等用例中会很方便,该库希望你组合选择器。花一些时间适应它。

对抗混乱的代码

我在熟练使用 Ramda 的用户编写的代码中看到的下一个有趣的模式是所谓的pointfree代码。这意味着只有一个地方我们传递所有数据。尽管听起来很美好,但我不建议你对此如此严格。但是,我们可以从这种方法中得到一个好处。

考虑将你的代码从这个重构成:

const myHoc = SomeComponent => R.compose(withLogger,withAnalytics, withRouter)(SomeComponent)

你可以重构成这样:

const myHoc = R.compose(withLogger,withAnalytics, withRouter) 

这将隐藏明显的部分。最常见的问题是它开始像一个魔盒,只有我们知道如何向它传递数据。如果你使用像 TypeScript 或 Flow 这样的类型系统,当你完全不知道时,很容易快速查找它。但令人惊讶的是,许多开发人员会在这一点上感到恐慌。他们对compose的工作方式了解得越少(特别是右到左的函数应用),他们就越有可能不知道要传递什么给这个函数。

考虑这个:

const TaskNamesList  = tasks => tasks
    .map({ name }) => (
        <View><Text>{name}</Text></View>   ))

现在将上一个例子与这个compose的疯狂版本进行比较:

const TaskComponent  = name => (<View><Text>{name}</Text></View>)

const TaskNamesList = compose(
    map(TaskComponent),
  map(prop('name')) // prop function maps object to title key
);

在第一个例子中,你可能能在不到 30 秒内理解发生了什么。在第二个例子中,一个初学者可能需要超过一分钟才能理解这段代码。这是不可接受的。

柯里化函数

好吧,考虑到上一节的挑战,现在让我们关注另一面。在旧应用程序中,我们可能会遇到这样的问题,即修改我们想以不同方式使用的函数非常危险或耗时。

Brownfield 应用程序是过去开发并且完全功能的应用程序。其中一些应用程序可能是使用旧模式或方法构建的。我们通常无法承担将它们重写为最新趋势,比如 React Native。如果它们经过了实战测试,我们为什么还要费心呢?因此,如果我们决定新趋势会给我们带来足够的好处,我们将需要找到一种方法来连接两个世界,以便切换到它的新功能。

想象一个函数,它希望你传递两个参数,但你想先传一个,然后再传另一个:

const oldFunc = (x, y) => { // something }

const expected = x => y => { // something }

如果您不想修改函数,这可能有些棘手。但是,我们可以编写一个util函数来为我们做这件事:

const expected = x => y => oldFunc(x, y)

太棒了。但为什么要在每种情况下都写一个辅助函数呢?是时候介绍curry了:

const notCurriedFunc = (x, y, z) => x + y + z;
  const curriedFunc = R.curry(notCurriedFunc);

// Usage: curriedFunc(a)(b)(c)
// or shorter: R.curry(notCurriedFunc)(a)(b)(c)

// So our case with partial application could be:
const first = R.curry(notCurriedFunc)(a)(b);
// ... <pass it somewhere else where c will be present> ...
const final = first(c)

就是这样。我们使它的行为就像我们想要的那样,并且甚至没有改变布朗场应用程序函数(oldFuncnotCurriedFunc)中的一行代码。

如果您的应用程序中只有一两个地方需要使用curry,请三思。将来会有更多的用例吗?如果没有,那么使用它可能是过度的。使用辅助箭头函数,如前所示。

翻转

我们可以curry一个函数,这很好,但如果我们想以不同的顺序传递参数怎么办?对于前两个参数的更改,有一个方便的函数叫做flip,在这里演示:

const someFunc = x => y => z => x + y + z;

const someFuncYFirst = R.flip(someFunc);
// equivalent to (y => x => z => x + y + z;)

如果我们需要颠倒所有参数,遗憾的是没有这样的函数。但是无论如何,我们可以为我们的用例编写它:

const someFuncReverseArgs = z => y => x => someFunc(x, y, z);

总结

在本章中,我们深入探讨了现代 JavaScript 中常见的不同模式,如迭代器、生成器、有用的 reduce 用例、选择器和函数组合。

您还学习了 Ramda 库中的一些函数。 Ramda 值得比几页简单用例更多的关注。请在空闲时间查看它。

在下一章中,我们将运用在这里学到的知识来探讨函数式编程及其好处。

进一步阅读

  • Mozilla 指南中的迭代器和生成器文章:

developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Iterators_and_Generators.

  • Reselect 文档常见问题解答:

github.com/reduxjs/reselect#faq.

  • 不仅在 JavaScript 中使用的老式设计模式:

medium.com/@tkssharma/js-design-patterns-quick-look-fbc9ebfaf9aa.

  • JavaScript 的异步迭代器的 TC39 提案:

github.com/tc39/proposal-async-iteration.

第九章:函数式编程模式的元素

这是一个专注于函数式编程范式和设计模式的高级章节,这些设计模式来自函数式编程世界。现在是深入了解为什么我们可以创建无状态和有状态组件的时候了。这归结于理解纯函数是什么,以及不可变对象如何帮助我们预测应用程序的行为。一旦我们搞清楚了这一点,我们将继续讨论高阶函数和高阶组件。你已经多次使用过它们,但这一次我们将从稍微不同的角度来看待它们。

在整本书中,我向你提出了许多概念,在阅读完这一章后,这些概念会变得更加清晰。我希望你能在应用程序中接受它们并明智地使用它们,牢记你的团队的成熟度。这些模式是值得了解的,但对于 React 或 React Native 的开发并非必不可少。然而,当阅读 React 或 React Native 存储库的拉取请求时,你会发现自己经常参考这一章。

在本章中,我们将涵盖以下主题:

  • 可变和不可变结构

  • 特定函数,如纯函数

  • Maybe单子和单子模式

  • 函数式编程的好处

  • 缓存和记忆

可变和不可变对象

这个概念在我的一次编程面试中让我感到惊讶。在我职业生涯的开始,我对可变和不可变对象知之甚少,而这甚至在我没有意识到根本原因的情况下产生了不良后果。

在第五章中,存储模式,我解释了可变性和不可变性的基础知识。我们甚至使用了Immutable.js库。这部分书重点关注了存储。现在让我们来看看更大的图景。我们为什么需要可变或不可变的对象?

通常,主要原因是能够快速推断我们应用的行为。例如,React 想要快速检查是否应该重新渲染组件。如果你创建了对象A并且可以保证它永远不会改变,那么为了确保没有任何更改,你唯一需要做的就是比较对象的引用。如果它与之前相同,那么对象A保持不变。如果对象A可能会改变,我们需要比较对象A中的每个嵌套键,以确保它保持不变。如果对象A有嵌套对象,并且我们想知道它们是否没有改变,我们需要为嵌套对象重复这个过程。这是很多工作,特别是当对象A增长时。但为什么我们需要以这种方式做呢?

JavaScript 中的不可变原始数据类型

在 JavaScript 中,原始数据类型(数字、字符串、布尔值、未定义、null 和符号)是不可变的。对象是可变的。此外,JavaScript 是弱类型的;这意味着变量不需要是某种类型。例如,你可以声明变量 A 并将数字 5 赋给它,然后稍后决定将对象赋给它。JavaScript 允许这样做。

为了简化事情,社区创建了两个非常重要的运动:

  • 保证对象的不可变性的库

  • JavaScript 的静态类型检查器,如 Flow 或 TypeScript

第一个提供了创建对象的功能,保证它们的不可变性。这意味着,每当你想要改变对象中的某些东西时,它会克隆自身,应用更改,并返回一个全新的不可变对象。

第二个,静态类型检查器,主要解决了开发人员在意外尝试将值分配给与最初预期的不同类型的变量时的人为错误问题。因此,如果你声明variableA是一个数字,你永远不能将一个字符串赋给它。对我们来说,这意味着类型的不可变性。如果你想要不同的类型,你需要创建一个新的变量并将variableA映射到它。

关于const关键字的一个重要说明:const在引用级别上运作。它禁止引用更改。常量变量的值不能被重新分配,也不能被重新声明。对于原始的不可变类型,它只是意味着永久冻结它们。你永远不能重新分配一个新值给变量。尝试分配不同的值也会失败,因为原始类型是不可变的,这只是意味着创建一个全新的引用。对于可变类型的对象,它只是意味着冻结对象引用。我们不能将一个新对象重新分配给变量,但我们可以改变对象的内容。这意味着我们可以改变内部的内容。这并不是很有用。

不可变性成本解释

当我第一次接触到这个概念时,我开始挠头。这样会更快吗?如果你想修改一个对象,你需要克隆它,这是任何简单改变的严重成本。我认为这是不可接受的。我假设它的成本与我们在每个级别执行相等检查是一样的。我既对也错。

这取决于你使用的工具。特殊的数据结构,比如 Immutable.js,进行了许多优化,以便轻松工作。然而,如果你用spread运算符或Object.assign()克隆你的对象,那么你会重新创建整个对象,或者在不知不觉中只是克隆一层。

“对于深层克隆,我们需要使用其他替代方案,因为 Object.assign()只会复制属性值。如果源值是对对象的引用,它只会复制该引用值。”

  • Mozilla JavaScript 文档

developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign。“扩展语法在复制数组时有效地进入一层。因此,它可能不适用于复制多维数组[...](与 Object.assign()和扩展语法相同)。”

  • Mozilla JavaScript 文档

developer.mozilla.org/pl/docs/Web/JavaScript/Reference/Operators/Spread_syntax.

这非常方便,我们在 React 应用程序中经常滥用这一事实。让我们通过一个例子来看看这一点。以下是我们将执行操作的对象:

const someObject = {
    x: "1",
  y: 2,
  z: {
        a: 1,
  b: 2,
  c: {
            x1: 1,
  x2: 2
  }
    }
};

首先,我们将只克隆一层深,然后在克隆的对象中对两层深的东西进行变异。观察原始对象会发生什么。

function naiveSpreadClone(obj) { // objects are passed by reference
    return { ...obj };
    // copy one level deep ( nested z cloned by reference ) }
const someObject2 = naiveSpreadClone(someObject); // invoke func someObject2.z.a = 10; // mutate two levels deep console.log(someObject2.z.a); // logs 10 console.log(someObject.z.a)**; //** **logs 10
// nested object in original someObject mutated too!** 

这是变异的一个陷阱。如果您不熟练地理解发生了什么,您可能会产生难以修复的错误。问题是,我们如何克隆两层深?请参见以下内容:

function controlledSpreadClone(obj) {
    return { ...obj, z: { ...obj.z } }; // copy 2 levels deep }

const someObject2 = controlledSpreadClone(someObject); someObject2.z.a = 10; // mutation only in copied object console.log(someObject2.z.a); // logs 10 console.log(someObject.z.a)**; // logs 1** 

如果需要,您可以使用这种技术来以这种方式复制整个对象。

仅复制一层被称为浅复制

读/写操作基准测试

为了更好地理解权衡和决定哪个库适合您的特定用例,请查看读写操作的基准测试。这应该作为一个一般的想法。在最终决定之前,请进行自己的测试。

我使用了由ImmutableAssign 作者创建的基准测试。该代码自动比较了许多库和方法来解决 JavaScript 中的不可变性。

首先,让我们看看纯 JavaScript,只使用简单的可变结构。我们不关心任何好处,只是用它们作为基准测试:

几乎全新的 MacBook Pro 15''(2018)没有后台任务 MacBook Pro 15''(2016)有一些后台任务在运行
可变对象和数组 对象:读取(x500000):9 毫秒 对象:写入(x100000):3 毫秒 对象:非常深的读取(x500000):31 毫秒 对象:非常深的写入(x100000):9 毫秒 对象:合并(x100000):17 毫秒 数组:读取(x500000):4 毫秒 数组:写入(x100000):3 毫秒 数组:深读(x500000):5 毫秒 数组:深写(x100000):2 毫秒 总计经过 49 毫秒(读取)+ 17 毫秒(写入) + 17 毫秒(合并)= 83 毫秒。 可变对象和数组 对象:读取(x500000):11 毫秒 对象:写入(x100000):4 毫秒 对象:非常深的读取(x500000):42 毫秒 对象:非常深的写入(x100000):12 毫秒 对象:合并(x100000):17 毫秒 数组:读取(x500000):7 毫秒 数组:写入(x100000):3 毫秒 数组:深读(x500000):7 毫秒 数组:深写(x100000):3 毫秒 总计经过 67 毫秒(读取)+ 22 毫秒(写入) + 17 毫秒(合并)= 106 毫秒。

括号中,您可以看到执行的操作次数。这是非常快的。没有不可变的解决方案可以超过这个基准,因为它只使用可变的 JS 对象和数组。

一些要注意的事情是基于我们阅读的深度而产生的差异。例如,读取对象(x500000)需要 11 毫秒,而非常深的对象读取(x500000)需要 42 毫秒,几乎是 4 倍长:

几乎全新的 MacBook Pro 15''(2018)没有后台任务 MacBook Pro 15''(2016)有一些后台任务在运行
不可变对象和数组(Object.assign) 对象:读取(x500000):13 毫秒 对象:写入(x100000):85 毫秒 对象:非常深的读取(x500000):30 毫秒 对象:非常深的写入(x100000):220 毫秒 对象:合并(x100000):91 毫秒 数组:读取(x500000):7 毫秒 数组:写入(x100000):402 毫秒 数组:深读(x500000):9 毫秒 数组:深写(x100000):400 毫秒 总计经过 59 毫秒(读取)+1107 毫秒(写入)+91 毫秒(合并)= 1257 毫秒。 不可变对象和数组(Object.assign) 对象:读取(x500000):19 毫秒 对象:写入(x100000):107 毫秒 对象:非常深的读取(x500000):33 毫秒 对象:非常深的写入(x100000):255 毫秒 对象:合并(x100000):136 毫秒 数组:读取(x500000):11 毫秒 数组:写入(x100000):547 毫秒 数组:深读(x500000):14 毫秒 数组:深写(x100000):504 毫秒 总计经过 77 毫秒(读取)+1413 毫秒(写入)+136 毫秒(合并)= 1626 毫秒。

Object.assign在写操作上创建了一个峰值。现在我们看到了复制不需要的东西的成本。非常深层级的对象写操作接近于比较昂贵。数组深写比可变方式慢 100 到 200 倍:

几乎全新的 MacBook Pro 15''(2018)没有后台任务 MacBook Pro 15''(2016)有一些后台任务在运行
Immutable.js 对象和数组 对象:读取(x500000):12 毫秒 对象:写入(x100000):19 毫秒 对象:非常深的读取(x500000):111 毫秒 对象:非常深的写入(x100000):80 毫秒 对象:合并(x100000):716 毫秒 数组:读取(x500000):18 毫秒 数组:写入(x100000):135 毫秒 数组:深读(x500000):51 毫秒 数组:深写(x100000):97 毫秒 总计经过 192 毫秒(读取)+331 毫秒(写入)+716 毫秒(合并)= 1239 毫秒。 Immutable.js 对象和数组 对象:读取(x500000):24 毫秒 对象:写入(x100000):52 毫秒 对象:非常深的读取(x500000):178 毫秒 对象:非常深的写入(x100000):125 毫秒 对象:合并(x100000):1207 毫秒 数组:读取(x500000):24 毫秒 数组:写入(x100000):255 毫秒 数组:深读(x500000):128 毫秒 数组:深写(x100000):137 毫秒 总计经过 354 毫秒(读取)+569 毫秒(写入)+1207 毫秒(合并)= 2130 毫秒。

对象写入的速度比可变方式慢 6 倍。非常深的对象写入几乎比可变方式慢 9 倍,并且比Object.assign()快 2.75 倍。合并操作,构造作为参数传递的两个对象合并结果的对象,要慢得多(比可变对象慢 42 倍,甚至如果用户正在使用其他程序,可能慢 70 倍)。

请注意所使用的硬件。要么是 2016 年的 MacBook Pro,要么是 2018 年的 MacBook Pro,两者都是速度非常快的机器。将这一点带到移动世界将会使这些基准值更高。本节的目的是让您对数字进行比较有一个大致的了解。在得出结论之前,请在与您的项目相关的特定硬件上运行您自己的测试。

纯函数

在本节中,我们将从不同的角度回顾我们已经学过的纯函数。您还记得 Redux 试图尽可能明确吗?这是有原因的。一切隐式的东西通常是麻烦的根源。您还记得数学课上的函数吗?那些是 100%明确的。除了将输入转换为某种输出之外,没有其他事情发生。

然而,在 JavaScript 中,函数可能具有隐式输出。它可能会更改一个值,更改外部系统,以及许多其他事情可能发生在函数范围之外。您已经在第五章 存储模式中学到了这一点。所有这些隐式输出通常被称为副作用。

我们需要解决所有不同类型的副作用。不可变性是我们的一种武器,它可以保护我们免受外部对象隐式更改的影响。这就是不可变性的作用——它保证绝对不会发生这种情况。

在 JavaScript 中,我们无法通过引入不可变性等武器来消除所有副作用。有些需要语言级别上的工具,而这些工具在 JavaScript 中是不可用的。在 Haskell 等函数式编程语言中,甚至输入/输出都由称为IO()的单独结构控制。然而,在 JavaScript 中,我们需要自己处理这些问题。这意味着我们无法避免一些函数是不纯的——因为这些函数需要处理 API 调用。

另一个例子是随机性。任何使用Math.random的函数都不能被认为是纯的,因为这些函数的一部分依赖于随机数生成器,这违背了纯函数的目的。一旦使用特定参数调用函数,就不能保证收到相同的输出。

同样,一切依赖于时间的东西都是不纯的。如果你的函数执行依赖于月份、日期、秒甚至年份,它就不能被认为是一个纯函数。在某个时刻,相同的参数将不会产生相同的输出。

最终,一切都归结为执行链。如果你想说一部分操作是纯净的,那么你需要知道它们每一个都是纯净的。一个最简单的例子是一个消耗另一个函数的函数:

const example = someArray => someFunc => someFunc(someArray);

在这个例子中,我们不知道someFunc会是什么。如果someFunc是不纯的,那么example函数也将是不纯的。

Redux 中的纯函数

好消息是我们可以将副作用推到我们应用程序的一个地方,并在真正需要时循环调用它们。这就是 Flux 所做的。Redux 甚至进一步采纯函数作为 reducers。这是可以理解的。当不纯的部分已经完成时,reducers 被调用。从那时起,我们可以保持不可变性,至少在 Redux 存储方面。

有些人可能会质疑这在性能方面是否是一个好选择。相信我,它是的。与状态访问和操作计算状态的选择器相比,我们发生的事件数量非常少(需要被减少,因此影响存储)。

为了保持状态不可变,我们得到了巨大的好处。我们可以知道导致特定状态的函数应用顺序。如果我们真的需要,我们可以追踪它。这是巨大的。我们可以在测试环境中再次应用这些函数,并且我们可以保证输出完全相同。这要归功于函数的纯净性 - 因此不会产生副作用。

缓存纯函数

缓存是一种记住计算的技术。如果你可以保证对于某些参数,你的函数总是返回相同的值,你可以安全地计算一次,并且始终返回这些特定参数的计算值。

让我们看看通常用于教学目的的微不足道的实现:

const memoize = yourFunction => {
  const cache = {};

  return (...args) => {
    const cacheKey = JSON.stringify(args);
    if (!cache[cacheKey]) {
        cache[cacheKey] = yourFunction(...args);
    }
    return cache[cacheKey];
  };
};

这是一种强大的技术,被用于 reselect 库。

引用透明度

纯函数是引用透明的,这意味着它们的函数调用可以用给定参数的相应结果替换。

现在,看一下引用透明和引用不透明函数的例子:

 let globalValue = 0;

 const inc1 = (num) => { // Referentially opaque (has side effects)
   globalValue += 1;
   return num + globalValue;
 }

 const inc2 = (num) => { // Referentially transparent
   return num + 1;
 }

让我们想象一个数学表达式:

inc(4) + inc(4) * 5

// With referentially transparent function you can simplify to:
inc(4) * ( 1 + 1*5 )
// and even to
inc(4) * 6

请注意,如果您的函数不是引用透明的,您需要避免这样的简化。类似前面的表达式或x() + x() * 0都是诱人的陷阱。

你是否使用它取决于你自己。另请参阅本章末尾的进一步阅读部分。

除了单子以外的一切

多年来,术语单子一直臭名昭著。不是因为它是一个非常有用的构造,而是因为它引入的复杂性。人们普遍认为,一旦你理解了单子,你就失去了解释它们的能力。

“为了理解单子,你需要先学习 Haskell 和范畴论。”

我认为这就像说:为了理解墨西哥卷饼,你必须先学习西班牙语。

  • Douglas Crockford:单子和性腺体(YUIConf 晚间主题演讲)

www.youtube.com/watch?v=dkZFtimgAcM

单子是一种组合函数的方式,尽管存在特殊情况,比如可空值、副作用、计算,或者条件执行。这样对单子的定义使它成为一个上下文持有者。这就是为什么 X 的单子不等同于 X。在被视为monad<X>之前,这个 X 需要首先被提升,这意味着创建所需的上下文。如果我们不再需要monad<X>,我们可以将结构展平为 X,这相当于失去了一个上下文。

这就像打开圣诞礼物一样。你很确定里面有礼物,但这取决于你整年表现如何。在一些罕见的不良行为情况下,你可能最终得到的是一根棍子或一块煤。这就是Maybe<X>单子的工作原理。它可能是 X,也可能是空。它与可空 API 值一起使用效果很好。

也许给我打电话

我们的代码中有一个地方需要简化。看一下taskSelector

export const tasksSelector = state => state.tasks;   export const tasksEntitiesSelector = createSelector(
    tasksSelector,
  tasks => (tasks ? tasks.get('entities') : null)
); 
export const getTaskById = taskId => createSelector(
    tasksEntitiesSelector,
  entities => (entities
        ? entities.find(task => task.id === taskId)
        : null)
);  

我们不断担心我们是否收到了某物还是空值。这是一个完美的情况,可以将这样的工作委托给Maybe单子。一旦我们实现了Maybe,以下代码将是完全功能的:

import Maybe from '../../../../utils/Maybe';   export const tasksSelector = state => Maybe(state).map(x => x.tasks);   export const tasksEntitiesSelector = createSelector(
    tasksSelector,
  maybeTasks => maybeTasks.map(tasks => tasks.get('entities'))
);   export const getTaskById = taskId => createSelector(
    tasksEntitiesSelector,
  entities => entities.map(e => e.find(task => task.id === taskId))
);

到目前为止,你已经了解了我们需要实现的Maybe monad 的一些知识:当null/undefined时,它需要是 nothing,当nullundefined时,它需要是Something

const Maybe = (value) => {
    const Nothing = {
        // Some trivial implementation   };
  const Something = val => ({
        // Some trivial implementation
    });    return (typeof value === 'undefined' || value === null)
        ? Nothing
        : Something(value); };

到目前为止,非常简单。问题是,我们既没有实现Nothing也没有实现Something。别担心,这很简单,就像我的评论一样。

我们需要它们都对三个函数做出反应:

  • isNothing

  • val

  • map

前两个函数很简单:

  • isNothingNothing返回trueSomething返回false

  • valNothing返回nullSomething返回它的值

最后一个是map,对于Nothing应该什么都不做(返回自身),对于Something应该将函数应用于值:

在普通字符串类型和Maybe<string> monad 上使用 map 函数对 toUpperCase 进行应用

让我们实现这个逻辑:

// src / Chapter 9 / Example 1 / src / utils / Maybe.js
const Maybe = (value) => {
    const Nothing = {
        map: () => this,
  isNothing: () => true,
  val: () => null
  };
  const Something = val => ({
        map: fn => Maybe(fn.call(this, val)),
  isNothing: () => false,
  val: () => val
    });    return (typeof value === 'undefined' || value === null)
        ? Nothing
        : Something(value); };   export default Maybe;

我们已经完成了,不到 20 行。我们的选择器现在使用Maybe monad。我们需要做的最后一件事是修复最终的用法;在选择器调用之后,它应该要求值,就像下面的例子中那样:

// src / Chapter 9 / Example 1
//         src/features/tasks/containers/TaskDetailsContainer.js 
const mapStateToProps = (state, ownProps) => ({
 task: getTaskById(ownProps.taskId)(state).val()
});

我们的Maybe实现是一个很酷的模式,可以避免空检查的负担,但它真的是一个 monad 吗?

Monad 接口要求

更正式地说,monad 接口应该定义两个基本运算符:

  • Return(a -> M a),一个接受a类型并将其包装成 monad(M a)的操作

  • Bind(M a ->(a -> M b)-> M b),一个接受两个参数的操作:a 类型的 monad 和一个在a上操作并返回M ba -> M b)monad 的函数

在这些条件下,我们的构造函数是return函数。然而,我们的 map 函数不符合bind的要求。它接受一个将a转换为ba -> b)的函数,然后我们的map函数自动将b包装成M b

除此之外,我们的 monad 需要遵守三个 monad 定律:

  • 左单位元:
// for all x, fn
Maybe(x).map(fn) == Maybe(fn(x))
  • 右单位元:
// for all x
Maybe(x).map(x => x) == Maybe(x)
  • 结合律:
// for all x, fn, gn
Maybe(x).map(fn).map(gn) == Maybe(x).map(x => gn(fn(x)));

数学证明超出了本书的范围。然而,我们可以用这些定律来验证一些随机的例子:

// Left identity example
Maybe("randomtext")
.map(str => String.prototype.toUpperCase.call(str))
.val() // RANDOMTEXT
 Maybe(String.prototype.toUpperCase.call("randomtext"))
.val()) // RANDOMTEXT

// Right identity example
Maybe("randomtext").map(str => str).val() // randomtext
Maybe("randomtext").val() // randomtext

// Associativity
const f = str => str.replace('1', 'one'); const g = str => str.slice(1); 
Maybe("1 2 3").map(f).map(g).val() // ne 2 3
Maybe("1 2 3").map(str => g(f(str))).val() // ne 2 3

高阶函数

我们已经了解了高阶组件,本节我们将看一下更一般的概念,称为高阶函数。

看看这个例子。非常简单。你甚至不会注意到你创建了什么特别的东西:

const add5 = x => x + 5; // function
const applyTwice = (f, x) => f(f(x)); // higher order function

applyTwice(add5, 7); // 17

那么什么是高阶函数呢?

高阶函数是一个做以下操作之一的函数:

  • 将一个或多个函数作为参数

  • 返回一个函数

就是这样,很简单。

高阶函数的例子

有许多高阶函数,你每天都在使用它们:

  • Array.prototype.map
someArray.map(function callback(currentValue, index, array){
    // Return new element
});

// or in the shorter form
someArray.map((currentValue, index, array) => { //... });
  • Array.prototype.filter
someArray.filter(function callback(currentValue, index, array){
    // Returns true or false
});

// or in the shorter form
someArray.filter((currentValue, index, array) => { //... });
  • Array.prototype.reduce
someArray.reduce(
    function callback(previousValue, currentValue, index, array){
        // Returns whatever
    },
    initialValue
);

// or in the shorter form
someArray.reduce((previousValue, currentValue, index, array) => {
    // ... 
}, initialValue);

// previousValue is usually referred as accumulator or short acc
// reduce callback is also referred as fold function

当然,还有composecallcurry等函数,我们已经学习过了。

一般来说,任何接受回调的函数都是高阶函数。你在各个地方都使用这样的函数。

你还记得它们是如何很好地组合的吗?请看下面:

someArray
    .map(...)
    .filter(...)
    .map(...)
    .reduce(...)

但有些不行,比如回调。你听说过回调地狱吗?

回调中的回调中的回调,这就是回调地狱。这就是为什么 Promise 被发明的原因。

然后,突然之间,Promise地狱开始了,所以聪明的人为 promise 创建了一种语法糖:asyncawait

除了函数式语言

首先,请阅读大卫的这个有趣观点。

“等等,等等。持久数据结构的性能与 JavaScript MVC 的未来有什么关系?

很多。

我们将看到,也许不直观的是,不可变数据允许一个新的库 Om,即使没有用户的手动优化,也能胜过像 Backbone.js 这样性能合理的 JavaScript MVC。Om 本身是建立在 Facebook 绝妙的 React 库之上的。

  • JavaScript MVC 框架的未来

大卫·诺伦(swannodette),2013 年 12 月 17 日

swannodette.github.io/2013/12/17/the-future-of-javascript-mvcs

在撰写本文时(2018 年 9 月),Backbone 已经停止运营。即使 Angular 的流行程度也难以与 React 竞争。React 迅速占领了市场,一旦它最终将许可证更改为 MIT,甚至加速了这一过程。

有趣的是requestAnimationFramerAF)并不像人们曾经认为的那样重要。

“我们在一个事件处理程序中在不同的 setState()之间进行批处理(当您退出时,所有内容都会被刷新)。对于许多情况来说,这足够好用,并且没有使用 rAF 更新的潜在问题。我们还在默认情况下查看异步渲染。但是,如果渲染树很大,rAF 并不会帮助太多。相反,我们希望使用 rIC 将非关键更新分成块,直到它们准备好被刷新。

(...) 我们使用了“过期”概念。来自交互事件的更新具有非常短的过期时间(必须很快刷新),网络事件具有更长的时间(可以等待)。基于此,我们决定刷新和时间切片的内容。

  • Dan Abramov 的推文

twitter.com/jaffathecake/status/952861127528124417.

我希望你从这两个引语中学到的教训是:不要想当然,不要过分美化一种方法,要学会在哪些情况下一种方法比另一种更好。函数式编程也是如此;像我曾经想的那样,简单地放弃这一章是愚蠢的。我有这种感觉:这对 React Native 程序员有用吗?是的,它有用。如果它足够流行,以至于在社区中涌现出许多公共 PR,那么你肯定会接触到这些概念,我希望你做好准备。

术语

不要被函子、EndoFunctors、CoMonads 和 CoRoutines 吓到——从理论抽象中获取有用的东西。让理论专家来处理它们。数学极客们一直走在前面,通常这是一件好事,但不要太疯狂。业务就是业务。截止日期不能等待你证明范畴论中最伟大的定律。

专注于理解即时的好处,比如本书中概述的好处。如果你发现自己在一个反对函数式编程模式的团队中,不要强制执行它们。毕竟,在 JavaScript 中它并不像在 Haskell 中那样重要。

“使用花哨的词而不是简单、常见的词会使事情更难理解。如果你坚持使用一个小的词汇表,你的写作会更清晰。”

  • Sophie Alpert 的推文(Facebook 的 React 工程经理)

twitter.com/sophiebits/status/1033450495069761536.

构建抽象

在本章的开始,我们对不可变库进行了基准测试,并比较了它们的性能。和任何事情一样,我强烈建议你在承诺任何库、模式或做事情的方式之前花一些时间。

大多数采用函数式编程模式的库都是为了真正的好处。如果你不确定,就把它留给别人,坚持你熟悉的命令式模式。事实证明,简单的代码通常在引擎层面上得到更好的优化。

React 并不迷恋纯函数

当你第一次接触 React 生态系统时,你可能会有些惊讶。有很多例子使用纯函数,并谈论时间旅行,使用 Redux,以及一个存储来统治它们所有。

事实上,React 和 Redux 都不仅仅使用纯函数。实际上,这两个库中有很多函数在外部范围中执行变异:

// Redux library code
// redux/src/createStore.js

let currentReducer = reducer
let currentState = preloadedState
let currentListeners = []
let nextListeners = currentListeners
let isDispatching = false

// Check yourself:
[`github.com/reduxjs/redux/blob/1448a7c565801029b67a84848582c6e61822f572/src/createStore.js`](https://github.com/reduxjs/redux/blob/1448a7c565801029b67a84848582c6e61822f572/src/createStore.js) [](https://github.com/reduxjs/redux/blob/1448a7c565801029b67a84848582c6e61822f572/src/createStore.js) 

这些变量正在被其他函数修改。

现在,看看 React 如何记住库所警告的内容:


let didWarnAboutMaps = false; 
// (...)

if (__DEV__) {   if (iteratorFn === children.entries) {
    warning(
      didWarnAboutMaps,
  'Using Maps as children is unsupported (...)'   );
  didWarnAboutMaps = true**;**
  }
}

// Check yourself
https://github.com/facebook/react/blob/f9358c51c8de93abe3cdd0f4720b489befad8c48/packages/react/src/ReactChildren.js

这个小的变异取决于环境。

如果你维护一个带有这些检查的库,当前的构建工具,比如 webpack,在构建生产压缩文件时可以删除这些死代码。所谓的死代码,我指的是因为环境(生产)而永远不会被访问的代码路径(如前面的if语句)。

一般来说,Facebook 并不羞于展示他们的代码库在某些地方是棘手的:

Facebook 代码库截图,由 Dan Abramov 在 Twitter 上发布

总结

在这一章中,我们深入研究了 JavaScript 编程中最神秘的分支之一。我们学习了单子,如何为了更大的利益使用它们,以及如果我们真的不需要的话,如何不关心数学定律。然后,我们开始使用词汇,比如纯函数,可变/不可变对象和引用透明度。

我们知道如果需要的话,纯函数有一个缓存模式。这种很好的方法在许多 Flux 应用中都很有用。现在你可以有效地使用选择器,并使用 Maybe monad 使它们变得非常简单,这消除了空值检查的负担。

有了所有这些专业知识,现在是时候学习维护依赖和大型代码库的挑战了。在下一章中,你将面临每个大型代码库的主要挑战,相信我,每个大公司在某个时候都会遇到这个问题——无论他们使用了多少编程模式或依赖了多少库。

进一步阅读

  • 一个关于 JavaScript 函数式编程的大部分合格指南——一本免费的关于 JavaScript 函数式编程的书:

github.com/MostlyAdequate/mostly-adequate-guide

  • 你可能想要与 Reselect 库一起使用的缓存函数的例子:

github.com/reduxjs/reselect#q-the-default-memoization-function-is-no-good-can-i-use-a-different-one

  • 关于引用透明性的信息:

softwareengineering.stackexchange.com/questions/254304/what-is-referential-transparency

  • Eric's Elliott 掌握 JavaScript 面试系列的一集,Pure Functions:

medium.com/javascript-scene/master-the-javascript-interview-what-is-a-pure-function-d1c076bec976

  • 一个预测未来的历史帖子,《JavaScript MVCs 的未来》:

swannodette.github.io/2013/12/17/the-future-of-javascript-mvcs

  • 这是旧的,但仍然值得一读,《反应性的一般理论》:

github.com/kriskowal/gtor

  • 关于 JavaScript 中的函数式编程的以下书籍,《JavaScript Allonge》(可免费在线阅读):

leanpub.com/javascriptallongesix/read#leanpub-auto-about-javascript-allong

  • Monad laws(Haskell Wiki):

wiki.haskell.org/Monad_laws

  • Douglas Crockford,Monad 和 Gonads:

www.youtube.com/watch?v=dkZFtimgAcM

  • Immutable.js 如何使用 Trie 图来优化写操作:

medium.com/@dtinth/immutable-js-persistent-data-structures-and-structural-sharing-6d163fbd73d2

en.wikipedia.org/wiki/Trie

  • React 是否应默认使用requestAnimationFrame

github.com/facebook/react/issues/11171

  • GitHub 上一个很棒的函数式编程收藏:

github.com/xgrommx/awesome-functional-programming/blob/master/README.md

  • 如果你迷恋函数式编程,这是一个非常好的资源,

《Learn You a Haskell for Great Good》(需要了解 Haskell):

learnyouahaskell.com/chapters.

第十章:管理依赖关系

本章专门讨论管理依赖关系,即您的移动应用程序所依赖的库。大多数当前的应用程序滥用了单例模式。然而,我坚信,总有一天,JavaScript 开发人员会采用众所周知的依赖注入DI)模式。即使他们决定使用单例模式,重构也会更容易。在本章中,我们将重点讨论 React 上下文以及 Redux 等库如何利用 DI 机制。这是您真正想要提升代码并使其易于测试的最安全选择。我们将深入研究 React Redux 库中的代码,该库广泛使用 React 上下文。您还将了解为什么 JavaScript 世界如此迟缓地放弃单例模式。

在本章中,您将学习以下主题:

  • 单例模式

  • ECMAScript 中的 DI 模式及其变体

  • storybook 模式,以提高生产力并记录您的组件

  • React 上下文 API

  • 如何管理大型代码库

准备好了吗,因为我们将立即开始单例模式。

单例模式

单例模式是一个只能有一个实例的类。按照其设计,每当我们尝试创建一个新实例时,它要么首次创建一个实例,要么返回先前创建的实例。

这种模式有什么用?如果我们想要为某些事情有一个单一的管理器,这就很方便,无论是 API 管理器还是缓存管理器。例如,如果您需要授权 API 以获取令牌,您只想这样做一次。第一个实例将启动必要的工作,然后任何其他实例将重用已经完成的工作。这种用例主要被服务器端应用程序滥用,但越来越多的人意识到有更好的选择。

如今,这种用例可以很容易地通过更好的模式来对抗。您可以简单地将令牌存储在缓存中,然后在任何新实例中,验证令牌是否已经在缓存中。如果是,您可以跳过授权并使用令牌。这个技巧利用了一个众所周知的事实,即缓存是存储数据的一个集中的地方。在这种情况下,它为我们提供了一个单例存储。无论是客户端还是云服务器的缓存,它都是完全相同的,唯一的区别是在服务器上调用可能更昂贵。

在 ECMAScript 中实现单例模式

尽管如今不鼓励使用单例模式,但学习如何创建这种机制非常有益。在这个代码示例中,我们将使用 ECMAScript 6 类和 ECMAScript 7 静态字段:

export default class Singleton {
    static instance;    constructor() {
        if (Singleton.instance) {
            return Singleton.instance;
  }

        this.instance = this;
  }
}

我们正在改变构造函数的行为。首先,在返回任何内容之前,我们需要检查实例是否已经被创建。如果是,当前调用将返回该实例。

为什么不鼓励使用单例模式

Singleton有时被视为全局变量。如果您尝试从许多不同的地方导入它,并且您的用例只是共享相同的实例,那么您可能滥用了该模式。这样,您将不同的部分紧密耦合到精确导入的对象上。如果您使用全局变量而不是传递它下去,这是代码异味的一个重要迹象。

此外,Singleton在测试方面非常不可预测。您会收到一个由突变效果产生的东西。它可能是一个新对象,也可能是先前创建的对象。您可能会被诱惑使用它来同步某种状态。例如,让我们看下面的例子:

export default class Singleton {
    static instance;    constructor() {
        if (Singleton.instance) {
            return Singleton.instance;
  }

        this.name = 'DEFAULT_NAME';
  this.instance = this;
  }

    getName() {
        return this.name;
  }

    setName(name) {
        this.name = name;
  }
}

这使Singleton不仅在全局范围内共享,而且在全局范围内可变。如果您想要使其可预测,这是一个可怕的故事。它通常会打败我们在第九章中学到的一切,函数式编程模式的要素

您需要向每个使用单例模式的组件保证它已准备好处理来自单例的任何类型的数据。这需要指数数量的测试,因此会降低生产力。这是不可接受的。

在本章的后面,您将找到一个通过 DI 解决所有这些问题的解决方案。

JavaScript 中的许多单例模式

说实话,除了之前的实现之外,我们可以看到许多其他变化,以达到相同的目的。让我们讨论一下。

在下面的代码中,单例已经作为instance导出:

class Singleton {
    static instance;
  constructor() {
        if (Singleton.instance) {
            return Singleton.instance;
  }
   this.instance = this;
  }
}

export default new Singleton();

这看起来像是一个很好的改进,除非你的Singleton需要参数。如果是这样,Singleton被导出的方式也更难测试,并且可能只接受硬编码的依赖项。

有时,你的Singleton可能非常小,只需要一个对象就足够了:

export default {
    apiRoot: API_URL,
    fetchData() {
        // ...
    },
};

重构这种模式可能会导致任何成熟的 JavaScript 开发人员都熟悉的语法:

// ./apiSingleton.js
export const apiRoot = API_URL;
export const fetchData = () => {
    // ...
}

// Then import as shown below
import * as API from './apiSingleton'

最后一个例子可能会让你开始担心,并且你可能已经开始问自己——我是否在不知不觉中使用单例?我敢打赌你是。但只要你正确地注入它们,这并不是世界末日。让我们来看一下 ECMAScript 和 JavaScript 模块方法的部分。这对于任何 JavaScript 程序员来说都是重要的知识。

要小心,因为一些模块捆绑器不能保证模块只会被实例化一次。像 webpack 这样的工具可能会在内部多次实例化一些模块,以进行优化或兼容性。

ES6 模块及更高版本

ES6 模块的最大优点之一是导入和导出声明的静态性质。由于这一点,我们可以在编译时检查导入和导出是否正确,执行注入(例如为旧浏览器提供 polyfill),并在必要时将它们捆绑在一起(就像 webpack 一样)。这些都是令人惊叹的积极因素,可以节省我们大量可能会减慢应用程序速度的运行时检查。

然而,有些人滥用了 ES6 模块的工作方式。语法非常简单——你可以在任何地方导入模块并轻松使用它。这是一个陷阱。你可能不想滥用导入。

DI 模式

在同一文件中导入并使用导入的值会将该文件锁定到具体的实现。例如,看一下以下应用程序代码的实现:

import AddTaskContainer from '../path/to/AddTaskContainer'; import TaskListContainer from '../path/to/TaskListContainer';   export const TasksSection = () => (
    <View>
 <AddTaskContainer /> <TaskListContainer /> </View> ); 

在这个代码示例中,TasksSection组件由两个容器组件AddTaskContainerTaskListContainer组成。重要的事实是,如果你是TasksSection组件的使用者,你不能修改任何一个容器组件。你需要依赖于导入模块提供的实现。

为了解决这个问题,我们可以使用 DI 模式。我们基本上是将依赖项作为 props 传递给组件。在这个例子中,这将如下所示:

export const TasksSection = ({
    AddTaskContainer,
    TaskListContainer
}) => (
    <View>
 <AddTaskContainer /> <TaskListContainer /> </View> );

如果有人对传递这些组件不感兴趣,我们可以创建一个容器来提供它们。但是,在我们想要用其他东西替换容器的情况下,这非常方便,例如在测试或 storybook 中!什么是 storybook?继续阅读。

使用 DI 模式与 storybook

storybook 是记录您的组件的一种方式。随着应用程序的增长,您可能很快就会拥有数百个组件。如果您构建一个严肃的应用程序,大多数组件都与设计规范对齐,并且所有预期的功能都已实现。诀窍在于知道发送哪些 props 以实现预期的结果。storybook 使这变得简单。当您实现一个组件时,您还为不同的场景创建一个 storybook。查看以下关于“按钮”组件的微不足道的示例:

按钮组件的示例 storybook

通过在左侧面板中选择场景,您可以快速查看组件在不同 props 下的外观。

我已经为您安装了 Storybook,可以在src/Example 10/Exercise 1中进行操作。您可以通过从该目录运行yarn run ios:storybookyarn run android:storybook来启动 Storybook。

如果您想学习如何自己设置 Storybook,请查看官方文档

github.com/storybooks/storybook/tree/master/app/react-native

您需要添加的大多数配置文件应该放在项目的storybook目录中。

Storybook 提供的安装命令行界面为您设置了游乐场故事。这些是在前面的截图中的那些(带有文本和表情符号的“按钮”)。

是时候添加我们自己的故事了。让我们从一些简单的东西开始 - TaskList组件。这个组件非常适合用于故事编写,因为它非常完善。它处理错误,并根据加载状态或错误状态显示各种消息。它可以显示 0 个任务,1 个任务和 2 个或更多任务。有很多故事可以看:

// src/Chapter_10/Example_1/src/features/tasks/stories/story.js

storiesOf('TaskList', module)
    .addDecorator(getStory => (  <ScrollView style={generalStyles.content}>{getStory()}</ScrollView>   ))
    .add('with one task', () => (
        <TaskList
  tasks={Immutable.List([exampleData.tasks[0]])}
            hasError={false}
            isLoading={false}
        />
  ))
    .add('with 7 tasks', () => (
        <TaskList
  tasks={Immutable.List(exampleData.tasks)}
            hasError={false}
            isLoading={false}
        />
    ));

在前面的代码示例中,我们为TaskList组件创建了我们的第一个故事。storiesOf函数是 storybook 自带的。然后,在装饰器中,我们用可滚动的视图和一般样式包装了每个故事,这些样式适用于左右的填充。最后,我们使用add函数创建了两个故事:只有一个故事的TaskList和带有7个故事的TaskList

不幸的是,我们的代码出现了以下错误:

Invariant Violation: withNavigation can only be used on a view hierarchy of a navigator. The wrapped component is unable to get access to navigation from props or context.
 - Runtime error in application

问题出在我们实现的NavButton组件上。它使用了withNavigation HOC,这实际上需要已经存在的上下文:

// src/ Chapter_10/ Example_1/ src/ components/ NavigateButton.js

export default withNavigation(NavigateButton);

幸运的是,withNavigation已经使用了 DI 模式,这要归功于依赖于 React 上下文。我们需要做的是将所需的上下文(导航)注入到我们的故事书示例中。为此,我们需要使用react-navigation中的NavigationProvider

// src/ Chapter_10/ Example_1/ src/ features/ tasks/ stories/ story.js
storiesOf('TaskList', module)
    .addDecorator(getStory => (
        <**NavigationProvider**
  value={{
                navigate: action('navigate')
            }}
        >
 <ScrollView style={generalStyles.content}>{getStory()}</ScrollView>
 </**NavigationProvider**>  ))
    .add('with one task', () => (
        // ...   ))
    .add('with 7 tasks', () => (
        // ...   ));

最后,我们可以欣赏我们新创建的两个故事:

storybook 中的 TaskList 组件故事

当你选择其中一个时,它将显示在模拟器上:

在 iPhone X 模拟器上显示的 TaskList 故事

稍微努力一下,我们可以向这个故事书添加更多的故事。例如,让我们尝试加载一个错误情况:

加载状态和错误状态的 TaskList 故事

我们还可以为组合创建一个故事,就像前面截图中显示的那样:

带有错误和加载状态的 TaskList 故事

带有 DI 的嵌套故事

前面的例子已经足够好了。它创建了一个故事书,是可重用的,每个人都很高兴。然而,随着应用程序的增长和我们添加更多的故事,有时候不可能仅通过Provider来修复这个问题,或者Provider可能已经在太多的故事中使用了。

在本节中,我们将重构我们的代码,以便能够注入我们自己的组件而不是导入NavButton容器。由于我们的目标是保留之前的功能,在故事书中我们将注入一个NavButton故事,它将解决导航问题。然而,在正常的应用程序中,我们将像以前一样将NavButton容器注入到TaskList容器中。这里的优势在于我们根本不需要使用NavigationProvider

// src/Chapter_10/Example_1/src/features/tasks/views/TaskList.js

const TaskList = ({
    tasks, isLoading, hasError, errorMsg, NavButton
}) => (
    <View style={styles.taskList}>
        // ...  <View style={styles.taskActions}>
 <**NavButton**  data={{ taskId: task.id }}
                        to="Task"
  text="Details"
  />
 </View>
        // ...
    </View> ); 

从现在开始,TaskList期望在 props 中有NavButton组件。我们需要在容器和 storybook 中遵守这些 props 的期望。以下是第一个容器的代码:

// src/Chapter_10/Example_1/src/features/tasks/containers/TaskList.js
import NavButton from '../../../components/NavigateButton';    const mapStateToProps = state => ({
    // ...   NavButton
});   const TasksContainer = connect(mapStateToProps)(fetchTasks(TaskListView));

到了有趣的部分了。我们需要解决一个 storybook 的问题。为了实现我们的 DI 目标,我们将为NavButton创建一个单独的 storybook。为了修复TaskList storybook,我们将导入NavButton story 并将其注入为TaskList视图的NavButton组件。

这可能听起来很复杂,但让我们在以下示例中看看。

要创建NavButton story,我们需要将NavButton重构为视图和容器:

// src/Chapter_10/Example_1/src/components/NavigateButton/index.js

// container for NavButtonView

import { withNavigation } from 'react-navigation'; import NavButtonView from './view';   export default withNavigation(NavButtonView); 

视图与以前完全相同-我已将代码移动到NavigateButton目录中的view.js中,紧邻前一个容器。我们现在可以继续创建 storybook:

// src/Chapter_10/Example_1/src/components/NavigateButton/story.js

import {
    withBackText,
  withDetailsText,
  withEmojisText } from './examples';
// ... 
storiesOf('**NavButton**', module)
    .addDecorator(scrollViewDecorator)
    .add('with details text', withDetailsText)
    .add('with back text', withBackText)
    .add('with emojis text', withEmojisText); 
// src/Chapter_10/Example_1/src/components/NavigateButton/examples.js
// ...
export const withDetailsText = () => (
    <NavButton
  navigation={{ navigate: () => action('navigate') }}
        text="Details"
  to=""
  data={{}}
    /> );

在这个代码示例中,我引入了一个小的改进。关注点分离的示例放在单独的文件中,这样它们可以在除了 storybook 之外的其他领域中重用,例如快照测试。

现在模拟navigation非常简单和直接。我们只需替换navigation对象和其中的navigate函数。

现在我们准备将该示例作为TaskList story 中的NavButton组件注入:

// src/Chapter_10/Example_2/src/features/tasks/stories/story.js

import NavButtonExample from '../../../components/NavigateButton/examples';   storiesOf('TaskList', module)
    .addDecorator(scrollViewDecorator)
    .add('with one task', () => (
        <TaskList
  tasks={Immutable.List([exampleData.tasks[0]])}
            hasError={false}
            isLoading={false}
            NavButton={NavButtonExample}
        />
  ))
    // ... rest of the TaskList stories

同时,我们的scrollViewDecorator非常简洁:

// src/ Chapter_10/ Example_2/ src/ utils/ scrollViewDecorator.js

const scrollViewDecorator = getStory => (
    <ScrollView style={generalStyles.content}>{getStory()}</ScrollView> ); 

使用 React context 进行 DI

在前一节中,我们通过简单地注入组件来非常直接地使用了 DI。React 自带了自己的 DI 机制。

React context 可以用于将依赖项注入到距离容器组件非常远的组件中。这使得 React context 非常适合在整个应用程序中重用的全局依赖项。

这样的全局依赖的好例子包括主题配置、日志记录器、调度程序、已登录用户对象或语言选项。

使用 React Context API

为了了解 React Context API,我们将使用一个简单的语言选择器。我创建了一个组件,允许我们选择两种语言中的一种,英语或波兰语。它将所选语言存储在 Redux 存储中:

应用程序标题中的语言选择器,左侧图像显示选择了英语;右侧图像显示选择了波兰语

我们的目标是通过 React 上下文 API 来暴露语言。为此,我们需要使用从 React 导入的createContext函数。这个函数将返回一个包含ProviderConsumer组件的对象:

// src/ Chapter_10/ Example_3/ src/ features/ language/ context.js
import { createContext } from 'react'; import { LANG_ENGLISH } from './constants';  // First function argument represents default value const { Provider, Consumer } = createContext(LANG_ENGLISH);   export const LanguageProvider = Provider; export const LanguageConsumer = Consumer;

LanguageConsumer用于获取遍历组件树的值。它遇到的第一个LanguageProvider将提供该值;否则,如果没有LanguageProvider,将使用createContext调用的默认值。

为了确保每个组件都可以访问语言,我们应该在根组件中添加LanguageProvider,最好是在屏幕组件中。为了方便使用已经学习的模式,我创建了一个称为withLanguageProvider的高阶组件:

src/Chapter_10/Example_3/src/features/language/hocs/withLanguageProvider.js

const withLanguageProvider = WrappedComponent => connect(state => ({
    language: languageSelector(state)
}))(({ language, ...otherProps }) => (
    <LanguageProvider value={language}**>**
 <WrappedComponent {...otherProps} />
 **</LanguageProvider>** ));   export default withLanguageProvider;

我们可以使用这个实用程序以以下方式包装屏幕组件:

withStoreProvider(withLanguageProvider(createDrawerNavigator({
    Home: TabNavigation,
  Profile: ProfileScreen,
  Settings: SettingsScreen
})));

请注意重构 - 我们也以相同的方式提供存储。

有了上下文中的语言,我们可以在任何较低级别的组件中进行消费,例如在TaskList组件中:

// src/Chapter_10/Example_3/src/features/tasks/views/TaskList.js
// ...

**<LanguageConsumer>**
  {language => (
        <Text style={styles.selectedLanguage}>
  Selected language: {language}
        </Text>
  )}
</LanguageConsumer>

结果如下截图所示:

在 TaskList 组件中使用 LanguageConsumer 的示例用法

请注意,这只是一个例子,目的是学习上下文 API。并没有进行实际的翻译。要向应用程序添加翻译,可以使用 Yahoo!的 React Intl 库。它还为您方便地暴露了Providergithub.com/yahoo/react-intl)。

React Redux 之外

如果你仔细注意之前的例子,你可能会发现一个有趣的部分 - withStoreProvider。这是我创建的一个高阶组件,用来用react-redux存储Provider包装根组件:

import { Provider } from 'react-redux';
// ... <**Provider** store={store}>
 <WrappedComponent {...props} /> </**Provider**>

暴露的Provider非常类似于 React 上下文 API。上下文在 React 库中已经存在很长时间,还有一个实验性的 API。然而,最新的上下文 API 是在 React 16 中引入的,你可能会注意到旧的库仍然使用他们自己的自定义提供者。例如,看一下 react-redux Provider的实现,如下所示:

class Provider extends Component {
    getChildContext() {
        return { [storeKey]: this[storeKey], [subscriptionKey]: null }
    }

    constructor(props, context) {
        super(props, context)
        this[storeKey] = props.store**;**
  }

    render() {
        return Children.only(this.props.children)
    }
}

// Full implementation available in react-redux source files
// https://github.com/reduxjs/react-redux/blob/73691e5a8d016ef9490bb20feae8671f3b8f32eb/src/components/Provider.js

这就是 react-redux connect函数如何访问你的 Redux 存储。与Consumer API 不同,这里有connect函数,我们用它来访问存储。你可能已经习惯了。把这当作如何使用暴露的提供者或消费者的指南。

管理代码库

我们的代码库已经开始增长。我们已经迈出了解决庞大架构问题的第一步,到目前为止,我们的文件结构相当不错:

当前 src/目录结构

尽管现在还可以,但如果我们想要扩大这个项目,我们应该重新考虑我们的方法并制定规则。

快速成功

当新的开发人员加入项目时,他们可能会对我们的代码库感到有些挑战。让我们解决一些简单的问题。

首先,我们的应用程序的入口文件在哪里?在根目录中。然而,在源代码(src/)目录中没有明确的入口点。这没关系,但将它放在靠近故事和示例的地方会很方便。一眼就可以看到示例、故事书和应用程序的根目录。

此外,我们可以重构当前的ScreenRoot组件。它作为AppRoot,并被包裹在两个 HOC 中。如你所知,这样的耦合不是一件好事。我进行了一点重构。看看新的结构:

应用程序的入口点现在清晰可见(index.js)

我们已经取得了一个非常快速的成功;现在找到根组件要容易得多。现在,让我们来看看componentsfeatures目录:

组件和特性目录

组件文件夹最初是用来收集无状态组件的。随着应用程序的增长,我们很快意识到仅仅为无状态组件创建一个共享目录是不够的。我们也想要重用有状态的组件。因此,我们应该将components目录重命名为common。这更好地代表了这个目录的内容:

组件目录已重命名为 common

我们很快会注意到的另一个问题是特性下的语言目录只会造成混淆。这主要是LanguageSwitcher,而不是一般的language。我们把这个放在特性下,只是因为我们想在应用程序特性组件中使用语言。语言上下文是一个特性吗?实际上不是;它是某种特性,但不是在用户体验的上下文中。这会造成混淆。

我们应该做两件事:

  1. 将上下文移到 common 目录,因为我们计划在整个应用程序中重用LanguageConsumer

  2. 承认我们不会重用LanguageSwitcher组件,并将其放在布局目录中,因为它不打算在布局组件之外的任何地方使用。

一旦我们这样做了,我们的应用结构就会再次变得更清晰:

语言目录已分为 LanguageSwitcher 和 LanguageContext

现在很容易找到LanguageContext。同样,我们在不改变布局的情况下不需要担心LanguageSwitcher的实现。

util 目录创建了类似的混乱,就像最初的语言目录一样。我们可以将其安全地移动到common目录:

重构后的目录结构

现在,任何新加入项目的开发人员都可以快速了解清楚。screenslayoutfluxfeaturescommon都是非常自解释的名称。

建立惯例

每当你构建一个大型项目时,依赖开发者自己的判断,就像在前面的部分中一样,可能是不够的。不同技术负责人采取的方法的不一致可能会迅速升级,并导致在探索代码迷宫上浪费数十个开发小时。

如果这对你来说听起来像一个外国问题,我可以承诺,在每天有数百名开发人员同时工作的代码库中,建立清晰的指南和惯例是非常重要的模式。

让我们看一些例子:

  • Linter:负责代码外观指南并自动强制执行它们。它还可以强制执行某些使用模式,并在有备选项列表时偏爱某些选项。

  • Flux 架构:连接和构造 JavaScript 代码以解决常见使用模式的一般架构。不会自动强制执行。

  • 纯净的 reducers:Reducers 需要像 Redux 库的架构决定一样纯净。这在经典的 Flux 架构中并不是强制执行的。这可能会自动执行,也可能不会。

  • 在 JavaScript 中定义的样式:这是 React Native 默认提供的解决方案。

清单还在继续。我希望这足以说服你,建立惯例是一件好事。它确实会稍微限制可用的功能,但可以让你更快地交付客户价值。React Native 本身就是一个很好的例子,它连接了许多不同的生态系统,提供了一种统一的开发移动应用程序的方式。它已被证明可以显著提高移动开发人员的生产力。

所有大型软件公司都面临类似的惯例问题。其中一些问题是如此普遍,以至于公司投资资金将它们开源,以树立自己的声誉。多亏了这一点,我们有了以下内容:

  • React 和 React Native 来自 Facebook

  • TypeScript,微软的 ECMAScript 上的类型化语言

  • 来自 Airbnb 的 eslint 配置

  • 来自 Yahoo 的 React 国际化库!

  • 来自 Mozilla 的 JavaScript 文档

  • 来自 Google 的 Material 设计指南,以及许多其他内容

这正在改变软件世界变得更好。

我希望您将这些智慧应用于未来的项目中。请用它来提高团队和组织的生产力。如果现在过度了,这也是一个很好的迹象,表明您已经发现了这一点。

总结

本章解决了应用程序中依赖项的常见问题。当您努力交付牢固的应用程序时,您会发现这些模式在测试中非常有用。除此之外,您还了解了 storybook 是什么,即记录组件用例的东西。现在您可以轻松地组合组件和 storybook。

生态系统也采纳了这些模式,我们已经使用了 React Context API 来将语言上下文传递到组件链中。您还可以一窥Provider的 react-redux 实现。

准备好迎接最后一章,介绍如何将类型引入您的应用程序。我们最终将确保传递的变量与消费者函数的期望相匹配。这将使我们能够在应用程序中对所有内容进行类型化,而不仅仅是为 React 视图使用PropTypes

进一步阅读

  • 由 Atlaskit 开发人员提供的目录结构指南:

这个指南将教你如何维护一个大型的代码库。这是关于如何处理由多个开发人员每天维护的前端代码库的可扩展性的许多例子之一。

atlaskit.atlassian.com/docs/guides/directory-structure)。

  • Airbnb 如何使用 React Native:

关于 Airbnb 技术堆栈的技术讨论,需要将其部署到三个不同的平台:浏览器、Android 和 iOS。了解 Airbnb 开发人员所面临的挑战。

www.youtube.com/watch?v=8qCociUB6aQ)。

  • Rafael de Oleza - 为 React Native 构建 JavaScript 捆绑包:

Rafael 解释了 React Native 中的 metro 捆绑器是如何工作的。

www.youtube.com/watch?v=tX2lg59Wm7g)。

第十一章:类型检查模式

为了能够让你的应用程序正常工作并忘记任何麻烦,你需要一种方法来确保应用程序的所有部分相互匹配。建立在 JavaScript 或 ECMAScript 之上的语言,如 Flow 或 TypeScript,为你的应用程序引入了类型系统。由于这些,你将知道没有人会向你的函数或组件发送错误的数据。我们已经在组件中使用了PropTypes进行断言。现在我们将把这个概念应用到任何 JavaScript 变量上。

在本章中,您将学习以下内容:

  • 类型系统的基础

  • 如何为函数和变量分配类型

  • 契约测试是什么;例如,Pact 测试

  • 泛型和联合类型

  • 解决类型问题的技巧

  • 类型系统如何使用名义和结构化类型

类型介绍

在 ECMAScript 中,我们有七种隐式类型。其中六种是原始类型。

六种原始数据类型如下:

  • 布尔值。

  • 数字。

  • 字符串。

  • 空值。

  • 未定义。

  • 符号——ECMAScript 中引入的唯一标识符。其目的是确保唯一性。这通常用作对象中的唯一键。

第七种类型是对象。

函数和数组也是对象。通常,任何不是原始类型的东西都是对象。

每当你给一个变量赋值时,类型会自动确定。根据类型,会有一些规则适用。

原始函数参数是按值传递的。对象是按引用传递的。

每个变量都以零和一的形式存储在内存中。按值传递意味着被调用的函数参数将被复制。这意味着创建一个具有新引用的新对象。按引用传递意味着只传递对象的引用——如果有人对引用的内存进行更改,那么会影响使用这个引用的所有人。

让我们看一下按值传递机制的例子:

// Passing by value

function increase(x) {
    x = x + 1;
    return x;
}

var num = 5;
increase(num);
console.log(num); // prints 5

num变量没有被改变,因为在函数调用时,该值被复制了。x变量引用了内存中的一个全新变量。现在让我们看一个类似的例子,但是使用对象:

// Passing by reference

function increase(obj) {
    obj.x = obj.x + 1;
    return obj;
}

var numObj = { x: 5 };
increase(numObj);
console.log(numObj); // prints { x: 6 }

这次,我们将numObj对象传递给了函数。它是按引用传递的,所以没有被复制。当我们改变obj变量时,它会对numObj产生外部影响。

然而,当我们调用前面的函数时,我们没有检查类型。默认情况下,我们可以传递任何东西。如果我们的函数无法处理传递的变量,那么它将以某种错误中断。

让我们来看看在使用increase函数时可能发生的隐藏和意外行为:

function increase(obj) {
    obj.x = obj.x + 1;
    return obj;
}

var numObj = { x: "5" };
increase(numObj);
console.log(numObj); // prints { x: "51" }

当我们将"5"1相加时,increase函数计算出51。这就是 JavaScript 的工作原理——它进行隐式类型转换以执行操作。

我们有办法防止这种情况并避免开发人员的意外错误吗?是的,我们可以进行运行时检查,以重新评估变量是否属于某种类型:

// Runtime checking if obj.x is a number

function increase(obj) {
 if (**typeof obj.x === 'number'**) {
        obj.x = obj.x + 1;
        return obj;
    } else {
        throw new Error("Obj.x must be a number");
    }
}

var numObj = { x: "5" };
increase(numObj);
console.log(numObj); // do not print, an Error message is shown
// Uncaught Error: Obj.x must be a number

运行时检查是在代码评估时执行的检查。它是代码执行阶段的一部分,会影响应用程序的速度。我们将在本章后面更仔细地研究运行时检查,在运行时验证问题解决部分。

当抛出Error消息时,我们还需要为组件替换使用错误边界或一些try{}catch(){}语法来处理异步代码错误。

如果您没有从头开始阅读本书,那么您可能会发现回到第二章,查看模式,以了解有关 React 中错误边界的更多信息。

然而,我们没有检查obj变量是否是Object类型!可以添加此类运行时检查,但让我们看看更方便的东西——TypeScript,这是建立在 JavaScript 之上的类型检查语言。

TypeScript 简介

TypeScript 为我们的代码带来了类型。我们可以明确表达函数只接受特定变量类型的要求。让我们看看如何在 TypeScript 的类型中使用上一节的示例:

type ObjXType = {
 x: number
}

function increase(obj: ObjXType) {
    obj.x = obj.x + 1;
    return obj;
}

var numObj = { x: "5" };
increase(numObj);
console.log(numObj);

这段代码将无法编译。静态检查将以错误退出,因为类型不匹配导致代码库损坏。

将显示以下错误:

Argument of type '{ x: string; }' is not assignable to parameter of type 'ObjXType'.
 Types of property 'x' are incompatible.
 **Type 'string' is not assignable to type 'number'.**

TypeScript 已经抓住了我们的错误。我们需要修复错误。除非开发人员修复错误,否则这样的代码永远不会到达最终用户。

配置 TypeScript

为了您的方便,我已经在我们的存储库中配置了 TypeScript。您可以在代码文件的src/Chapter 11/Example 1下查看它。

有几件事我希望您能理解。

TypeScript 有自己的配置文件,称为tsconfig.json。在这个文件中,您会发现多个配置属性,控制着 TypeScript 编译器的严格程度。您可以在官方文档中找到属性和解释的详细列表www.typescriptlang.org/docs/handbook/compiler-options.html

在选项中,您可以找到outDir。这指定了编译器输出应该保存在哪里。在我们的存储库中,它设置为"outDir": "build/dist"。从现在开始,我们的应用程序将从build/dist目录运行编译后的代码。因此,我已经将根App.js文件更改如下:

// src/ Chapter_11/ Example_1_TypeScript_support/ App.js

import StandaloneApp from './build/dist/Root'; import StoryBookApp from './build/dist/storybook';   // ... export default process.env['REACT_NATIVE_IS_STORY_BOOK'] ? StoryBookApp : StandaloneApp; 

现在您了解了配置更改,我们现在可以继续学习基本类型。

学习基本类型

要充分利用 TypeScript,您应该尽可能多地为代码添加类型。然而,在我们的应用之前并没有类型。对于大型应用程序,显然不能突然在所有地方添加类型。因此,我们将逐渐增加应用程序类型覆盖范围。

TypeScript 的基本类型列表相当长 - 布尔、数字、字符串、数组、元组、枚举、any、void、null、undefined、never 和对象。如果您对其中任何一个不熟悉,请查看以下页面:

www.typescriptlang.org/docs/handbook/basic-types.html

首先,让我们看一下我们使用的组件之一:

import PropTypes from 'prop-types';   export const NavigateButton = ({
    navigation, to, data, text
}) => (
    // ...  );   NavigateButton.propTypes = {
    // ...
};  

我们现在将切换到 TypeScript。让我们从Prop类型开始:

import {
    NavigationParams, NavigationScreenProp, NavigationState
} from 'react-navigation';   type NavigateButtonProps = {
 to: string,
 data: any,
 text: string,
 **navigation: NavigationScreenProp<NavigationState, NavigationParams>** }; 

在这些小例子中,我们已经定义了NavigationButton props 的结构。data prop 是any类型,因为我们无法控制传递的数据是什么类型。

navigation prop 使用了react-navigation库定义的类型。这对于重用已经暴露的类型至关重要。在项目文件中,我使用yarn add @types/react-navigation命令安装了react-navigation类型。

我们可以继续为NavigationButton添加类型:

export const NavigateButton:React.SFC<NavigateButtonProps> = ({
    navigation, to, data, text }) => (
    // ...  );

// Full example available at
// src/ Chapter_11/ Example_1/ src/ common/ NavigateButton/ view.tsx

SFC类型由 React 库导出。它是一个通用类型,可以接受任何可能的 prop 类型定义。因此,我们需要指定它是什么样的 prop 类型:SFC<NavigateButtonProps>

就是这样 - 我们还需要删除底部的旧NavigateButton.propTypes定义。从现在开始,TypeScript 将验证传递给NavigateButton函数的类型。

枚举和常量模式

在我看到的任何代码库中,都有一个长期受到赞扬的概念:常量。它们节省了很多价值,几乎每个人都同意定义保存特定常量值的变量是必须的。如果我们将它复制到需要它们的每个地方,更新值将会更加困难。

一些常量需要灵活,因此,聪明的开发人员将它们提取到配置文件中。这些文件存储在代码库中,有时以许多不同的风格存储(例如,用于测试:dev,质量保证和生产环境)。

在许多情况下,我们定义的常量只允许一组常量有效值。例如,如果我们要定义可用环境,那么我们可以创建一个列表:

const ENV_TEST = 'environment_test';
// ...

const availableEnvironments = [ENV_TEST, ENV_DEV, ENV_QA, ENV_PROD]

在旧式的 JavaScript 编程中,你可以简单地使用switch-case来切换环境,并将相关信息传播给应用程序中的特定对象。如果环境无法识别,那么它会进入一个默认子句,通常会抛出一个错误,说“无法识别的环境”,然后关闭应用程序。

如果你认为在 TypeScript 中,你不需要检查这些东西,那么你是错的。你从外部获取的任何东西都需要运行时验证。你不能允许 JavaScript 自行失败并以不可预测的方式使应用程序崩溃。这是一个经常被忽视的巨大“陷阱”。

你可能遇到的最常见问题之一是 API 的更改。如果你期望http://XYZ端点返回带有tasks键的 JSON,并且你没有验证实际返回给你的内容,那么你就会遇到麻烦。例如,如果另一个团队决定将键更改为projectTasks,并且不知道你对tasks的依赖,那么肯定会导致问题。我们该如何解决这个问题?

对 API 的预期返回值很容易强制执行。很久以前,就出现了术语合同测试。这意味着在前端和后端系统中创建合同。合同不能更改,而不确定两个代码库是否准备好。这通常由一些自动化工具强制执行,其中之一可能是 Pact 测试。

Pact(名词):

个人或团体之间的正式协议。“该国与美国谈判达成了一项贸易协定。

同义词:协议,协议,协议,合同”

牛津词典(https://en.oxforddictionaries.com/definition/pact)

如果您正在寻找一种以编程方式强制执行此操作的方法,请查看github.com/pact-foundation/pact-js。这个主题很难,也需要对后端语言有所了解,因此超出了本书的范围。

一旦我们 100%确定外部世界的数据已经得到验证,我们可能希望确保我们自己的计算永远不会导致变量的改变(例如通过不可变性,参见第九章 函数式编程模式的元素),或者如果变化是预期的,它将始终保留允许集合的值。

这就是 TypeScript 派上用场的时候。您可以确保您的计算将始终导致允许的状态之一。您将不需要任何运行时验证。TypeScript 将帮助您避免不必要的检查,大量的检查可能会导致您的应用程序减慢几毫秒。让我们看看我们可以如何做到这一点:

// src/ Chapter_11/
// Example_2/ src/ features/ tasks/ actions/ TasksActionTypes.ts

enum TasksActionType {
    ADD_TASK = 'ADD_TASK',
  TASKS_FETCH_START = 'TASKS_FETCH_START',
  TASKS_FETCH_COMPLETE = 'TASKS_FETCH_COMPLETE',
  TASKS_FETCH_ERROR = 'TASKS_FETCH_ERROR',
  TASK_FETCH_START = 'TASK_FETCH_START',
  TASK_FETCH_COMPLETE = 'TASK_FETCH_COMPLETE',
  TASK_FETCH_ERROR = 'TASK_FETCH_ERROR' }

我们已经定义了一个enum类型。如果变量预期是TasksActionType类型,它只能被赋予前面enum TasksActionType中的值。

现在我们可以定义AddTaskActionType

export type TaskAddFormData = {
    name: string,
  description: string }

export type AddTaskActionType = {
 type: TasksActionType.ADD_TASK,
 task: TaskAddFormData
};

它将用于addTask动作创建者:

// src/ Chapter_11/
// Example_2/ src/ features/ tasks/ actions/ TaskActions.ts

const addTask = (task:TaskAddFormData): AddTaskActionType => ({
    type: TasksActionType.ADD_TASK,
  task
});

现在我们的动作创建者经过了很好的类型检查。如果任何开发人员错误地将type对象键更改为其他任何值,例如TasksActionType.TASK_FETCH_COMPLETE,那么 TypeScript 将检测到并显示不兼容错误。

我们有AddTaskActionType,但是我们如何将其与我们的 reducer 可能接受的其他动作类型组合起来?我们可以使用联合类型。

创建联合类型和交集

联合类型描述了可以是多种类型之一的值。这非常适合我们的Tasks reducer 类型:

export type TaskReduxActionType =
    AddTaskActionType |
    TasksFetchActionType |
    TasksFetchCompleteActionType |
    TasksFetchErrorActionType |
    TaskFetchActionType |
    TaskFetchCompleteActionType |
    TaskFetchErrorActionType;

联合类型是使用**|**运算符创建的。它的工作方式就像|。一个类型或另一个类型。

现在我们可以在Reducer函数中使用之前的类型:

// src/ Chapter_11/ 
// Example_3/ src/ features/ tasks/ state/ reducers/ tasksReducer.ts

const tasksReducer = (
    state = Immutable.Map<string, any>({
        entities: Immutable.List<TaskType>([]),
  isLoading: false,
  hasError: false,
  errorMsg: ''
  }),
  action:TaskReduxActionType
) => {
    // ...
}

为了让 TypeScript 满意,我们需要为所有参数添加类型。因此,我已经添加了其余的类型。其中一个仍然缺失:TaskType

在前面的代码示例中,您可能会对Immutable.List<TaskType>的表示法感到惊讶,特别是< >符号。这些需要使用,因为List是一种通用类型。我们将在下一节讨论通用类型。

要创建TaskType,我们可以将其类型写成如下形式:

type TaskType = {
    name: string,
    description: string
    likes: number,
  id: number }

然而,这并不是重用我们已经创建的类型:TaskAddFormData。是否要这样做是另一个讨论的话题。让我们假设我们想要这样做。

为了重用现有类型并声明或创建所需形状的TaskType,我们需要使用交集:


export type TaskAddFormData = {
    name: string,
  description: string }

export type TaskType = TaskAddFormData & {
    likes: number,
  id: number }

在这个例子中,我们使用&交集运算符创建了一个新类型。创建的类型是&运算符左侧和右侧类型的交集:

交集图,其中交集是既在圆 A 中又在圆 B 中的空间。

AB的交集具有AB的属性。因此,由类型A和类型B的交集创建的类型必须同时具有类型A和类型B的类型。总结一下,TaskType现在必须具有以下形状:

{
    name: string,
    description: string
    likes: number,
  id: number
}

如您所见,交集可能很方便。有时,当我们依赖外部库时,我们不希望像前面的例子中那样硬编码键类型。让我们再看一遍:

type NavigateButtonProps = {
    to: string,
  data: any,
  text: string,
  navigation: NavigationScreenProp<NavigationState, NavigationParams>
};

导航键在我们的类型中是硬编码的。我们可以使用交集来符合外部库形状可能发生的未来变化:

// src/ Chapter_11/ 
// Example_3/ src/ common/ NavigateButton/ view.tsx

import { NavigationInjectedProps, NavigationParams } from 'react-navigation';   type NavigateButtonProps = {
    to: string,
  data: any,
  text: string, } & NavigationInjectedProps<NavigationParams>;

在这个例子中,我们再次使用<>符号。这是因为NavigationInjectedProps是一种泛型类型。让我们学习一下泛型类型是什么。

泛型类型

泛型允许您编写能够处理任何类型对象的代码。例如,您知道列表是一种泛型类型。您可以创建任何类型的列表。因此,当我们使用Immutable.List时,我们必须指定列表将包含哪种类型的对象:

Immutable.List<TaskType>

任务列表。现在让我们创建我们自己的泛型类型。

在我们的代码库中,有一个工具应该能够处理任何类型。它是一个Maybe单子。

如果您已经跳转到本章,则可能会发现在第九章中阅读有关单子模式的信息很有用,函数式编程模式的元素

Maybe单子要么是Nothing,当变量恰好是nullundefined时,要么是该类型的Something。这非常适合泛型类型:

export type MaybeType<T> = Something<T> | Nothing; 
const Maybe = <T>(value: T):MaybeType<T> => {
    // ...  }; 

棘手的部分是实现Something<T>Nothing。让我们从Nothing开始,因为它要容易得多。它应该在值检查时返回null,并始终映射到自身:

export type Nothing = {
    map: (args: any) => Nothing,
  isNothing: () => true,
  val: () => **null** }

Something<T>应该映射到Something<MappingResult>Nothing。值检查应该返回T

export type Something<T> = {
    map: <Z>(fn: ((a:T) => Z)) => MaybeType<Z>,
  isNothing: () => false,
  val: () => T
}

通过在map函数签名中引入的Z泛型类型来保存结果类型的映射。

然而,如果我们尝试使用我们新定义的类型,它们将不起作用。不幸的是,TypeScript 并不总是正确地推断联合类型。当类型的联合导致特定键的不同调用签名时,就会出现这个问题。在我们的情况下,这发生在map函数上。它的类型是(args: any) => Nothing<Z>(fn: ((a:T) => Z)) => MaybeType<Z>。因此,map没有兼容的调用签名。

这个问题的快速解决方法是定义一个独立的MaybeType,满足两个冲突的类型定义:

export type MaybeType<T> = {
    map: <Z>(fn: ((a:T) => Z)) => (MaybeType<Z> | Nothing),
  isNothing: () => boolean,
  val: () => (T | null)
}

有了这样的类型定义,我们可以继续使用新的泛型类型:

// src/ Chapter_11/ 
// Example_4/ src/ features/ tasks/ state/ selectors/ tasks.ts

export const tasksSelector =
    (state: TasksState):MaybeType<Immutable.Map<string, any>> =>
        Maybe<TasksState>(state).map((x:TasksState) => x.tasks);

选择器函数以TasksState作为参数,并且期望返回一个分配给状态中tasks键的映射。这可能看起来有点难以理解,因此,我强烈建议你打开前面的文件,仔细看一看。如果你有困难,在本章末尾的“进一步阅读”部分中,我已经包含了一个在 GitHub 上讨论这个问题的参考链接。

理解 TypeScript

在前面的部分中,我们遇到了一个问题,如果你从未使用过类型系统,可能很难理解。让我们稍微了解一下 TypeScript 本身,以更好地理解这个问题。

类型推断

我想让你明白的第一件事是类型推断。你不需要输入所有的类型。一些类型可以被 TypeScript 推断出来。

想象一种情况,我告诉你,“我只在你桌子上的盒子里放了巧克力甜甜圈。”在这个例子中,我假装是计算机,你可以相信我。因此,当你到达你的桌子时,你百分之百确定这个盒子是Box<ChocolateDonut[]>类型。你知道这一点,而不需要打开盒子或者在上面贴上写着“装满巧克力甜甜圈的盒子”的标签。

在实际代码中,它的工作方式非常相似。让我们看下面的最小示例:

const someVar = 123; // someVar type is number

这很琐碎。现在我们可以看一些我更喜欢的东西,ChocolateDonuts,如下:

enum FLAVOURS {
    CHOCOLATE = 'Chocolate',
    VANILLA = 'Vanilla',
}
type ChocolateDonut = { flavour: FLAVOURS.CHOCOLATE }

const clone = <T>(sth:T):T => JSON.parse(JSON.stringify(sth));

const produceBox: <T>(recipe: T) => T[] = <T>(recipe: T) => [
    clone(recipe), clone(recipe), clone(recipe)
];

// box type is inferred
const box = produceBox<ChocolateDonut>({ flavour: flavours.CHOCOLATE });

// inferred type correctly contains flavor key within donut object
for (const donut of box) {
    console.log(donut.flavour);
} // compiles and when run prints "Chocolate" three times

在这个例子中,我们同时使用了enum和泛型类型。clone简单地将任何类型克隆成一个全新的类型,并委托给 JSON 函数:stringifyparseProduceBox函数简单地接受一个配方,并根据该配方创建一个克隆数组。最后,我们创建了一个巧克力甜甜圈盒子。类型被正确地推断,因为我们为produceBox指定了一个泛型类型。

结构类型

TypeScript 使用结构类型。为了理解这意味着什么,让我们看下面的例子:

interface Donut {
    flavour: FLAVOURS;
}

class ChocolateDonut {
    flavour: FLAVOURS.CHOCOLATE;
}

let p: Donut;

// OK, because of structural typing
p = new ChocolateDonut();

在这个例子中,我们首先声明了变量p,然后将ChocolateDonut的一个新实例赋给它。这在 TypeScript 中是有效的。在 Java 中是行不通的。为什么呢?

我们从未明确指出ChocolateDonut实现了Donut接口。如果 TypeScript 没有使用结构类型,你需要将前面的代码部分重构为以下内容:

class ChocolateDonut implements Donut {
    flavour: FLAVOURS.CHOCOLATE;
}

使用结构类型的原因通常被称为鸭子类型:

如果它走起来像鸭子,叫起来像鸭子,那么它一定是鸭子。

因此,在 TypeScript 中不需要implements Donut,因为ChocolateDonut已经表现得像一个甜甜圈,所以它一定是一个甜甜圈。万岁!

TypeScript 中的不可变性

在这一部分,我想重申一下不可变性的观点。这个话题在 JavaScript 中非常重要,在某些情况下,TypeScript 可能是比其他任何不可变性路径更好的解决方案。

TypeScript 带有特殊的readonly关键字,它强制某个变量是只读的。你不能改变这样的变量。这在编译时强制执行。因此,你没有不可变性的运行时检查。如果这对你来说是一个巨大的胜利,那么你甚至可能不需要任何 API,比如 Immutable.js。当你需要克隆大对象以避免变异时,Immutable.js 就会发光。如果你可以自己使用扩展操作来解决问题,那么这意味着你的对象可能还不够大,不需要 Immutable.js。

readonly

由于我们的应用程序目前还不是很大,因此作为一个练习,让我们用 TypeScript 的readonly来替换 Immutable.js:

export type TasksReducerState = {
    readonly entities: TaskType[],
 readonly isLoading: boolean,
 readonly hasError: boolean,
 readonly errorMsg: string }

这看起来有很多重复。我们可以使用Readonly< T >代替:

export type TasksReducerState = Readonly<{
    entities: TaskType[],
  isLoading: boolean,
  hasError: boolean,
  errorMsg: string }>

这看起来干净多了。然而,它并不完全不可变。你仍然可以改变entities数组。为了防止这种情况,我们需要使用ReadonlyArray<TaskType>

export type TasksReducerState = Readonly<{
    entities: ReadonlyArray<TaskType>,
 // ...  }>

剩下的工作是在整个应用程序中用ReadonlyArray<TaskType>替换每个TaskType[]。然后,您需要将 Immutable.js 对象更改为标准的 JavaScript 数组。这样的重构很长,不适合这些书页,但我已经在代码文件中进行了重构。如果您想查看已更改的内容,请转到src/Chapter_11/Example_5的代码文件目录。

使用 linter 来强制不可变性

您可以使用 TypeScript linter 在 TypeScript 文件中强制使用readonly关键字。允许您执行此操作的开源解决方案之一是tslint-immutable

它向tslint.json配置文件添加了额外的规则:

"no-var-keyword": true,  "no-let": true,  "no-object-mutation": true, "no-delete": true,  "no-parameter-reassignment": true,  "readonly-keyword": true, "readonly-array": true,

从现在开始,当您运行 linter 时,如果违反了前述规则,您将看到错误。我已经重构了代码以符合这些规则。在src/Chapter_11/Example_6的代码文件目录中检查完整示例。要运行 linter,您可以在终端中使用以下命令:

  yarn run lint

摘要

在本章中,您已经了解了一个非常强大的工具:建立在 JavaScript 之上的类型化语言。类型检查对于任何代码库都有无数的优势。它可以防止您部署违反预期的破坏性更改。您已经学会了如何告诉 TypeScript 什么是允许的。您知道什么是泛型类型,以及如何在类型文件中使用它们以减少代码重复。

新工具带来了新知识,因此您还学会了类型推断和结构类型的基础知识。TypeScript 的这一部分绝对需要反复试验。练习以更好地理解它。

这是本书的最后一章。我希望你已经学到了许多有趣的概念和模式。我在整本书中都在向你挑战;现在是你挑战你的代码库的时候了。看看哪些适合你的应用程序,也许重新思考你和你的团队之前所做的选择。

如果您不理解某些模式,不要担心。并非所有模式都是必需的。有些是通过经验获得的,有些仅适用于大型代码库,有些是偏好问题。

选择能够保证应用程序正确性的模式,以及能够更快地为客户增加价值的模式。祝你好运!

进一步阅读

  • 精通 TypeScript(第二版),Nathan Rozentals:这是一本深入学习 TypeScript 的好书。它演示了如何对一些非常高级的用例进行类型化。这是我的个人推荐,而不是出版商的。

  • TypeScript 的官方文档可以在www.typescriptlang.org找到。

  • 在本章前面提到的调用签名问题的讨论可以在 TypeScript GitHub 存储库的github.com/Microsoft/TypeScript/issues/7294找到。

posted @ 2024-05-16 14:50  绝不原创的飞龙  阅读(33)  评论(0编辑  收藏  举报