一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第1天,点击查看活动详情。
背景
最近接到了一个需求,要求在密码框输入$符号时出现一个列表,点击列表中的某一项,自动填充密码输入框中的value值,并且输入框中需要支持明密文切换。
我立马想到了聊天输入框中输入@就会提及某人或某事这个场景,而Antd的Mentions组件就具备这种功能:
但是还需要支持明密文切换,纳尼?Mentions组件没这功能啊!这是要我把密码输入框与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>
)
}