玩一下低代码框架amis,并动手封装一个表情输入框组件

4,609 阅读9分钟

一:amis是什么东西,怎么使用

1.1 关于概念的介绍

amis: 前端低代码框架,通过 JSON 配置就能生成各种后台页面,极大减少开发成本,甚至可以不需要了解前端。

这句是来自它git上的一句描述,字面意思说:”不需要了解前端仅通过一些json配置就能搞出各种mis后台页面“,嗯...看起来挺吊的。但是它真的有这么吊嘛,我们一起去看看。

正式介绍amis之前,可能有小伙伴不明白什么是低代码平台。这里小白借助知乎的一篇回答简单介绍一下

可以把低代码框架理解成普通框架的进化版本,我们都知道向react、vue这些框架的出现主要目的是为了简便我们的开发。但是使用他们还是需要一定的前端知识的,那么框架进化到最后可不可以达到这样的效果。我们无需任何的知识储备,(像一些快速搭建网站的可视化的东西)框架自动就帮助我们生成一个页面

就像这种:

嗯嗯...,将来框架要是都往这个方向发展是不是有一批前端工程师就要被优化了呢。。。

以后的事情我不知道,但是起码现在这种低代码框架火候还达不到。它们定制性还是蛮差的,但是对于一些mis项目使用它真的是超级简便开发

作为一个专业的前端开发,咱们是肯定不会使用什么可视化的东西来搞的。我们下面主要来看一下amis是怎样使用json配置来写页面

1.2 怎样来使用amis

amis的使用有两种方式,1是通过sdk 2是通过react 。这里我们是以react的形式为例。

这里我就直接拉一下它的一个demo,直接看一下app.tsx

为了可以看的清晰一点我先把不那么重要东西都折叠起来

看来这个renderAmis是负责把传到它肚子里的东西转成一个jsx的,即(schema, props, env) => JSX.Element;

它需要三个参数

  • schema
  • props
  • env

这里最重要就是schema,这个schema也即我们要写的json配置。还是先简单介绍一下另外两个是干啥使得

props:类似于一个全局变量的东西,props里面的数据会传给内部的所有组件

env :就是一些传给组件的工具函数,比较请求了、系统消息了...

接下来看一下怎么使用amis写组件了,比如写一个表单吧

  render() {
    return renderAmis(

      {
        "type": "page",
        "body": {
          "type": "form",
          "api": "https://houtai.baidu.com/api/mock2/form/saveForm",
          "controls": [
            {
              "type": "text",
              "name": "name",
              "label": "姓名:"
            },
            {
              "name": "email",
              "type": "email",
              "label": "邮箱:"
            }
          ]
        }

      },
      {
        // props...
      },
      env
    );
  }

简单介绍一下属性的含义

  • type:指定amis的组件类型
  • body:可以理解为容器属性
          "type": "form",       指定为表单组件
          "api": "https://houtai.baidu.com/api/mock2/form/saveForm",   表单组件提交的地址
          "controls": [		用来装表单项的
            {
              "type": "text",   指定为input的text组件
              "name": "name",		相当于这个input的name值,下同
              "label": "姓名:"
            },
            {
              "name": "email",
              "type": "email",
              "label": "邮箱:"
            }
          ]

再往下关于amis的具体使用就不介绍了还是比较简单的这里我们主要是自己写一个amis组件,详细看它的文档就可以了

二:先简单了解amis的基本原理

我们的主要目的,是封装一个amis组件。在封装之前我们肯定要先了解它的基本原理是什么。怎么我们写一个json配置最后就出来一个对应的页面呢?

它的工作原理还是蛮简单的,普通的ui框架会怎么写呢?写完模板组件就直接导出了。这里仅是又增加了一个步骤。

amis的核心工作就是你给一个schema它帮你映射到对应的模板组件然后由react执行渲染

所以关键是它如何产生的这个映射关系

比如上面的例子,为什么我们只写了type:form amis就帮我映射到了它内部已经写好的form模板组件呢

要达到这个目的肯定是需要两个步骤的

  1. 写模板组件的时候要有一个"唯一标识",即标上个记号方便我们能够快递定位到它
  2. 对于schema的解析,比如解析到schema的type值为"form",就可以马上定位到模板组件

核心代码在这里,感兴趣的可以自行查阅(这篇文章先不详细介绍)

既然最主要的就是这两个,且开始的时候我们就发现了。在app.tsx中的renderAmis函数就是干的解析json的活

故我们要自己写一个自定义的amis组件就差一步了,那就是写模板组件的时候搞一个”标识“

不过值得高兴的是,amis也为我们提供了一个这样的工具函数Renderer。它是一个HOC

比如这样操作

@Renderer({
  test: /(^|\/)my-amis$/
})
export class FormRenderer extends React.Component { 
  render() {
    return (
      <div>你好啊</div>
    );
  }
}

那么现在一个简单的映射关系就搞好了

这时你在schema中指定type为my-amis,上面这个react组件就被找到并渲染出来。schema中的其他值就作为这个组件的props传进去了

其实就是做了一个字典,简单帖一下它的实现(先去掉不重要的)核心代码都在factory.tsx感兴趣直接翻翻就ok了

