组件开发的单元素模式

组件开发的单元素模式

原文:Introducing the Single Element Pattern

作者:Diego Haz

译者:JeLewine

使用react和其他基于组件的库构建可靠基础组件的基本准则与最佳实践

早在2002年——当我开始从事web开发时,包括我在内的大多数开发者都在使用<table>标签来构建页面的布局。

到了2005年我才开始遵循web标准

当一个网站或网页被称为符合web标准时,通常意味着该网站/网页具有有效的HTML,CSS和JavaScript。HTML还应当具有可访问性和遵循语义化的准则。

我学习到了语义化与可访问性,然后开始使用正确的HTML标签和外部CSS。我非常骄傲的将W3C徽章放在了我制作的每一个站点上。

我编写的HTML代码与经过浏览器输出出来的代码几乎相同。这也意味着通过使用W3C校验器和其他工具可以帮助我写出更好的代码。

时间流逝。为了去分离前端复用的部分,我开始使用PHP,模板系统,JQuery,Polymer,Angular和React。特别是后者,这三年来我一直都在使用。

伴随着时间的推移,我们编写的代码合最终呈现出给用户的代码越来越不同。如今,我们以许多不同的方式来编写代码(例如,使用babel和typescript)。我们写的是ES2015JSX,但是最后的输出代码却仅仅是普通的HTML和JavaScript。

今天,即使我们依然可以使用W3C校验器去校验我们的网站,但是这却不会再对我们的代码编写有帮助了。我们仍然在追求最佳实践去让我们的代码具有一致性和可维护性。而且,如果你正在阅读这篇文章,我想你也在寻找同样的东西。

现在,我想给你看个大宝贝。

单元素模式(Singel)

我不知道至今为止我已经写了多少个组件。不过,如果把Polymer,Angular和React加一起,我可以打包票说:'至少超过了1000个'。

除了公司的项目,我还维护着一个带有40多个样例组件的React脚手架。此外,我还在与Raphael Thomazella合作。他也为这个项目做贡献,在一个UI库中大量使用了他们。

很多开发者都有一个误解,就是如果他们以完美的文件结构启动项目,他们就不会有问题。不过事实上,文件结构的一致性并不重要。如果你的组件不遵循一些明确定义的规则,那么你的项目最终都将会变得难以维护。

在创建和维护了这么多组件之后,我发现了一些使它们更加一致和可靠的特性,这样用起来能够让人更加舒服。一个组件如果越像HTML元素,他就会变得越可靠。

没有什么是比<div>更可靠的了

【译者注:评论有一条评论被点赞最多,非常有趣——“我信任<div>胜过我爸爸”】

当使用一个组件的时候,你应该问问你自己以下列表中一个或多个问题:

  • 问题1:如果我需要将props传递给嵌套元素会怎样?
  • 问题2:这么做有可能会出于某种原因中断程序运行么?
  • 问题3:如果我想传递id或其他HTML属性呢?
  • 问题4:我可以通过className或Style属性设置样式么?
  • 问题5:事件响应怎么处理?

可靠性意味着,在当前上下文内,不需要打开文件去查看代码来理解它是怎样工作的。例如,如果你正在使用<div>,你将会立刻知道下面问题的答案:

  • 规则1:只渲染一个元素
  • 规则2:永远不中断程序
  • 规则3:渲染所有作为属性来传递来的HTML属性
  • 规则4:始终会合并作为属性传递的样式
  • 规则5:添加所有作为属性传递的事件处理函数

这就是我们称之为单元素模式(Singel)的一组规则!

重构驱动开发

先让它跑起来,然后再使它变得更好

当然,想让所有的组件都遵循Singel是不可能的。在一些时候——事实上,在许多时候——你一定会打破第一条准则。

应当遵循这些规则的组件是应用程序中最重要的部分:原子,基石,元素或者你称之为基础组件的任何内容。在这篇文章里,我会将它们都称作单元素

这其中有一些是很容易立即抽象出来的:ButtonImageInput。换句话说,就是那些与HTML元素有直接关系的组件。在其他一些情况下,你只能在开始复用代码时认出它们。不过那没关系。

通常,每当你需要更改某个组件,添加一些新功能或修复错误时,你会看到或者开始写一些重复的样式和行为。这是将它们抽象成单元素的信号。

与其他组件相比,代码中单元素的百分比越高,你的程序会越容易维护和具有一致性。

