【DailyENJS第7期】掌握 React 函数式组件

1,788 阅读8分钟

DailyENJS 致力于翻译优秀的前端英文技术文章,为技术同学带来更好的技术视野。

多年来,我意识到开发高质量React应用程序的唯一正确方法是编写无状态的函数式组件。

在本文中,我将简要介绍函数式组件和高阶组件。在此之后,我们将深入研究将膨胀的React组件重构为由多个可组合的高阶组件组成的简洁优雅的解决方案。

函数式组件介绍

函数组件之所以被称为函数式组件是因为它们就是普通的JavaScript函数。一个优秀的React应用程序应该只包含函数式组件。

首先让我们来看一个非常简单的类组件(class component)。

class MyComponent extends React.Component {
  render() {
    return (
      <div>
        <h1>Hi, {this.props.name}</h1>
      </div>
    );
  }
}

现在让我们将其重写为函数式组件:

const MyComponent = ({name}) => (
  <div>
    <h1>Hi, {name}</h1>
  </div>
);

如你所见,函数式组件更清晰,更简洁,更易于阅读。也没有必要使用this。

其他的一些好处:

  • 易于理解 - 函数式组件是纯函数,这意味着相同的输入生成相同的输出。给定名称 Ilya ,上面的组件将呈现<h1> Hi,Ilya </ h1>

  • 易于测试 - 由于函数式组件是纯函数,因此很容易运行断言:给定一些 props,期望它呈现相应的组件。

  • 防止滥用组件的 state

  • 可重复使用的模块化代码

  • 避免出现过于复杂,职责过于庞大的组件

  • 可组合 - 可以根据需要使用更高阶的组件

如果你的组件除了 render 没有其他方法,那么实际上没有什么理由去使用类组件。

高阶组件

高阶组件(HOC)是React中用于重用(和隔离)组件逻辑的技术。你可能已经遇到过HOC——Redux的connect是一个高阶组件。

将HOC应用于组件能够增强现有组件,增加功能。这通常是通过添加新的props来完成的,这些props将传递给你的组件。在Redux connect的例子中,你的组件将获得使用mapStateToProps和mapDispatchToProps函数映射的新props。

我们经常需要使用localStorage,但是,直接在组件内部与localStorage交互是错误的,因为它是一个副作用。在React中,组件应该没有副作用。以下简单的高阶组件将向包装组件添加三个新props,并使其能够与localStorage交互。

const withLocalStorage = (WrappedComponent) => {
  const loadFromStorage   = (key) => localStorage.getItem(key);
  const saveToStorage     = (key, value) => localStorage.setItem(key, value);
  const removeFromStorage = (key) => localStorage.removeItem(key);

  return (props) => (
      <WrappedComponent
            loadFromStorage={loadFromStorage}
            saveToStorage={saveToStorage}
            removeFromStorage={removeFromStorage}
            {...props}
        />
  );
}

然后我们可以按照以下方式使用它:

withLocalStorage(MyComponent)

凌乱的类组件(A Messy Class Component)

让我向你介绍我们将要使用的组件。它是一个简单的注册表单,由三个字段和一些基本表单验证组成。

import React from "react";
import { TextField, Button, Grid } from "@material-ui/core";
import axios from 'axios';

class SignupForm extends React.Component {
  state = {
    email: "",
    emailError: "",
    password: "",
    passwordError: "",
    confirmPassword: "",
    confirmPasswordError: ""
  };

  getEmailError = email => {
    const emailRegex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;

    const isValidEmail = emailRegex.test(email);
    return !isValidEmail ? "Invalid email." : "";
  };

  validateEmail = () => {
    const error = this.getEmailError(this.state.email);

    this.setState({ emailError: error });
    return !error;
  };

  getPasswordError = password => {
    const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/;

    const isValidPassword = passwordRegex.test(password);
    return !isValidPassword
      ? "The password must contain minimum eight characters, at least one letter and one number."
      : "";
  };

  validatePassword = () => {
    const error = this.getPasswordError(this.state.password);

    this.setState({ passwordError: error });
    return !error;
  };

  getConfirmPasswordError = (password, confirmPassword) => {
    const passwordsMatch = password === confirmPassword;

    return !passwordsMatch ? "Passwords don't match." : "";
  };

  validateConfirmPassword = () => {
    const error = this.getConfirmPasswordError(
      this.state.password,
      this.state.confirmPassword
    );

    this.setState({ confirmPasswordError: error });
    return !error;
  };

  onChangeEmail = event =>
    this.setState({
      email: event.target.value
    });

  onChangePassword = event =>
    this.setState({
      password: event.target.value
    });

