近期因为产品迭代,须要新增一个评论性能,且须要反对插入自定义表情。评论性能很多人一开始跟我一样,第一个想到的就是用textarea,然而textarea是不反对的插入图片的,因为咱们的表情包是以图片的模式插入文本中的,所以这里是应用HTML5的新个性contenteditable,让div里的内容变成可编辑的。
demo地址

先看下效果图:

组件性能:

  • 反对插入自定义表情
  • 字符验证,超出局部主动切割(以字符进行计算,不是以长度进行计算,一个中文2个字符,一个字母1个字符,一个表情4个字符),因为咱们公司业务是应用字符进行字数统计,所以验证是用字符,须要用length能够自行批改
  • 在光标地位精确插入表情

遇到的问题:(先列出问题,前面再写解决办法)

  • 如果有对显示的内容进行强制更新时(如插入表情包,或者字符切割),光标会跑到文本最后面
  • 无奈间接利用vue进行双向绑定,无奈用v-model与v-html进行数据双向绑定
  • 定位光标地位,如果是在输出时就点击表情包 应该插入到最光标地位,如果不是在输出时候点击表情应该插入到文本到最初面
  • 进行文本字节计算时对表情的解决,表情占位符如何保障惟一
  • 超出字数限度如何切割 当超出的字符是表情的时候怎么切割
  • 按下回车会插入<div><br></div>标签,或者会用<div>标签包裹住文本
  • 按下空格符会有&nbsp;,以字符进行计算的话空格符&nbsp;会被当成6个字符,实际上空格代表1个
兼容性问题:
  • IE9以及局部Safari createContextualFragment 不兼容该办法
  • window.getSelection , window.getSelection().getRangeAt这两个办法的兼容性,因为咱们的业务不须要兼容低版本的IE所以这里我就没做兼容

开始实现

html局部于css局部比较简单就不过多赘述,先不贴代码了,文末看残缺代码或者间接下载残缺小demo运行看看

参数

