taro icon indicating copy to clipboard operation
taro copied to clipboard

Input controlled 模式无效

Open nuintun opened this issue 3 years ago • 3 comments

相关平台

微信小程序

复现仓库

https://github.com/nuintun/taro-bugs

小程序基础库: 2.52.2 使用框架: React

index.module.scss

.inputNumber {
  width: 176px;
  display: flex;
  min-height: 54px;
  position: relative;
  border-radius: 8px;
  align-items: center;
  box-sizing: border-box;
  background-color: #fff;
  border: 1px solid #e1e1e1;

  .input {
    outline: none;
    padding: 0 2px;
    font-size: 28px;
    text-align: center;
  }

  .decrease {
    border-right: 1px solid #e1e1e1;
  }

  .increase {
    border-left: 1px solid #e1e1e1;
  }

  .decrease,
  .increase {
    .text{
      width: 48px;
      height: 48px;
      line-height: 48px;
      text-align: center;
      display: inline-block;
      vertical-align: middle;
    }

    &.disabled {
      .text{
        opacity: 0.12;
      }
    }
  }
}

utils.ts

/**
 * @function isFunction
 * @description 是否为函数
 * @param value 需要验证的值
 */
export function isFunction(value: any): value is Function {
  return typeof value === 'function';
}

useIsMounted.tsx

/**
 * @module useIsMounted
 */

import { useCallback, useEffect, useRef } from 'react';

/**
 * @function useIsMounted
 * @description [hook] 检查组件是否已经挂载
 */
export default function useIsMounted(): () => boolean {
  const isMountedRef = useRef(false);
  const isMounted = useCallback(() => isMountedRef.current, []);

  useEffect(() => {
    isMountedRef.current = true;

    return () => {
      isMountedRef.current = false;
    };
  }, []);

  return isMounted;
}

usePersistRef.ts

/**
 * @module usePersistRef
 */

import React, { useRef } from 'react';

/**
 * @function usePersistRef
 * @description 生成自更新 useRef 对象
 */
export default function usePersistRef<T = undefined>(): React.MutableRefObject<T | undefined>;
/**
 * @function usePersistRef
 * @description 生成自更新 useRef 对象
 * @param value 引用值
 */
export default function usePersistRef<T>(value: T): React.MutableRefObject<T>;
export default function usePersistRef<T = undefined>(value?: T): React.MutableRefObject<T | undefined> {
  const valueRef = useRef(value);

  valueRef.current = value;

  return valueRef;
}

useUpdateEffect.ts

/**
 * @module useUpdateEffect
 */

import React, { useEffect, useRef } from 'react';

/**
 * @function useUpdateEffect
 * @description [hook] 组件 useEffect 后回调
 * @param effect 回调函数
 * @param deps 回调依赖
 */
export default function useUpdateEffect(effect: React.EffectCallback, deps?: React.DependencyList): void {
  const isMountedRef = useRef(false);

  useEffect(() => {
    if (!isMountedRef.current) {
      isMountedRef.current = true;
    } else {
      return effect();
    }
  }, deps);
}

useControllableValue.ts

/**
 * @module useControllableValue
 */

import React, { useCallback, useState } from 'react';

import { isFunction } from './utils';
import useIsMounted from './useIsMounted';
import usePersistRef from './usePersistRef';
import useUpdateEffect from './useUpdateEffect';

export interface Props {
  [prop: string]: any;
}

export interface Options<V> {
  trigger?: string;
  defaultValue?: V;
  valuePropName?: string;
  defaultValuePropName?: string;
}

/**
 * @function getValuePropName
 * @param options 配置选项
 */
function getValuePropName<V>(options: Options<V>): string {
  const { valuePropName = 'value' } = options;

  return valuePropName;
}

/**
 * @function getDefaultValuePropName
 * @param options 配置选项
 */
function getDefaultValuePropName<V>(options: Options<V>): string {
  const { defaultValuePropName = 'defaultValue' } = options;

  return defaultValuePropName;
}

/**
 * @function isControlled
 * @param props 组件 Props
 * @param options 配置选项
 */
function isControlled<V>(props: Props, options: Options<V>): boolean {
  const valuePropName = getValuePropName(options);

  return valuePropName in props;
}

/**
 * @function isUncontrolled
 * @param props 组件 Props
 * @param options 配置选项
 */
function isUncontrolled<V>(props: Props, options: Options<V>): boolean {
  const defaultValuePropName = getDefaultValuePropName(options);

  return defaultValuePropName in props;
}

/**
 * @function getValue
 * @param props 组件 Props
 * @param options 配置选项
 */
function getValue<V>(props: Props, options: Options<V>): V {
  const valuePropName = getValuePropName(options);

  return props[valuePropName];
}

/**
 * @function getDefaultValue
 * @param props 组件 Props
 * @param options 配置选项
 */
function getDefaultValue<V>(props: Props, options: Options<V>): V {
  const defaultValuePropName = getDefaultValuePropName(options);

  return props[defaultValuePropName];
}

/**
 * @function useControllableValue
 * @description [hook] 生成同时支持受控和非受控状态的值
 * @param props 组件 Props
 * @param options 配置选项
 */
