Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Повторное использование кода с помощью HOC в React

Повторное использование кода с помощью HOC в React

РИТ++ 2018

Dmitry Tsepelev

May 28, 2018
Tweet

More Decks by Dmitry Tsepelev

Other Decks in Programming

Transcript

  1. Перерисовка компонентов 8 const ProductRow = ({ id, name, price

    }) => ( <tr> <td>{name}</td> <td>{price}</td> { } <td> <Link to={`/products/${id}`}>Details</Link> </td> </tr> )
  2. Перерисовка компонентов •  pure •  проверяет shallowEqual •  может работать

    медленно, если state и props объемные •  ложно-положительные срабатывания •  shouldComponentUpdate •  реализуем сами •  дает больше контроля 9
  3. Принцип разделения ответственности •  Эдсгер Дейкстра, “On the role of

    scientific thought”, 1974 •  Крис Рид, «Элементы функционального программирования», 1989 •  программа разделяется на секции, ответственные за определенные области бизнес-логики 11
  4. Компонент высшего порядка (HOC) 12 const Name = () =>

    <p>Guest</p> const withGreeting = WrappedComponent => props => ( <div> Hi, <WrappedComponent {...props} /> </div> ) withGreeting(Name)
  5. Компонент высшего порядка (HOC) 13 const Name = () =>

    <p>Guest</p> const withGreeting = WrappedComponent => props => ( <div> Hi, <WrappedComponent {...props} /> </div> ) withGreeting(Name)
  6. Компонент высшего порядка (HOC) 14 const Name = () =>

    <p>Guest</p> const withGreeting = WrappedComponent => props => ( <div> Hi, <WrappedComponent {...props} /> </div> ) withGreeting(Name)
  7. Компонент высшего порядка (HOC) 15 const Name = () =>

    <p>Guest</p> const withGreeting = WrappedComponent => props => ( <div> Hi, <WrappedComponent {...props} /> </div> ) withGreeting(Name)
  8. onlyUpdateForKeys 16 const ProductRow = onlyUpdateForKeys(['id'])( ({ id, name, price

    /* ...больше свойств */ }) => ( <tr> <td>{name}</td> <td>{price}</td> <td> <Link to={`/products/${id}`}>Details</Link> </td> </tr> ) )
  9. onlyUpdateForKeys 17 const enhance = onlyUpdateForKeys(['id']) const ProductRow = ({

    id, name, price /* ...больше свойств */ }) => ( <tr> <td>{name}</td> <td>{price}</td> <td> <Link to={`/products/${id}`}>Details</Link> </td> </tr> ) export default enhance(ProductRow)
  10. onlyUpdateForKeys 18 const onlyUpdateForKeys = propKeys => WrappedComponent => class

    extends Component { shouldComponentUpdate(nextProps) { return propKeys.some(key => this.props[key] !== nextProps[key] ) } render() { return <WrappedComponent {...this.props} /> } }
  11. onlyUpdateForKeys 19 const onlyUpdateForKeys = propKeys => WrappedComponent => class

    extends Component { shouldComponentUpdate(nextProps) { return propKeys.some(key => this.props[key] !== nextProps[key] ) } render() { return <WrappedComponent {...this.props} /> } }
  12. onlyUpdateForKeys 20 const onlyUpdateForKeys = propKeys => WrappedComponent => class

    extends Component { shouldComponentUpdate(nextProps) { return propKeys.some(key => this.props[key] !== nextProps[key] ) } render() { return <WrappedComponent {...this.props} /> } }
  13. onlyUpdateForKeys 21 const onlyUpdateForKeys = propKeys => WrappedComponent => class

    extends Component { shouldComponentUpdate(nextProps) { return propKeys.some(key => this.props[key] !== nextProps[key] ) } render() { return <WrappedComponent {...this.props} /> } }
  14. onlyUpdateForKeys 22 const onlyUpdateForKeys = propKeys => WrappedComponent => class

    extends Component { shouldComponentUpdate(nextProps) { return propKeys.some(key => this.props[key] !== nextProps[key] ) } render() { return <WrappedComponent {...this.props} /> } }
  15. Граничные условия в render 24 const ProductList = ({ products

    }) => ( <table> <thead> <tr> <th>Name</th> <th>Price</th> </tr> </thead> <tbody> {products.map(p => <ProductRow key={p.id} {...p} />)} </tbody> </table> )
  16. Граничные условия в render 25 const ProductList = ({ products

    }) => { if (!products.length) { return <NoData /> } return ( <table> {/* та же таблица */} </table> ) }
  17. Граничные условия в render 26 const ProductList = ({ loading,

    products }) => { if (loading) { return <Spinner /> } if (!products.length) { return <NoData /> } return (...) }
  18. Граничные условия в render 27 const ProductList = ({ currentUser,

    loading, products }) => { if (!currentUser) { return <NotAuthorized /> } if (loading) { return <Spinner /> } if (!products.length) { return <NoData /> } return (...) }
  19. 28 const enhance = branch( ({ products }) => !products.length,

    () => null ) const ProductList = ({ products }) => (...) export default enhance(ProductList) Граничные условия в render
  20. 29 const enhance = branch( ({ products }) => !products.length,

    () => null ) const ProductList = ({ products }) => (...) export default enhance(ProductList) Граничные условия в render
  21. 30 const enhance = branch( ({ products }) => !products.length,

    () => null ) const ProductList = ({ products }) => (...) export default enhance(ProductList) Граничные условия в render
  22. 31 const renderNothing = () => null const enhance =

    branch( ({ products }) => !products.length, renderNothing ) const ProductList = ({ products }) => (...) export default enhance(ProductList) Граничные условия в render
  23. branch 32 const branch = (cond, cb) => WrappedComponent =>

    props => cond(props) ? cb(props) : <WrappedComponent {...props} />
  24. branch 33 const branch = (cond, cb) => WrappedComponent =>

    props => cond(props) ? cb(props) : <WrappedComponent {...props} />
  25. Подготовка данных для отрисовки 35 const formattedPrice = price =>

    `$${price}` const enhance = onlyUpdateForKeys(['id']) const ProductRow = ({ id, name, price }) => ( <tr> <td>{name}</td> <td>{formattedPrice(price)}</td> <td> <Link to={`/products/${id}`}>Details</Link> </td> </tr> ) export default enhance(ProductRow)
  26. 36 const formattedPrice = price => `$${price}` const enhance =

    onlyUpdateForKeys(['id'])( withProps(({ price }) => ({ price: formattedPrice(price) })) ) const ProductRow = ({ id, name, price }) => (...) export default enhance(ProductRow) Подготовка данных для отрисовки
  27. withProps 37 const withProps = mapper => WrappedComponent => props

    => { const newProps = { ...props, ...mapper(props) } return <WrappedComponent {...newProps} /> }
  28. withProps 38 const withProps = mapper => WrappedComponent => props

    => { const newProps = { ...props, ...mapper(props) } return <WrappedComponent {...newProps} /> }
  29. 44 const formattedPrice = price => `$${price}` const enhance =

    onlyUpdateForKeys(['id'])( withProps(({ price }) => ({ price: formattedPrice(price) })) ) const ProductRow = ({ id, name, price }) => (...) export default enhance(ProductRow) compose
  30. 45 const formattedPrice = price => `$${price}` const enhance =

    compose( onlyUpdateForKeys(['id']), withProps(({ price }) => ({ price: formattedPrice(price) })) ) const ProductRow = ({ id, name, price }) => (...) export default enhance(ProductRow) compose
  31. 46 const ProductList = ({ currentUser, loading, products }) =>

    { if (!currentUser) { return <NotAuthorized /> } if (loading) { return <Spinner /> } if (!products.length) { return <NoData /> } return (...) } Еще раз про граничные условия
  32. 47 const renderComponent = component => component const enhance =

    compose( branch( ({ currentUser }) => !currentUser, renderComponent(NotAuthorized) ), branch( ({ loading }) => loading, renderComponent(Spinner) ), branch( ({ products }) => !products.length, renderComponent(NoData) ) ) Еще раз про граничные условия
  33. const renderComponent = component => component const enhance = compose(

    branch( ({ currentUser }) => !currentUser, renderComponent(NotAuthorized) ), branch( ({ loading }) => loading, renderComponent(Spinner) ), branch( ({ products }) => !products.length, renderComponent(NoData) ) ) 48 Еще раз про граничные условия
  34. 49 const mapStateToProps = state => ({ currentUser: getCurrentUser(state) })

    const enhance = compose( connect(mapStateToProps), branch( ({ currentUser }) => !currentUser, renderComponent(NotAuthorized) ), ... ) Еще раз про граничные условия
  35. 50 const checkAuthorization = compose( connect(state => ({ currentUser: getCurrentUser(state)

    })), branch( ({ currentUser }) => !currentUser, renderComponent(NotAuthorized) ) ) Еще раз про граничные условия
  36. 51 const enhance = compose( checkAuthorization, branch( ({ loading })

    => loading, renderComponent(Spinner) ), branch(({ products }) => !products.length, renderComponent(NoData)) ) const ProductList = ({ products }) => (...) export default enhance(ProductList) Еще раз про граничные условия
  37. 52 const enhance = compose( checkAuthorization, branch( ({ loading })

    => loading, renderComponent(Spinner) ), branch(({ products }) => !products.length, renderComponent(NoData)) ) const ProductList = ({ products }) => (...) export default enhance(ProductList) Еще раз про граничные условия
  38. 53 const showSpinnerWhileLoading = branch( ({ loading }) => loading,

    renderComponent(Spinner) ) Еще раз про граничные условия
  39. 54 const enhance = compose( checkAuthorization, showSpinnerWhileLoading, branch(({ products })

    => !products.length, renderComponent(NoData)) ) const ProductList = ({ products }) => (...) export default enhance(ProductList) Еще раз про граничные условия
  40. recompose •  https://github.com/acdlite/recompose •  есть все функции, которые мы реализовали,

    и множество других •  почти 10 000 звёд, хорошая поддержка •  flow •  RxJS •  TypeScript •  React Native 55
  41. withHandlers 57 const AddToCartButton = ({ price, productId, onAddToCart })

    => ( <div> <span>{price}</span> <button onClick={() => onAddToCart(productId)}> Add to cart </button> </div> )
  42. withHandlers 58 const AddToCartButton = ({ price, productId, onAddToCart })

    => ( <div> <span>{price}</span> <button onClick={() => onAddToCart(productId)}> Add to cart </button> </div> )
  43. withHandlers 59 const AddToCartButton = ({ price, productId, onAddToCart })

    => ( <div> <span>{price}</span> <button onClick={() => onAddToCart(productId)}> Add to cart </button> </div> )
  44. withHandlers 60 const enhance = withHandlers({ onClickHandler: ({ onAddToCart, productId

    }) => () => onAddToCart(productId) }) const AddToCartButton = ({ price, onClickHandler }) => ( <div> <span>{price}</span> <button onClick={onClickHandler}>Add to cart</button> </div> ) export default enhance(AddToCartButton)
  45. withHandlers 61 const enhance = withHandlers({ onClickHandler: ({ onAddToCart, productId

    }) => () => onAddToCart(productId) }) const AddToCartButton = ({ price, onClickHandler }) => ( <div> <span>{price}</span> <button onClick={onClickHandler}>Add to cart</button> </div> ) export default enhance(AddToCartButton)
  46. withHandlers 62 const enhance = withHandlers({ onClickHandler: ({ onAddToCart, productId

    }) => () => onAddToCart(productId) }) const AddToCartButton = ({ price, onClickHandler }) => ( <div> <span>{price}</span> <button onClick={onClickHandler}>Add to cart</button> </div> ) export default enhance(AddToCartButton)
  47. withHandlers 63 const enhance = withHandlers({ onClickHandler: ({ onAddToCart, productId

    }) => () => onAddToCart(productId) }) const AddToCartButton = ({ price, onClickHandler }) => ( <div> <span>{price}</span> <button onClick={onClickHandler}>Add to cart</button> </div> ) export default enhance(AddToCartButton)
  48. Загрузка данных 65 class ProductListContainer extends Component { constructor(props) {

    /* инициализируем cтейт */ } loadProducts = () => { /* идем на сервер за данными */ } componentWillMount() { this.loadProducts() } render() { /* рисуем */ } }
  49. Загрузка данных 66 class ProductListContainer extends Component { constructor(props) {

    super(props) this.state = { products: [], loading: false } } //... }
  50. Загрузка данных 67 class ProductListContainer extends Component { loadProducts =

    () => { this.setState({ loading: true }) fetchProducts().then(products => this.setState({ products, loading: false }) ) } //... }
  51. Загрузка данных 68 class ProductListContainer extends Component { render() {

    return ( <ProductList products={this.state.products} loading={this.state.loading} /> ) } //... }
  52. class ProductListContainer extends Component { constructor(props) { super(props) this.state =

    { products: [], loading: false } } loadProducts = () => { this.setState({ loading: true }) fetchProducts().then(products => this.setState({ products, loading: false }) ) } componentWillMount() { this.loadProducts() } render() { const { products, loading } = this.state return (<ProductList products={products} loading={loading} />) } } 69
  53. 70 const enhance = compose( withState('products', 'setProducts', []), withState('loading', 'setLoading',

    false), withHandlers({ loadProducts: ({ setLoading, setProducts }) => () => { setLoading(true) fetchProducts().then(products => { setLoading(false) setProducts(products) }) } }), lifecycle({ componentWillMount() { this.props.loadProducts() } }) ) export default enhance(ProductList)
  54. 71 const enhance = compose( withState('products', 'setProducts', []), withState('loading', 'setLoading',

    false), withHandlers({ loadProducts: ({ setLoading, setProducts }) => () => { setLoading(true) fetchProducts().then(products => { setLoading(false) setProducts(products) }) } }), lifecycle({ componentWillMount() { this.props.loadProducts() } }) ) export default enhance(ProductList)
  55. 72 const enhance = compose( withState('products', 'setProducts', []), withState('loading', 'setLoading',

    false), withHandlers({ loadProducts: ({ setLoading, setProducts }) => () => { setLoading(true) fetchProducts().then(products => { setLoading(false) setProducts(products) }) } }), lifecycle({ componentWillMount() { this.props.loadProducts() } }) ) export default enhance(ProductList)
  56. 75 const NavbarItem = ({ to, onClick, children }) =>

    { const className = 'navbar-item' if (to) { return ( <Link className={className} to={to}>{children}</Link> ) } if (onClick) { return ( <button className={className} onClick={onClick}>{children}</button> ) } return null }
  57. 76 const enhance = compose( withProps({ className: 'navbar-item' }), branch(

    ({ to }) => to, withProps({ component: Link }) ), branch( ({ onClick }) => onClick, withProps({ component: 'button' }) ) ) export default enhance(componentFromProp('component'))
  58. 77 const enhance = compose( withProps({ className: 'navbar-item' }), branch(

    ({ to }) => to, withProps({ component: Link }) ), branch( ({ onClick }) => onClick, withProps({ component: 'button' }) ) ) export default enhance(componentFromProp('component'))
  59. redux + загрузка данных •  запрос выполняется вне компонента • 

    локального состояния нет •  не перерисовывать компонент, если ничего не изменилось •  по возможности – брать данные из store 78
  60. const mapStateToProps = (state, { match: { params: { id

    } } }) => ({ product: getProduct(state, id) }) class ProductProfileContainer extends Component { shouldComponentUpdate(nextProps) { return nextProps.match.params.id !== this.props.match.params.id } componentDidMount() { requestProduct(this.props.match.params.id) } render() { return <ProductProfile product={this.props.product} /> } } export default connect( mapStateToProps, mapDispatchToProps )(ProductProfileContainer) 79
  61. const mapStateToProps = (state, { match: { params: { id

    } } }) => ({ product: getProduct(state, id) }) class ProductProfileContainer extends Component { shouldComponentUpdate(nextProps) { return nextProps.match.params.id !== this.props.match.params.id } componentDidMount() { requestProduct(this.props.match.params.id) } render() { return <ProductProfile product={this.props.product} /> } } export default connect( mapStateToProps, mapDispatchToProps )(ProductProfileContainer) 80
  62. const mapStateToProps = (state, { match: { params: { id

    } } }) => ({ product: getProduct(state, id) }) class ProductProfileContainer extends Component { shouldComponentUpdate(nextProps) { return nextProps.match.params.id !== this.props.match.params.id } componentDidMount() { requestProduct(this.props.match.params.id) } render() { return <ProductProfile product={this.props.product} /> } } export default connect( mapStateToProps, mapDispatchToProps )(ProductProfileContainer) 81
  63. const mapStateToProps = (state, { match: { params: { id

    } } }) => ({ product: getProduct(state, id) }) class ProductProfileContainer extends Component { shouldComponentUpdate(nextProps) { return nextProps.match.params.id !== this.props.match.params.id } componentDidMount() { requestProduct(this.props.match.params.id) } render() { return <ProductProfile product={this.props.product} /> } } export default connect( mapStateToProps, mapDispatchToProps )(ProductProfileContainer) 82
  64. const mapStateToProps = (state, { match: { params: { id

    } } }) => { id, product: getProduct(state, id) } class ProductProfileContainer extends Component { shouldComponentUpdate(nextProps) { return nextProps.match.params.id !== this.props.id } componentDidMount() { requestProduct(this.props.id) } render() { return <ProductProfile product={this.props.product} /> } } export default connect( mapStateToProps, mapDispatchToProps )(ProductProfileContainer) 83
  65. const mapStateToProps = (state, { match: { params: { id

    } } }) => { id, product: getProduct(state, id) } class ProductProfileContainer extends Component { shouldComponentUpdate(nextProps) { return nextProps.match.params.id !== this.props.id } componentDidMount() { requestProduct(this.props.id) } render() { return <ProductProfile product={this.props.product} /> } } export default connect( mapStateToProps, mapDispatchToProps )(ProductProfileContainer) 84
  66. const mapStateToProps = (state, { match: { params: { id

    } } }) => { id, product: getProduct(state, id) } class ProductProfileContainer extends Component { shouldComponentUpdate(nextProps) { return nextProps.match.params.id !== this.props.id } componentDidMount() { requestProduct(this.props.id) } render() { return <ProductProfile product={this.props.product} /> } } export default connect( mapStateToProps, mapDispatchToProps )(ProductProfileContainer) 85
  67. 86 const mapStateToProps = (state, { match: { params: {

    id } } }) => ({ id, product: getProduct(state, id) }) const enhance = compose( onlyUpdateForPropTypes, setPropTypes({ product: ProductShape }), connect(mapStateToProps, mapDispatchToProps), lifecycle({ componentDidMount() { requestProduct(this.props.id) } }) ) export default enhance(ProductProfile)
  68. 87 const mapStateToProps = (state, { match: { params: {

    id } } }) => ({ id, product: getProduct(state, id) }) const enhance = compose( onlyUpdateForPropTypes, setPropTypes({ product: ProductShape }), connect(mapStateToProps, mapDispatchToProps), lifecycle({ componentDidMount() { requestProduct(this.props.id) } }) ) export default enhance(ProductProfile)
  69. 88 const mapStateToProps = (state, { match: { params: {

    id } } }) => ({ id, product: getProduct(state, id) }) const enhance = compose( onlyUpdateForPropTypes, setPropTypes({ product: ProductShape }), connect(mapStateToProps, mapDispatchToProps), lifecycle({ componentDidMount() { requestProduct(this.props.id) } }) ) export default enhance(ProductProfile)
  70. Альтернативы recompose •  написать руками •  Recompact 38.2kB minified, 6.9kB

    gzipped, 260 звёзд (https:// github.com/neoziro/recompact) •  Reassemble 21.7kB minified, 4.5kB gzipped, 54 звезды (https://github.com/wikiwi/reassemble) 89
  71. Производительность •  производительность может ухудшиться из-за появления новых компонентов в

    дереве •  легче управлять перерисовкой компонентов •  многие компоненты реализованы в виде чистых функций (без использования классов), в будущем возможна оптимизация работы таких компонентов в React 91
  72. Тестирование 92 const withProductCount = withProps( ({ products }) =>

    ({ count: products.length }) ) describe('withProductCount decorator', () => { const products = [{ id: 1 }] const MockComponent = () => <div /> it('adds product count prop', () => { const Enhanced = withProductCount(MockComponent) const wrapper = mount(<Enhanced products={products} />) const subject = wrapper.find(MockComponent) expect(subject.prop('count')).toBe(1) }) })
  73. Тестирование 93 const withProductCount = withProps( ({ products }) =>

    ({ count: products.length }) ) describe('withProductCount decorator', () => { const products = [{ id: 1 }] const MockComponent = () => <div /> it('adds product count prop', () => { const Enhanced = withProductCount(MockComponent) const wrapper = mount(<Enhanced products={products} />) const subject = wrapper.find(MockComponent) expect(subject.prop('count')).toBe(1) }) })
  74. Тестирование 94 const withProductCount = withProps( ({ products }) =>

    ({ count: products.length }) ) describe('withProductCount decorator', () => { const products = [{ id: 1 }] const MockComponent = () => <div /> it('adds product count prop', () => { const Enhanced = withProductCount(MockComponent) const wrapper = mount(<Enhanced products={products} />) const subject = wrapper.find(MockComponent) expect(subject.prop('count')).toBe(1) }) })
  75. Тестирование 95 const withProductCount = withProps( ({ products }) =>

    ({ count: products.length }) ) describe('withProductCount decorator', () => { const products = [{ id: 1 }] const MockComponent = () => <div /> it('adds product count prop', () => { const Enhanced = withProductCount(MockComponent) const wrapper = mount(<Enhanced products={products} />) const subject = wrapper.find(MockComponent) expect(subject.prop('count')).toBe(1) }) })
  76. const withGreeting = compose( setDisplayName('withGreeting'), setPropTypes({ greeting: PropTypes.string.required, children: PropTypes.string.required

    }), onlyUpdateForPropTypes, mapProps(({ greeting, children }) => ({ text: `${greeting}, ${children}` })) ) const Label = ({ text }) => (<h1>{text}</h1>) const GreetingLabel = withGreeting(Label) const App = () => <GreetingLabel greeting="Hi">John</GreetingLabel> Отладка
  77. const withGreeting = compose( setDisplayName('withGreeting'), setPropTypes({ greeting: PropTypes.string.required, children: PropTypes.string.required

    }), onlyUpdateForPropTypes, mapProps(({ greeting, children }) => ({ text: `${greeting}, ${children}` })) ) const Label = ({ text }) => (<h1>{text}</h1>) const GreetingLabel = withGreeting(Label) const App = () => <GreetingLabel greeting="Hi">John</GreetingLabel> Отладка
  78. const withGreeting = compose( setDisplayName('withGreeting'), setPropTypes({ greeting: PropTypes.string.required, children: PropTypes.string.required

    }), onlyUpdateForPropTypes, mapProps(({ greeting, children }) => ({ text: `${greeting}, ${children}` })) ) const Label = ({ text }) => (<h1>{text}</h1>) const GreetingLabel = withGreeting(Label) const App = () => <GreetingLabel greeting="Hi">John</GreetingLabel> Отладка
  79. const withGreeting = compose( setDisplayName('withGreeting'), setPropTypes({ greeting: PropTypes.string.required, children: PropTypes.string.required

    }), onlyUpdateForPropTypes, mapProps(({ greeting, children }) => ({ text: `${greeting}, ${children}` })) ) const Label = ({ text }) => (<h1>{text}</h1>) const GreetingLabel = withGreeting(Label) const App = () => <GreetingLabel greeting="Hi">John</GreetingLabel> Отладка
  80. Зачем нам HOC? •  убирать логику из presentational components и

    описать ее декларативно •  повторно использовать presentational components в разных частях приложения •  тестировать логику отдельно от представления 100
  81. Что может пойти не так? •  нельзя использовать HOC внутри

    render (ок, не будем) •  статические методы придется копировать •  с refs работать неудобно (но можно) •  коллизии имен 101 https://reactjs.org/docs/higher-order-components.html#caveats
  82. Зачем нам recompose? •  не писать кучу boilerplate-кода •  удобно

    управлять перерисовкой компонентов •  класть в props обработчики и переменные по мере необходимости •  удобно обрабатывать граничные условия с помощью branch 102
  83. class ProductListContainer extends Component { constructor(props) { super(props) this.state =

    { products: [], loading: false } } loadProducts = () => { this.setState({ loading: true }) fetchProducts().then(products => this.setState({ products, loading: false }) ) } componentWillMount() { this.loadProducts() } render() { const { products, loading } = this.state return (<ProductList products={products} loading={loading} />) } } 104
  84. class ProductList extends Component { ... render() { return this.props.render(this.state)

    } } <ProductList render={ ({ products }) => (<table>...</table>) } /> 105