100 行实现一个搜索选择组件

前言

前一阵子遇到了个需求:商户与其账户是一对多的,因此需要分两个表(商户 & 账户),先创建商家再为其创建账户,账户创建需要商家的 id。

table-relationship

对于这类需求,之前一直用 Modal、Table 和 Button 组合的 antd 自定义表单控件来完成:

import store

可仔细一想,以后商户量特别大怎么办,难道要一页一页人工去找么?不行,至少得在表格上加个搜索,但这样组件过于复杂,不利于复用 & 维护。于是又翻了翻 ant design 的文档,看到了两个 Select 组件的例子:

search & select

搜索可选项 & 单选,远程搜索 & 多选

嗯...结合一下就成了:远程搜索 & 单选组件。

其实这个组件做起来不难,但在设计与实现中遇到了几个有意思的问题,因此想把这些问题分享出来,希望能引起大家的思考。

Demo

Note:

Demo 内的资源较大,用流量的同学请谨慎打开。JSFiddle 里的 babel parser 在 iOS9 的 Safari 会报错,Android 及 iOS10 都可以运行,无法正常显示的同学请用 PC 端访问 https://fiddle.jshell.net/SebastianBlade/3nr3wwug/

100 lines SearchSelect Comonent - JSFiddle

SearchSelect 组件实现

JS 语法环境:

其中 stage-2 包含:

后续的代码中用到了目前(2017.6.22)实验性的 ES 特性:Class property、async await 和 Decorator。

一、组件功能

  • 远程搜索关键词;
  • placeholder/size/style/value 属性传递;
  • Option 组件显示商家 logo 和名称;
  • 搜索防抖:用 debounce 函数来避免大量重复请求;
  • 搜索请求取消:用 axios.CancelToken 来取消无用的请求。

二、基本的 antd 自定义表单控件

为了能让组件配合 ant design 的表单验证功能,我们需要参考 ant design 文档中的自定义表单控件 来实现。

1. options

假设服务端搜索接口返回的数据为:

[{
  "id": 100001,
  "logo": "https://static.example.com/imgs/J2ahNQ.jpg",
  "name": "Chrome"
}, {
  "id": 100002,
  "logo": "https://static.example.com/imgs/HxG29a.jpg",
  "name": "Safari"
}, {
  "id": 100003,
  "name": "Null"
}]

options 的 render 函数为:

const nameStyle = { marginLeft: 8 }  
const optionStyle = {  
  display: 'flex',
  alignItems: 'center'
}

const options = array.map(item => (  
  <Option key={item.id}>
    <div style={optionStyle}>
      {
        item.logo
          ? <Avatar src={item.logo} size='small' />
          : <Avatar icon='shop' size='small' />
      }
      <span style={nameStyle}>{item.name}</span>
    </div>
  </Option>
))

option

Option 由 Avatar 组件和 flex 样式组合

2. 自定义表单控件


点击展开组件代码

import { Select, Spin, Avatar } from 'antd'  
import axios, { CancelToken } from 'antd'  
import _debounce from 'lodash.debounce'

const { Option } = Select  
const loadingStyle = {  
  display: 'flex',
  justifyContent: 'center'
}
const nameStyle = { marginLeft: 8 }  
const optionStyle = {  
  display: 'flex',
  alignItems: 'center'
}

class SearchSelect extends React.Component {  
  static defaultProps = {
    placeholder: '输入关键字搜索'
  }

  state = {
    value: null,
    loading: false,
    data: []
  }

  constructor (props) {
    super(props)

    this.state.value = props.value ? [props.value] : []
  }

  componentWillReceiveProps (nextProps) {
    if ('value' in nextProps) {
      const value = nextProps.value ? [nextProps.value] : []
      this.setState({ value })
    }
  }

  onSearch = value => {
    console.log(value)
  }

  onChange = value => {
    const { onChange } = this.props
    if (typeof onChange === 'function') onChange(value)
  }

  renderOptions () {
    return this.state.data.map(item => (
      <Option key={item.id}>
        <div style={optionStyle}>
          {
            item.logo
              ? <Avatar src={item.logo} size='small' />
              : <Avatar icon='shop' size='small' />
          }
          <span style={nameStyle}>{item.name}</span>
        </div>
      </Option>
    ))
  }

  getNotFoundContent () {
    if (!this.state.loading) return null
    return <div style={loadingStyle}><Spin size='small' /></div>
  }

