Skip to content

Latest commit

 

History

History
623 lines (427 loc) · 28.2 KB

File metadata and controls

623 lines (427 loc) · 28.2 KB

十、管理依赖项

本章专门讨论管理移动应用程序所依赖的依赖项,即库。当前大多数应用程序都滥用单例模式。然而,我坚信,有一天,JavaScript 开发人员将采用众所周知的依赖注入DI模式。即使他们决定使用单例模式,重构也会更容易。在本章中,我们将重点讨论 React 上下文以及诸如 Redux 之类的库如何利用 DI 机制。如果您真的想提高代码的性能并使其易于测试,那么这是最安全的选择。我们将深入研究 React Redux 库中的代码,该库广泛使用 React 上下文。您还将理解为什么 JavaScript 世界放弃单例模式的速度如此之慢。

在本章中,您将了解以下主题:

  • 单一模式
  • ECMAScript 中的 DI 模式及其风格
  • 故事书模式,以提高生产效率并记录组件
  • React 上下文 API
  • 如何管理大型代码库

准备好,因为我们将从单例模式开始。

单一模式

singleton 模式是一个只能有一个实例的类。根据它的设计,每当我们试图创建一个新实例时,它要么第一次创建一个实例,要么返回先前创建的实例。

这个模式如何有用?如果我们想为某些事情使用一个单一的管理器,无论是 API 管理器还是缓存管理器,这都很方便。例如,如果需要授权 API 来获取令牌,则只需执行一次。第一个实例将启动任何必要的工作,然后任何其他实例将重用已经完成的工作。这个用例主要被服务器端应用程序滥用,但越来越多的人意识到有更好的替代方案。

如今,这样的用例可以很容易地被更好的模式所抵消。不必创建单例模式,只需将令牌存储在缓存中,并在任何新实例中验证令牌是否已在缓存中。如果是,您可以跳过授权并使用令牌。这个技巧利用了一个众所周知的事实,即缓存是存储数据的一个集中位置。在这种情况下,它为我们提供了一个单件商店。无论是客户端缓存还是云服务器缓存,都是一样的,只是在服务器上,调用它的成本可能更高。

在 ECMAScript 中实现单例模式

虽然现在不鼓励使用单例模式,但是学习如何创建这种机制是非常有益的。对于这个代码示例,我们将使用 ECMAScript 6 类和 ECMAScript 7 静态字段:

export default class Singleton {
    static instance;

    constructor() {
        if (Singleton.instance) {
            return Singleton.instance;
        }

        this.instance = this;
    }
}

我们正在更改构造函数的行为。首先,在返回任何内容之前,我们需要检查实例是否已经创建。如果是,则当前调用将返回该实例。

为什么不鼓励使用单例模式

Singleton有时被视为global变量。如果您试图从许多不同的地方导入它,而您的用例只是共享同一个实例,那么您可能滥用了该模式。这样,您可以将不同的片段紧密地耦合到精确导入的对象。如果您使用global变量而不是将其传递下去,则这是代码气味的生命体征之一。

除此之外,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不仅是全局共享的,而且是全局可变的。这是一个可怕的故事,如果你想让它变得可预测的话。它通常会破坏我们在第 9 章函数式编程模式元素中了解的所有内容。

您需要向每个消费者组件保证,它已经准备好处理来自单例的任何类型的数据。这需要指数级的测试次数,因此会降低生产率。这是不能接受的。

Later on in this chapter, you will find a solution that will fix all of these issues via 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 程序员来说都是非常重要的知识。

Be careful, as some module bundlers do not guarantee that modules will be instantiated only once. Tools such as webpack may internally, for the sake of optimization or compatibility, instantiate some modules multiple times.

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 模式。我们本质上是将依赖项作为道具传递给组件。在本例中,如下所示:

export const TasksSection = ({
    AddTaskContainer,
    TaskListContainer
}) => (
    <View>
        <AddTaskContainer />
        <TaskListContainer />
    </View>
);

如果有人对传递这些组件不感兴趣,我们可以创建一个容器来提供它们。然而,在我们想用容器代替其他东西的情况下,这非常方便,例如,在测试或故事书中!什么是故事书?继续读。

在故事书中使用 DI 模式

故事书是记录组件的一种方式。随着应用程序的增长,您可能很快就会拥有数百个组件。如果您构建了一个严肃的应用程序,那么大多数应用程序都符合设计规范,并且所有预期的功能都已经实现。诀窍在于知道要发送哪些道具才能达到预期的效果。故事书使这变得简单。实现组件时,还可以为不同的场景创建故事书。请查看Button组件的以下简单示例:

Example storybook of the Button component

通过在左侧面板中选择场景,您可以快速查看组件在不同道具下的外观。

我已经安装了故事书供您在src/Example 10/Exercise 1中玩。您可以从该目录运行yarn run ios:storybookyarn run android:storybook来启动 Storybook。

