近期由于产品迭代,需要新增一个评论功能,且需要支持插入自定义表情。评论功能很多人一开始跟我一样,第一个想到的就是用textarea,但是textarea是不支持的插入图片的,因为我们的表情包是以图片的形式插入文本中的,所以这里是使用HTML5的新特性contenteditable,让div里的内容变成可编辑的。
demo地址

先看下效果图(目前已支持多套表情,详情请看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", "评论为空");  // 不符合规则时则抛出错误的方法submitErrorreturn;} else if (this.limtText && length > this.limtText) {self.$emit("submitError", "评论超出字数"); // 不符合规则时则抛出错误的方法submitErrorreturn;}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);  // 设置光标位于表情节点的后方,到这里文本里的表情还是选中状态,这里设置的是克隆后的rangewinSn.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"><divref="cmt_input"class="content_edit"contenteditable="true":placeholder="placeholder"@focus="changeHandle"@input="changeText"@blur="blurInput"v-html="content"></div><div class="cmt_handle"><imgid="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"><liclass="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"><divref="cmt_input"class="content_edit"contenteditable="true":placeholder="placeholder"@focus="changeHandle"@input="changeText"@blur="blurInput"v-html="content"></div><div class="cmt_handle"><imgid="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"><liclass="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-escapelet 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-escapelet 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-regexl += 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跟safarinode = 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地址啦
本人前端小学生,欢迎交流指正~

【Vue组件】从零开始实现一个支持插入自定义表情的评论组件相关推荐

  1. 往写好的html插入标签,写一个可插入自定义标签的 Textarea 组件

    - "插入自定义标签是什么鬼?" - "比如你要插入一个的标签..." - "什么情况下会有这种需求?" - "得罪了产品的情况下 ...

  2. weui上传组件的图片封装到formdata_自定义toast-ui富文本组件的图片黏贴上传

    最近博客中用到了这个富文本组件,发现这组件的图片无法支持 截图黏贴以及上传图片是一长串base64字符串,非常不方便,所以通过文档,自定义一个一下上传方式,效果如图 其中,上传图片是调用组件自带的ho ...

  3. angular 自定义组件_如何创建Angular 6自定义元素和Web组件

    angular 自定义组件 by Prateek Mishra 通过Prateek Mishra 如何创建Angular 6自定义元素和Web组件 (How to create Angular 6 C ...

  4. 让IjkPlayer支持插入自定义的GPU滤镜

    转自: https://blog.csdn.net/junzia/article/details/75172160 最近因为工作的原因,需要提供一个将我们的AiyaEffectsSDK插入到IjkPl ...

  5. TextMesh Pro 的图文混排功能:插入自定义表情图

    新的油管教程https://www.youtube.com/watch?v=q1pwuBhpr5E 制作Sprite Assets: 首先有张图片格式的图集,导入设置Sprite Mode为Multi ...

  6. vue 专题 vue2.0各大前端移动端ui框架组件展示

    Vue 专题 一个数据驱动的组件,为现代化的 Web 界面而生.具有可扩展的数据绑定机制,原生对象即模型,简洁明了的 API 组件化 UI 构建 多个轻量库搭配使用 请访问链接: https://ww ...

  7. Vue项目实战——实现一个任务清单【基于 Vue3.x 全家桶(简易版)】

    Vue3.x 项目实战(一) 内容 参考链接 Vue2.x全家桶 Vue2.x 全家桶参考链接 Vue2.x项目(一) Vue2.x 实现一个任务清单 Vue2.x项目(二) Vue2.x 实现Git ...

  8. Vue.js如何写一个简单的原生js模块,浏览器中的表现如何?

    2019独角兽企业重金招聘Python工程师标准>>> 浏览器正在逐步的支持原生JavaScript模块.Safari和Chrome的最新版本已经支持它们了,Firefox和Edge ...

  9. vue 将字符串最后一个字符给替换_初尝VUE

    VUE的两个版本 VUE分为完整版(vue.js)和非完整版(vue.runtime.js) 二者的区别在于完整版的VUE(vue.js)会包含一个编译器,由于这个编译器的存在,完整版VUE(vue. ...

  10. 从零开始开发一个vue组件打包并发布到npm (把vue组件打包成一个可以直接引用的js文件)

    自己写的组件 有的也挺好的,为了方便以后用自己再用或者给别人用,把组件打包发布到npm是最好不过了,本次打包支持 支持正常的组件调用方式,也支持Vue.use, 也可以直接引用打包好的js文件, 配合 ...

最新文章

  1. 编解码技术学习网站汇总
  2. 这台无人机40小时经历上万次事故,终于借助AI学会了自动飞行
  3. Paxos一致性协议
  4. 网络namespace
  5. 软考系统架构师笔记-最后知识点总结(二)
  6. 从高中生活步入大学生活
  7. Docker for Windows 中文文档(3)——Docker Settings
  8. 轮盘算法 java_java – 使用轮盘选择的遗传算法
  9. android studio找不到r文件,Apk 中找不到r类文件
  10. 深度学习文本分类|模型代码技巧
  11. oracle中使用java存贮过程
  12. airpods pro连接安卓声音小_安卓手机用 AirPods ?你需要这个 App
  13. C# Graphics 透明 gif 进度条
  14. 计算机财务应用实验心得,金蝶财务软件实训心得.doc
  15. 2021进销存管理软件最具影响力榜单排名
  16. 设置input框只能输入6位为数字的支付密码
  17. 10电脑睡眠后自动关机怎么回事 win_win10电脑睡眠变关机怎么解决_win10睡眠变自动关机的处理方法-系统城...
  18. 解决Android Studio无法下载
  19. 打开matlab只出现蓝色的界面,win10 windows设置无法打开总卡在纯蓝色界面如何解决...
  20. CS和IP寄存器的作用及执行分析

热门文章

  1. 数据结构:八种数据结构大全
  2. 计算机科学与专业大学排名,计算机科学与技术专业大学排名
  3. centos6 安装 directAdmin
  4. Java中文英文数字混合掩码_Java8 中文教程
  5. GWT项目创建时遇到的问题
  6. Word怎么删除页眉页尾
  7. OpenCV中feature2D学习——Shi-Tomasi角点检测
  8. 学习累了吗,来听听乔布斯的演讲吧!
  9. Win11系统桌面状态栏电池图标不显示怎么办?
  10. JPinyin繁体相互转换