三:表情输入框要做的事情及要注意的坑

好了开始正题!

先来看一下我们接下要实现的最终效果(无论是因为前端展示还是数据库存储我们其实都不好直接使用emoji,所以这里我还是选择的表情图片)

这就是一个简单的表情输入框的东西,之所以选择这个为例子编写,是因为我发现这个小东西看起来简单但是还是存在不少坑的

首先我们先来确定一下怎样划分组件

  • 编辑区
  • 表情区

如果之前没有做过这类的需求,你会怎么做呢。编辑区直接搞一个textarea就完事了?

我之前也是这样想的,先提前透露一下这个输入框是这个需求中坑最多的

3.1.1 字典映射及基础组件编写

字典映射

下面的这个是csdn的私信它做的比较简单直接用的一个textarea

再来看掘金的,(从技术上看就知道谁更专业了吧,并且直接把emoji格式的传到后台了想必它也升级了数据库。因为虽然emoji也是Unicode编码,但是它以utf8编码时好像是4个字节来着原来的mysql的utf8是3个字节来着。偷个懒不去查了)

为了不动数据库,这里我们在把信息发出去的时候再转会[xx]这种形式

那么首先要做的事情就是要搞一个映射关系了,即进如输入框中应该是有[微笑]=>🙂,发送时在由🙂转为[微笑](这里的小表情用的是emoji格式的操作中我们放进去的是图片)。这比较简单搞一个字典就可以了

像是这样:(对应的值就是每一个小表情图片的地址)

const emojiDictionary = {
    "[微笑]": imgMap.img01,
    "[撇嘴]": imgMap.img02,
    "[色]": imgMap.img03,
    "[发呆]": imgMap.img04,
    "[得意]": imgMap.img05,
    "[流泪]": imgMap.img06,
    "[害羞]": imgMap.img07,
    "[闭嘴]": imgMap.img08,
    "[睡]": imgMap.img09,
    "[大哭]": imgMap.img10,
   ...
}

后面再是怎么个逻辑呢?

  1. 选中表情弹框点击拿到选中的小表情信息
  2. 创建一个img的dom结构塞进输入框里
  3. 发送时在将对应的img的dom替换成相应的[xx]字符串

组件编写

我们接下来先把基本的组件写好吧

输入框组件

import * as React from 'react';
import { useState } from "react";
import { Renderer } from 'amis';
import classnames from 'classnames'

import Emoji from './emojinew'
import './blog.css'

const emojiInput = (props) => {

    const [show, setShow] = useState(true)

    const showEmoji = () => {
        setShow(!show)
    }
    return (
        <div className="emoji-input">
            <div className="input-textarea">
                请输入...
            </div>
            <div className="input-col">
                <i className="iconfont icon-biaoqing" onClick={showEmoji}></i>
                <i className="iconfont icon-icon-"></i>
            </div>
            <div className={classnames('input-emoji', { show: show })}>
                <Emoji />
            </div>
        </div>
    );
}


const emojiInputRender = Renderer({
    test: /(^|\/)emoji-input$/,
    name: "emoji-input"
})(emojiInput)
export default emojiInputRender;

表情框组件

import * as React from 'react';
import { useEffect } from 'react'
import { Scrollbars } from 'react-custom-scrollbars';


import emojiDictionary from '../lib/emojiDictionaries'
const newEmojiDictionary = Object.entries(emojiDictionary)

const EmojiItem = (props) => {
    const { msg, pic } = props
    return (
        <span className="emoji-item-img">
            <img src={pic} data-msg={msg} />
        </span>
    )
}


const EmojiNew = (props) => {
    const { } = props
    useEffect(() => {
        console.log();
    })
    return (
        <Scrollbars
            style={{ height: '100px' }}
            autoHide
        >
            <div className="emoji-new">

                {
                    newEmojiDictionary.map(
                        item => {
                            return <EmojiItem
                                key={item[0]}
                                msg={item[0]}
                                pic={item[1]}
                            />
                        }
                    )
                }

            </div>
        </Scrollbars >
    );
}

export default EmojiNew;

3.1.2 添加事件,解析替换

下面我们先简单点,每次点击表情的时候就将这个小表情放到最后。

给表情组件传一个处理事件下去,input组件中添加

   const clickEmoji = (pic, msg) => {
        const img = document.createElement('img')
        img.src = pic
        img.setAttribute("data-msg", msg)
        inputRef.current.appendChild(img)

    }

透传到EmojiItem,作为它的点击事件的处理函数并拿去pic与msg

const EmojiItem = (props) => {
    const { msg, pic, clickEmoji } = props
    return (
        <span className="emoji-item-img" onClick={() => { clickEmoji(pic, msg) }}>
            <img src={pic} data-msg={msg} />
        </span>
    )
}

然后怎么在div中编辑文字呢?很简单给这个div加一个属性<div className="input-textarea" ref={inputRef} contentEditable={true}>

但是react中设置它在控制台会有很恶心的warning故还需要再设置一个属性去干掉它。

到这最终形式

 <div className="input-textarea" ref={inputRef} contentEditable={true}
                suppressContentEditableWarning={true}
            >
                请输入...
 </div>

