首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >依赖什么啊?依赖注入……,什么注入啊?

依赖什么啊?依赖注入……,什么注入啊?

作者头像
ThoughtWorks
发布2021-07-29 14:06:35
1.8K0
发布2021-07-29 14:06:35
举报
文章被收录于专栏:ThoughtWorksThoughtWorks

前言

在过去的几个月里,我和客户团队在对一个设计系统进行优化。表面上看起来这个优化工作包括两大部分:性能优化和结构重整。不过经过这几个月对十多个组件的重构之后,我们发现这两部分工作在很大程度上是同一件事的两个方面:好的设计往往可以带来更好的性能,反之亦然。这是一个非常有趣的发现,我们在讨论性能优化的时候,一个经常被忽略的因素恰恰是软件本身的设计。我们会关注文件大小,是否会有多重渲染,甚至一些细节如CSS selector的优先级等等,但是很少为了性能而审视代码的设计。另一方面,如果一个组件写的不符合S.O.L.I.D原则,我们会认为它的可扩展性不够好,或者由于文件体量过大,且职责不清而变得难以维护,但是往往不会认为糟糕的设计会对性能造成影响(也可能是由于性能总是在实现已经完成之后才被注意到)。为了更好的说明这个问题,以及如何在实践中修改我们的设计,使得代码更可能具有比较优秀的性能,我们可以一起讨论几个典型的例子。

头像组件Avatar

在这个设计系统较早的一个版本中,头像Avatar组件有一个很方便的功能:如果给Avatar传入了name属性,则当鼠标悬停到头像时,头像下方会显示一个提示信息(Tooltip),内容为对应的name。

在实现中,Avatar使用了另一个组件Tooltip来完成这个功能:import Tooltip from "@atlaskit/tooltip";

const Avatar = (props) => {
  if (props.name) {
    return (
      <Tooltip content={props.name}>
        <Circle>
          <img src={props.url} />
        </Circle>
      </Tooltip>
    );
  }

  return (
    <Circle>
      <img src={props.url} />
    </Circle>
  );
};

这个功能本身并没有问题,不过当用户提出更多的需求后,我们就开始失去了对Avatar的控制。比如用户A希望鼠标悬停的时候,Tooltip可以显示在头像的上方。而用户B则希望可以定制Tooltip的背景色/字体/字号等等。当然,我们可以开放一些新的参数给Avatar来实现这些需求,比如:

<Avatar
  tooltipPosition="top"
  tooltipBackgroundColor="blue"
  tooltipColor="whitesmoke"
/>;

或者更进一步,开放一个选项对象:

<Avatar
  tooltipProps={{
    position: "top",
    backgroundColor: "blue",
    color: "whitesmoke",
  }}
/>;

然后在实现中我们将其透传给Tooltip组件。不过很快我们会发现这样的方式会带来一些问题:

  • 由于Avatar依赖于Tooltip,打包后文件的尺寸会增加
  • 如果用户需要以新的方式定制Tooltip,Avatar的接口也需要相应的更新
  • 由于这个依赖,当Tooltip的API变化时,Avatar需要重新打包

而如果我们审视Avatar组件的话,会发现Tooltip对其核心功能(显示用户头像)来说,更像是起到了辅助作用,而并非不可或缺。比如,假设不使用Tooltip组件,我们可以把Avatar简化为:

const Avatar = (props) => (
  <Circle>
    <img src={props.url} title={props.name || ""} />
  </Circle>
);

那么除了用户体验的不一致外,并不影响使用。这时候我们就应该考虑是否可以将Tooltip和Avatar两个组件彻底分开。并将是否使用Tooltip交给最终的使用者来决定。也就是说,Avatar通过更加可组合的方式,将Tooltip从依赖中删除,最终的代码就变成了:

import Avatar from "@atlaskit/avatar";
import Tooltip from "@atlaskit/tooltip";
const MyAvatar = (props) => (
  <Tooltip
    content="Juntao Qiu"
    position="top"
    css={{ color: "whitesmoke", backgroundColor: "blue" }}
  >
    <Avatar
      name="Juntao Qiu"
      url="https://avatars.githubusercontent.com/u/122324"
    />
  </Tooltip>
);