  render () {
    const { placeholder, style, size } = this.props

    return (
      <Select
        showSearch
        filterOption={false}
        defaultActiveFirstOption={false}
        value={this.state.value}
        style={style}
        size={size}
        placeholder={placeholder}
        notFoundContent={this.getNotFoundContent()}
        onSearch={this.onSearch}
        onChange={this.onChange}
      >
        {this.renderOptions()}
      </Select>
    )
  }
}

简单介绍下 Select 的几个特殊 props:

  • showSearch:使单选模式可搜索;
  • filterOption:筛选 options 值;
  • defaultActiveFirstOption:默认高亮第一个选项;
  • notFoundContent:当下拉列表为空时显示的内容。

注意:

constructorcomponentWillReceiveProps 里修改 value,用 props.value ? [props.value] : [] 而不是 props.value || '' 是有原因的:
尽管 Select 组件的 mode 为空,但 value 无论是 ''/null/undefined 都会使 placeholder 隐藏。这是 rc-select 在初始化 value 时的逻辑问题,用空数组可以避免,有兴趣的同学可以提个 issue。

三、搜索方法

搜索要实现两个 feature:

  • 防抖:避免频繁输入(onChange)时带来的重复请求;
  • 中断请求:尽管有防抖,如果在网络较差的环境下,且用户输入过慢(超过防抖时间)仍会有多余请求,为了避免这些多余请求(成功)的后续副作用,需要中断先前的多余请求。

1. 防抖装饰器

关于 JS 装饰器和介绍,可以看这篇文章:Decorators in ES7

function debounce (wait) {  
  return function debounceDecorator (target, key, descriptor) {
    descriptor.value = _debounce(descriptor.value, wait)
    return descriptor
  }
}

用函数工厂生成防抖装饰器

debounce(600) 会返回装饰器函数,我们再用 @debounce(600) 来装饰类方法,即可修改为有防抖功能的类方法。

2. 装饰器的问题

写 "babel script"1 的时候,我偏爱用 Class properties 来给方法赋值(懒得写 bind),然而在用 @debounce(600) 修饰时,发现被修饰的方法没有任何防抖效果……

class Foo {  
  @debounce(600)
  bar = _ => {
    // 调用 this.bar() 不会延时
  }
}

朋友讨论了一下,发现是使用 Class properties 造成的问题,先看一看 decorators 的概要:

Decorators make it possible to annotate and modify classes and properties at design time.
装饰器可以在类的设计阶段注解/修改类及其属性。

—— wycats/javascript-decorators

类的「设计阶段」包含类的声明、初始化和分配阶段,装饰器的运作方式大致可以分解为(下面做法不完善,实际实现较复杂,可以参考 babel 转换的代码):

// Syntax
class Foo {  
  @debounce(600)
  bar () { }
}
// 解语法糖 (ES6)
let Foo = (function () {  
  function Foo () { }
  Foo.prototype.bar = function bar () { }

  let descriptor = debounce(600)(
    Foo.prototype,
    'bar',
    descriptor = Object.getOwnPropertyDescriptor(Foo.prototype, 'bar')
  ) || descriptor

  if (descriptor) Object.defineProperty(Foo.prototype, 'bar', descriptor)
  return Foo
}())
  1. 声明 Foo 函数;
  2. 在其原型链上增加属性名为 bar 的函数;
  3. Object.getOwnPropertyDescriptor 取得 Foo.prototypebar 属性的描述符;
  4. 用装饰器修饰描述符,并返回这个新的描述符;
  5. 若描述符确实存在,修改原型上属性 bar 的描述符。

Note:
class 和 let/const 一样,不会进行声明提升(Declaration hoisting),根本原因是在语法设计时考虑到声明提升坑比较多,为避免用户出错,因此将声明与初始化阶段解耦,并增加临时死区(Temporal dead zone)来禁止对变量的访问,具体可见这篇文章 JavaScript variables lifecycle: why let is not hoisted

而 Class properties 经过 babel 转换后为: class properties

这表明 Class properties 语法只会在类的实例化阶段执行,所以 @debounce(600) 收到的只是个 valueundefined 的描述符(descriptor),且返回的也是相同的描述符,不会对类属性产生任何修饰效果。

因此正确做法应该是:停止使用 Class properties 语法,在构造器中用 bind 绑定方法上下文,代码如下

class Foo {  
  constructor () {
    this.bar = this.bar.bind(this)
  }  

  @debounce(600)
  bar () { this }
}