好了现在基本的效果已经出来了

再来处理发送消息时把对应的img替换成对应的描述[xx]

提醒一点:dom的innerHTML取得的东西完全是可以当作字符串来处理的。那就好办了接下来我们就直接搞个正则字符串替换一下

给小飞机图标一个发送的点击事件

    const sendData = () => {
        let str = inputRef.current.innerHTML
        let imgReg = /<img.*?(?:>|\/>)/gi
        let nameReg = /<img[^>]+data-msg[=\'\"\s]+([^\'\"]*)[\'\"]?[\s\S]*/i
        let arr = str.match(imgReg)

        if (arr) {
            for (let i = 0; i < arr.length; i++) {
                let names = arr[i].match(nameReg)
                if (names && names[1]) {
                    str = str.replace(arr[i], names[1])
                }
            }
        }
        console.log(str)
    }

好了,搞到这看起来就基本上是大功告成了吧

3.1.3 处理换行与空格

别高兴得太早,当你在输入框中换个行、空个格的时候

好家伙,原来在可编辑的div中它的换行是通过搞个div实现的

不过虽然有些烦人,但是处理也是蛮简单。还是用正则呗,干掉div换成\n(有时也会出现br的情况,所以br的情况也要处理一下,也要考虑只换行没写内容的情况)

   str = str.replace(/<div><\/div>/g, "")
   str = str.replace(/<div>/g, "\n")
   str = str.replace(/<br>/g, "\n")
   str = str.replace(/<\/div>/g, "")

再来处理一个空格的情况

 str = str.replace(/&nbsp;/g, " ")

还得考虑一种情况,即把原来的第一行删掉了,那么下面的就成了这个<div>我是小白&nbsp; 啊</div><div>你好认识我嘛</div><div>好久不见了</div>。我们前面的处理仅是把<div>换成了\n,处理完了岂不是在开头就给人家搞了一个换行,这不太合适吧

做一下判断吧,如果\n在首部位置就将它干掉好了

  if (str.indexOf('\n') === 0) {
            str = str.replace(/\n/, "")
   }

好了,这一阶段我们的活基本已经处理完毕了

3.1.4 处理copy进去文字的样式问题

这里来试试效果吧,我们去随便找个网址粘贴一段文字放进去

卧槽,这下真的是我直接就好家伙了,我们预想的仅是把一个纯文本帖过来,没想到把人家样式都整来了

这要咋办,还是正则去拿数据?这看着就脑袋疼,怎么写这个正则呢?

我之前搞这个玩意时,主要的思路也是放在我应该怎么写这个正则上面了。但是我在寻找相关资料的时候却发现自己好像一个傻蛋...

还记得我们是怎么让一个div可以编辑文字的嘛,我们是给他加了一个contentEditable属性。当时我们给它的值是一个true。但是这个contentEditable还有其他的属性值。plaintext-only来看一下这个属性值字面意思好像就我们需要的啊。来试一试吧

额...牛皮。(样式问题是因为我给了编辑盒子一个固定的高度。改成由内容撑高就好了)

3.1.5 处理焦点问题

走到这,基本的问题我们都处理完了,但是别忘了。开始的时候我们只是让小表情图片默认加到了编辑盒子的最后,但是谁家的东西是这样弱智的...它应该是跟在光标的后面的呀

再来处理一下这个最后的问题吧

解决思路也不复杂 。像上图中,我们想在失败后面加一个表情。首先是先把光标定到那里,然后点击表情框选中相应表情

我们要怎么做呢,要知道当我们在表情框上选表情并且点击时本来在输入框中的焦点便没有了,所以我们要记录一下这个最后位置

给编辑框一个失焦事件来记录最后光标位置(对getSelection不熟悉的话看一下mdn就可以了)

再搞一个变量存一下

  //失去焦点要做的事情
const record = () => {
        let selection = getSelection()
        let range = selection.getRangeAt(0)
        lastRange = range
    }

再重新改造一下点击小表情的处理函数

    const clickEmoji = (pic, msg) => {
        const img = document.createElement('img')
        img.src = pic
        img.setAttribute("data-msg", msg)
        let selection = window.getSelection()
        // 看一下有没有最后的光标对象
        if (lastRange) {
            selection.removeAllRanges()
            selection.addRange(lastRange)
        }
        let range = selection.getRangeAt(0)
        range.insertNode(img)
        range.collapse(false)
        lastRange = range

    }

大功告成!

四:写道最后

这几个月来一直很忙,最近静了心来想了想。我也不知道我究竟忙了些什么。真的是有些浮躁。接下来自己不能再闷着头玩了,每一个阶段都要有每一个阶段的目标,人终归是有惰性的,还是得适量得逼自己一把

2021打工人一起加油

借助一句歌词

用力撕掉那些乱标签早就听腻了风凉话

说过的都在慢慢实现没想过做个空想家

总是太理想化 创作难道不需要灵感吗

用心的 走好每个阶段不需要他们任何评价

自己带队伍 不做个废物 从开始就没有想过留退路

不会妥协 不管是谁 编造的套路别想将我束缚