手写一个Input.Password与Mentions功能结合的组件

1,540 阅读1分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第1天,点击查看活动详情

背景

最近接到了一个需求,要求在密码框输入$符号时出现一个列表,点击列表中的某一项,自动填充密码输入框中的value值,并且输入框中需要支持明密文切换

我立马想到了聊天输入框中输入@就会提及某人或某事这个场景,而Antd的Mentions组件就具备这种功能:

新建画布 (1).png 但是还需要支持明密文切换,纳尼?Mentions组件没这功能啊!这是要我把密码输入框与Mentions组件的功能相结合吗?额...今日无法摸鱼了...

目标

手写一个Input.Password与Mentions功能结合的组件

需要解决的问题

  1. 输入@符号出现的成员列表出现在输入框下面,并且层级更高。点击页面其他地方成员列表需要消失。
  2. 点击目标成员之后,需要动态改变输入框中显示的值,这就涉及到输入框光标处插值以及重新设置光标位置的问题
  3. 支持明密文切换。密文状态下,也可以支持@功能,并且点击成员之后,在输入框里显示的值是密文。

涉及知识点

  1. 明密文切换 -> 直接使用原生input里的type=“password”属性
  2. 光标处插值以及重新设置光标位置 -> 使用dom元素的selectionEndsetSelectionRange方法
  3. 点击页面其他地方成员列表需要消失 -> 直接使用ahooks库里的useClickAway方法
  4. 成员列表出现在输入框下面,并且层级更高 -> 使用rc-trigger
  5. setState更新值的异步问题
  6. 受控组件与非受控组件
const dom = document.getElementById('id');
const idx = dom.selectionEnd; // 获取鼠标光标的位置
dom.setSelectionRange(startIndex, endIndex);  // 自定义选中输入框里的文字范围

踩坑记录

1.不能基于Antd的Mentions组件进行实现吗?

在开始手写组件之前,笔者确实有尝试过直接基于Antd的Mentions组件进行二次封装或改造,但是发现还是太天真了。

笔者先是通过ref去获取到mentions这个组件实例:

image.png

其中,当我们在页面里进行输入时,就会触发mentions组件里的textarea的原生onChange事件,从而触发其内部的triggerChange方法,方法内部去改变textarea组件值,也就是说,内部的textarea是一个受控组件。

内部源码: image.png

此时,我如果要实现我的需求,我需要去动态更改textarea组件值,然而mentions组件暴露出来的方法我们能用到的也只是triggerChange事件, 我们在业务代码里,本身是通过监听Mensions组件的onChange事件才需要去动态更改textarea值,这样的话,就会陷入循环调用,进行报错。

2.Antd的Mentions是基于textarea实现的,这里为何要替换成input?

这个其实就是是否要自己实现一个密码输入框的问题。笔者这里因为业务需求比较急,这里就不深入去探讨如何实现了,就直接使用原生的input密码框了。有兴趣或有想法的朋友可以留言评论。

3.setSelectionRange方法失效?造成自定义光标位置无法实现?

笔者一开始是使用state值来控制输入框显示的值,使得输入框变成了受控组件,但是由于React中的useState更新数据会有一定的延迟,当执行到setSelectionRange方法时,其实页面中的dom元素还没有更新,当setSelectionRange方法之后,dom元素进行了更新,使得元素进行重新绘制,也就自然导致方法失效了。

另外需要注意的就是需要先聚焦,setSelectionRange才能生效。

const onSelect = (value: string) => {
    // 光标插值
    const selectionEndIdx = getSelectionEndIdx(); // 光标位置
    const newValue = inputText.slice(0, selectionEndIdx) + value + inputText.slice(selectionEndIdx)
    // setInputText(newValue);  这里弄成了受控组件,使用下一行代码,摆脱这个问题
    inputRef.current.value = newValue;
    // 重新设置鼠标光标位置
    const idx = selectionEndIdx + value.length
    inputRef.current.focus();   // 这里需要先聚焦,setSelectionRange才能生效
    inputRef.current.setSelectionRange(0, 0)
  }
  

