在 egghead.io 上 "实施反转控制"

127 阅读11分钟

在 egghead.io 上观看 "实施反转控制"

如果你曾经编写过在多个地方使用的代码,那么你可能对这个故事很熟悉:

  1. 你构建了一段可重复使用的代码(函数、React组件或React钩子等),并将其分享(给同事或作为开放源码发布)。
  2. 有人向你提出了一个新的用例,你的代码并不完全支持,但只要稍作调整就可以。
  3. 你在你的可重复使用的代码和相关的逻辑中添加一个参数/prop/option,以使该用例得到支持。
  4. 重复步骤2和3几次(或多次😬)。
  5. 现在的可重用代码已经成为使用和维护的噩梦了😭

那么到底是什么让代码成为使用和维护的噩梦呢? 有几件事可能是问题所在:

  1. 😵捆绑大小和/或性能,有更多的代码供设备运行,这可能会以负面的方式影响性能。有时,由于这些问题,人们甚至决定不研究使用你的代码,这已经够糟糕了。
  2. 😖维护开销,以前,你的可重复使用的代码只有几个选项,而且它专注于做好一件事,但现在它可以做一堆不同的事情,你需要为这些功能提供文档。此外,你会收到很多人问你关于如何在他们的特定用例中使用它的问题,这些用例可能与你已经添加支持的用例对应,也可能不对应。你甚至可能有两个功能,基本上可以做同样的事情,但略有不同,所以你会回答关于哪个是更好的方法的问题。
  3. 🐛实施的复杂性,它从来不是"仅仅一个if 语句"。你代码中的每个逻辑分支都与现有的逻辑分支复合在一起。 事实上,有些情况下,你可能会支持没有人使用的参数/选项/道具的组合,但你必须确保在添加新功能时不会中断,因为你不知道是否有人在使用这种组合。
  4. 😕API的复杂性,你为你的可重用代码添加的每一个新参数/选项/prop都会使终端用户更难使用,因为你现在有一个巨大的README/docs网站,记录了所有可用的功能,人们必须学习所有可用的功能才能有效地使用它们。使用起来也不那么愉快,因为你的API的复杂性往往会泄漏到应用开发者的代码中,使他们的代码也更加复杂。

