头图

需求背景

事情是这样的,后端返回类似这样的数据格式`
cshjdkvghsv<xxx xx='xxx' />sdhjkshfv'<xxx xx='xxx'></xxx>'sdhgjdsk,然后需要前端将字符串中的xxx`解析出来并渲染到页面中,还要保证渲染顺序,其中xxx可能是一个自定义的react组件名,也有可能是一个html元素标签名,而xx则可能是属性名,'xxx'则是属性值,比如className='base-1'。比如xxx是div元素,我们最终的页面渲染结果就应该是。

cshjdkvghsv<div className='test-1'></div>sdhjkshfv'<div className='test-2'></div>'sdhgjdsk

如果只是单纯的html元素,其实我们只需要使用dangerousSetInnerHTML属性即可,可是这里还有自定义组件元素呀。

最终的结果就是,我们需要对模板字符串进行解析,解析成一个数组,方便渲染,最终我们的react组件使用如下:

import React, { Fragment, createElement } from 'react';
import simpleJSXParser from 'jsx-parser';
// 导入需要渲染的组件
import xxx from 'xxx';
// 定义组件对象
const components = {
    CustomComponent: xxx, 
}
const RenderStr = (props: { str?: string }) => {
  const { str = '' } = props;
  // 可以考虑写一个自定义的过滤props方法,对props进行更详细的规范化
  const filterProps = (data: Record<string, string>, index: number) => {
    const { xxx = '', ...rest } = data || {};
    return {
      xxx: xxx,
      ...rest,
      key: `item-${xxx}-${index}`,
      className: 'test-1',
    };
  };
  // CustomComponent为自己想要渲染的组件
  return (
    <>
      {simpleJSXParser(str).map((item, index) => (
        <Fragment key={`item-${index}`}>
          {typeof item === 'string' ? item : createElement(components[item.type], filterProps(item.props, index), null)}
        </Fragment>
      ))}
    </>
  );
};

然后就可以得到我们实际想要渲染的效果,那么现在的问题就是,如何将模板字符串渲染成一个顺序正常的数组。

即实现如下效果:

const str = '111<CustomComponent value="123">222';
const parser = simpleJSXParser(str); // ['111',{ type:'CustomComponent', props:{ value:'123' }},'222']

这里,我们就需要用到正则表达式,当然由于正则表达式的实现不同,可能会存在限制。

实现思路

我们实现这个解析器的思路很简单,我们会用一个结果数组来存储最终的结果,这个数组的数组项可能是字符串,也有可能是react组件对象,即{type: string,props:Record<string,string>},因此我们是选需要定义一个类型,如下所示:

type ReactComponentObj = { type: string; props: Record<string, string> };
ps:其实严格来说props的属性值不一定是字符串,不过这里我们是根据需求来扩展的,我们先实现一个基础版本。

为了提取出props和组件元素名,我们需要定义3个正则表达式,如下所示:

