背景
最近接到了一个需求,要求在密码框输入$
符号时出现一个列表,点击列表中的某一项,自动填充密码输入框中的value值,并且输入框中需要支持明密文切换
。
我立马想到了聊天输入框中输入@
就会提及某人或某事这个场景,而Antd的Mentions组件就具备这种功能:
目标
手写一个Input.Password与Mentions功能结合的组件
需要解决的问题
- 输入
@
符号出现的成员列表出现在输入框下面,并且层级更高。点击页面其他地方成员列表需要消失。 - 点击目标成员之后,需要动态改变输入框中显示的值,这就涉及到输入框
光标处插值
以及重新设置光标位置
的问题 - 支持
明密文切换
。密文状态下,也可以支持@功能,并且点击成员之后,在输入框里显示的值是密文。
涉及知识点
- 明密文切换 -> 直接使用原生input里的type=“password”属性
光标处插值
以及重新设置光标位置
-> 使用dom元素的selectionEnd
与setSelectionRange
方法- 点击页面其他地方成员列表需要消失 -> 直接使用
ahooks
库里的useClickAway
方法 - 成员列表出现在输入框下面,并且层级更高 -> 使用
rc-trigger
- setState更新值的异步问题
- 受控组件与非受控组件
const dom = document.getElementById('id'); const idx = dom.selectionEnd; // 获取鼠标光标的位置 dom.setSelectionRange(startIndex, endIndex); // 自定义选中输入框里的文字范围 复制代码
踩坑记录
1.不能基于Antd的Mentions组件进行实现吗?
在开始手写组件之前,笔者确实有尝试过直接基于Antd的Mentions组件进行二次封装或改造,但是发现还是太天真了。
笔者先是通过ref去获取到mentions这个组件实例:
其中,当我们在页面里进行输入时,就会触发mentions组件里的textarea的原生onChange事件,从而触发其内部的triggerChange方法,方法内部去改变textarea组件值,也就是说,内部的textarea是一个受控组件。
内部源码:
此时,我如果要实现我的需求,我需要去动态更改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> ) } 复制代码