扯皮
最近已经上手公司的业务了,但是整体难度不大,因为大部分的总结都是写在了内网语雀里,外加下班后回家被杨戬各种虐,“猴欢喜” 的台词听了有小几十遍🤐,所以好久没有在掘金上写过文章了😶
作为一名校招生进来接手的都是一些简单需求,能记录的还真不多,这不稍微有一点需要动脑子的咱就赶紧总结到掘金上🤪,废话不多说先简单阐述一下问题背景:
目前我这边接手的是一个关于 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 上的属性下来:
ts
代码解读
复制代码
interface ContenteditableInputProps extends React.HTMLAttributes<HTMLDivElement> { // your props... }
为什么要先继承 div 的属性?因为原生 div 上也是能绑定一些输入表单相关事件的哦,有些属性就没必要再重复写上去了:
其次再来看我们这个需求都需要哪些相关的属性:
ts
代码解读
复制代码
interface ContenteditableInputProps extends React.HTMLAttributes<HTMLDivElement> { placeholder?: string; additionalKey?: string; // 问答模板标识 id(方便获取 DOM) additionalContent?: React.ReactNode; // 问答模板结构 onTextChange?: (text: string) => void; // 输入更新时对外抛出事件 }
前两个属性就不用说了,additionalContent 相关的就是我们要让用户传入的一些表单相关的内容模板
除此之外还需要向父组件提供一个 reset 方法,用来重置该组件为初始状态,具体等做到了再说:
ts
代码解读
复制代码
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 代码:
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,自己封装的话肯定把这里的边框换成自己的,这样就对胃了:
tsx
代码解读
复制代码
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 值:
tsx
代码解读
复制代码
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() 获取我们上面的自定义属性:
css
代码解读
复制代码
.editable-input-container:empty::before { content: attr(data-placeholder); color: #bfbfbf; }
关于 attr 可以自行查阅 MDN 文档,实现的 placeholder 效果还是不错的:
额外内容配置
下面就要针对于这次需求的核心功能:初始内容配置,因为在 div 中可以包裹任何元素,就包括我们最初展示的表单:
html
代码解读
复制代码
<div {...props} contentEditable="true" suppressContentEditableWarning className={`editable-input-container ${className || ""}`} data-placeholder={placeholder || "请输入..."} > <Input placeholder="输入名称..." style={{ width: "200px" }} /> </div>
但问题来了,这里的表单状态组件内该怎么维护呢🤔?毕竟用户可以传入任意表单模板,甚至还包含其他文字。想想既然是用户定制化的,就全交给用户好了,想受控受控,爱咋咋地🙃,我们一开始的 additionalContent 就是干这个的,不过这里最好给一个 <br />
来进行换行,以此来区分出配置内容和输入内容:
tsx
代码解读
复制代码
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> ); });
现在再在父组件中使用就能看到想要的效果了:
tsx
代码解读
复制代码
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 来获取内容:
tsx
代码解读
复制代码
<div {...otherProps} contentEditable="true" suppressContentEditableWarning className={`editable-input-container ${className || ""}`} data-placeholder={placeholder || "请输入..."} onInput={(e: any) => { console.log("e:", e.target.textContent); }} > {/* new */} {additionalContent} {additionalContent && <br />} </div>
感觉不太对劲🤔,这把用户初始配置的内容都携带上了,还有表单的 placeholder...🤣
说好了针对于用户配置的内容让他们自己玩去,我们内部肯定要把这里的内容排除的,那就需要换个思路了,需要用户给我们提供一些信息辅助我们排除,那就是 additionalKey 属性
需要用户将配置的模板 DOM id 传递过来,这样我们内部就能够通过获取 DOM 的方式进行处理:
tsx
代码解读
复制代码
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 进行比较排除即可,来看父组件和效果:
tsx
代码解读
复制代码
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 就好了:
tsx
代码解读
复制代码
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 属性主要使用在循环遍历中设置,但是也可以放在单一元素上,我们这样设置再来看看效果:
tsx
代码解读
复制代码
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 属性就说明可玩性极强,涉及到编辑器的内容都属于是大坑,所以有现成的还是直接用吧,不过像以上比较小的定制需求还是可以自己手搓看看的
源码如下:
tsx
代码解读
复制代码
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;
css
代码解读
复制代码
.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; }
原文链接:https://juejin.cn/post/7418413333622014015