其他技巧

提取组件中的业务逻辑

编写 React 组件时我们往往会将业务逻辑相关的代码封装在组件类内,譬如笔者在 fractal-components 组件库中实现的简单展示金额信息的组件:

class Money extends Component {
  static propTypes = {
    currency: PropTypes.string.isRequired,
    amount: PropTypes.number.isRequired
  };

  getCurrencyData() {
    return {
      CNY: { base: 100, symbol: "¥" }
      // ...
    }[this.props.currency];
  }

  formatAmount(amount, base) {
    return parseFloat(amount / base).toFixed(2);
  }

  render() {
    const currency = this.getCurrencyData();
    // ...
  }
}

组件本身只是用来进行界面渲染与交互处理的,而在上面的 Money 组件中,为了对于传入的金额信息进行格式化,内置了 getCurrencyData 与 formatAmount 这两个辅助函数。getCurrencyData 根据输入的金额类型返回不同的基准值与金额符号,实际上随着应用支持国家或语言的增加,该函数会不断地增长;因此该函数是可以被独立提取出来到单独的逻辑文件中。而 formatAmount 用于针对金额的类型与大小进行合适的格式化,可以预见同样随着支持语言的增加我们需要不断扩充该函数。另一方面,笔者在函数式编程的介绍中也提及,纯函数相较于类方法更容易进行单元测试,因此我们将这两个函数提取出来不仅能实践单一职责原则,保证组件类的可读性;还能更方便地编写这些逻辑函数的单元测试用例。我们首先将这两个函数提取到单独的 logic.js 文件中:

export const getCurrencyData = currency => {
  return {
    // ...
  }[currency];
};

export const formatAmount = (amount, base) => {
  return parseFloat(amount / base).toFixed(2);
};

然后可以编写针对这两个函数的测试用例:

test("it formats the amount to 2 dp", () => {
  expect(formatAmount(2000, 100)).toEqual("20.00");
});

test("respects the base", () => {
  expect(formatAmount(2000, 10)).toEqual("200.00");
});

test("it deals with decimal places correctly", () => {
  expect(formatAmount(2050, 100)).toEqual("20.50");
});

test("for GBP it returns the right data", () => {
  expect(getCurrencyData("GBP")).toEqual({
    base: 100,
    symbol: "£"
  });
});

此时 Money 组件中仅保留了单独的 render 方法,我们自然可以将该组件重构为无状态函数式组件,在进行逻辑分割的同时提升了组件性能:

const Money = ({ currency, amount }) => {
  const currencyData = getCurrencyData(currency);
  // ...
};

Money.propTypes = {
  currency: PropTypes.string.isRequired,
  amount: PropTypes.number.isRequired
};

软件开发的工程本身就是不断地重构,通过将组件中的业务逻辑处理流程提取为独立函数,我们不仅简化了组件本身的代码,还同时提升了测试覆盖率与组件的性能。在;而在代码可读性提升的同时,我们也更容易发现

重构冗余代码

在开发的过程中我们往往会存在大量的复制粘贴代码的行为,这一点在项目的开发初期尤其显著;而在项目逐步稳定,功能需求逐步完善之后我们就需要考虑对代码库的优化与重构,尽量编写清晰可维护的代码。好的代码往往是在合理范围内尽可能地避免重复代码,遵循单一职责与 Single Source of Truth 等原则,本部分我们尝试使用 jsinspect 对于代码库进行自动检索,根据其反馈的重复或者近似的代码片进行合理的优化。

当然,我们并不是单纯地追求公共代码地完全剥离化,过度的抽象反而会降低代码的可读性与可理解性。jsinspect 利用 babylon 对于 JavaScript 或者 JSX 代码构建 AST 语法树,根据不同的 AST 节点类型,譬如 BlockStatement、VariableDeclaration、ObjectExpression 等标记相似结构的代码块。我们可以使用 npm 全局安装 jsinspect 命令:

Usage: jsinspect [options] <paths ...>

Detect copy-pasted and structurally similar JavaScript code
Example use: jsinspect -I -L -t 20 --ignore "test" ./path/to/src

Options:
  -h, --help output usage information
  -V, --versionoutput the version number
  -t, --threshold <number> number of nodes (default: 30)
  -m, --min-instances <number> min instances for a match (default: 2)
  -c, --config path to config file (default: .jsinspectrc)
  -r, --reporter [default|json|pmd]specify the reporter to use
  -I, --no-identifiers do not match identifiers
  -L, --no-literalsdo not match literals
  -C, --no-color disable colors
  --ignore <pattern> ignore paths matching a regex
  --truncate <number>length to truncate lines (default: 100, off: 0)