所以,现在每个人都在为这个问题而难过。当我们开发应用程序时,运输是最重要的,这是有道理的。但我认为,如果我们能考虑到我们的抽象(读作AHA编程,并让我们的应用程序被运送出去,那将是一件很酷的事情。如果我们能做些什么来减少可重用代码的问题,同时又能获得这些抽象的好处,那就更好了。

进入反转控制

我学到的一个原则是 "反转控制",这是一个真正有效的抽象简化机制。以下是维基百科的反转控制页面对它的描述。

......在传统的编程中,表达程序目的的自定义代码会调用可重复使用的库来处理通用任务,但在控制反转中,是框架调用了自定义或特定任务的代码。

你可以把它想成这样。"让你的抽象做更少的事情,而让你的用户做这些事情"。这似乎是反直觉的,因为抽象之所以如此伟大,部分原因是我们可以在抽象中处理所有复杂和重复的任务,这样我们其余的代码就可以 "简单"、"整齐 "或 "干净"。但正如我们已经经历过的那样,传统的抽象有时并不是这样的。

什么是代码中的反转控制?

首先,这里有一个超级矫揉造作的例子。

// let's pretend that Array.prototype.filter does not exist
function filter(array) {
  let newArray = []
  for (let index = 0; index < array.length; index++) {
    const element = array[index]
    if (element !== null && element !== undefined) {
      newArray[newArray.length] = element
    }
  }
  return newArray
}

// use case:

filter([0, 1, undefined, 2, null, 3, 'four', ''])
// [0, 1, 2, 3, 'four', '']

现在,让我们通过向这个抽象抛出一堆新的相关用例来演绎典型的 "抽象的生命周期",并 "不假思索地增强 "它以支持这些新用例。

// let's pretend that Array.prototype.filter does not exist
function filter(
  array,
  {
    filterNull = true,
    filterUndefined = true,
    filterZero = false,
    filterEmptyString = false,
  } = {},
) {
  let newArray = []
  for (let index = 0; index < array.length; index++) {
    const element = array[index]
    if (
      (filterNull && element === null) ||
      (filterUndefined && element === undefined) ||
      (filterZero && element === 0) ||
      (filterEmptyString && element === '')
    ) {
      continue
    }

    newArray[newArray.length] = element
  }
  return newArray
}

filter([0, 1, undefined, 2, null, 3, 'four', ''])
// [0, 1, 2, 3, 'four', '']

filter([0, 1, undefined, 2, null, 3, 'four', ''], {filterNull: false})
// [0, 1, 2, null, 3, 'four', '']

filter([0, 1, undefined, 2, null, 3, 'four', ''], {filterUndefined: false})
// [0, 1, 2, undefined, 3, 'four', '']

filter([0, 1, undefined, 2, null, 3, 'four', ''], {filterZero: true})
// [1, 2, 3, 'four', '']

filter([0, 1, undefined, 2, null, 3, 'four', ''], {filterEmptyString: true})
// [0, 1, 2, 3, 'four']

好吧,我们的应用程序只关心六个用例,但我们实际上支持这些功能的任何组合,也就是25个(如果我的计算正确的话)。

一般来说,这是一个相当简单的抽象。我确信它可以被简化。但往往当你在时间之轮旋转了一段时间后再回到一个抽象概念时,你会发现它可以为它实际支持的用例而被大大地简化。不幸的是,一旦一个抽象支持某些东西(比如做{filterZero: true, filterUndefined: false} ),我们就不敢删除这个功能,因为害怕破坏使用我们抽象的应用程序开发人员。

我们甚至会为我们实际上没有的用例写测试,只是因为我们的抽象支持它,而且我们 "可能 "在未来需要这样做。然后,当用例不再需要时,我们不会删除对它们的支持,因为我们只是忘记了,我们认为我们将来可能会需要它们,或者我们害怕触碰代码。

好了,现在,让我们在这个函数上应用一些深思熟虑的抽象,并应用反转控制来支持所有这些用例。

// let's pretend that Array.prototype.filter does not exist
function filter(array, filterFn) {
  let newArray = []
  for (let index = 0; index < array.length; index++) {
    const element = array[index]
    if (filterFn(element)) {
      newArray[newArray.length] = element
    }
  }
  return newArray
}

filter(
  [0, 1, undefined, 2, null, 3, 'four', ''],
  el => el !== null && el !== undefined,
)
// [0, 1, 2, 3, 'four', '']

filter([0, 1, undefined, 2, null, 3, 'four', ''], el => el !== undefined)
// [0, 1, 2, null, 3, 'four', '']

filter([0, 1, undefined, 2, null, 3, 'four', ''], el => el !== null)
// [0, 1, 2, undefined, 3, 'four', '']

filter(
  [0, 1, undefined, 2, null, 3, 'four', ''],
  el => el !== undefined && el !== null && el !== 0,
)
// [1, 2, 3, 'four', '']

filter(
  [0, 1, undefined, 2, null, 3, 'four', ''],
  el => el !== undefined && el !== null && el !== '',
)
// [0, 1, 2, 3, 'four']

很好!这就简单了。我们所做的是颠倒了控制权!我们改变了决定哪一方的责任。我们把决定哪个元素进入新数组的责任从filter 函数转移到了调用filter 函数的那一个。请注意,filter 函数本身仍然是一个有用的抽象,但它更有能力。

但是,这个抽象的前一个版本就那么糟糕吗?也许不是。但因为我们颠倒了控制,我们现在可以支持更多独特的用例。

filter(
  [
    {name: 'dog', legs: 4, mammal: true},
    {name: 'dolphin', legs: 0, mammal: true},
    {name: 'eagle', legs: 2, mammal: false},
    {name: 'elephant', legs: 4, mammal: true},
    {name: 'robin', legs: 2, mammal: false},
    {name: 'cat', legs: 4, mammal: true},
    {name: 'salmon', legs: 0, mammal: false},
  ],
  animal => animal.legs === 0,
)
// [
//   {name: 'dolphin', legs: 0, mammal: true},
//   {name: 'salmon', legs: 0, mammal: false},
// ]

想象一下,在颠倒控制权之前就必须增加对这个的支持?那就太傻了......

一个更糟糕的API?

我从人们那里听到的关于我所建立的控制反转的API的常见抱怨之一是。"是的,但是现在它比以前更难用了。"就拿这个例子来说吧。

// before
filter([0, 1, undefined, 2, null, 3, 'four', ''])

// after
filter(
  [0, 1, undefined, 2, null, 3, 'four', ''],
  el => el !== null && el !== undefined,
)

是的,其中一个显然比另一个更容易使用。但关于控制转换的API的问题是,你可以用它们来重新实现以前的API,而且这样做通常是非常微不足道的。比如说。

function filterWithOptions(
  array,
  {
    filterNull = true,
    filterUndefined = true,
    filterZero = false,
    filterEmptyString = false,
  } = {},
) {
  return filter(
    array,
    element =>
      !(
        (filterNull && element === null) ||
        (filterUndefined && element === undefined) ||
        (filterZero && element === 0) ||
        (filterEmptyString && element === '')
      ),
  )
}

酷吧!?因此,我们可以在控制转换的API之上建立抽象,以提供人们正在寻找的更简单的API。更重要的是,如果我们的 "更简单 "的API不足以满足他们的使用情况,那么他们可以使用我们用来构建高层API的相同构件来完成他们更复杂的任务。他们不需要要求我们在filterWithOptions ,并等待其完成的新功能。他们有自己需要的构件,因为我们已经给了他们这样做的工具,所以他们的东西可以自己运出去。

哦,只是为了好玩。

function filterByLegCount(array, legCount) {
  return filter(array, animal => animal.legs === legCount)
}

filterByLegCount(
  [
    {name: 'dog', legs: 4, mammal: true},
    {name: 'dolphin', legs: 0, mammal: true},
    {name: 'eagle', legs: 2, mammal: false},
    {name: 'elephant', legs: 4, mammal: true},
    {name: 'robin', legs: 2, mammal: false},
    {name: 'cat', legs: 4, mammal: true},
    {name: 'salmon', legs: 0, mammal: false},
  ],
  0,
)
// [
//   {name: 'dolphin', legs: 0, mammal: true},
//   {name: 'salmon', legs: 0, mammal: false},
// ]

你可以把这些东西编成你想要的样子,以解决你所拥有的普通用例。

好吧,但现在是真的吗?

那么,这对简单的用例来说是可行的,但这个概念在现实世界中有什么用呢?好吧,你可能一直在不知不觉中使用反转控制的API。 例如,实际的 Array.prototype.filter 函数就反转控制。Array.prototype.map 函数也是如此。

还有一些你可能熟悉的模式,基本上也是一种倒置控制的形式。

我最喜欢的两种模式是"复合组件 ""状态降低器"。下面是一个关于如何使用这些模式的快速例子。

复合组件

假设你想建立一个Menu 组件,它有一个用于打开菜单的按钮和一个菜单项目的列表,当它被点击时显示。然后当一个项目被选中时,它将执行一些动作。这类组件的一个常见方法是为这些东西中的每一个创建props。

function App() {
  return (
    <Menu
      buttonContents={
        <>
          Actions <span aria-hidden></span>
        </>
      }
      items={[
        {contents: 'Download', onSelect: () => alert('Download')},
        {contents: 'Create a Copy', onSelect: () => alert('Create a Copy')},
        {contents: 'Delete', onSelect: () => alert('Delete')},
      ]}
    />
  )
}

这使得我们可以定制很多关于我们的菜单项。但是,如果我们想在Delete菜单项之前插入一行字呢?我们是否必须在项目对象中添加一个选项?比如,我不知道:precedeWithLine ?呀。也许我们会有一个特殊的菜单项,它是一个{contents: <hr />} 。我想这是可行的,但是我们必须要处理没有提供onSelect 的情况。说实话,这是个很尴尬的API。

当你考虑如何为那些试图以稍微不同的方式做事的人创建一个好的API时,与其伸手去拿if 语句和三元组,不如考虑倒置控制的可能性。在这种情况下,如果我们只是把渲染的责任交给我们的菜单的用户,会怎么样?让我们利用React最大的优势之一--可组合性。

function App() {
  return (
    <Menu>
      <MenuButton>
        Actions <span aria-hidden></span>
      </MenuButton>
      <MenuList>
        <MenuItem onSelect={() => alert('Download')}>Download</MenuItem>
        <MenuItem onSelect={() => alert('Copy')}>Create a Copy</MenuItem>
        <MenuItem onSelect={() => alert('Delete')}>Delete</MenuItem>
      </MenuList>
    </Menu>
  )
}

这里需要注意的关键是,组件的用户看不到任何状态。状态在这些组件之间是隐式共享的。这就是复合组件模式的主要价值。通过使用这种能力,我们已经把一些渲染控制权交给了我们的组件的用户,现在在这里添加一个额外的行(或其他任何东西)是非常简单和直观的。没有API文档需要查询,也没有额外的功能、代码或测试需要添加。对每个人来说都是巨大的胜利。

你可以在我的博客上阅读更多关于这种模式的信息。向教我这个模式的Ryan Florence致敬。

状态还原器

这是我为了解决组件逻辑定制的问题而想出的模式。你可以在我的博文"The State Reducer Pattern "中读到更多关于具体情况的信息,但基本要点是我有一个输入搜索/打字机/自动完成库,名为Downshift,有人正在建立一个多选版本的组件,所以他们希望即使在选择了一个元素后,菜单仍然保持打开状态。

Downshift ,我们的逻辑是,当选择后,它应该关闭。需要这个功能的人建议添加一个道具,叫做closeOnSelection 。我拒绝了这个建议,因为我以前也走过这样的,我想避免这样。

因此,我想出了一个API,让人们控制状态变化的方式。把状态还原器想象成一个函数,在组件的状态发生变化时被调用,并给应用开发者一个修改即将发生的状态变化的机会。

下面是一个例子,如果你想让Downshift在用户选择一个项目后不关闭菜单,你会怎么做?

function stateReducer(state, changes) {
  switch (changes.type) {
    case Downshift.stateChangeTypes.keyDownEnter:
    case Downshift.stateChangeTypes.clickItem:
      return {
        ...changes,
        // we're fine with any changes Downshift wants to make
        // except we're going to leave isOpen and highlightedIndex as-is.
        isOpen: state.isOpen,
        highlightedIndex: state.highlightedIndex,
      }
    default:
      return changes
  }
}

// then when you render the component
// <Downshift stateReducer={stateReducer} {...restOfTheProps} />

一旦我们添加了这个道具,我们收到的定制组件的请求就少了很多。它变得更有能力了,人们可以更简单地让它做任何他们想做的事情。

渲染道具

我只是简单地介绍一下渲染道具模式,它是控制权倒置的一个完美例子,但我们不再经常需要它们了,所以我不打算谈论它们了。

阅读为什么我们不再需要渲染道具了

注意事项

反转控制是一个很好的方法,可以避开对我们可重用代码的未来用例做出不正确的假设。但在你走之前,我只想给你一些建议。让我们快速回到我们设计好的例子。

// let's pretend that Array.prototype.filter does not exist
function filter(array) {
  let newArray = []
  for (let index = 0; index < array.length; index++) {
    const element = array[index]
    if (element !== null && element !== undefined) {
      newArray[newArray.length] = element
    }
  }
  return newArray
}

// use case:

filter([0, 1, undefined, 2, null, 3, 'four', ''])
// [0, 1, 2, 3, 'four', '']

如果我们只需要filter ,而我们从来没有遇到过除了nullundefined 之外的任何需要过滤的情况呢?在这种情况下,为一个单一的用例添加反转控制只会使代码更加复杂,而不会提供太多的价值。

就像所有的抽象一样,请深思熟虑,应用AHA编程的原则,避免仓促的抽象!

结语

我希望这对你有帮助。我已经向你展示了React社区中一些利用控制反转概念的模式。还有更多,而且这个概念不仅仅适用于React(正如我们在filter 这个例子中看到的)。下次你发现自己在你的应用程序的coreBusinessLogic 功能中又添加了一个if 语句时,请考虑如何反转控制,将逻辑移到它被使用的地方(或者如果它被用在多个地方,那么你可以为该特定用例建立一个更定制的抽象)。

如果你想玩一玩这篇博文中的例子,请随意。

Edit Inversion of Control

祝您好运!

P.S. 如果你喜欢这篇博文,那么你可能会喜欢这个讲座。