题外话:
推荐大家用 Airbnb 的规范:为每个需要用到 this 的方法手动 bind(this)(高瞻远瞩爱彼迎 😂),不过,我还是会继续使用 Standard Style。

3. async await 与 axios

真正应用 fetch 还需要很多封装,因此我选择用 axios 作为请求库。

其实 async await 语法糖使用起来很简单,只要你掌握了 Promise 的精髓就可以很好理解了,这里附一篇文章,不了解的同学看一看:Understanding JavaScript’s async await

class SearchSelect extends React.Component {  
  // ...省略部分代码

  constructor (props) {
    super(props)
    this.onSearch = this.onSearch.bind(this)
  }  

  @debounce(600)
  async onSearch (value) {
    this.setState({ loading: true })
    try {
      const config = {
        params: { q: value.trim() }
      }
      const { data = [] } = await axios.get('api/search', config)
      if (result.length > 0) this.setState({ data })
    } catch (err) {
      console.error(err)
    } finally {
      this.setState({ loading: false })
    }
  }
}

看起来像同步代码的异步请求

4. 用 CancelToken 来中断 ajax 请求

前面提到了「尽管有防抖,如果在网络较差的环境下,用户输入过慢(超过防抖时间)还是会有多余请求,因此需要中断先前的无用搜索。」,ajax 中断本身很好做到:

对于原生的 XMLHttpRequest 来说,直接调用 xhr.abort() 即可:

var xhr = new XMLHttpRequest()  
xhr.open('GET', '/api/example')  
xhr.send()  
xhr.abort()  

jQuery v2 版本的 $.ajax 也是类似的:

var jqXHR = $.ajax({ /* ... */ })  
jqXHR.abort()  

但是,一旦涉及到 Promise/A+,ajax 中断的实现就很麻烦了。
如返回纯 Promise 对象(不附加其他方法)的 jQuery v3 $.ajax,是这样实现中断的:

var xhr = new window.XMLHttpRequest()  
var promise = $.ajax({  
  /* ... */
  xhr: function () {
    return xhr
  }
})
xhr.abort()  

究其原因是目前的 Promise/A+ 规范未规定如何取消处在 pending 状态的 promise 对象,没有规定,也就没有实现。

因此 axios 为了更贴近规范,基于 "cancelable-promises" 提案实现了 CancelToken,用法详见 Cancellation

然而 Cancelable promises 在去年(2016)12 月份的时候被撤下了提案。
因为遭到了在 TC39 委员会内部分谷歌员工的抵制,作为提出者的 Domenic Denicola 无奈只能撤回了此提案,并且未能发表任何撤销的理由(有关此事件的讨论),至今(2017.6)社区还在讨论 cancellation 的问题

import axios, { CancelToken } from 'axios'

class SearchSelect extends React.Component {  
  // ...省略部分代码
  source // source 作为实例属性

  constructor (props) {
    super(props)
    this.onSearch = this.onSearch.bind(this)
  }  

  @debounce(600)
  async onSearch (value) {
    // 如果 `this.source` 还存在,
    // 则上一次请求还未完成,取消上次请求
    if (this.source) this.source.cancel()
    // 生成新的 cancelToken
    const source = this.source = CancelToken.source()
    let isCanceled // canceled flag

    this.setState({ loading: true })
    try {
      const config = {
        params: { q: value.trim() },
        cancelToken: source.token // 传入 token
      }
      const { data = [] } = await axios.get('api/search', config)
      if (result.length > 0) this.setState({ data })
    } catch (err) {
      if (axios.isCancel(err)) { // 请求被取消
        isCanceled = true
      } else {
        console.error(err)
      }
    } finally {
      if (!isCanceled) {
        // 若请求正常结束
        // 重置 source,并结束 loading 动画
        this.source = null
        this.setState({ loading: false })
      }
    }
  }
}

cancellation

中断多余的 ajax 请求

对 Cancelable promises 有兴趣的,可以看看这篇:Promise Cancellation Is Dead — Long Live Promise Cancellation!

至此,(大概)100 行的搜索选择组件已完成,效果如下:

SelectSearch showcase


完整 SearchSelect 组件代码(点击展开)

import { Select, Spin, message, Avatar } from 'antd'  
import _debounce from 'lodash.debounce'  
import axios, { CancelToken } from 'axios'

