- 作者:老汪软件技巧
- 发表时间:2024-09-26 17:01
- 浏览量:
扯皮
最近已经上手公司的业务了,但是整体难度不大,因为大部分的总结都是写在了内网语雀里,外加下班后回家被杨戬各种虐,“猴欢喜” 的台词听了有小几十遍,所以好久没有在掘金上写过文章了
作为一名校招生进来接手的都是一些简单需求,能记录的还真不多,这不稍微有一点需要动脑子的咱就赶紧总结到掘金上,废话不多说先简单阐述一下问题背景:
目前我这边接手的是一个关于 AI 的移动端项目,脱敏描述一下就是一个移动端的 GPT,底层 AI 相关的内容肯定都是由蚂蚁的大佬们负责,我一个小前端只是简单对接一下流式接口展示下问答界面
所以视图上就是一个聊天窗口,这里拿百度的的文心一言来看,整体样式都大差不差:
我们在用户输入的文本框中做了不少工作,比如用户可以输入一些特殊字符如 / 来唤起该平台的一些内置指令场景,以此方便用户更针对性的提问,可以看到文心一言也有类似的功能:
只不过它这里是让用户自己创建,而我们是内置的,不管怎样从实现角度来看都只需要让文本框组件受控,监听用户输入内容展示对应的操作即可,所以这里针对于用户输入内容的处理堆积的逻辑还是很复杂的
直到有一天产品提出了这样的新需求,要实现大概这样的效果:
嘶,好家伙,好好的文本框直接升级成富文本了?文本框里还内置了其他表单组件?因此就有了这篇文章...
正文需求思考
一开始只拿到了设计同学出的设计稿,简单来说就是需要内置几套 prompt 喂给 AI,只不过这里的 prompt 又允许用户手动配置一些内容罢了...
但看着内部封装的一个将近 1000 行的 TextAreaInput 屎山代码我陷入的沉思... 内部实现的一个 / 快捷指令我看了下就有小几百行,而且完全属于硬编码没有考虑扩展性,现在依旧要在输入框上做文章,且又是新功能,那势必又要堆屎山了
除此之外,TextArea 真的能实现这样的效果么?这里有一个大大的问号,纯文本输入肯定是不可能了,所以第一时间想到的方案是内置这套模板结构将其定位到 TextArea 中,然后用户输入的内容根据该模板高度进行换行处理,想想都麻烦...
抛开 TextArea,社区中是否有可用的相关组件?不太行,说它是富文本也不完全是,调研了常见富文本编辑器都不太行,而且太过沉重,只是一个小需求没必要再引入一个第三方库解决,不过在调研过程中有注意到这样的一个属性:contentEditable
突然想到这个属性不是我第一次见了,记得刚开始校招培训时要求实现一个 ToDoList,期间要实现一个点击 item 编辑对应文本并进行保存的效果,当时以为就是需要创建额外 state 控制编辑态,点击时将文本替换为一个 Input 进行编辑,之后再切回来...
后来看了眼我们培训讲师的实现,只是利用了 contentEditable 属性就轻易的实现了这样的效果,就是样式有些丑,但还是学到了...
因此考虑到这个需求本身没有过多的定制要求,虽然有些特殊但是较为简单,所以直接靠 contentEditable 手搓个这样的效果完全没问题,因为现在公司用的是 React 技术栈,所以后续文章的实现都会转向 React,开搞!
组件封装确定组件 props 和样式搭建
既然要封装组件肯定要先确定其入口,这里简单起个名字就叫 ContenteditableInput 吧
需要哪些 props?当然要看我们的需求啦,因为我们就是在 div 上设置对应的 contentEditable 属性,组件实质上就是个 div 套壳,所以先继承 div 上的属性下来:
interface ContenteditableInputProps extends React.HTMLAttributes<HTMLDivElement> {
// your props...
}
为什么要先继承 div 的属性?因为原生 div 上也是能绑定一些输入表单相关事件的哦,有些属性就没必要再重复写上去了:
其次再来看我们这个需求都需要哪些相关的属性:
interface ContenteditableInputProps extends React.HTMLAttributes<HTMLDivElement> {
placeholder?: string;
additionalKey?: string; // 问答模板标识 id(方便获取 DOM)
additionalContent?: React.ReactNode; // 问答模板结构
onTextChange?: (text: string) => void; // 输入更新时对外抛出事件
}
前两个属性就不用说了,additionalContent 相关的就是我们要让用户传入的一些表单相关的内容模板
除此之外还需要向父组件提供一个 reset 方法,用来重置该组件为初始状态,具体等做到了再说:
interface ContenteditableInputActionType {
reset: () => void;
}
const ContenteditableInput = forwardRef<ContenteditableInputActionType, ContenteditableInputProps>((props, ref) => {
const reset = () => {
// ... //
};
useImperativeHandle(ref, () => ({
reset,
}));
return <div {...props}>div>;
});
export default ContenteditableInput;
基本属性确定了,现在开始来搭建基本样式,首先肯定要给 div 设置 contentEditable 属性,当然在 React 中如果只设置该属性,那么在 div 中设置初始值时会有以下警告:
React 官方文档给出了解决方式,设置 suppressContentEditableWarning 即可:
下面就简单来先来写写 CSS,给容器一个类名,补充如下 CSS 代码:
.editable-input-container {
box-sizing: border-box;
padding: 15px;
width: 100%;
height: 100px;
border: 1px solid #d9d9d9;
border-radius: 10px;
transition: border 0.3s;
overflow: auto;
}
.editable-input-container:focus {
outline: none; /* key */
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(5, 145, 255, 0.1);
}
其他属性没什么好说的,主要是这里聚焦时的 outline 需要设置为 none,因为只是开启 contentEditable 后聚焦会有这样的效果:
应该不会只有我一个人一开始觉得这丑到不行的边框是 border 吧?扒开 F12 看半天才发现是 outline,自己封装的话肯定把这里的边框换成自己的,这样就对胃了:
const ContenteditableInput = forwardRef<ContenteditableInputActionType, ContenteditableInputProps>((props, ref) => {
// ...
return (
<div
{...props}
contentEditable="true"
suppressContentEditableWarning
className={`editable-input-container ${className || ""}`}
>
hello
div>
);
});
下面实现一下原生 div 没有的属性:placeholder,通常有两种解法:CSS、JS,这里我们肯定选择纯 CSS 的实现,JS 还需要监听额外事件并保存对应的聚焦状态,想想都费劲
纯 CSS 的思路也很简单,既然 div 没有 placeholder 属性,那我们可以 data- 开头自定义属性,给它绑定对应的 placeholder 值:
const ContenteditableInput = forwardRef<ContenteditableInputActionType, ContenteditableInputProps>((props, ref) => {
// new
const { className, placeholder, ...otherProps } = props;
// ...
return (
<div
{...otherProps}
contentEditable="true"
suppressContentEditableWarning
className={`editable-input-container ${className || ""}`}
// new
data-placeholder={placeholder || "请输入..."}
>
hello
div>
);
});
下面就是 CSS 的表演了,通过伪类 + 伪元素的方式,在其 empty 状态下设置对应的 before 伪元素即可,至于 content 可以直接通过 attr() 获取我们上面的自定义属性:
.editable-input-container:empty::before {
content: attr(data-placeholder);
color: #bfbfbf;
}
关于 attr 可以自行查阅 MDN 文档,实现的 placeholder 效果还是不错的:
额外内容配置
下面就要针对于这次需求的核心功能:初始内容配置,因为在 div 中可以包裹任何元素,就包括我们最初展示的表单:
<div
{...props}
contentEditable="true"
suppressContentEditableWarning
className={`editable-input-container ${className || ""}`}
data-placeholder={placeholder || "请输入..."}
>
<Input placeholder="输入名称..." style={{ width: "200px" }} />
div>
但问题来了,这里的表单状态组件内该怎么维护呢?毕竟用户可以传入任意表单模板,甚至还包含其他文字。想想既然是用户定制化的,就全交给用户好了,想受控受控,爱咋咋地,我们一开始的 additionalContent 就是干这个的,不过这里最好给一个
来进行换行,以此来区分出配置内容和输入内容:
const ContenteditableInput = forwardRef<ContenteditableInputActionType, ContenteditableInputProps>((props, ref) => {
const { className, placeholder, additionalContent, ...otherProps } = props;
// ...
return (
<div
{...otherProps}
contentEditable="true"
suppressContentEditableWarning
className={`editable-input-container ${className || ""}`}
data-placeholder={placeholder || "请输入..."}
>
{/* new */}
{additionalContent}
{additionalContent && <br />}
div>
);
});
现在再在父组件中使用就能看到想要的效果了:
const CustomTemplate = () => {
return (
<div>
<span>请选择使用的框架:span>
<Select style={{ width: "100px" }} placeholder="请选择" />,<span>使用的状态管理库:span>
<Select style={{ width: "100px" }} placeholder="请选择" />
,请根据以上内容生成。
div>
);
};
function App() {
return (
<div style={{ padding: "15px" }}>
<ContenteditableInput style={{ height: "150px" }} additionalContent={<CustomTemplate />} />
div>
);
}
只有效果可不行,怎么拿到用户输入的内容呢?可以监听 input 事件,通过 DOM 的 textCotent 来获取内容:
"true"
suppressContentEditableWarning
className={`editable-input-container ${className || ""}`}
data-placeholder={placeholder || "请输入..."}
onInput={(e: any) => {
console.log("e:", e.target.textContent);
}}
>
{/* new */}
{additionalContent}
{additionalContent && <br />}
感觉不太对劲,这把用户初始配置的内容都携带上了,还有表单的 placeholder...
说好了针对于用户配置的内容让他们自己玩去,我们内部肯定要把这里的内容排除的,那就需要换个思路了,需要用户给我们提供一些信息辅助我们排除,那就是 additionalKey 属性
需要用户将配置的模板 DOM id 传递过来,这样我们内部就能够通过获取 DOM 的方式进行处理:
const ContenteditableInput = forwardRef<ContenteditableInputActionType, ContenteditableInputProps>((props, ref) => {
const { className, placeholder, additionalContent, additionalKey, onTextChange, ...otherProps } = props;
// new
const inputDomRef = useRef<HTMLDivElement>(null);
// ...
return (
<div
{...otherProps}
ref={inputDomRef}
contentEditable="true"
suppressContentEditableWarning
className={`editable-input-container ${className || ""}`}
data-placeholder={placeholder || "请输入..."}
onInput={(e: any) => {
// new
let text = "";
if (additionalKey) {
const children = inputDomRef.current!.childNodes;
children.forEach((item: any) => {
if (item.id !== additionalKey) {
text += item.textContent;
}
});
} else {
text = e.target.textContent;
}
onTextChange?.(text);
}}
>
{additionalContent}
{additionalContent && <br />}
div>
);
});
思路很简单,就是通过拿到我们的 div DOM 获取其孩子节点,输入内容时进行遍历与传入的 id 进行比较排除即可,来看父组件和效果:
const CustomTemplate = () => {
return (
<div id="Template">
<span>请选择使用的框架:span>
<Select style={{ width: "100px" }} placeholder="请选择" />,<span>使用的状态管理库:span>
<Select style={{ width: "100px" }} placeholder="请选择" />
,请根据以上内容生成。
div>
);
};
function App() {
return (
<div style={{ padding: "15px" }}>
<ContenteditableInput
style={{ height: "150px" }}
additionalContent={<CustomTemplate />}
additionalKey="Template"
onTextChange={(text) => {
console.log("text:", text);
}}
/>
div>
);
}
重置功能
contentEditable 允许我们编辑内部的任何内容,也可以直接编辑配置的内容,这显然不是我们想要的:
好像也没什么办法去阻止,或许可以通过监听其他事件来特殊判断,但耗时又耗力,跟产品 battle 可以折中一下,给用户提供一个重置操作,点击按钮后可以恢复原来初始状态,唉这就简单啦,那就是我们最开始定义的 reset 方法
实际上就是当用户点击按钮时需要强制刷新一下组件,那内部定义一个状态来触发 dispatch 就好了:
const ContenteditableInput = forwardRef<ContenteditableInputActionType, ContenteditableInputProps>((props, ref) => {
const { className, placeholder, additionalContent, additionalKey, onTextChange, ...otherProps } = props;
// new
const [, update] = useState(0);
const inputDomRef = useRef<HTMLDivElement>(null);
console.log("render");
// new
const reset = () => {
update((pre) => pre + 1);
};
useImperativeHandle(ref, () => ({
reset,
}));
// ...省略部分属性
return (
<div
{...otherProps}
ref={inputDomRef}
contentEditable="true"
suppressContentEditableWarning
>
{additionalContent}
{additionalContent && <br />}
div>
);
});
// 父组件
function App() {
// new
const inputDomRef = useRef<React.ComponentRef<typeof ContenteditableInput>>(null);
return (
<div style={{ padding: "15px" }}>
<ContenteditableInput
ref={inputDomRef}
style={{ height: "150px" }}
additionalContent={<CustomTemplate />}
additionalKey="template"
/>
<Button
type="primary"
onClick={() => {
inputDomRef.current?.reset();
}}
>
重置
Button>
div>
);
}
好像没有生效啊,点击按钮右侧日志打印确实更新组件了,但是内容没有还原:
检查看右侧 DOM 元素好像都没有刷新:
这时候就要思考一个问题,最外层的 div 进行重置肯定是在 diff 算法的过程中给排除掉了,现在我想要每次组件更新时也把 div 给强制更新了,该怎么做呢?
回想 diff 算法比对的依据,无论是 Vue 还是 React 都会以 key 属性作为唯一标识,即便这里的 key 属性主要使用在循环遍历中设置,但是也可以放在单一元素上,我们这样设置再来看看效果:
const ContenteditableInput = forwardRef<ContenteditableInputActionType, ContenteditableInputProps>((props, ref) => {
const { className, placeholder, additionalContent, additionalKey, onTextChange, ...otherProps } = props;
// new
const [key, update] = useState(0);
const inputDomRef = useRef<HTMLDivElement>(null);
const reset = () => {
update((pre) => pre + 1);
};
useImperativeHandle(ref, () => ({
reset,
}));
// ...省略部分属性
return (
<div
{...otherProps}
ref={inputDomRef}
// new
key={key}
contentEditable="true"
suppressContentEditableWarning
>
{additionalContent}
{additionalContent && <br />}
div>
);
});
符合预期!!!强制更改 key 属性来达到触发 diff 更新元素的目的,结束
End
整个组件实现起来还是比较简单的,核心就是 contentEditable 属性,但实际以上实现还有很多缺陷,比如删除初始内容再输入时会将输入的内容保持在初始 DOM 元素中而不是创建新元素,以及删除初始内容的过程中针对于表单项的删除十分怪异,后来跟产品沟通了一下都还可以接受就没有再去优化
从开始调研发现不少富文本编辑器都是基于 contentEditable 属性就说明可玩性极强,涉及到编辑器的内容都属于是大坑,所以有现成的还是直接用吧,不过像以上比较小的定制需求还是可以自己手搓看看的
源码如下:
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import "./index.css";
interface ContenteditableInputProps extends React.HTMLAttributes<HTMLDivElement> {
placeholder?: string;
additionalKey?: string; // 问答模板标识 id(方便获取 DOM)
additionalContent?: React.ReactNode; // 问答模板结构
onTextChange?: (text: string) => void; // 输入更新时对外抛出事件(问答)
}
interface ContenteditableInputActionType {
reset: () => void;
}
const ContenteditableInput = forwardRef<ContenteditableInputActionType, ContenteditableInputProps>((props, ref) => {
const { className, placeholder, additionalContent, additionalKey, onTextChange, ...otherProps } = props;
const [key, update] = useState(0);
const inputDomRef = useRef<HTMLDivElement>(null);
const reset = () => {
update((pre) => pre + 1);
};
useImperativeHandle(ref, () => ({
reset,
}));
return (
<div
{...otherProps}
ref={inputDomRef}
key={key}
contentEditable="true"
suppressContentEditableWarning
className={`editable-input-container ${className || ""}`}
data-placeholder={placeholder || "请输入..."}
onInput={(e: any) => {
let text = "";
if (additionalKey) {
const children = inputDomRef.current!.childNodes;
children.forEach((item: any) => {
if (item.id !== additionalKey) {
text += item.textContent;
}
});
} else {
text = e.target.textContent;
}
onTextChange?.(text);
}}
>
{additionalContent}
{additionalContent && <br />}
div>
);
});
export default ContenteditableInput;
.editable-input-container {
box-sizing: border-box;
padding: 15px;
width: 100%;
border: 1px solid #d9d9d9;
border-radius: 10px;
transition: border 0.3s;
overflow: auto;
}
.editable-input-container:focus {
outline: none;
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(5, 145, 255, 0.1);
}
.editable-input-container:empty::before {
content: attr(data-placeholder);
color: #bfbfbf;
}