将它们放到一个单独的文件夹中——那些元素,原子或是基石。每当你从中导入一些组件时,你就会清楚的知道它所遵循的规则。

一个例子

在这篇文章中,我将会把重点放在React上。相同的规则可以应用于任何基于组件的库之上。

举例来说,假设我们拥有一个Card组件。它由Card.jsCard.css构成,我们有.card.top-bar.avatar和其他类选择器的样式。

const Card = ({ profile, imageUrl, imageAlt, title, description }) => (
  <div className="card">
    <div className="top-bar">
      <img className="avatar" src={profile.photoUrl} alt={profile.photoAlt} />
      <div className="username">{profile.username}</div>
    </div>
    <img className="image" src={imageUrl} alt={imageAlt} />
    <div className="content">
      <h2 className="title">{title}</h2>
      <p className="description">{description}</p>
    </div>
  </div>
);

在某些时候,我们必须将头像放在应用的另一部分。我们将创建一个新的单元素Avatar,而不是复制HTML和CSS,以便我们可以重用它。

规则1:只渲染一个元素

新的AvatarAvatar.jsAvatar.css组成,它具有我们从Card.css中提取的.avatar样式。它呈现出来只是一个<img>

const Avatar = ({ profile, ...props }) => (
  <img
    className="avatar" 
    src={profile.photoSrc} 
    alt={profile.photoAlt} 
    {...props} 
  />
);

下面的例子是我们如何在Card和应用的其他部分去使用它:

<Avatar profile={profile} />

规则2:永远不会中断程序

即使你没有传递src属性,<img>也不会打断应用。即便这是一个必须的属性。不过,在我们的组件中,如果不传递profile,整个应用将会被打断。

React16提供了一个名为componentDidCatch的新的生命周期方法,可用于优雅的处理组件内的各种错误。尽管在你的应用中使用error boundaries是一种很好的做法,但它仍然可能会掩盖掉我们单元素中的错误。

我们必须要确保Avatar本身是可靠的,而且我们要假设它的父组件可能不会提供那些必要的props。在这种情况下,我们在使用profile之前需要先检查一下其是否存在。我们应该使用诸如Flow,TypescriptPropTypes之类的工具去发出警告

const Avatar = ({ profile, ...props }) => (
  <img 
    className="avatar" 
    src={profile && profile.photoUrl} 
    alt={profile && profile.photoAlt} 
    {...props}
  />
);

Avatar.propTypes = {
  profile: PropTypes.shape({
    photoUrl: PropTypes.string.isRequired,
    photoAlt: PropTypes.string.isRequired
  }).isRequired
};

现在我们可以渲染一个没有props的<Avatar />来看看控制台上它期望接收到什么:

通常,我们可能忽略掉这些警告,然后控制台的警告会越积越多。这便让PropTypes失去了它该有的作用,即使出现了一些新的警告我们可能也不会注意到。所以,请务必在事情继续恶化前解决掉这些警告。

规则3:渲染所有作为属性来传递来的HTML属性

到目前为止,我们的单元素组件使用了一个我们自定义的profile属性。我们应当避免使用这种自定义属性,特别是当他们直接映射到HTML属性时。在文章后面,我会详细的介绍这一点:避免添加自定义props。

通过将所有的props传递给底层元素,我们可以在单元素中轻易的接收所有的HTML属性。我们可以通过设置期望相映射的HTML属性来解决自定义属性的问题:

const Avatar = props => <img className="avatar" {...props} />;

Avatar.propTypes = {
  src: PropTypes.string.isRequired,
  alt: PropTypes.string.isRequired
};

现在Avatar更像一个HTML元素了:

<Avatar src={profile.photoUrl} alt={profile.photoAlt} />

这条规则同样适用于当基础元素试图渲染children的时候。

规则4:始终会合并作为属性传递的样式

在你应用的某些地方,你希望单元素组件能够具有些不同的样式。无论是使用className或者style属性,你都应当能够自定义它。

单元素的内部样式应当等同于浏览器应用在原生HTML元素身上的样式。话虽如此,我们的Avatar在接收到className属性时,不应当替换内部类名,而是应当追加上去。

const Avatar = ({ className, ...props }) => (
  <img className={`avatar ${className}`} {...props} />
);

