Nov 15, 2019

Conditional Rendering in React

如何进行条件渲染是一个 MVx 框架最基础的问题之一,但是它在 React 中总是会给人提出各种各样的问题。要么「不够优雅」,要么「不够可靠」,要么「不够好用」,现有的各种各样的方法之中,总是逃不过这三种问题的其中之一。至于 React-Native,虽然它与 React 「原则上一致」,但它存在的问题实际上就是要比 React 更多一些。

if 语句与三元表达式

在 JSX 世界中,用 if 语句以及三元表达式去完成条件渲染是最直观的方式。

function Greeting(props) {
  const isLoggedIn = props.isLoggedIn;
  // the 'if' way
  if (isLoggedIn) {
    return <UserGreeting />;
  }
  return <GuestGreeting />;
  // or the 'conditional operator' way
  // return isLoggedIn ? <UserGreeting /> : <GuestGreeting />;
}

这种方式确实足够优雅,也足够可靠:毕竟它完美地沿用了语言本身的逻辑和语法,没有创造其它累赘的东西。但是它真的好用吗?我相信许多重度 React 使用者都会对此表示无奈:现实工程中诸如此类需要做条件渲染的地方多如牛毛,如果每一处我们都得给它写 if-else(三元表达式虽然相对来说更好用一些,但是它的应用场景毕竟更有限)并且将条件渲染体抽离主体作为子组件来做,那我真的好绝望,这感觉就好像是在现代社会躬行刀耕火种一样。况且不是所有的项目都需要「完美地优雅」,更多时候我们这种开发者只想尽快把工作完成,仅此而已。

实际上这种方案已经足以应付 100% 场景的需求了,并且你可能已经意识到,本质上来说这就是唯一的方案。但其存在的问题实在过于让人沮丧,因此才有了下面的一些「拓展」方案。

使用变量

React 官方文档提出的第二种方式是使用变量,通过将元素暂存在变量中,可以让开发者控制组件中的一部分而不影响其它内容。

class LoginControl extends React.Component {
  //...
  render() {
    const isLoggedIn = this.state.isLoggedIn;
    let button;

    if (isLoggedIn) {
      button = <LogoutButton onClick={this.handleLogoutClick} />;
    } else {
      button = <LoginButton onClick={this.handleLoginClick} />;
    }

    return (
      <div>
        <Greeting isLoggedIn={isLoggedIn} />
        {button}
      </div>
    );
  }
}

这样做的好处是能够更灵活地控制组件内部的渲染,而不必去创建更多的子组件。但是其问题非常明显:创建了一个「多余」的变量,非常地不「优雅」。所谓「如无必要,勿增实体」,放在代码世界同样适用。它所做的事情也仅仅是将原本在 JSX 内部的条件判断挪到了外面,仅此而已。

想象一下这种场景:一个导航栏组件,其中的每个菜单、每个按钮都要根据某种条件去决定是否渲染,一个多余的变量就会变成几十个,最终导致代码中充斥着这样重复的、没有实际意义的垃圾,这是一个有追求的码农绝对无法忍受的。

这种方式有一个变体,就是通过创建一个类的 getter 来代替创建一个变量:

class LoginControl extends React.Component {
  //...
  get _button () {
    const isLoggedIn = this.state.isLoggedIn;
    if (isLoggedIn) {
      return <LogoutButton onClick={this.handleLogoutClick} />;
    } else {
      return <LoginButton onClick={this.handleLoginClick} />;
    }
  }
  
  render() {
    return (
      <div>
        <Greeting isLoggedIn={isLoggedIn} />
        {this._button}
      </div>
    );
  }
}

这是一个小变通,好处是可以让 render 函数在条件渲染的数量上来以后不那么臃肿,易于维护。它与使用变量的方式并没有本质区别,同样是创建了一个无必要的 getter,但是确实是看起来更「优雅」了一些。但它只能在类组件中使用,无法在函数式组件中使用。

行内表达式

行内三元表达式可以用来解决一些小的 case,但由于其本身存在着巨大的限制,不可能被广泛使用。

render() {
  const isLoggedIn = this.state.isLoggedIn;
  return (
    <div>
      The user is <b>{isLoggedIn ? 'currently' : 'not'}</b> logged in.
    </div>
  );
}

它的自身限制,一是只能做是非判断,如果要在是非的结果中继续判断就要再套一层三元表达式,那将会相当臃肿;二是它本身的语法就只适用于「小」的东西,像这种案例:

render() {
  const isLoggedIn = this.state.isLoggedIn;
  return (
    <div>
      {isLoggedIn ? (
        <LogoutButton onClick={this.handleLogoutClick} />
      ) : (
        <LoginButton onClick={this.handleLoginClick} />
      )}
    </div>
  );
}