初略看起来这段代码好像和最初的代码没有太大差异,不过注意这里的代码片段是Avatar的消费者写的,也就是说,Avatar组件本身不再知道(也不需要知道)Tooltip的存在。如果需要,上面的代码还可以修改为:

import Avatar from "@atlaskit/avatar";
import Tooltip from "@material-ui/core/Tooltip";
const MyAvatar = (props) => (
  <Tooltip title="Juntao Qiu" placement="top" classes={...}>
    <Avatar
      name="Juntao Qiu"
      url="https://avatars.githubusercontent.com/u/122324"
    />
  </Tooltip>
);

也就是说,对于消费者来说Tooltip不再是一个绑定在Avatar中的黑盒。这种更加可组合的方式有这样一些好处:

  • 对于单个库来说,体积更小
  • 对于消费者来说,更容易按需定制(比如可以选择默认不引入Tooltip)
  • 不再绑定到某一个Tooltip的具体实现上,可以替换成其他库(比如上述material-ui中的Tooltip)

事实上,这种场景在我们的整改中遇到了很多。比如接下来我们要看的另一个类似的例子:内联编辑器inline<wbr>-edit中的校验错误弹框(invalid dialog)

内联编辑器(Inline Edit)

内联编辑器(inline edit)是另一个在很多产品中都在使用的组件,通过它你可以在页面中对内容进行实时编辑并保存。从根本上来说,它相当于只有一个字段的表单。在以前的版本中,该组件提供了这样一个功能:如果提供了validate函数,那么用户每一次输入都会触发validate函数,如果vali<wbr>date返回false, 则在编辑器的右侧会有一个错误消息弹框出现。

而实现的逻辑大约是这样的:

import InlineDialog from "@atlaskit/inline-dialog";
const InlineEdit = (props) => {
  const { validate, editView } = props;
  return (
    <Field>
      {({ fieldProps, error }) => (
        <div>
          {editView(fieldProps)}
          {validate && (
            <InlineDialog
              isOpen={fieldProps.isInvalid}
              placement="right"
              content={<span>{error}</span>}
            />
          )}
        </div>
      )}
    </Field>
  );
};

注意此处的editView是一个会返回一个ReactNode的函数,用户可以自定义此处的editView。和Avatar的例子相似,这里对InlineDialog组件的使用事实上阻断了其使用其他组件的可能性。如果我们通过类似对Avatar改造的方式重构InlineEd<wbr>it的话,会发现该方式在此处行不通:和Avatar于Tooltip间松散的关系不同,Inline<wbr>EditInlineDialog的有紧密的关联关系:仅当InlineEdit处于invalid时,InlineD<wbr>ialog才需要显示,默认情况则不显示。也就是说,我们无法简单的将其重构为:

import InlineDialog from "@atlaskit/inline-dialog";
const MyEdit = () => {
  return (
      <InlineDialog content={} isOpen={} placement="top">
      <InlineEdit
          editView={(fieldProps) => <Textfield {...fieldProps} />}
        validate={(value) => {
          return false;
        }}
      />
    </InlineDialog>
  );
};

因为作为父节点,InlineDialog无法获知其子节点的状态(当然可以通过context来传递状态,不过那样又会失去组件的通用性)。虽然关联关系无法忽略,但是我们还是可以将具体的InlineDialog消除掉,换成一个针对如果出错了怎么办的抽象的操作。

方案1

事实上,我们在此处关注的是:如果定义了校验函数, 而且如果校验失败,则触发一个行为。这个行为既可以是在控制台上打印一个错误语句,也可以是使用浏览器的alert,也可以是任意其他用户定义的组件。我们姑且称这个行为定义为一个叫做invalidView的函数,这个函数接受isInvalid(是否校验失败)状态,以及一个error(错误消息)字符串。她的签名是这样的:invalidView: (isInvalid: boolean, error: string) => React.ReactNode;这样我们可以在InlineEdit中消除对InlineDia<wbr>log的直接使用:

const InlineEdit = (props) => {
  const { validate, editView, invalidView } = props;
  return (
    <Field>
      {({ fieldProps, error }) => (
        <div>
          {editView(fieldProps)}
          {validate && invalidView(isInvalid, error)}
        </div>
      )}
    </Field>
  );
};

