• 作者:老汪软件技巧
  • 发表时间: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;
}