我是绝对写不出来的。实在是太不优雅、太难以阅读了。想象一下充斥着 && / || / ? / : 的 JSX 代码。😄

至于行内的 if-else,虽然不能直接写 if-else,但有一个利用了语言本身特性的方案,即利用逻辑操作符:

function Mailbox(props) {
  const unreadMessages = props.unreadMessages;
  return (
    <div>
      <h1>Hello!</h1>
      {unreadMessages.length > 0 &&
        <h2>
          You have {unreadMessages.length} unread messages.
        </h2>
      }
    </div>
  );
}

这种方式适合用来做单方面的条件渲染,即条件成立则渲染,否则不渲染,或者反之。使用场景依然有限,同样可读性较差,只适合用来渲染较小的代码块。

而且,很多人也许不知道,这种写法在 React-Native 中是一个陷阱。由于 JavaScript 本身的特性允许(或者说鼓励)truthyfalsy 形式的条件判断,很多情况下我们不会去刻意将它显式转换为一个 Boolean 值。当然在大多数情况下这都没有问题,除非它是一个空字符串:

render() {
  const isLoggedIn = this.state.isLoggedIn;
  // when it is ''
  return (
    <View>
      {isLoggedIn && <LogoutButton/>}
    </View>
  );
}

这种代码在运行的时候会抛出一个 Error,导致应用崩溃:

Error: Text strings must be rendered within a <Text> component.

比如说,当你用某个 API 返回的数据中的某个值去进行条件渲染的时候,正常来说没有问题,但某一天服务突然出错了,这个字段返回了一个空字符串,那么应用就会突然面临大规模的崩溃。数据来源不可靠且没有进行显式 Boolean 转换的条件判断就像一个地雷,随时随地都可能会爆炸。

封装的方法

以上的几种形式其实都与刀耕火种无异,因此我们还有更高级的方案,比如封装一个方法:

function renderIf (flag) {
  return function _renderIf (componentA, componentB = null) {
    return flag ? componentA : componentB;
  };
}

这样一来代码就可以简洁多了:

class LoginControl extends React.Component {
  //...
  render() {
    const isLoggedIn = this.state.isLoggedIn;
    return (
      <div>
        <Greeting isLoggedIn={isLoggedIn} />
        {renderIf(isLoggedIn)(<LogoutButton/>, <LoginButton/>)}
      </div>
    );
  }
}

类似的方案还有封装组件:

function RenderIf ({flag, children}}) {
  return flag ? children : null;
}

class LoginControl extends React.Component {
  //...
  render() {
    const isLoggedIn = this.state.isLoggedIn;
    return (
      <div>
        <Greeting isLoggedIn={isLoggedIn} />
        <RenderIf flag={isLoggedIn}>
          <LogoutButton/>
        </RenderIf>
        <RenderIf flag={!isLoggedIn}>
          <LoginButton/>
        </RenderIf>
      </div>
    );
  }
}

这种方案避免了在渲染函数体内出现大量的逻辑控制语句,取而代之的是可读性更强,也更易于使用的方法或组件,可以认为是在 React 世界中比较好的条件渲染解决方案了,「优雅」与「好用」都得到了较好的兼顾。但是,问题就出在,它不够「可靠」。

比如说以下的代码:

class NavBar extends React.Component {
  //...
  render() {
    const user = this.state.user;
    return (
      <div>
        {renderIf(user)(<Welcome name={user.name}/>)}
      </div>
    );
  }
}

跟以上提及过的所有方案都不一样的是,当 usernullundefined,或者其它任何在执行 user.name 会报错的 value 时,这一段代码就会报错。如果是 React-Native 应用,很不幸它就崩溃了。

TypeError: Cannot read property 'name' of null

导致这个区别的原因是,JavaScript 函数在执行之前会先对它的参数进行解析,而非等到真正运行时才解析。所以当 user 为以上 value 之一的时候,虽然按照函数的流程来说应该会对第一个参数直接忽略,但是实际上它还是被解析了。而 if-else 等内置条件判断语句则不同,假值条件之后的代码块会被完全忽略。

说到这里我已经开始怀念 ng-ifv-if 了:我只是一个普普通通的开发者,为什么我需要在如此基础的事情上考虑这么多?大家都是前端 MVx 框架,为什么 Vue.js 和 Angular.js 从来没有提出过这种问题?

大概这就是 React,这就是 Facebook 吧!

其实在这个方案出现问题之后,我已经找不出别的更好的方案了,除非某一天 EcmaScript 有了新的提案,新的语法,否则都将无解。因为无论怎么做,最终都无法绕过传参必然会被事先解释这一障碍。也就是说,在目前的大环境下,我无法得到一个在各种场景下都同时兼具「优雅」、「可靠」、「好用」的条件渲染解决方案,只能通过以上各种方案在不同场景下的混用来达到一个(有追求的码农的内心的)平衡。