export default function useControllableValue<V = undefined>(
  props: Props,
  options: Options<V> = {}
): [value: V | undefined, setValue: (value: React.SetStateAction<V | undefined>, ...args: any[]) => void] {
  const isMounted = useIsMounted();
  const propsRef = usePersistRef(props);
  const optionsRef = usePersistRef(options);

  const [value, setValueState] = useState<V | undefined>(() => {
    if (isControlled(props, options)) {
      return getValue(props, options);
    }

    if (isUncontrolled(props, options)) {
      return getDefaultValue(props, options);
    }

    return options.defaultValue;
  });

  const prevValueRef = usePersistRef(value);

  const setValue = useCallback((value: React.SetStateAction<V | undefined>, ...args: any[]) => {
    if (isMounted()) {
      const props = propsRef.current;

      const setStateAction = (prevState: V | undefined): V | undefined => {
        const { trigger = 'onChange' } = props;
        const nextState = isFunction(value) ? value(prevState) : value;

        if (nextState !== prevState && isFunction(props[trigger])) {
          props[trigger](nextState, ...args);
        }

        return nextState;
      };

      if (isControlled(props, optionsRef.current)) {
        setStateAction(prevValueRef.current);
      } else {
        setValueState(setStateAction);
      }
    }
  }, []);

  useUpdateEffect(() => {
    if (isControlled(props, options)) {
      const prevValue = prevValueRef.current;
      const nextValue = getValue(props, options);

      if (nextValue !== prevValue) {
        setValueState(nextValue);
      }
    }
  });

  return [value, setValue];
}

index.tsx

import styles from './index.module.scss';

import React, { memo, useCallback, useMemo } from 'react';

import classNames from 'classnames';
import useControllableValue from './useControllableValue';
import { BaseEventOrig, Image, Input, InputProps, Text, View } from '@tarojs/components';

export interface InputNumberProps extends Omit<InputProps, 'value'> {
  min?: number;
  max?: number;
  step?: number;
  value?: number;
  controls?: boolean;
  defaultValue?: number;
  prefix?: React.ReactNode;
  suffix?: React.ReactNode;
  onChange?: (value: number) => void;
}

function resolveValue(value: number, props: InputNumberProps): number {
  const { min, max } = props;

  if (min != null && value < min) {
    return min;
  }

  if (max != null && value > max) {
    return max;
  }

  return value;
}

export default memo(function InputNumber(props: InputNumberProps): React.ReactElement {
  const { min, max, step = 1, className, disabled, controls = true, ...restProps } = props;

  const [value = 0, setValue] = useControllableValue<number>(props);

  const onIncrease = useCallback(() => {
    if (!disabled) {
      setValue((value = 0) => resolveValue(value + step, props));
    }
  }, [min, max, step, disabled]);

  const onDecrease = useCallback(() => {
    if (!disabled) {
      setValue((value = 0) => resolveValue(value - step, props));
    }
  }, [min, max, step, disabled]);

  const onInput = useCallback(
    (e: BaseEventOrig<InputProps.inputValueEventDetail>) => {
      const input = +e.detail.value;

      if (!Number.isNaN(input)) {
        const nextValue = resolveValue(input, props);

        setValue(nextValue);

        return nextValue;
      }

      return value;
    },
    [value, min, max]
  );

  const [increaseClassName, decreaseClassName] = useMemo(() => {
    const { disabled: disabledClassName } = styles;

    return [
      classNames(styles.increase, { [disabledClassName]: max == null ? disabled : value >= max }),
      classNames(styles.decrease, { [disabledClassName]: min == null ? disabled : value <= min })
    ];
  }, [value, min, max, disabled]);

  return (
    <View className={classNames(styles.inputNumber, className)}>
      {controls && (
        <View className={decreaseClassName} onClick={onDecrease}>
          <Text className={styles.text}>-</Text>
        </View>
      )}
      <Input
        {...restProps}
        controlled
        type="number"
        onInput={onInput}
        confirmType="done"
        cursorSpacing={50}
        disabled={disabled}
        value={value.toString()}
        className={classNames('ui-input', styles.input)}
      />
      {controls && (
        <View className={increaseClassName} onClick={onIncrease}>
          <Text className={styles.text}>+</Text>
        </View>
      )}
    </View>
  );
});

复现步骤

输入,无法限定 Input 值 受控和非受控模式很怪异,非受控,除了初始化后面修改 value 不应该更新视图,受控才应该修改。 现在 controlled 模式就像个摆设! 不如学习 React Input 用 defaultValue 和 value 区分受控和非受控😁

期望结果

受控模式生效

实际结果

受控模式无效

环境信息

👽 Taro v3.5.1

  Taro CLI 3.5.1 environment info:
    System:
      OS: Windows 10
    Binaries:
      Node: 18.7.0 - C:\Program Files\nodejs\node.EXE
      Yarn: 1.22.19 - C:\Users\nuintun\AppData\Roaming\npm\yarn.CMD
      npm: 8.15.0 - C:\Program Files\nodejs\npm.CMD

input

nuintun avatar Aug 03 '22 08:08 nuintun

@nuintun 麻烦提供一下完整 demo

Chen-jj avatar Aug 09 '22 13:08 Chen-jj

@Chen-jj 缺失文件已补全

https://github.com/nuintun/taro-bugs

nuintun avatar Aug 10 '22 02:08 nuintun

@Chen-jj ??

nuintun avatar Aug 30 '22 02:08 nuintun