美文网首页
依赖什么啊?依赖注入……,什么注入啊?

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

作者: ThoughtWorks | 来源:发表于2021-08-13 14:56 被阅读0次

    前言

    在过去的几个月里,我和客户团队在对一个设计系统进行优化。表面上看起来这个优化工作包括两大部分:性能优化和结构重整。不过经过这几个月对十多个组件的重构之后,我们发现这两部分工作在很大程度上是同一件事的两个方面:好的设计往往可以带来更好的性能,反之亦然。

    这是一个非常有趣的发现,我们在讨论性能优化的时候,一个经常被忽略的因素恰恰是软件本身的设计。我们会关注文件大小,是否会有多重渲染,甚至一些细节如CSS selector的优先级等等,但是很少为了性能而审视代码的设计。另一方面,如果一个组件写的不符合S.O.L.I.D原则,我们会认为它的可扩展性不够好,或者由于文件体量过大,且职责不清而变得难以维护,但是往往不会认为糟糕的设计会对性能造成影响(也可能是由于性能总是在实现已经完成之后才被注意到)。

    为了更好的说明这个问题,以及如何在实践中修改我们的设计,使得代码更可能具有比较优秀的性能,我们可以一起讨论几个典型的例子。

    头像组件Avatar

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

    image

    在实现中,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><wbr>date返回false, 则在编辑器的右侧会有一个错误消息弹框出现。

    image

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

    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><wbr>it的话,会发现该方式在此处行不通:和Avatar于Tooltip间松散的关系不同,Inline</wbr><wbr>EditInlineDialog的有紧密的关联关系:仅当InlineEdit处于invalid时,InlineD</wbr><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><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><wbr>og的直接引用。在上述InlineEdit代码中我们可以看到</wbr><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><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><wbr>ineEdit使用了@atlaskit/inline-</wbr><wbr>dialog也关闭了使用Popover的可能性。

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

    </wbr>

    相关文章

      网友评论

          本文标题:依赖什么啊?依赖注入……,什么注入啊?

          本文链接:https://www.haomeiwen.com/subject/zlodbltx.html