我们也可以选择在项目目录下添加 .jsinspect 配置文件指明 jsinspect 运行配置:

{
  "threshold": 30,
  "identifiers": true,
  "literals": true,
  "ignore": "test|spec|mock",
  "reporter": "json",
  "truncate": 100
}

在配置完毕之后,我们可以使用 jsinspect -t 50 --ignore "test" ./path/to/src 来对于代码库进行分析,以笔者找到的某个代码库为例,其检测出了上百个重复的代码片,其中典型的代表如下所示。可以看到在某个组件中重复编写了多次密码输入的元素,我们可以选择将其封装为函数式组件,将 labelhintText 等通用属性包裹在内,从而减少代码的重复率。

Match - 2 instances

./src/view/main/component/tabs/account/operation/login/forget_password.js:96,110
return <div className="my_register__register">
  <div className="item">
  <Paper zDepth={2}>
  <EnhancedTextFieldWithLabel
  label="密码"
  hintText="请输入密码,6-20位字母,数字"
  onChange={(event, value)=> {
  this.setState({
  userPwd: value
  })
  }}
  />
  </Paper>
  </div>
  <div className="item">

./src/view/main/component/tabs/my/login/forget_password.js:111,125
return <div className="my_register__register">
  <div className="item">
  <Paper zDepth={2}>
  <EnhancedTextFieldWithLabel
  label="密码"
  hintText="请输入密码,6-20位字母,数字"
  onChange={(event, value)=> {
  this.setState({
  userPwd: value
  })
  }}
  />
  </Paper>
  </div>
  <div className="item">

笔者也对于 React 源码进行了简要分析,在 246 个文件中共发现 16 个近似代码片,并且其中的大部分重复源于目前基于 Stack 的调和算法与基于 Fiber 重构的调和算法之间的过渡时期带来的重复,譬如:

// ./src/renderers/dom/fiber/wrappers/ReactDOMFiberTextarea.js:134,153
  var value = props.value;
  if (value != null) {
  // Cast `value` to a string to ensure the value is set correctly. While
  // browsers typically do this as necessary, jsdom doesn't.
  var newValue = '' + value;


  // To avoid side effects (such as losing text selection), only set value if changed
  if (newValue !== node.value) {
  node.value = newValue;
  }
  if (props.defaultValue == null) {
  node.defaultValue = newValue;
  }
  }
  if (props.defaultValue != null) {
  node.defaultValue = props.defaultValue;
  }
},

postMountWrapper: function(element: Element, props: Object) {

// ./src/renderers/dom/stack/client/wrappers/ReactDOMTextarea.js:129,148
  var value = props.value;
  if (value != null) {
  // Cast `value` to a string to ensure the value is set correctly. While
  // browsers typically do this as necessary, jsdom doesn't.
  var newValue = '' + value;


  // To avoid side effects (such as losing text selection), only set value if changed
  if (newValue !== node.value) {
  node.value = newValue;
  }
  if (props.defaultValue == null) {
  node.defaultValue = newValue;
  }
  }
  if (props.defaultValue != null) {
  node.defaultValue = props.defaultValue;
  }
},

