需求背景
事情是这样的,后端返回类似这样的数据格式`
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>
}
最终会得到如下图所示的渲染效果:
感谢大家阅读本文,觉得本文不错,希望不吝啬点赞收藏。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。