If you would like to learn how to set up Storybook yourself, check out the official documentation at  https://github.com/storybooks/storybook/tree/master/app/react-native. Most of the configuration files you will need to add should go in the storybook directory within the project.

storybook 提供的安装命令行界面为您设置游乐场故事。这些是前面截图中的内容(带有文本和表情符号的Button)。

是时候添加我们自己的故事了。让我们从简单的东西开始,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功能随故事书一起提供。然后,在 decorator 中,我们用可滚动的视图和在左右两侧应用填充的一般样式包装每个故事。最后,我们使用add功能创建了两个故事:TaskList只有一个故事;TaskList7故事。

不幸的是,我们的代码出现以下错误:

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组件。它使用了withNavigationHOC,实际上已经需要上下文:

// src/ Chapter_10/ Example_1/ src/ components/ NavigateButton.js

export default withNavigation(NavigateButton);

幸运的是,withNavigation由于依赖于 React 上下文,已经在使用 DI 模式。我们需要做的是将所需的上下文(导航)注入到我们的故事书示例中。为此,我们需要使用 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', () => (
        // ...
    ));

最后,我们可以欣赏两个新创作的故事:

TaskList component stories in storybook

当您选择其中一个时,它将显示在模拟器上:

TaskList stories displayed on the iPhone X simulators

再努力一点,我们就可以在这本故事书中添加更多的故事。例如,让我们尝试加载一个错误案例:

TaskList stories for loading state and error state

我们还可以为前面的屏幕截图所示的组合创建一个故事:

TaskList story with error and loading state

带有 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期待道具中的NavButton组件。我们需要在容器和故事书中遵守这些道具期望。以下是第一个容器的代码:

// 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));

是时候开始有趣的部分了。我们需要解决一个故事书的问题。为了实现 DI 的目标,我们将为NavButton创建一本单独的故事书。为了修复TaskList故事书,我们将导入NavButton故事,并将其作为NavButton组件注入TaskList视图。

这听起来可能很复杂,但让我们在下面的示例中看到这一点。

要创建NavButton故事,我们需要将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。我们现在可以继续创建故事书:

// 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={{}}
    />
);

在这个代码示例中,我引入了一些改进。关注点分离示例放入单独的文件中,这样它们就可以在故事书以外的领域中重用,例如,在快照测试中。

嘲弄navigation现在非常简单和直接。我们只是替换了navigation对象和其中的navigate函数。

我们现在准备将该示例作为TaskList故事中的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>
);

具有反应上下文的 DI

在上一节中,我们通过注入组件以非常简单的方式使用 DI。React 具有自己的 DI 机制。

React 上下文可用于将依赖项注入到链中距离容器组件很远的组件中。这使得 React 上下文非常适合在整个应用程序中重用的全局依赖项。

这种全局依赖性的好例子有主题配置、记录器、分派器、登录用户对象或语言选项。

使用 React 上下文 API

为了了解 React 上下文 API,我们将使用一个简单的语言选择器。我创建了一个组件,允许我们从两种语言中选择一种,英语或波兰语。它将所选语言存储在 Redux 存储中:

Language selector in the application's header and the left image shows English selected; the right image shows Polish selected

我们现在的目标是通过 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,最好是在 screens 组件中。为了使用已经学过的模式轻松实现这一点,我创建了一个名为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>

结果显示在以下屏幕截图中:

Example usage of LanguageConsumer in the TaskList component

请注意,这只是了解上下文 API 的一个示例。没有进行实际的翻译。要向应用程序添加翻译,请使用 Yahoo!提供的 React Intl 库!。为了您的方便,它还公开了Providerhttps://github.com/yahoo/react-intl

反应到一边

如果您密切关注前面的示例,您可能已经发现了一个有趣的部分——withStoreProvider.这是一个更高阶的组件,我用react-redux存储区Provider来包装根组件:

import { Provider } from 'react-redux';
// ...
<Provider store={store}>
    <WrappedComponent {...props} />
</Provider>

公开的Provider与 React 上下文 API 非常相似。上下文与一个实验性 API 一起在 React 库中保存了很长一段时间。但是,最新的上下文 API 是在 React 16 中引入的,您可能会注意到,旧库仍然使用自己的自定义提供程序。例如,看看 react reduxProvider实现,如下所示:

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-reduxconnect函数访问您的 redux 存储的方式。这里没有ConsumerAPI,而是有connect函数,我们用它来访问存储。你可能已经习惯了。将此作为如何使用公开提供者或使用者的指南。

管理代码库

我们的代码库已经开始增长。我们已经采取了解决单片体系结构问题的第一步,到目前为止,我们有一个非常好的文件结构:

Current src/ directory structure

虽然现在已经足够好了,但如果我们想让这个项目更大,我们应该重新思考我们的方法并制定规则。