const { Option } = Select  
const loadingStyle = { display: 'flex', justifyContent: 'center' }  
const nameStyle = { marginLeft: 8 }  
const optionStyle = { isplay: 'flex', alignItems: 'center' }

function debounce (wait) {  
  return function debounceDecorator (target, key, descriptor) {
    descriptor.value = _debounce(descriptor.value, wait)
    return descriptor
  }
}

class SearchSelect extends React.Component {  
  static defaultProps = {
    placeholder: '输入关键字搜索'
  }

  source
  state = {
    value: null,
    loading: false,
    data: []
  };

  constructor (props) {
    super(props)

    this.onSearch = this.onSearch.bind(this)
    this.state.value = props.value ? [props.value] : []
  }

  componentWillReceiveProps (nextProps) {
    if ('value' in nextProps) {
      const value = nextProps.value ? [nextProps.value] : []
      this.setState({ value })
    }
  }

  @debounce(600)
  async onSearch (q) {
    if (this.source) this.source.cancel()
    const source = this.source = CancelToken.source()
    let isCanceled

    this.setState({ loading: true })
    try {
      const config = {
        params: { q: q.trim() },
        cancelToken: source.token
      }
      const { data = [] } = await axios.get('api/search', config)
      if (result.length > 0) this.setState({ data })
    } catch (err) {
      if (axios.isCancel(err)) {
        isCanceled = true
      } else {
        console.error(err)
      }
    } finally {
      if (!isCanceled) {
        this.source = null
        this.setState({ loading: false })
      }
    }
  }

  onChange = value => {
    const { onChange } = this.props
    if (typeof onChange === 'function') onChange(value)
  }

  renderOptions () {
    return this.state.data.map(item => (
      <Option key={item.id}>
        <div style={optionStyle}>
          {
            item.logo
              ? <Avatar src={item.logo} size='small' />
              : <Avatar icon='shop' size='small' />
          }
          <span style={nameStyle}>{item.name}</span>
        </div>
      </Option>
    ))
  }

  getNotFoundContent () {
    if (!this.state.loading) return null
    return <div style={loadingStyle}><Spin size='small' /></div>
  }

  render () {
    const { placeholder, style, size } = this.props

    return (
      <Select
        showSearch
        filterOption={false}
        defaultActiveFirstOption={false}
        value={this.state.value}
        style={style}
        size={size}
        placeholder={placeholder}
        notFoundContent={this.getNotFoundContent()}
        onSearch={this.onSearch}
        onChange={this.onChange}
      >
        {this.renderOptions()}
      </Select>
    )
  }
}

export default SearchSelect  

不过,在一些特殊条件下,组件表现的不是很完美,这和 ant design 对 Select 组件的实现有关,下面来说一下 ant design 组件的问题。

关于 ant design

1. Select 组件不缓存 value

以上实现的搜索选择组件,在这几种情况下的展现不太完美:

  • 低速网络
    1. 当搜索了 "Chro" 后,options 为 [Chrome, Chromium],选中 Chrome;
    2. 再搜索 "Saf",当搜索数据展示后,清空搜索内容;
    3. 此时 options 没有选中的 Chrome 的数据,且网速较慢暂时没有返回关键字为空的搜索数据,Select 会直接展示其 value
      error1
  • 受限接口
    1. 若服务端接口只返回匹配到的前 10 条数据(理应如此);
    2. 在搜索并选中 option 后,再次搜索空关键字内容;
    3. 若返回的 10 条数据中没有选中的 option,一样会出现上图的情况。

目前已经提了 issue,还待反馈。

2. 缺少搜索中提示

Select 组件在 options 数据为空才会展示 notFoundContent 组件,但在修改关键词继续搜索时,组件无任何加载中反馈,除非清空 options(state.data = []),但清空又会导致 Select 直接显示之前选中的 option value

3. rc-select title 取值问题

wrong title 这个还是 rc-select 内部的逻辑问题。

总结

虽然组件实现不是很困难,但其中涉及到的部分知识点还是很值得深思的:

  • Decorators 与 Class properties;
  • class 不进行声明提升;
  • Cancelable promises 与 CancelToken;
  • 对组件的完善程度的思考,和开源库仍然存在的问题。

对于前端,我了解的不是特别深入,但也会尽量查阅资料,保证正确性,如果有错误的概念希望大家能在下方评论区指正。

引用

  1. 以前在群里聊的时候,王见充说咱们现在写的既不是 JavaScript 也不是 ECMAScript 而是 "babel script"

正在加载 Disqus 评论组件...