postMountWrapper: function(inst) {

笔者认为在新特性的开发过程中我们不一定需要时刻地考虑代码重构,而是应该相对独立地开发新功能。最后我们再简单地讨论下 jsinspect 的工作原理,这样我们可以在项目需要时自定义类似的工具以进行特殊代码的匹配或者提取。jsinspect 的核心工作流可以反映在 inspector.js 文件中:

// ...
this._filePaths.forEach(filePath => {
  var src = fs.readFileSync(filePath, { encoding: "utf8" });
  this._fileContents[filePath] = src.split("\n");
  var syntaxTree = parse(src, filePath);
  this._traversals[filePath] = nodeUtils.getDFSTraversal(syntaxTree);
  this._walk(syntaxTree, nodes => this._insert(nodes));
});

this._analyze();
// ...

上述流程还是较为清晰的,jsinspect 会遍历所有的有效源码文件,提取其源码内容然后通过 babylon 转化为 AST 语法树,某个文件的语法树格式如下:

Node {
  type: 'Program',
  start: 0,
  end: 31,
  loc:
 SourceLocation {
 start: Position { line: 1, column: 0 },
 end: Position { line: 2, column: 15 },
 filename: './__test__/a.js' },
  sourceType: 'script',
  body:
 [ Node {
 type: 'ExpressionStatement',
 start: 0,
 end: 15,
 loc: [Object],
 expression: [Object] },
 Node {
 type: 'ExpressionStatement',
 start: 16,
 end: 31,
 loc: [Object],
 expression: [Object] } ],
  directives: [] }
{ './__test__/a.js': [ 'console.log(a);', 'console.log(b);' ] }

其后我们通过深度优先遍历算法在 AST 语法树上构建所有节点的数组,然后遍历整个数组构建待比较对象。这里我们在运行时输入的 -t 参数就是用来指定分割的原子比较对象的维度,当我们将该参数指定为 2 时,经过遍历构建阶段形成的内部映射数组 _map 结构如下:

{
  "uj3VAExwF5Avx0SGBDFu8beU+Lk=": [[[Object], [Object]], [[Object], [Object]]],
  "eMqg1hUXEFYNbKkbsd2QWECLiYU=": [[[Object], [Object]], [[Object], [Object]]],
  "gvSCaZfmhte6tfnpfmnTeH+eylw=": [[[Object], [Object]], [[Object], [Object]]],
  "eHqT9EuPomhWLlo9nwU0DWOkcXk=": [[[Object], [Object]], [[Object], [Object]]]
}

如果有大规模代码数据的话我们可能形成很多有重叠的实例,这里使用了 _omitOverlappingInstances 函数来进行去重;譬如如果某个实例包含节点 abcd,另一个实例包含节点组 bcde,那么会选择将后者从数组中移除。另一个优化加速的方法就是在每次比较结束之后移除已经匹配到的代码片:

_prune(nodeArrays) {
  for (let i = 0; i < nodeArrays.length; i++) {
    let nodes = nodeArrays[i];
    for (let j = 0; j < nodes.length; j++) {
      this._removeNode(nodes[j]);
    }
  }
}

避免深层嵌套

随着代码库中组件数量与对应的 Props 类型的增长,我们也需要寻求好的模式以在父子组件之间传递 Props。在基于组件的开发模式中,我们往往通过将多个相同逻辑领域内的组件组合为单一父组件,并且将外部的函数、属性依赖传递给父组件,再由父组件传递给特定子组件的方式来进行模块封装。譬如在经典的 TodoList 项目中,我们需要在 TodoListPage 组件中展示待做事项列表,并且还需要提供筛选、新增待做事项等功能。出于单一职责原则,我们需要将不同粒度的功能切分到不同的子组件中;而具体的待做事项列表、过滤添加函数等逻辑代码则通过 Props 传递给 TodosListPage 组件。TodosListPage 组件的原型可以声明如下:

class TodosListPage extends Component {

 componentDidMount() {
  this.props.fetchTodos();
 }

 render() {
  const {
     todos,
     newTodoText,
     addTodo,
     filterText,
     setFilterText
  } = this.props;

  return (
     <div>
      <AddTodoForm newTodoText={newTodoText} addTodo={addTodo}/>
      <TodosFilter filterText={filterText}
      setFilterText={setFilterText}/>
      <TodoList todos={todos}
     </div>
  );
 }
}

上述代码中 TodosListPage 仅负责将 AddTodoForm、TodosFilter、TodoList 这几个组件组合,而具体的事务皆由这三个组件完成。这种模式存在的问题在于每次我们想为子组件添加新的属性,我们都必须为其父组件添加该属性,然后确保一层一层地传递下去。如果是嵌套层次更多的组件树,这种模式可扩展性与可维护性自然更差。我们可以考虑不再将每个属性独立地传递给父组件或者由父组件传递给子组件,而是将某个子组件需要的属性包裹到单一的对象中,然后可以使用对象解构语法传递给子组件。而对于属性的校验则可以直接校验是否负责子组件声明的 propTypes 类型,示例代码如下:

class TodosListPage extends Component {
  render() {
    const { addTodoForm, todosList, todosFilter } = this.props;
    return (
      <div>
          <AddTodoForm {...addTodoForm} />
          <TodosFilter {...todosFilter} />
          <TodoList {...todosList} /> 
      </div>
    );
  }
  componentDidMount() {
    this.props.fetchTodos();
  }
}
上一页
下一页