组件value和onChange巧用 以及 ahooks useControllableValue解读

65 阅读4分钟

1.组件开发思路

设计组件的基本思路,我们通常考虑一个组件是否应该为受控组件或非受控组件,在我们日常使用antd组件时,会巧妙的发现,antd的所有组件都是支持受控和非受控的,使用者的场景可以更加丰富,因为我们今天就来探索如何设计一款支持受控和非受控的组件。

常规思路:

image.png

   import React, { useState } from 'react';
import { Button, Form, Input, Select } from 'antd';

const { Option } = Select;

type Currency = 'rmb' | 'dollar';

interface PriceValue {
  number?: number;
  currency?: Currency;
}

interface PriceInputProps {
  id?: string;
  value?: PriceValue;
  onChange?: (value: PriceValue) => void;
}

const PriceInput: React.FC<PriceInputProps> = (props) => {
  const { id, value = {}, onChange } = props;
  const [number, setNumber] = useState(0);
  const [currency, setCurrency] = useState<Currency>('rmb');

  const triggerChange = (changedValue: { number?: number; currency?: Currency }) => {
    onChange?.({ number, currency, ...value, ...changedValue });
  };

  const onNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const newNumber = parseInt(e.target.value || '0', 10);
    if (Number.isNaN(number)) {
      return;
    }
    if (!('number' in value)) {
      setNumber(newNumber);
    }
    triggerChange({ number: newNumber });
  };

  const onCurrencyChange = (newCurrency: Currency) => {
    if (!('currency' in value)) {
      setCurrency(newCurrency);
    }
    triggerChange({ currency: newCurrency });
  };

  return (
    <span id={id}>
      <Input
        type="text"
        value={value.number || number}
        onChange={onNumberChange}
        style={{ width: 100 }}
      />
      <Select
        value={value.currency || currency}
        style={{ width: 80, margin: '0 8px' }}
        onChange={onCurrencyChange}
      >
        <Option value="rmb">RMB</Option>
        <Option value="dollar">Dollar</Option>
      </Select>
    </span>
  );
};

const App: React.FC = () => {
  const onFinish = (values: any) => {
    console.log('Received values from form: ', values);
  };

  const checkPrice = (_: any, value: { number: number }) => {
    if (value.number > 0) {
      return Promise.resolve();
    }
    return Promise.reject(new Error('Price must be greater than zero!'));
  };

  return (
    <Form
      name="customized_form_controls"
      layout="inline"
      onFinish={onFinish}
      initialValues={{
        price: {
          number: 0,
          currency: 'rmb',
        },
      }}
    >
      <Form.Item name="price" label="Price" rules={[{ validator: checkPrice }]}>
        <PriceInput />
      </Form.Item>
      <Form.Item>
        <Button type="primary" htmlType="submit">
          Submit
        </Button>
      </Form.Item>
    </Form>
  );
};

export default App;

我们可以看到这里需要内部支持state和setState,核心是使用triggerChange方式来适配。

如果我们使用useControllableValue 会使代码量大幅的减少,同时达到同样的效果。

import React from 'react';
import { Button, Form, Input, Select } from 'antd';
import useControllableValue from 'ahooks';

const { Option } = Select;

type Currency = 'rmb' | 'dollar';

interface PriceValue {
  number?: number;
  currency?: Currency;
}

interface PriceInputProps {
  id?: string;
  value?: PriceValue;
  onChange?: (value: PriceValue) => void;
}

const PriceInput: React.FC<PriceInputProps> = (props: PriceInputProps) => {
  const { id } = props;
  
  // 使用 useControllableValue 管理组件状态
  const [value, setValue] = useControllableValue<PriceValue>(props, {
    defaultValue: { number: 0, currency: 'rmb' },
  });

  const onNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const newNumber = parseInt(e.target.value || '0', 10);
    if (Number.isNaN(newNumber)) {
      return;
    }
    setValue({ ...value, number: newNumber });
  };

  const onCurrencyChange = (newCurrency: Currency) => {
    setValue({ ...value, currency: newCurrency });
  };

  return (
    <span id={id}>
      <Input
        type="text"
        value={value?.number || 0}
        onChange={onNumberChange}
        style={{ width: 100 }}
      />
      <Select
        value={value?.currency || 'rmb'}
        style={{ width: 80, margin: '0 8px' }}
        onChange={onCurrencyChange}
      >
        <Option value="rmb">RMB</Option>
        <Option value="dollar">Dollar</Option>
      </Select>
    </span>
  );
};