  onChangeConfirmPassword = event =>
    this.setState({
      confirmPassword: event.target.value
    });

  handleSubmit = () => {
    if (
      !this.validateEmail() ||
      !this.validatePassword() ||
      !this.validateConfirmPassword()
    ) {
      return;
    }

    const data = {
      email: this.state.email,
      password: this.state.password
    };

    axios.post(`https://mywebsite.com/api/signup`, data);
  };

  render() {
    return (
      <Grid container spacing={16}>
        <Grid item xs={4}>
          <TextField
            label="Email"
            value={this.state.email}
            error={!!this.state.emailError}
            helperText={this.state.emailError}
            onChange={this.onChangeEmail}
            margin="normal"
          />

          <TextField
            label="Password"
            value={this.state.password}
            error={!!this.state.passwordError}
            helperText={this.state.passwordError}
            type="password"
            onChange={this.onChangePassword}
            margin="normal"
          />

          <TextField
            label="Confirm Password"
            value={this.state.confirmPassword}
            error={!!this.state.confirmPasswordError}
            helperText={this.state.confirmPasswordError}
            type="password"
            onChange={this.onChangeConfirmPassword}
            margin="normal"
          />

          <Button
            variant="contained"
            color="primary"
            onClick={this.handleSubmit}
            margin="normal"
          >
            Sign Up
          </Button>
        </Grid>
      </Grid>
    );
  }
}

export default SignupForm

上面的组件很乱,它一次做很多事情:处理它的state,验证表单字段,以及渲染表单。它已经有140行代码。添加更多功能很快就无法维护。我们不能做得更好吗?

让我们看看我们能做些什么。

需要使用 Recompose

注:Recompose是一个React实用库,用于功能组件和高阶组件。可以把它想象成React的lodash。

Recompose允许你通过添加state,生命周期方法,context等来增强函数式编组件。

最重要的是,它允许你清楚地分离关注点 - 你可以让主要组件专门负责布局,负责处理表单输入的高阶组件,另一个用于处理表单验证,另一个用于提交表单。它很容易测试!

优雅的函数式组件

让我们看看可以对复杂的类组件做些什么。

Step 0. 安装 recompose

yarn add recompose

Step 1. 提取表单 state

我们将从Recompose中使用 withStateHandlers。它将允许我们将组件 state 与组件本身隔离开来。我们将使用它为电子邮件,密码和确认密码字段添加表单状态,以及上述字段的事件处理程序。

import { withStateHandlers, compose } from "recompose";

const initialState = {
  email: { value: "" },
  password: { value: "" },
  confirmPassword: { value: "" }
};

const onChangeEmail = props => event => ({
  email: {
    value: event.target.value,
    isDirty: true
  }
});

const onChangePassword = props => event => ({
  password: {
    value: event.target.value,
    isDirty: true
  }
});

const onChangeConfirmPassword = props => event => ({
  confirmPassword: {
    value: event.target.value,
    isDirty: true
  }
});

const withTextFieldState = withStateHandlers(initialState, {
  onChangeEmail,
  onChangePassword,
  onChangeConfirmPassword
});

export default withTextFieldState;

withStateHandlers 高阶组件非常简单 - 它接受初始state,以及包含state处理程序的对象作为参数。每个state处理程序在调用时都将返回新的state。

Step 2.提取表单验证逻辑

我们将从 Recompose 中使用 withProps 高阶组件。它允许将任意 props 添加到现有组件。

我们将使用 withProps 来添加 emailErrorpasswordErrorconfirmPasswordError props,如果我们的任何表单字段无效,它们就会出错。

还应该注意,每个表单字段的验证逻辑都保存在一个单独的文件中(为了更好地分离关注点)。

import { withProps } from "recompose";

const getEmailError = email => {
  if (!email.isDirty) {
    return "";
  }

  const emailRegex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;

  const isValidEmail = emailRegex.test(email.value);
  return !isValidEmail ? "Invalid email." : "";
};

const withEmailError = withProps(ownerProps => ({
  emailError: getEmailError(ownerProps.email)
}));

export default withEmailError;
import { withProps } from "recompose";

const getPasswordError = password => {
  if (!password.isDirty) {
    return "";
  }

  const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/;

  const isValidPassword = passwordRegex.test(password.value);
  return !isValidPassword
    ? "The password must contain minimum eight characters, at least one letter and one number."
    : "";
};

const withPasswordError = withProps(ownerProps => ({
  passwordError: getPasswordError(ownerProps.password)
}));

export default withPasswordError;
import { withProps } from "recompose";

