Taroify's Stepper 源码感悟

316 阅读3分钟

本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
这是源码共读的第38期,链接:经常用 vant-weapp 开发小程序,却不知道如何开发一个组件?学!

这是我第一次写文章,以往也都是看了源码,然后...就没有然后了,在偶然的机会下,接触到了若川的这个计划,个人感觉蛮好,就试着参加了这个计划。

Taroify

Taroify 是移动端组件库 Vant 的 Taro React 版本,往常我经常使用 antd 的组件库,当前主要项目多用于 PC 端,这期的源码读取是 Stepper 组件。

结构

Stepper 的结构当前为 3 个部分:

  1. stepper.tsx 主要控制组件的输出和 Stepper 的主要逻辑
  2. stepper-button.tsx 就是作为 steppper 的按钮 => '+'/'-'
  3. stepper-input.tsx 是作为 用户输入的控制

主要是通过这 3 个部分仅限构建

通讯

Stepper 的数据源通信主要通过,Stepper 的 Props, 和 组件内部的 context 来通讯。 Stepper 的 Props 数据输入,这毫无问题,进行正常的数据(正常操作),主要部分是内部的 context 通讯。

    <StepperContext.Provider
      value={{
        value: valueRef.current,
        min,
        max,
        size,
        disabled,
        precision,
        longPress,
        formatValue,
        onChange: setValue,
        onStep,
      }}
    >
      <View
        className={classNames(
          prefixClassname("stepper"),
          {
            [prefixClassname("stepper--square")]: shape === "square",
            [prefixClassname("stepper--rounded")]: shape === "rounded",
            [prefixClassname("stepper--circular")]: shape === "circular" || shape === "round",
          },
          className,
        )}
        {...restProps}
      >
        {decrease}
        {input}
        {increase}
      </View>
    </StepperContext.Provider>

StepperContext 来组织,组件之间的数据传递,这当前有个好处,组件之间所有的 context 都一样,也就保证了组件内的数据输入的统一性。

逻辑

Stepper 的值逻辑在于 Stepper.tsx 函数中

  const onStep = useCallback(
    (actionType: StepperActionType) => {
      const diff = actionType === "decrease" ? -step : +step
      setValue(formatValue(addNumber(valueRef.current as number, diff)))
    },
    [formatValue, setValue, step, valueRef],
  )

这函数,保证了你点击按钮的时候,主逻辑由 stepper.tsx 掌控,而值的输入有 formatValue 控制,保证输入值的有效性

const formatValue = useCallback(
    (value: string | number) => {
      if (value === "") {
        return value
      }

      value = formatNumber(String(value), precision > 0)
      value = value === "" ? 0 : +value
      value = Number.isNaN(value) ? +min : value
      value = Math.max(Math.min(+max, value), +min)

      // format decimal
      if (precision > 0) {
        value = value.toFixed(+precision)
      }

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

而对于 stepper 组件内的小组件 children 的生成,主要由 useStepperChildren 输出

function useStepperChildren(children?: ReactNode): StepperChildren {
  return useMemo(() => {
    const __children__: StepperChildren = {}

    Children.forEach(children, (child: ReactNode) => {
      if (!isValidElement(child)) {
        return
      }

      const element = child as ReactElement
      const elementType = element.type

      if (elementType === StepperButton) {
        if (__children__.decrease === undefined) {
          __children__.decrease = cloneElement(element, {
            type: "decrease",
          })
        } else if (__children__.increase === undefined) {
          __children__.increase = cloneElement(element, {
            type: "increase",
          })
        }
      } else if (elementType === StepperInput) {
        __children__.input = element
      }
    })

    if (!children) {
      const element = <StepperButton />
      if (__children__.decrease === undefined) {
        __children__.decrease = cloneElement(element, { type: "decrease" })
      }

      if (__children__.input === undefined) {
        __children__.input = <StepperInput />
      }

      if (__children__.increase === undefined) {
        __children__.increase = cloneElement(element, { type: "increase" })
      }
    }

    return __children__
  }, [children])
}

这个函数保证了,在你 children 值没有传递时,能够正常的渲染出来,设置了预设。 这是当前主要的一个逻辑映射,下面是一些内部小组件的逻辑。

stepper-button

  const longPressStep = useCallback(() => {
    longPressTimerRef.current = setTimeout(() => {
      onStep?.(type)
      longPressStep()
    }, LONG_PRESS_INTERVAL)
  }, [onStep, type])

  const handleTouchStart = useCallback(() => {
    if (longPress) {
      longPressRef.current = false
      if (longPressTimerRef.current) {
        clearTimeout(longPressTimerRef.current)
      }
      longPressTimerRef.current = setTimeout(() => {
        longPressRef.current = true
        onStep?.(type)
        longPressStep()
      }, LONG_PRESS_START_TIME)
    }
  }, [longPress, longPressStep, onStep, type])

  const handleTouchEnd = useCallback(
    (event: ITouchEvent) => {
      if (longPress) {
        if (longPressTimerRef.current) {
          clearTimeout(longPressTimerRef.current)
        }
        if (longPressRef.current) {
          preventDefault(event)
        }
      }
    },
    [longPress],
  )

button 这部分呢,主要是通过 setTimeout 进行一个防抖操作,已防止用户多次频繁点击,而导致数值变动异常。

stepper-input

主要是通过 onInput 进行数值的更改操作,但只有在 onBlur 失焦时,才会更新父内容,而保证,减少兄弟组件的重复 render

  const handleFocus = useCallback(
    (event: BaseEventOrig<InputProps.inputForceEventDetail>) => {
      // readonly not work in legacy mobile safari
      if (disabledProp) {
        rootRef.current?.blur()
      } else {
        onFocus?.(event)
      }
    },
    [disabledProp, onFocus],
  )

  const onInput = useCallback(
    ({ detail }: BaseEventOrig<InputProps.inputEventDetail>) => {
      const { value: inputValue } = detail

      let formatted = formatNumber(String(inputValue), digit)

      // limit max decimal length
      if (precision > 0 && formatted.includes(".")) {
        const pair = formatted.split(".")
        formatted = `${pair[0]}.${pair[1].slice(0, precision)}`
      }

      // prefer number type
      const isNumeric = formatted === String(+formatted)
      setValue(isNumeric ? +formatted : formatted)
    },
    [digit, precision],
  )

  const onBlur = useCallback(
    ({ detail }: BaseEventOrig<InputProps.inputValueEventDetail>) => {
      const { value: inputValue } = detail
      const value = formatValue?.(inputValue)
      setValue(value)
      onChange?.(value)
    },
    [formatValue, onChange],
  )

总结

在这次 Stepper 源码阅读中,我们可以看到,我们尽量把内容放在组件内部,减少其他兄弟级的组件进行无用的重复渲染,在有 button 的前提下,我们可以通过防抖来处理,以减少无用的渲染或数值的频繁更新。