const App: React.FC = () => {
  const onFinish = (values: any) => {
    console.log('Received values from form: ', values);
  };

  const checkPrice = (_: any, value: { number: number }) => {
    if (value.number > 0) {
      return Promise.resolve();
    }
    return Promise.reject(new Error('Price must be greater than zero!'));
  };

  return (
    <Form
      name="customized_form_controls"
      layout="inline"
      onFinish={onFinish}
      initialValues={{
        price: {
          number: 0,
          currency: 'rmb',
        },
      }}
    >
      <Form.Item name="price" label="Price" rules={[{ validator: checkPrice }]}>
        <PriceInput />
      </Form.Item>
      <Form.Item>
        <Button type="primary" htmlType="submit">
          Submit
        </Button>
      </Form.Item>
    </Form>
  );
};

export default App;

两个案例的对比,能明显感受到useControllableValue的好处,我们不需要自己再去设置内部的state,我们只需要把外部传入的value和onChange的props透传到useControllableValue即可。后续我们在setValue时候,只需要像更新内部setSate一样。就可以达到受控和非受控的效果。 我认为设计的很巧妙,因此我去看了ahooks的具体实现原理如下:

首先传统的开发思路模式,在做受控和非受控时思路局限于创建一个本地的state,如果父组件有value就用父组件的value,onChange的时候触发修改,如果没有传入就使用本地的state更新 核心代码如下

if (!('number' in value)) { setNumber(newNumber); } triggerChange({ number: newNumber });

然后在useControllableValue的实现过程中放弃了这一思路,采用useRef来实现, 核心代码如下

function useControllableValue<T = any>(defaultProps: Props, options: Options<T> = {}) {
  const props = defaultProps ?? {};

  const {
    defaultValue,
    defaultValuePropName = 'defaultValue',
    valuePropName = 'value',
    trigger = 'onChange',
  } = options;

  const value = props[valuePropName] as T;
  const isControlled = Object.prototype.hasOwnProperty.call(props, valuePropName);

  const initialValue = useMemo(() => {
    if (isControlled) {
      return value;
    }
    if (Object.prototype.hasOwnProperty.call(props, defaultValuePropName)) {
      return props[defaultValuePropName];
    }
    return defaultValue;
  }, []);

  const stateRef = useRef(initialValue);
  
 if (isControlled) {
    stateRef.current = value;
  } 

  const update = useUpdate();

  function setState(v: SetStateAction<T>, ...args: any[]) {
    const r = isFunction(v) ? v(stateRef.current) : v;

    if (!isControlled) {
      stateRef.current = r;
      update();
    }
    if (props[trigger]) {
      props[trigger](r, ...args);
    }
  }

  return [stateRef.current, useMemoizedFn(setState)] as const;
}

这里我们可以看到本质思路外部是采用stateRef.current 作为页面的渲染,从这个hooks的设计思路中可以巧妙的看到 如果!isControlled 是受控组件,只需要内部更新value,手动触发update,update的思路也十分巧妙

import { useCallback, useState } from 'react';

const useUpdate = () => {
  const [, setState] = useState({});

  return useCallback(() => setState({}), []);
};

export default useUpdate;

只需要刷新触发当前组件的reRender stateRef.current的值更新后,外部使用该hooks的值也会同步更新。

如果是受控组件,只需要每次更新触发onChange即可

if (props[trigger]) { props[trigger](r, ...args); }

每次onChange后 会更新props,value触发最新值,每次render时候都赋值为最新即可

if (isControlled) { stateRef.current = value; }

总结: 在同学们去开发组件,想要设计为value和onChange时,推荐将组件设计为受控和非受控的支持,更加的便于维护,useControllableValue是得力的助手帮助我们实现。