const getConfirmPasswordError = (password, confirmPassword) => {
  if (!confirmPassword.isDirty) {
      return "";
  }

  const passwordsMatch = password.value === confirmPassword.value;

  return !passwordsMatch ? "Passwords don't match." : "";
};

const withConfirmPasswordError = withProps(
    (ownerProps) => ({
        confirmPasswordError: getConfirmPasswordError(
            ownerProps.password,
            ownerProps.confirmPassword
        )
    })
);

export default withConfirmPasswordError;

Step3.提取表单提交逻辑

在这一步中,我们将提取表单提交逻辑。这次我们将使用 withHandlers 高阶组件来添加 onSubmit

为什么不像以前一样使用 withProps?在 withProps 中使用箭头函数会严重损害性能。withHandlerswithProps 的特殊版本,旨在与箭头函数一起使用。

handleSubmit 函数接受从上一步传递下来的 emailErrorpasswordErrorconfirmPasswordError props,检查是否有任何错误,如果没有错误将数据发送到我们的API。

import { withHandlers } from "recompose";
import axios from "axios";

const handleSubmit = ({
  email,
  password,
  emailError,
  passwordError,
  confirmPasswordError
}) => {
  if (emailError || passwordError || confirmPasswordError) {
    return;
  }

  const data = {
    email: email.value,
    password: password.value
  };

  axios.post(`https://mywebsite.com/api/signup`, data);
};

const withSubmitForm = withHandlers({
  onSubmit: (props) => () => handleSubmit(props)
});

export default withSubmitForm;

Step4. 接下来就是见证奇迹的时候

最后,我们将我们创建的高阶组件组合到一个可以在我们的表单上使用的增强器中。我们将使用 recompose 的 compose 函数,它可以组合多个高阶组件。

import { compose } from "recompose";

import withTextFieldState from "./withTextFieldState";
import withEmailError from "./withEmailError";
import withPasswordError from "./withPasswordError";
import withConfirmPasswordError from "./withConfirmPasswordError";
import withSubmitForm from "./withSubmitForm";

export default compose(
    withTextFieldState,
    withEmailError,
    withPasswordError,
    withConfirmPasswordError,
    withSubmitForm
);

注意这个解决方案是多么优雅和干净。所有必需的逻辑只是被添加到另一个上面以生成一个增强器组件。

Step 5.新的开始

现在让我们来看看SignupForm组件本身。

mport React from "react";
import { TextField, Button, Grid } from "@material-ui/core";
import withFormLogic from "./logic";

const SignupForm = ({
    email, onChangeEmail, emailError,
    password, onChangePassword, passwordError,
    confirmPassword, onChangeConfirmPassword, confirmPasswordError,
    onSubmit
}) => (
  <Grid container spacing={16}>
    <Grid item xs={4}>
      <TextField
        label="Email"
        value={email.value}
        error={!!emailError}
        helperText={emailError}
        onChange={onChangeEmail}
        margin="normal"
      />

      <TextField
        label="Password"
        value={password.value}
        error={!!passwordError}
        helperText={passwordError}
        type="password"
        onChange={onChangePassword}
        margin="normal"
      />

      <TextField
        label="Confirm Password"
        value={confirmPassword.value}
        error={!!confirmPasswordError}
        helperText={confirmPasswordError}
        type="password"
        onChange={onChangeConfirmPassword}
        margin="normal"
      />

      <Button
        variant="contained"
        color="primary"
        onClick={onSubmit}
        margin="normal"
      >
        Sign Up
      </Button>
    </Grid>
  </Grid>
);

export default withFormLogic(SignupForm);

新的重构过后的组件非常干净,只做一件事 - 渲染。单一责任原则规定模块应该做一件事。我相信我们已经实现了这一目标。

所有必需的数据和输入处理程序都只是作为props传递下来。这反过来使组件非常容易测试。

我们应该始终努力使我们的组件完全不包含逻辑,并且只负责渲染。Recompose 可以帮助我们做到这一点。

彩蛋:用 pure 优化性能

Recompose 有 pure 组件,这是一个很好的高阶组件,它允许我们仅在需要时重新渲染组件。pure将确保组件不会重新渲染,除非任何props已更改。

import { compose, pure } from "recompose";

...

export default compose(
  pure,
  withFormLogic
)(SignupForm);

总结

我们应该始终遵循单一职责原则,努力将逻辑与表现隔离开来。我们首先禁止类组件。主要的组件本身应该是函数式的,并且应该只负责呈现内容而不是其他任何内容。然后将所有必需的状态和逻辑添加为高阶组件。

遵循上述规则将使你的代码清晰,易读,易于维护且易于测试。

原文: medium.com/codeiq/mast…

代码: github.com/suzdalnitsk…