4.如何判断一个字符串是否是以某个子字符串开头?

const isTargetStart = strCode.indexOf("ssss"); 

// isTargetStart === 0 表示strCode是以ssss开头
// isTargetStart === -1 表示strCode不是以ssss开头

最终代码

import { useClickAway } from "ahooks";
import React from 'react';
import Trigger from 'rc-trigger';
import { EyeInvisibleOutlined, EyeTwoTone } from '@ant-design/icons';

const optionsBase = [
  { name: '1' },
  { name: '2' }
]
export const MentionsWithPassword = (): React.ReactElement => {
  const [showPwd, setShowPwd] = React.useState(true);
  const [popupVisible, setPopupVisible] = React.useState(false);
  const popupRef = React.useRef<any>(null);
  const inputRef = React.useRef<any>(null);
  const [visibleOption, setVisibleOptions] = React.useState(optionsBase);

  useClickAway(() => {
    setPopupVisible(false)
  }, popupRef);

  const onPwdVisibleChange = () => {
    setShowPwd(!showPwd);
  }

  // 判断是否要出现引用参数列表
  const calcPopupVisible = (text: string) => {
    const selectionEndIdx = getSelectionEndIdx(); // 光标位置
    const textBeforeSelectionEnd = text.substring(0, selectionEndIdx);  // 光标前面的文字
    const lastIdx = textBeforeSelectionEnd.lastIndexOf('$');
    // 1.无匹配字符
    if (lastIdx === -1) return;
    // 2.匹配字符在光标位置
    if (lastIdx === textBeforeSelectionEnd.length - 1) {
      setPopupVisible(true);
      setVisibleOptions(optionsBase)
      return;
    }
    // 3.不止输入了匹配字符,还输入了其他字符
    const centerText = textBeforeSelectionEnd.substring(lastIdx + 1);
    if (centerText.includes(' ')) {
      setPopupVisible(false)
      return;
    }
    setPopupVisible(true);
    setVisibleOptions(getFilterOptions(centerText, options));
  }

  // 获取光标位置
  const getSelectionEndIdx = () => {
    return inputRef.current.selectionEnd;
  }

  // 过滤引用参数列表
  const getFilterOptions = (text: string, options: any[]) => {
    return options.filter(item => item.name.indexOf(text) > -1)
  }

  const onChange = (e: any) => {
    const value = e.target.value;
    calcPopupVisible(value);
  }
  const onSelect = (value: string) => {
    // 光标插值
    const selectionEndIdx = getSelectionEndIdx(); // 光标位置
    const oldInputText = inputRef.current.value;
    const newValue = oldInputText.slice(0, selectionEndIdx) + value + oldInputText.slice(selectionEndIdx)
    // setInputText(newValue);
    inputRef.current.value = newValue;
    // 重新设置鼠标光标位置
    const idx = selectionEndIdx + value.length
    inputRef.current.focus();
    inputRef.current.setSelectionRange(idx, idx);
  }

  return (
    <div>
      <Trigger
        popup={(
          <div ref={popupRef} style={{ background: 'red' }}>
            {visibleOption.map(item => <div onClick={() => onSelect(item.name)}>{item.name}</div>)}
          </div>
        )}
        destroyPopupOnHide
        popupVisible={popupVisible}
        popupAlign={{
          points: ['tl', 'bl'],
          offset: [0, 3]
        }}
      >
        <input
          type={showPwd ? 'text' : 'password'}
          onChange={onChange}
          ref={inputRef}
        />
      </Trigger>
      <span onClick={onPwdVisibleChange}>{showPwd ? <EyeInvisibleOutlined /> : <EyeTwoTone />}</span>
    </div>
  )
}

参考链接

  1. HTMLInputElement.setSelectionRange() - MDN