速胜

当一个新的开发人员加入这个项目时,他们理解我们的代码库可能会有点困难。让我们解决几个简单的问题。

首先,我们的应用程序的条目文件在哪里?它在根目录中。但是,在源(src/目录中没有明确的入口点。这是可以的,但它将是方便的,让它接近的故事和例子。一眼就可以看到示例、故事书和应用程序根目录。

此外,我们可以重构当前的ScreenRoot组件。作为AppRoot使用,包裹在两个 HOC 中。正如您已经知道的,这种耦合不是一件好事。我做了一点重构。看看新的结构:

The entry point to the application is now clearly visible (index.js)

我们很快取得了胜利;现在更容易找到根组件。现在,让我们看看componentsfeatures目录:

Components and features directories

components 文件夹最初用于收集无状态组件。随着应用程序的发展,我们很快意识到仅仅为无状态组件提供一个共享目录是不够的。我们也希望重用有状态的。因此,我们应该将components目录重命名为common。它更好地表示目录是什么:

The Components directory has been renamed to common

我们很快就会注意到的另一个问题是,features 下的 language 目录只会造成混乱。主要是LanguageSwitcher,而不是一般的language。我们之所以将其置于功能项下,只是因为我们希望使用应用程序功能组件中的语言。语言语境是一个特征吗?不是真的;这是某种功能,但不是在用户体验的上下文中。这造成了混乱。

我们应该做两件事:

  1. 将上下文移动到公共目录,因为我们计划在整个应用程序中重用LanguageConsumer
  2. 承认我们不会重用LanguageSwitcher组件并将其放在布局目录中,因为它不打算在布局组件之外的任何地方使用。

一旦我们做到这一点,我们的应用程序结构将再次变得更清晰:

Language directory has been split into LanguageSwitcher and LanguageContext

LanguageContext现在很容易找到。类似地,在改变布局之前,我们不需要为LanguageSwitcher实现操心。

util 目录会造成类似的混乱,就像初始语言目录一样。我们可以安全地将其移动到common目录:

The refactored directory structure

现在,任何新加入该项目的开发人员都可以很快对其有一个清晰的认识。screenslayoutfluxfeaturescommon都是非常不言自明的名称。

建立公约

无论何时构建一个大型项目,像上一节一样,依靠开发人员自己的判断可能是不够的。不同技术领先者采用的方法的不一致性可能会迅速升级,并导致在探索代码丛林时损失数十个开发小时。

如果这听起来像是一个外来问题,我可以保证,在每天有数百名开发人员同时工作的代码库中,建立明确的指导方针和约定是非常重要的模式。

让我们看几个例子:

  • Linter:负责代码外观指南并自动执行。它还可以强制执行某些使用模式,并在有备选方案列表的情况下优先选择某些选项。
  • Flux 架构:如何连接和构造 JavaScript 代码以解决常见使用模式的通用架构。不是自动执行的。
  • 纯还原器:还原器需要像 Redux 库的架构决策一样纯净。这在经典的 Flux 体系结构中是不强制的。这可能会自动执行,也可能不会自动执行。
  • 在 JavaScript 中定义的样式:一种使用 React Native 的现成解决方案。

这个名单还有很多。我希望这足以让你相信建立惯例是件好事。它确实稍微限制了可用功能,但使您能够更快地传递客户价值。React Native 本身就是一个很好的例子,它连接了许多不同的生态系统,提供了开发移动应用程序的统一方法。事实证明,它可以显著提高移动开发者的生产力。

All big software companies approach similar convention problems. Some of them are so common that companies invest money into making them open source to make a name for themselves. Thanks to this, we have the following:

  • React 和 React 来自 Facebook
  • TypeScript,微软 ECMAScript 之上的一种类型化语言
  • 来自 Airbnb 的 eslint 配置
  • 来自雅虎的 React 国际化库!
  • 来自 Mozilla 的 JavaScript 文档
  • 谷歌的材料设计指南,还有更多

这正在使软件世界变得更好。

我希望你能将这种智慧运用到你未来的项目中。请使用它来提高您的团队和组织的生产力。如果它现在是过度杀伤力,这也是一个好迹象,你已经发现了这一点。

总结

本章讨论了应用程序中依赖项的常见问题。当您努力交付防弹应用程序时,您会发现这些模式在测试中很有用。除此之外,您还了解了什么是故事书,即记录组件用例的东西。现在,您可以轻松地编写组件和故事书。

生态系统也包含这些模式,我们使用 React 上下文 API 将语言上下文传递给组件链。您还了解了Provider的 react redux 实现。

为最后一章做好准备,这一章将类型引入到应用程序中。我们将最终确保传递的变量符合消费者的功能期望。这将使我们能够在应用程序中键入所有内容,而不是仅使用PropTypes作为 React 视图。

进一步阅读