最终的消费者可以选择使用何种组件来实现错误处理:import InlineDialog from "@atlaskit/inline-dialog"; //注意InlineDialog为最终消费者引入

const MyEdit = () => {
  return (
    <InlineEdit
        editView={(fieldProps) => <Textfield {...fieldProps} />}
      validate={(value) => {
        return false;
      }}
      invalidView={(isInvalid, error) => (
        <InlineDialog isOpen={isInvalid} content={error} placement="top" />
      )}
    />
  );
};

由于invalidView理论上可以是任何组件,那么关于校验失败弹框(或者其他UI)就有无限的可能性。

方案2

除此之外,我们还可以通过其他方式来消除对InlineDial<wbr>og的直接引用。在上述InlineEdit代码中我们可以看到<wbr>editView函数本身就是设计非常通用的视图函数:

editView: (fieldProps: FieldProps) => React.ReactNode;

如果我们可以将其略加扩展:将isInvalid和error传递给函数editView:

const InlineEdit = (props) => {
  const { validate, editView } = props;
  return (
    <Field>
      {({ fieldProps, isInvalid, error }) => (
        <div>
          {editView(fieldProps, isInvalid, error)}
        </div>
      )}
    </Field>
  );
};

这样用户在传入editView时,只需要包装一个Inline<wbr>Dialog(或者其他UI组件)即可:

import InlineDialog from "@atlaskit/inline-dialog";

const MyEdit = () => {
  return (
    <InlineEdit
      editView={(fieldProps, isInvalid, error) => (
        <InlineDialog isOpen={isInvalid} content={error} placement="top">
          <Textfield {...fieldProps} />
        </InlineDialog>
      )}
      validate={(value) => {
        return false;
      }}
    />
  );
};

当然,此处的InlineDialog完全可以替换为material ui中的Popover:

import InlineDialog from "@atlaskit/inline-dialog";

import Popover from "@material-ui/core/Popover";

import Typography from "@material-ui/core/Typography";

const MyEdit = () => {
  return (
    <InlineEdit
      editView={(fieldProps, isInvalid, error) => (
        <Popover open={isInvalid}>
          <Typography>{error}</Typography>
          <Textfield {...fieldProps} />
        </Popover>
      )}
      validate={(value) => {
        return false;
      }}
    />
  );
};

或者用户可以用其他方式来消费此处的错误消息:

const MyEdit = () => {
  return (
    <InlineEdit
      editView={(fieldProps, isInvalid, error) => {
        if (isInvalid) {
          console.log(error);
        }

        return (<Textfield {...fieldProps} />);
      }}
      validate={(value) => {
        return false;
      }}
    />
  );
};

不论是方案1还是方案2,我们所做的都是尽量让组件尽可能不感知错误处理/相应,而把这个决定交还给组件的消费者。这样做的好处就是让组件对错误处理的方式更加开放(而不是通过引入一个具体实现而关闭其他选项),而客观上由于我们不再引入一个额外的组件,组件本身的尺寸会减小,而随着代码的简化,逻辑本身出错的几率也会随之降低。

总结

通过上面的两个例子,我们大约可以得出这样的结论:在代码中,一旦选择了某种具体(一个抽象的具体实现),你就不可避免的关闭了使用其他替代品的可能性。比如在Avatar中使用@atlaskit/tooltip,那么最终的消费者就不能使用其他的Tooltip组件,而Inl<wbr>ineEdit使用了@atlaskit/inline-<wbr>dialog也关闭了使用Popover的可能性。

事实上,一旦我们识别出问题所在,解决方案其实非常简单。对于可以完全将辅助性功能的剥离(如Tooltip之于Avatar)的情况,我们只需要将其移出本组件即可。而对于这些要移除的组件与本组件有关联关系的情况,我们则需要修改代码使其依赖于抽象,而不是具体的实现。这样才可以最大程度的降低依赖,提高灵活性。


- 相关阅读 -

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2021-07-27,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 ThoughtWorks洞见 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 方案1
  • 方案2
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档