本文已参与「新人创作礼」活动,一起开启掘金创作之路
1. 提出需求
产品又双叒叕提出了神奇需求,就是在进行文本框操作时候,文本框失焦时,判断文本是否存在某些特定词汇,如果存在,标记这些特定词汇为不同颜色,大概如下图。这一块的业务逻辑本身已经足够复杂了,现在加上这块需求简直雪上加霜(不是)。
2. 解决方案
由于这块表单一开始使用elementui做的,里面还加上了挺多复杂的业务逻辑的且耦合度超级高,如果要改造这一块的话需要保证写出一个Input组件,且输入输出和el-form-item基本一致,才能做到对整体最小的改动。如果只在input上改的话基本不可能,input里面没法加样式。后来想到了元素的一个属性:contenteditable 属性,这个属性可以将只读元素变为可编辑元素。
3. 理论存在,实践开始
3.1 可编辑元素--contenteditable
3.1.1 contenteditable
全局属性 contenteditable 是一个枚举属性,表示元素是否可被用户编辑。
如果可以,浏览器会修改元素的部件以允许编辑。
目前contenteditable属性属于非实验阶段的值有三个:true/false/plaintext-only
- true: 浏览器会修改元素的部件以允许编辑。
- false: 元素不是可编辑的。
- plaintext-only: 只允许编辑纯文本值(ctrl + shift + v)。 tips: 如果没给出该属性或设置了无效的属性值,则其默认值继承自父元素:即,如果父元素可编辑,该子元素也可编辑。 按照产品目前的需求,我这边需要使用plaintext-only这个值,不过看了一下感人的兼容性,我晒干了沉默,那就假设世界上没有火狐,先撸出来demo再说。
- 先给div加个contenteditable属性和一个自定义属性(placeholder)
<div class="input_wrapper">
<span class="input_label">测试</span>
<div contenteditable="plaintext-only" class="input_inner" data-text="请输入测试"
@focus="inputFocus"></div>
</div>
- 使用data-text模拟placeholder属性: 除去自定义属性外,还需要再样式那边加上一个伪类,才会出现placeholder
.input_inner:empty:before {
content: attr(data-text);
color: rgba(0, 0, 0, 0.08);
}
- 聚焦的时候,input外框变色
const inputFocus = (e) => {
const tar = e.target || e.srcEvent;
if (!tar.className.includes('is_focus')) {
tar.className += ' is_focus';
}
};
- 这样一个最最最基础的demo就写完了
3.1.2 光标乱飘
cv了好几个demo之后发现了一个挺严重的问题,👆上面的代码块应该可以看出来,先focus第一个输入框,然后点击第一个输入框和第二个输入框之间,还没有focus第二个输入框,光标已经再第二个输入框了。我真的谢
然后排查问题排查了好久以为差不多这个方案G的时候,发现原来是可编辑区域加上了display: inline-block导致的,我真的佛了,后续加上定位之后就不会乱飘了,👆上面的代码块可以试试。
3.2 标记输入框内文字不同颜色
接下来时核心功能:失焦后标记输入框内文字不同颜色。我这边采用的方案是遍历特定词汇数组和输入框内文本(联调之后是后端返回给我,我这边暂时先瞎写一下),如果文本内存在特定词汇,就替换掉可编辑区域的innerHTML来实现。
<!-- 父组件 -->
<CustomziInput
v-model="someValue"
:audit="['violence', 'fantasticy']"
:sensitive="['pornography', 'forg']"
/>
父组件传的audit是显示红色特定词汇数组,sensitive是显示黄色特定词汇数组。
<!-- 子组件 -->
<div
contenteditable="plaintext-only"
class="input_inner"
@blur="inputBlur"
ref="inner"
></div>
<script lang="ts">
import { defineComponent, onMounted, ref, computed } from '@vue/composition-api';
export default defineComponent({
props: {
// 父组件v-model值
value: {
default: '',
type: String,
},
// 父组件传入第一个特定词汇数组
audit: {
default: () => [],
type: Array,
},
// 父组件传入第一个特定词汇数组
sensitive: {
default: () => [],
type: Array,
},
},
setup(){
const inner = ref(null); // ref
const inputBlur = (e) => {};
return {
inner,
}
}
});
</script>
这块逻辑主要是循环判断匹配所有的特定词汇,并加上样式(这里样式应该由父组件传入,我这边偷懒就暂时这么写了)
const inputBlur = (e) => {
const tar = e.target || e.srcEvent;
if (tar.className.includes('is_focus')) {
tar.className = 'input_inner';
}
let text = tar.innerText;
props.sensitive.map((item) => {
text = text.replace(new RegExp(item, 'g'), `<span class='sensitive_text'>${item}</span>`);
});
props.audit.map((item) => {
text = text.replace(new RegExp(item, 'g'), `<span class='audit_text'>${item}</span>`);
});
if ((text.includes('audit_text') && !tar.className.includes('is_audit'))) {
tar.className += ' is_audit';
} else if (text.includes('sensitive_text') && !tar.className.includes('is_sensitive')) {
tar.className += ' is_sensitive';
} else {
tar.className = 'input_inner';
}
tar.innerHTML = text;
emit('input', tar.innerText);
};
.input_wrapper {
margin-bottom: 40px;
height: 40px;
width: 600px;
position: relative;
.input_label {
width: 90px;
margin-right: 12px;
box-sizing: border-box;
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
float: left;
&:before {
content: '*';
color: transparent;
margin-right: 4px;
}
}
.input_inner {
-webkit-user-select: text;
float: left;
background-color: #fff;
background-image: none;
border-radius: 4px;
border: 1px solid #dcdfe6;
color: #606266;
font-size: inherit;
box-sizing: border-box;
padding-left: 15px;
padding-right: 50px;
height: 40px;
line-height: 40px;
transition: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
border-radius: 6px;
width: calc(100% - 102px);
outline: none;
&:empty:before {
content: attr(data-text);
color: rgba(0, 0, 0, 0.08);
}
}
.is_required {
&:before {
content: '*';
color: #f56c6c;
margin-right: 4px;
}
}
.is_focus {
border: 1px solid #1860ff;
}
.is_audit {
border: 1px solid red;
}
.is_sensitive {
border: 1px solid yellow;
}
}
.sensitive_text {
color: yellow;
}
.audit_text {
color: red;
}
这里还有个需要注意的点是input框不能回车,但是加上contenteditable属性的元素是可以回车的,所以这里需要阻止键盘的默认事件
<div
contenteditable="plaintext-only"
class="input_inner"
@blur="inputBlur"
@keydown="enter"
ref="inner"
></div>
const enter = (e) => {
if (e.keyCode == 13) {
e.preventDefault();
// 如果回车加上失焦功能
const tar = e.target || e.srcEvent;
tar.blur();
}
};
这样基本就能实现了,我认为比较困难的是不破坏原有代码结构和功能,需要将这部分功能封装成和el-form-item类似的组件。
3.3 模拟 elementui传参
现在加上input框和表单项的一些属性,因为项目中暂时只用到这些,所以暂时只加上了placeholder/isRequire/maxLength/rule这几个属性。
3.3.1 label&必填项&placeholder
<!-- 父组件 -->
<CustomziInput
label="necessary"
v-model="someValue"
:isRequire="true"
placeholder="请输入内容"
:audit="['violence', 'fantasticy']"
:sensitive="['pornography', 'forg']"
/>
如果是必填项,在组件前面加个*
<!-- 子组件 -->
<div class="input_wrapper">
<span class="input_label" :class="isRequire && 'is_required'"
>{{ label ? label + ':' : '' }}</span
>
<div
contenteditable="plaintext-only"
:data-text="placeholder"
class="input_inner"
@blur="inputBlur"
@keydown="enter"
ref="inner"
></div>
</div>
props: {
placeholder: {
default: '请输入内容',
},
label: {
default: '',
},
isRequire: {
default: false,
},
value: {
default: '',
type: String,
},
audit: {
default: () => [],
type: Array,
},
sensitive: {
default: () => [],
type: Array,
},
},
3.3.2 校验规则&提示语
模拟校验规则还是比较麻烦的,因为要保证入参和出参大致相同。先来模拟一下父组件的入参:
const inputRule = [
{ required: true, message: 'Please enter required fields', trigger: 'blur' },
{ min: 3, max: 10, message: '长度在 3 到 10 个字符', trigger: 'blur' },
];
因为校验规则需要对事件监听,可能一个事件需要对应多个操作,所以这里使用addEventListener(一个事件可以重复写且不会被覆盖),而且入参的规则触发条件有可能是数组:['blur','change']。 所以我这边在mounted钩子这里取循环规则数组,然后监听。 先在子组件这边加上一个校验未通过提示语。
<div class="tips_message">{{ tips }}</div>
const tips = ref<string>(''); // tips
在mounted时候我这里区分了触发条件tigger是数组还是字符串。同时,校验规则有两个默认规则:是否必填项和最大最小长度判断。 判断是字符串还是数组我这边用了Object.prototype.toString.call(),我感觉这里写的挺孬的,后续再看怎么改吧=_=
onMounted(() => {
props.value && (inner.value.innerText = props.value);
for (let i = 0; i < props.rule.length; i++) {
let _item = props.rule[i] as any;
if (Object.prototype.toString.call(_item.trigger) === '[object String]') {
if (_item.required) {
判断必填项(_item.trigger);
}
if (_item.min || _item.max) {
判断最大长度(_item.trigger);
}
}
if (Object.prototype.toString.call(_item.trigger) === '[object Array]') {
_item.trigger.map((item) => {
if (_item.required) {
判断必填项(item);
}
if (_item.min || _item.max) {
判断最大长度(item);
}
});
}
}
});
先来写对必填项的判断:如果输入框内的值trim之后不为空,清空必填项的提示语,否则提示语为传入的rule的message。
const requireFn = (trigger: string) => {
inner.value.addEventListener(
trigger,
() => {
if (!props.value.trim()) {
tips.value = _item.message;
} else {
tips.value = '';
}
},
true
);
};
然后是对长度的判断:当输入框内存在值的时候(这里一定要加,不然的话提示语tips会被替换),如果当前输入框内值不满足规则长度需求,提示语为传入rule的message,否则清空。
const minMaxFn = (trigger: string) => {
inner.value.addEventListener(
trigger,
() => {
let vl = props.value.length;
if (props.value.trim()) {
if (vl < _item.min || vl > _item.max) {
tips.value = _item.message;
} else {
tips.value = '';
}
}
},
true
);
};
现在已经基本实现了: 输入框为空的时候 ->
不满足字数要求的时候 ->
3.3.3 长度限制
input框有个功能就是字数超过限制的时候就禁止输入了,所以需要再加上一个input的事件。注意!是input事件不能使用change事件是无效的。
<div v-if="maxLength" class="max_length">{{ value.length }}/{{ maxLength }}</div>
const inputChange = () => {
if (props.maxLength && inner.value.innerText.length >= props.maxLength) {
inner.value.innerText = inner.value.innerText.slice(0, props.maxLength);
}
emit('input', inner.value.innerText);
inner.value.className = 'input_inner is_focus';
};
3.3.4 光标乱飘 x2
加上长度限制的时候,当字数超过限制的时候进行了slice操作,这个操作会导致光标移到首位,需要在这个操作之后,将光标移到文本最后。
const inputChange = () => {
if (props.maxLength && inner.value.innerText.length >= props.maxLength) {
inner.value.innerText = inner.value.innerText.slice(0, props.maxLength);
if (window.getSelection) {
let range = window.getSelection();
range.selectAllChildren(inner.value); //range 选择obj下所有子内容
range.collapseToEnd(); //光标移至最后
}
}
emit('input', inner.value.innerText);
inner.value.className = 'input_inner is_focus';
};
3.4 代码
代码放这里了 代码
4. 总结
到这里基本写完了,但是还是存在很多问题没有考虑,比如说自定义的校验规则函数怎么写,提交表单时的异步校验等等等,还有关于contenteditable属性的兼容性问题,光标乱飘等,甚至想直接用封装好的富文本库,这些写出来的话后续补充。=_=