既然是封装成组件,那就须要从父组件那里接管一下数据

  submitCmtLoading: {   //  是否正在提交,如果是正在提交就不须要反复提交了      type: Boolean,      default: false    },    limtText: {    //  限度的字符数,默认是0,0是不须要限度      type: Number,      default: 0    },    iconWidth: {  //  插入的表情宽度      type: Number,      dafault: 24    },    iconHeight: {  //  插入的表情高度      type: Number,      dafault: 24    },    cmt_show: {  // 是否显示组件      type: Boolean,      default: true    },    iconList: {  // 表情包列表,以数组的模式且是必传项      type: Array,      required: true    },    placeholder: {  // 提醒语      type: String,      default: "踊跃回复可吸引更多人评论"    },    info: {  // 信息,这个看业务需要,如果须要对具体项在子组件中进行解决能够传递这个      type: Object    }

因为还没贴上html代码,下文中呈现的this.$refs.cmt_input都代表输入框,既带属性contenteditable的元素

数据

  data: function() {    return {      content: "",  //  评论输出的内容      widFouce: "",   // 用于定位输入框失焦点前的光标地位      rangeFouce: "",  // 用于定位输入框失焦点前的光标地位      iconShow: false,  // 是否显示表情列表      isSubmit: false  //  是否提交  如果输出内容为空是不给提交的    };  },  watch: {    cmt_show: {   //  当组件显示时须要将光标定位到文末,不然第一次点击表情包会报错      handler(value) {        if (value) {          this.$nextTick(() => {            this.toLast(this.$refs.cmt_input);  //  将光标定位到文末          });        }      },      immediate: true    }  },

在表情列表开展的时候,点击其余非表情列表区域要让这个表情列表隐没。

 mounted() {    const self = this;    document      .getElementsByClassName("g-doc")[0]      .addEventListener("click", self.setHideClick);     //  g-doc是根节点,当然也能够换成body啥的,然而在组件销毁的时候要把工夫也给移除掉    self.$once("hook:beforeDestroy", () => {      document.getElementsByClassName("g-doc")[0] &&        document          .getElementsByClassName("g-doc")[0]          .removeEventListener("click", self.setHideClick);    });  },  //  setHideClick 办法,放在method里的      setHideClick(event) {      let target = event.srcElement;      let nameList = ["icon_item", "icon_list", "icon_box"];  // 这个三个类名是整个表情包列表      if (        !nameList.includes(target.className) &&        !nameList.includes(target.parentElement.className)      ) { // 如果不是表情包列表区域就敞开表情包列表面板        this.iconShow = false;      }    },

办法

几个简略点的办法,不过多的解说

**问题:如果有对显示的内容进行强制更新时(如插入表情包,或者字符切割),光标会跑到文本最后面
解决办法: 每次进行更新操作后调一次toLast(obj)办法,让光标回到最初面, 当然了,如果光标不在最初面的就不须要调这个办法了**

    toLast(obj) {      // 将光标移到最初,obj为输入框节点      if (window.getSelection) {        let winSel = window.getSelection();         winSel.selectAllChildren(obj);        winSel.collapseToEnd();      }    },    blurInput() { // 失焦时触发的办法,失焦的时候保留光标地位      this.getFouceInput();    },    getFouceInput() { // 保留光标地位      this.widFouce = window.getSelection();      this.rangeFouce = this.widFouce.getRangeAt(0);    },    showIconListPlane() { // 显示或者敞开表情包列表      // 显示表情包列表      if (!this.iconShow) { //  如果是关上,要保留一下光标地位        this.getFouceInput();      }      this.iconShow = !this.iconShow;    },     submitCmt() {      // 提交评论      const self = this;      let text = this.$refs.cmt_input.innerHTML;      let length = this.getCharLen(this.paseText(text).text);      if (self.submitCmtLoading || !self.isSubmit) return;      if (self.cmt_text == "") {        self.$emit("submitError", "评论为空");  // 不合乎规定时则抛出谬误的办法submitError        return;      } else if (this.limtText && length > this.limtText) {        self.$emit("submitError", "评论超出字数"); // 不合乎规定时则抛出谬误的办法submitError        return;      }      self.$emit("submitSuccess", text, self.info); // 验证通过则抛出正确的办法,且将文本与信息同时传递给父组件    }

字符验证

接下来讲一下字符验证的函数,因为光标相干的逻辑里有用到局部字符验证,所以先讲一下字符相干的办法。
判断文本占了多少个字符,这里传进来的文本是通过解决的,因为如果没解决过的文本里如果带表情包,而表情包是以img标签存在文本里的这样会占据很多个字符,所以会对表情包做一个占位符解决,让一个表情包只占4个字符,前面会讲这个。当初先看下判断字符个数的办法,因为如果是输出空格会变成&nbsp;所以在这个办法里也会对这个进行解决

    getCharLen(sSource) {      // 空格&nbsp; 要当1个字符算,所以最初要给每个空格减去5      // 获取字符长度      var l = 0;      var schar;      for (var i = 0; (schar = sSource.charAt(i)); i++) {        l += schar.match(/[^\x00-\xff]/) != null ? 2 : 1;      }      let nbsp = sSource.match(/&nbsp;/gi);      if (nbsp) {        let len = nbsp.length;        l = l - len * 5;      }      return l;    },

应用占位符来代替表情包,因为1个表情包为4个字节,所以用4个数字来占位。这里应用随机获取工夫戳的4为数,而后与文本进行比拟,如果文本中存在,则递归再次获取,直到文本中不存在这4个数字。
对于回车键会让文本中增加div与br标签,因为咱们的评论是不容许手动换行的,所以把div标签与br标签都替换成两个空格“ ”留神不是&nbsp;

这也就解决了下面说的这三个问题

  • 进行文本字节计算时对表情的解决,表情占位符如何保障惟一

    • 按下回车会插入<div><br></div>标签,或者会用<div>标签包裹住文本
    • 按下空格符会有&nbsp;,以字符进行计算的话空格符&nbsp;会被当成6个字符,实际上空格代表1个
    getRandomFour(str) {      // 在工夫戳里取随机4位数作为key,且如果文本中蕴含key则递归      let timeStr = new Date().getTime() + "";      let result = "";      for (let i = 0; i < 4; i++) {        let nums = Math.floor(Math.random() * 13);        result = result + timeStr[nums];      }      return str.indexOf(result) == -1 ? result : this.getRandomFour(str);    },    paseText(str) {      // 解析内容,把图片全副用占位替换掉  跟换行相干(div,br)全副改成两个空格      let str1 = str.replace(/<div>|<\/div>|<br>/gi, "  ");      let imgReg = /<img[^>]*src[=\'\"\s]+([^\"\']*)[\"\']?[^>]*>/gi;      let imgMatch = str1.match(imgReg);      let key = this.getRandomFour(str1);      let isReplace = false;      if (imgMatch) {        isReplace = true;        for (let i = 0; i < imgMatch.length; i++) {          str1 = str1.replace(imgMatch[i], key);        }      }      return {        isReplace,  // 这个是有没有进行占位符替换的标示,如果没有前面就不须要进行还原操作        key,        text: str1      };    },    reductionStr(sourceText, text, key) {        // 解析内容,把图片全副用占位替换掉  sourceText:原文本  text:替换后的文本  key:占位符标示      let imgReg = /<img[^>]*src[=\'\"\s]+([^\"\']*)[\"\']?[^>]*>/gi;      let imgMatch = sourceText.match(imgReg);      let result = text;      if (imgMatch) {        for (let i = 0; i < imgMatch.length; i++) {          result = result.replace(key, imgMatch[i]);        }      }      return result;    },

当超出限度时进行文本切割,留神:如果是图片占位符,须要整个进行切割,不能切割局部,不然就会产生多余的文本 如 超出1个字符,然而最初为表情包,而表情包为4个数字的占位符,要切割的时候要把4个数字一起移除掉,不能只切除一个这样前面就跟key值匹配不上了。

这也就解决下面说的这个问题

  • 超出字数限度如何切割 当超出的字符是表情的时候怎么切割
setCmtTextByLimit(sSource, key) {      let self = this;      //  文字切割 如果超出文字限度须要切割      if (typeof sSource !== "string") return;      var str = changeLast(sSource, key); // 先判断最初是否为表情包      if (this.getCharLen(str) <= this.limtText) return str;      while (this.getCharLen(str) > this.limtText) {        str =          4 + str.lastIndexOf(key) == str.length            ? str.substring(0, str.length - 4)            : str.substring(0, str.length - 1);      }      function changeLast(strl, key) {        // 始终切割到最初一个不为表情包或不超出限度为止        while (          4 + strl.lastIndexOf(key) == strl.length &&          self.getCharLen(strl) > self.limtText        ) {          strl = sSource.substring(0, sSource.length - 4);        }        return strl;      }      return str;    },

输出与光标

输出时要把文本实时传给父组件,且输出时要进行字符判断,超出时就不能输出。当然先要进行是否验证的判断,如果不须要验证则省略前面的一系列步骤,

    changeText(e) {      //  表情包插入时不触发该办法,只有输出时会触发      // 判断字数,要先把自定义表情改成占位符,一个自定义表情按俩2个字符算      let text = e.srcElement.innerHTML;      let emitText = text;      if (this.limtText) {        let textObj = this.paseText(text);  // 文本替换        let len = this.getCharLen(textObj.text);  // 获取字符长度        if (len > this.limtText) {          let str = this.setCmtTextByLimit(textObj.text, textObj.key);  // 字符切割          emitText = textObj.isReplace            ? this.reductionStr(text, str, textObj.key)            : str;   // 如果有进行替换  要将文本还原          e.srcElement.innerHTML = emitText;          this.toLast(e.srcElement);   // 进行强制批改内容后 要把光标定位到最初        }      }      this.isSubmit = emitText.length ? true : false;  // 输入框不为空则能够提交      this.$emit("changeText", emitText);      },

插入表情时,须要先进行判断加上4个字符是否超出字符限度,如果超出了就不给插入。而且这里要判断上一次光标的状态,如果上一次的光标是在输入框里,那么就在光标地位插入表情且将光标地位定位至该表情前方,如果上一次的光标不在输入框里,那么就间接在文末插入表情包。对于创立节点createContextualFragment在IE9跟局部safari浏览器中不兼容,能够改成createElement来进行创立,当然也能够都写成createElement,这里写createContextualFragment只是我懒得改哈哈哈。

解决问题:

  • 定位光标地位,如果是在输出时就点击表情包 应该插入到最光标地位,如果不是在输出时候点击表情应该插入到文本到最初面
  • IE9以及局部Safari createContextualFragment 不兼容该办法
   insertIcon(url) { // 插入表情,url为表情地址      // 判断是否超出字数,如果超出不给插入      const self = this;      this.isSubmit || (this.isSubmit = true);      const length = this.getCharLen(        this.paseText(this.$refs.cmt_input.innerHTML).text,      );      if (this.limtText && length + 4 > this.limtText) return;      const img = `<img src='${url}' width=${this.iconWidth} height=${this.iconHeight} />`;      //  兼容性判断 如果不兼容不往下执行,尽管说不兼容IE9以下,然而还是做一下判断 不便前面灵便管制      if (window.getSelection && window.getSelection().getRangeAt) {        const winSn = this.widFouce;        let range = this.rangeFouce;        //  要判断的光标状态,如果上一次光标不在输入框里,须要手动定位到输入框里        if (          winSn.focusNode.className !== 'content_edit'          && winSn.focusNode.parentElement.className !== 'content_edit'        ) {          winSn.selectAllChildren(self.$refs.cmt_input);  // 选中输入框里的元素          winSn.collapseToEnd();  // 定位光标至文末          range = winSn.getRangeAt(0);        }        range.collapse(false);        let node;        if (range.createContextualFragment) {          // 兼容IE9跟safari,以下为创立img节点          node = range.createContextualFragment(img);        } else {          const tempDom = document.createElement('div');          tempDom.innerHTML = img;          node = tempDom;        }        const dom = node.firstChild;        range.insertNode(dom); // 将表情包节点增加进文本        const clRang = range.cloneRange();  // 复制range对象,留神这里复制的不是援用,所以在复制的对象上做批改不会影响到原对象        clRang.setStartAfter(dom);  // 设置光标位于表情节点的前方,到这里文本里的表情还是选中状态,这里设置的是克隆后的range        winSn.removeAllRanges();  // 移除选中状态        winSn.addRange(clRang);  // 将克隆的range增加进去        self.$emit('changeText', self.$refs.cmt_input.innerHTML); // 因为监听input,无奈监听到表情包的输出,所以这里要再向父组件再抛出一次办法      } else {        console.log('不兼容~');      }    },

还有一个问题就是无奈间接利用vue进行双向绑定,无奈用v-model与v-html进行数据双向绑定:
因为输入框是通过contenteditable来实现,所以没方法用v-model进行文本内容的双向绑定,应用v-html也是没方法进行双向绑定的。所以这里是通过监听输出的input办法与插入表情包的办法来进行实时向上传递最新文本。

html代码

    <!--  父组件 -->     <Comment      :info="info"      :submitCmtLoading="submitCmtLoading"      :limtText="limtText"      :iconWidth="iconWidth"      :iconHeight="iconHeight"      :cmt_show="cmt_show"      :iconList="iconList"      @changeText="changeText"      @submitError="submitError"      @submitSuccess="submitSuccess"    />    <!-- 子组件 -->   <div v-if="cmt_show" class="cmt_box">    <div      ref="cmt_input"      class="content_edit"      contenteditable="true"      :placeholder="placeholder"      @focus="changeHandle"      @input="changeText"      @blur="blurInput"      v-html="content"    ></div>    <div class="cmt_handle">      <img        id="emoticon_icon"        v-if="iconList.length"        src="//www1.pconline.com.cn/20200929/pgc/cmt/icon.png"        @click.stop="showIconListPlane"      />      <div        @click="submitCmt()"        :class="['btn_submit', isSubmit ? 'btn_submit_y' : '']"      >        公布      </div>      <div v-show="iconShow" class="icon_list">        <ul class="icon_box">          <li            class="icon_item"            v-for="(item, index) in iconList"            :key="`icon${index}`"            @click="insertIcon(item)"          >            <img :src="item" />          </li>        </ul>      </div>    </div>  </div>

组件残缺代码

<template>  <div v-if="cmt_show" class="cmt_box">    <div      ref="cmt_input"      class="content_edit"      contenteditable="true"      :placeholder="placeholder"      @focus="changeHandle"      @input="changeText"      @blur="blurInput"      v-html="content"    ></div>    <div class="cmt_handle">      <img        id="emoticon_icon"        v-if="iconList.length"        src="//www1.pconline.com.cn/20200929/pgc/cmt/icon.png"        @click.stop="showIconListPlane"      />      <div        @click="submitCmt()"        :class="['btn_submit', isSubmit ? 'btn_submit_y' : '']"      >        公布      </div>      <div v-show="iconShow" class="icon_list">        <ul class="icon_box">          <li            class="icon_item"            v-for="(item, index) in iconList"            :key="`icon${index}`"            @click="insertIcon(item)"          >            <img :src="item" />          </li>        </ul>      </div>    </div>  </div></template><script>export default {  name: "Comment",  props: {    submitCmtLoading: {      type: Boolean,      default: false    },    limtText: {      type: Number,      default: 0    },    iconWidth: {      type: Number,      dafault: 24    },    iconHeight: {      type: Number,      dafault: 24    },    cmt_show: {      type: Boolean,      default: true    },    iconList: {      type: Array,      required: true    },    placeholder: {      type: String,      default: "踊跃回复可吸引更多人评论"    },    info: {      type: Object    }  },  data: function() {    return {      content: "",      widFouce: "",      rangeFouce: "",      iconShow: false,      isSubmit: false    };  },  watch: {    cmt_show: {      handler(value) {        if (value) {          this.$nextTick(() => {            console.log("this.$refs.cmt_input", this.$refs.cmt_input);            this.toLast(this.$refs.cmt_input);          });        }      },      immediate: true    }  },  mounted() {    const self = this;    document.getElementById("app").addEventListener("click", self.setHideClick);    self.$once("hook:beforeDestroy", () => {      document.getElementById("app") &&        document          .getElementById("app")          .removeEventListener("click", self.setHideClick);    });  },  methods: {    setHideClick(event) {      let target = event.srcElement;      let nameList = ["icon_item", "icon_list", "icon_box"];      if (        !nameList.includes(target.className) &&        !nameList.includes(target.parentElement.className)      ) {        this.iconShow = false;      }    },    changeHandle() {      console.log("blur");    },    changeText(e) {      //  表情包插入时不触发该办法,只有输出时会触发      // 判断字数,要先把自定义表情改成占位符,一个自定义表情按俩2个字符算      let text = e.srcElement.innerHTML;      let emitText = text;      if (this.limtText) {        let textObj = this.paseText(text);        let len = this.getCharLen(textObj.text);        if (len > this.limtText) {          let str = this.setCmtTextByLimit(textObj.text, textObj.key);          emitText = textObj.isReplace            ? this.reductionStr(text, str, textObj.key)            : str;          e.srcElement.innerHTML = emitText;          this.toLast(e.srcElement);        }      }      this.isSubmit = emitText.length ? true : false;      this.$emit("changeText", emitText);    },    paseText(str) {      // 解析内容,把图片全副用占位替换掉  跟换行相干(div,br)全副改成两个空格      let str1 = str.replace(/<div>|<\/div>|<br>/gi, "  ");      // eslint-disable-next-line no-useless-escape      let imgReg = /<img[^>]*src[=\'\"\s]+([^\"\']*)[\"\']?[^>]*>/gi;      let imgMatch = str1.match(imgReg);      let key = this.getRandomFour(str1);      let isReplace = false;      if (imgMatch) {        isReplace = true;        for (let i = 0; i < imgMatch.length; i++) {          str1 = str1.replace(imgMatch[i], key);        }      }      return {        isReplace,        key,        text: str1      };    },    /**     * @description: 还原评论内容     */    reductionStr(sourceText, text, key) {      // 解析内容,把图片全副用占位替换掉      // eslint-disable-next-line no-useless-escape      let imgReg = /<img[^>]*src[=\'\"\s]+([^\"\']*)[\"\']?[^>]*>/gi;      let imgMatch = sourceText.match(imgReg);      let result = text;      if (imgMatch) {        for (let i = 0; i < imgMatch.length; i++) {          result = result.replace(key, imgMatch[i]);        }      }      return result;    },    getRandomFour(str) {      // 在工夫戳里取随机4位数作为key,且如果文本中蕴含key则递归      let timeStr = new Date().getTime() + "";      let result = "";      for (let i = 0; i < 4; i++) {        let nums = Math.floor(Math.random() * 13);        result = result + timeStr[nums];      }      return str.indexOf(result) == -1 ? result : this.getRandomFour(str);    },    getCharLen(sSource) {      // 空格&nbsp; 要当1个字符算,所以最初要给每个空格减去5      // 获取字符长度      var l = 0;      var schar;      for (var i = 0; (schar = sSource.charAt(i)); i++) {        // eslint-disable-next-line no-control-regex        l += schar.match(/[^\x00-\xff]/) != null ? 2 : 1;      }      let nbsp = sSource.match(/&nbsp;/gi);      if (nbsp) {        let len = nbsp.length;        l = l - len * 5;      }      return l;    },    setCmtTextByLimit(sSource, key) {      let self = this;      //  文字切割 如果超出文字限度须要切割      if (typeof sSource !== "string") return;      var str = changeLast(sSource, key);      if (this.getCharLen(str) <= this.limtText) return str;      while (this.getCharLen(str) > this.limtText) {        str =          4 + str.lastIndexOf(key) == str.length            ? str.substring(0, str.length - 4)            : str.substring(0, str.length - 1);      }      function changeLast(strl, key) {        // 始终切割到最初一个不为表情包或不超出限度为止        while (          4 + strl.lastIndexOf(key) == strl.length &&          self.getCharLen(strl) > self.limtText        ) {          strl = sSource.substring(0, sSource.length - 4);        }        return strl;      }      return str;    },    showIconListPlane() {      // 显示表情包列表      if (!this.iconShow) {        this.getFouceInput();      }      this.iconShow = !this.iconShow;      //  iconShow    },    getFouceInput() {      this.widFouce = window.getSelection();      this.rangeFouce = this.widFouce.getRangeAt(0);    },    toLast(obj) {      // 将光标移到最初      if (window.getSelection) {        let range = window.getSelection();        range.selectAllChildren(obj);        range.collapseToEnd();      }    },    insertIcon(url) {      // 判断是否超出字数,如果超出不给插入      const self = this;      this.isSubmit || (this.isSubmit = true);      let length = this.getCharLen(        this.paseText(this.$refs.cmt_input.innerHTML).text      );      if (this.limtText && length + 4 > this.limtText) return;      const img = `<img src='${url}' width=${this.iconWidth} height=${this.iconHeight} />`;      //  兼容性判断 如果不兼容不往下执行,尽管说不兼容IE9以下,然而还是做一下判断 不便前面灵便管制      if (window.getSelection && window.getSelection().getRangeAt) {        let winSn = this.widFouce,          range = this.rangeFouce;        //  要判断的光标状态        if (          winSn.focusNode.className !== "content_edit" &&          winSn.focusNode.parentElement.className !== "content_edit"        ) {          winSn.selectAllChildren(self.$refs.cmt_input);          winSn.collapseToEnd();          range = winSn.getRangeAt(0);        }        range.collapse(false);        let node;        if (range.createContextualFragment) {          // 兼容IE9跟safari          node = range.createContextualFragment(img);        } else {          let tempDom = document.createElement("div");          tempDom.innerHTML = img;          node = tempDom;        }        let dom = node.firstChild;        range.insertNode(dom);        let clRang = range.cloneRange();        clRang.setStartAfter(dom);        winSn.removeAllRanges();        winSn.addRange(clRang);        self.$emit("changeText", self.$refs.cmt_input.innerHTML);      } else {        console.log("不兼容~");      }    },    blurInput() {      this.getFouceInput();    },    submitCmt() {      // 提交评论      const self = this;      let text = this.$refs.cmt_input.innerHTML;      let length = this.getCharLen(this.paseText(text).text);      if (self.submitCmtLoading || !self.isSubmit) return;      if (self.cmt_text == "") {        self.$emit("submitError", "评论为空");        return;      } else if (this.limtText && length > this.limtText) {        self.$emit("submitError", "评论超出字数");        return;      }      self.$emit("submitSuccess", text, self.info);    }  }};</script><style lang="scss" scoped>.cmt_box {  width: 510px;  height: 180px;  background-color: #ffffff;  border-radius: 2px;  border: solid 1px #ececec;  padding: 14px;  box-sizing: border-box;  margin: auto;  .content_edit {    width: 100%;    height: 120px;    outline: none;    border: none;    text-align: left;    font-size: 14px;    &:empty::before {      color: #cccccc;      content: attr(placeholder);    }  }  .cmt_handle {    display: flex;    justify-content: space-between;    align-items: center;    position: relative;    #emoticon_icon {      width: 21px;      height: 21px;      display: block;      flex-shrink: 0;      cursor: pointer;    }    .btn_submit {      width: 68px;      height: 32px;      background-color: #cccccc;      border-radius: 16px;      text-align: center;      line-height: 32px;      color: #ffffff;      font-size: 14px;      cursor: pointer;      &.btn_submit_y {        background-color: #f95354;      }    }    .icon_list {      position: absolute;      top: 40px;      left: -10px;      width: 280px;      border-radius: 2px;      background: #fff;      box-shadow: 0 5px 18px 0 rgba(0, 0, 0, 0.16);      padding: 15px;      &::before {        content: "";        width: 0;        height: 0;        display: block;        padding: 0;        position: absolute;        top: -10px;        left: 10px;        border-left: 10px solid transparent;        border-right: 10px solid transparent;        border-bottom: 10px solid #fff;      }      .icon_box {        list-style: none;        display: flex;        justify-content: start;        align-items: center;        flex-wrap: wrap;        padding: 0;        margin: 0;        .icon_item {          padding: 5px;          text-align: center;          cursor: pointer;          > img {            width: 30px;            height: 30px;          }        }      }    }  }}</style>

最初再附上demo地址啦
自己前端小学生,欢送交换斧正~