Avatar.propTypes = {
  src: PropTypes.string.isRequired,
  alt: PropTypes.string.isRequired,
  className: PropTypes.string
};

如果我们将一个style属性添加到Avatar上,它应当很轻松的通过拓展运算符来进行添加:

const Avatar = ({ className, style, ...props }) => (
  <img 
    className={`avatar ${className}`}
    style={{ borderRadius: "50%", ...style }}
    {...props} 
  />
);

Avatar.propTypes = {
  src: PropTypes.string.isRequired,
  alt: PropTypes.string.isRequired,
  className: PropTypes.string,
  style: PropTypes.object
};

现在我们可以非常信任的将任意样式应用于我们的单元素上了。

<Avatar
  className="my-avatar"
  style={{ borderWidth: 1 }}
/>

如果你发现自己需要复用一些新样式,不要犹豫,通过组合Avatar来创建另一个单元素吧。创建一个渲染另一个单元素的单元素,是多见且很有必要的。

规则5:添加所有作为属性传递的事件处理函数

由于我们已经将所有的props传递了下去,现在我们的单元素组件已经做好了接收任意事件处理函数的准备了。不过,如果我们已经在内部添加了事件处理程序,我们该怎么办呢?

在这种情况下,我们有两个选择:我们可以完全用属性替换掉,或者同时调用它们。这取决于你,只需要确保始终是通过prop来添加事件处理。

const callAll = (...fns) => (...args) => fns.forEach(fn => fn && fn(...args));

const internalOnLoad = () => console.log("loaded");

const Avatar = ({ className, style, onLoad, ...props }) => (
  <img 
    className={`avatar ${className}`}
    style={{ borderRadius: "50%", ...style }}
    onLoad={callAll(internalOnLoad, onLoad)}
    {...props} 
  />
);

Avatar.propTypes = {
  src: PropTypes.string.isRequired,
  alt: PropTypes.string.isRequired,
  className: PropTypes.string,
  style: PropTypes.object,
  onLoad: PropTypes.func
};

建议

建议1:避免使用自定义props

当创建单元素时——尤其是在应用中开发新功能时,你会想要通过添加自定义props的方式来实现自定义配置。

Avatar为例子,一些设计师的怪癖可能会让你在某些地方展示方角,某些地方展示圆角。你也许会认为往Avatar中添加一个rounded属性是一个不错的主意。

除非你正在创建一个记录良好的开源库,否则请抵制这种行为。除了文档需要,这样还会导致它不可拓展和代码的不易维护。始终去尝试创建一个新的单元素组件——例如AvatarRounded——通过渲染Avatar并修改它,而不是添加自定义props。

如果你持续使用独特且具有描述性的名字去构建可靠的组件,你最终可能会产生数百个组件。这仍然是具有高可维护性的。这些组件的名称本身就是一个文档。

建议2:接收底层的HTML元素作为props

不是所有的自定义属性都是恶魔。你可能经常想要更改由单元素组件渲染的基础HTML元素。添加自定义属性是实现这一目标的唯一方法。

const Button = ({ as: T, ...props }) => <T {...props} />;

Button.propTypes = {
  as: PropTypes.oneOfType([PropTypes.string, PropTypes.func])
};

Button.defaultProps = {
  as: "button"
};

下面是一个将Button渲染为<a>的例子:

<Button as="a" href="https://google.com">
  Go To Google
</Button>

或者是渲染为另一个组件:

<Button as={Link} to="/posts">
  Posts
</Button>

如果你对这种功能感兴趣,我建议你可以看一眼ReaKit,这是一个基于Singel原则构建的React UI工具包。

通过使用Singel CLI来校验你的单元素组件

最后,在阅读完所有的内容之后,你可能想知道是否有工具来根据此模式来进行自动校验。我开发了一个工具,Singel CLI

如果你想要在正在开发中的项目中使用它,我建议你创建一个新的文件夹并开始将你的单元素组件放在那里。

如果你正在使用React,你可以通过npm安装singel并通过以下方式运行:

$ npm install --global singel
$ singel components/*.js

输出将会像下面那样:


另一个比较好的方法就是在项目中将其作为dev依赖项安装,并将一个脚本添加到package.json中:

$ npm install --dev singel
{
  "scripts": {
    "singel": "singel components/*.js"
  }
}

然后,只需要运行npm脚本就ok了:

$ npm run singel

编辑于 2018-07-17