// 匹配组件元素
const componentRegExp = /(<\w+(([\s\S])*?)<\/\w+>)/;
// 匹配props
const componentPropRegExp = /(\w+)=["|']?(.+?)["|']/g;
// 匹配标签元素
const tagRegExp = /(<.+?>)?<\/?.+?>/;

以上正则表达式也有可能存在问题,不过还是可以满足当下的需求。

首先我们还是需要定义一个函数以及一些变量,如下所示:

const simpleJSXParser = (template: string) => {
    const componentRegExp = /(<\w+(([\s\S])*?)<\/\w+>)/;
    const componentPropRegExp = /(\w+)=["|']?(.+?)["|']/g;
    const tagRegExp = /(<.+?>)?<\/?.+?>/;
    const len = template.length; // 字符串长度
    const res: (string | ReactComponentObj)[] = []; // 结果数组
    if(len > 0){
        // ...
    }
    // 返回结果
    return res;
};

接下来就是核心逻辑,我们可以如此实现,我们需要定义2个变量,一个用来拼接普通内容字符串,一个用来拼接匹配到的元素字符串,然后我们根据字符长度去循环,每次匹配到一个拼接的字符串,就将总字符串长度减去拼接的字符串的长度,这也是结束循环所必须要做的。代码如下所示:

const simpleJSXParser = (template: string) => {
    // ...
    if(len > 0){
        let i = len,
            resStr = '',
            componentRes = '';
         while(i > 0){
             // ...
         }
    }
    // 返回结果
    return res;
};

如果匹配到组件元素字符串,我们则需要单独进行处理,因此我们实现一个createComponent方法,该方法的参数就是组件元素字符串,如下所示:

const simpleJSXParser = (template: string) => {
    // ...
    if(len > 0){
        let i = len,
            resStr = '',
            componentRes = '';
         const createComponent = (v: string): ReactComponentObj => {
            // ...
         }
         while(i > 0){
             // ...
         }
    }
    // 返回结果
    return res;
};

可以看到,这个方法我们会返回一个组件对象,这将在后面介绍实现原理,这里我们先跳过,接下来我们来看循环里面的逻辑实现。

在循环里面,我们会先判断是否匹配到组件元素,如果没有,则整个模板字符串就是普通的字符串内容,直接添加到结果数组中即可。

如果含有组件元素,则获取匹配到的组件元素的起始索引值,通过字符串的match方法即可,当match方法匹配到对应的字符串,则会有对应的index属性,这就是匹配的索引值。

然后我们从0开始到起始索引值为止,拼接每一个字符,因为组件元素字符串前面可能会存在普通内容字符串,因此我们需要循环拼接这个字符串。

拼接完成之后,我们要添加到结果数组当中,然后从模板字符串中剔除掉resStr字符,使用replace方法匹配resStr即可,并修改外层循环i的索引值,即i -= resStr.length,修改完成之后,我们还要重置该结果值,即resStr = ''

根据如上分析,我们可以写出如下代码:

const simpleJSXParser = (template: string) => {
    // ...
    if(len > 0){
        // ...
         while(i > 0){
             const match = template.match(componentRegExp);
             // 如果匹配到组件元素
             if(match){
                 // 获取起始索引值
                 const start = match?.index;
                 // 循环
                 for(let i = 0;i < start!;i++){
                     // 此时只是拼接组件元素字符串前面的字符串,这个字符串应该是一个普通内容字符串
                     resStr += template[i];
                 }
                 // 将拼接好的字符串添加到结果数组中
                 res.push(resStr);
                 // 循环值变更
                 i -= resStr.length;
                 // 修改模板字符串,剔除符合条件的resStr
                 template = template.replace(resStr,'');
                 // 重置resStr变量
                 resStr = '';
             }else{
                 // 没有则是一个普通字符串,直接添加,并重置索引值为0
                 i = 0;
                 res.push(template);
             }
         }
    }
    // 返回结果
    return res;
};

接下来剩余字符串还会有组件元素和普通字符串的情况,我们继续匹配组件元素,按照同样的思路,拼接组件元素字符串到另一个变量,并添加到结果数组中,注意这个时候,我们就需要调用createComponent方法,将组件元素字符串转换成对应的组件数据对象。如下所示:

const simpleJSXParser = (template: string) => {
    // ...
    if(len > 0){
        // ...
         while(i > 0){
             const match = template.match(componentRegExp);
             // 如果匹配到组件元素
             if(match){
                 //...
                 // 继续匹配标签元素,事实上我们定义的第一个正则表达式也可以
                 const matchComponent = template.match(tagRegExp);
                 if(matchComponent){
                     // 匹配到结果就是最终的组件元素字符串,赋值给另一个变量
                     componentRes = matchComponent[0];
                     // 从模板字符串中剔除组件元素字符串
                     template = template.replace(componentRes,'');
                     // 循环索引值递减
                     i -= componentRes.length;
                     // 添加结果
                     res.push(createComponent(componentRes));
                     // 清空变量
                     componentRes = '';
                 }else{
                     // 整个剩余字符就是普通字符串,直接添加即可
                     res.push(template);
                     // 索引值递减
                     i -= template.length;
                 }
             }else{
                 //...
             }
         }
    }
    // 返回结果
    return res;
};

接下来,我们就来看createComponent的实现,这个函数的作用其实就是从中找出组件名,以及相应的属性,然后返回即可。同样我们还是利用正则表达式,这里也涉及到了一个正则表达式捕获组的概念。

ps: 关于正则表达式捕获组的概念可以参考这篇文章

通常我们的组件元素都是英文字母,因此我们可以通过/\w/来匹配组件名,而属性名和属性值,我们则可以通过componentPropRegExp来匹配,可以发现我们创建了2个捕获组,第一个捕获组和第二个捕获组分别就是我们想要的属性名和属性值,因此我们只需要提取属性名和属性值组合成对象即可。

根据以上分析,我们就可以写出如下代码:

const simpleJSXParser = (template: string) => {
    // ...
    if(len > 0){
        let i = len,
            resStr = '',
            componentRes = '';
         const createComponent = (v: string): ReactComponentObj => {
             // 匹配组件名
             const componentName = v.match(/\w+/)?.[0] as string;
             // 匹配属性名和属性值
             const props = v.match(componentPropRegExp)?.map(item => ({
                 // 第一个捕获组即属性名,第二个捕获组即属性值
                 key: item.replace(componentPropRegExp,(_,_1) => _1),
                 value: item.replace(componentPropRegExp,(_,_1,_2) => _2)
             })).reduce((res,item) => {
                 // 构造成props对象,并返回
                 res[item.key] = item.value;
                 return res;
             },{} as Record<string, string>)
             return {
                 type: componentName,
                 props
             }
         }
         while(i > 0){
             // ...
         }
    }
    // 返回结果
    return res;
};

将以上代码整合起来就得到了我们最终解析器的代码,如下所示:

type ReactComponentObj = { type: string; props: Record<string, string> };
export const simpleJSXParser = (template: string) => {
    const componentRegExp = /(<\w+(([\s\S])*?)<\/\w+>)/;
    const componentPropRegExp = /(\w+)=["|']?(.+?)["|']/g;
    const tagRegExp = /(<.+?>)?<\/?.+?>/;
    const len = template.length;
    const res: (string | ReactComponentObj)[] = [];
    if (len > 0) {
        let i = len,
            resStr = '',
            componentRes = '';
        const createComponent = (v: string): ReactComponentObj => {
            const componentName = v.match(/\w+/)?.[0] as string;
            const props =
                v
                    .match(componentPropRegExp)
                    ?.map(item => {
                        return {
                            key: item.replace(componentPropRegExp, (_, _1) => _1),
                            value: item.replace(componentPropRegExp, (_, _1, _2) => _2),
                        };
                    })
                    .reduce((res, item) => {
                        res[item.key] = item.value;
                        return res;
                    }, {} as Record<string, string>) || {};
            return {
                type: componentName,
                props,
            };
        };
        while (i > 0) {
            const match = template.match(componentRegExp);
            if (match) {
                const start = match?.index;
                for (let i = 0; i < start!; i++) {
                    resStr += template[i];
                }
                res.push(resStr);
                i -= resStr.length;
                template = template.replace(resStr, '');
                resStr = '';
                const matchComponent = template.match(tagRegExp);
                if (matchComponent) {
                    componentRes = matchComponent[0];
                    template = template.replace(componentRes, '');
                    i -= componentRes.length;
                    res.push(createComponent(componentRes));
                    componentRes = '';
                } else {
                    res.push(template);
                    i -= template.length;
                }
            } else {
                i = 0;
                res.push(template);
            }
        }
    }
    // console.log(111, res);
    return res;
};

当然,以上代码还存在不少问题,首先组件元素名我们也会有存在"-"的情况,这里我们并没有考虑进去,其次匹配标签的时候,这里的正则表达式是忽略掉了单闭合标签的,只能匹配成对的标签,这也是后续需要考虑优化的事情。

接下来,我们来使用一下这个解析器。

应用

使用vite初始化一个react-ts项目,然后新建一个utils目录,创建data.ts,代码如下所示:

import { createUUID } from "./uuid";

export const renderList = [
    {
        key: createUUID(),
        value: `这是一个自定义的输入框组件:<CustomInput placeholder="请输入内容" className="base-input"/></CustomInput>这是一个自定义的输入框组件:<CustomInput placeholder="请输入内容" className="base-input" value="123"/></CustomInput>`
    },
    {
        key: createUUID(),
        value: `这是一个自定义的标签组件:<CustomTag className="base-tag" color="#2396ef"/ value="标签1"></CustomTag>这是一个自定义的标签组件:<CustomTag className="base-tag" color="#2396ef" value="标签2"/></CustomTag>`
    },
    {
        key: createUUID(),
        value: `这是一个自定义的输入框组件:<CustomInput placeholder="请输入内容" className="base-input" value="123"/></CustomInput>这是一个自定义的输入框组件:<CustomInput placeholder="请输入内容" className="base-input" value="123"/></CustomInput>`
    },
    {
        key: createUUID(),
        value: `这是一个自定义的标签组件:<CustomTag className="base-tag" color="#2396ef" value="标签1"/></CustomTag>这是一个自定义的标签组件:<CustomTag className="base-tag" color="#2396ef" value="标签2"/></CustomTag>`
    },
    {
        key: createUUID(),
        value: `<CustomTag className="base-tag" color="#2396ef" value="标签1"/></CustomTag>这是一个自定义的标签组件:<CustomTag className="base-tag" color="#2396ef" value="标签2"/></CustomTag>`
    },
]

嗯,这里涉及到了一个uuid方法,代码如下:

export const createUUID = () => (Math.random() * 10000000).toString(16).substr(0, 4) + '-' + (new Date()).getTime() + '-' + Math.random().toString().substr(2, 5);

接下来新建一个components目录,实现我们的2个组件CustomInput与CustomTag并写上一些样式,然后在app.tsx里面,我们就可以使用了,我们会使用createElement方法,如下所示:

import { createUUID } from './utils/uuid';
import { renderList } from './utils/data';
import { simpleJSXParser } from './utils/jsx-parser';
// ...
import CustomInput from './components/custom-input';
import CustomTag from './components/custom-tag';
// ...
// 定义自定义的组件对象
const components: Record<
  string,
  (props: Record<string, unknown>) => JSX.Element
> = {
  CustomInput,
  CustomTag
};
// app组件内部
const App = () => {
  // 渲染列表
  const renderItem = () => {
    const list = renderList?.map(item => simpleJSXParser(item.value));
    return list?.map(item => (
      <Fragment key={createUUID()}>
        {item?.map(com =>
          createElement('div', { key: createUUID(), className: 'row' }, [
            typeof com === 'string'
              ? com
              : createElement(components[com.type], {
                  ...com.props,
                  key: createUUID()
                })
          ])
        )}
      </Fragment>
    ));
  };
  // ....
  return <div>{/*....*/}{renderItem()}</div>
}

最终会得到如下图所示的渲染效果:

截屏2024-04-23 下午8.05.00.png

想要查看在线效果,可以点击这里查看,源码地址在这里

jcode

感谢大家阅读本文,觉得本文不错,希望不吝啬点赞收藏。


夕水
5.2k 声望5.7k 粉丝

问之以是非而观其志,穷之以辞辩而观其变,资之以计谋而观其识,告知以祸难而观其勇,醉之以酒而观其性,临之以利而观其廉,期之以事而观其信。