实现过程
一、准备工作
- 安装好最新[DevEco Studio]开发工具,创建一个新的空项目。
二、整体思路
主要有以下几个步骤
- 正则处理自闭合标签例如img 、input 等,方便后续处理。
- 递归解析标签,并且判断处理特殊标签给他们加上默认样式 例如 h1~h6 以及 strong 、b、big、a、s、等标签。
- 解析标签上的 style样式、以及其他属性样式等。
- 利用[@Builder装饰器]自定义构建函数 递归构造解析生成对应的ArkUI。
- 利用[@Extend装饰器]定义扩展组件样式,方便一些样式的集成共用。
大致的流程图如下:
三、解析富文本内容–转换为JSON
- 处理自闭合标签。 在ets/pages 目录下新增文件
parseHtmlToJson.ts
export interface VNode {
type: string;
level?: number;
props: {}; // 属性可以是字符串或对象(如样式)
text?: string; // 标签内的文本内容
children?: VNode[]; // 子节点列表
}
export class parseHTML{
selfClosingTagRegex = /<([a-zA-Z0-9-]+)([^>]*)/>/g; //匹配自闭合标签
constructor() {
}
parseHTMLtoJSON(htmlString:string){
// 使用正则表达式的替换功能来转换自闭合标签
const result = htmlString.replace(this.selfClosingTagRegex, (match, tagName, attributes) => {
// 构造结束标签
return `<${tagName}${attributes}></${tagName}>`;
});
console.log("result",result)
}
}
修改ets/pages/Index.ets文件。
import {parseHTML} from "./parseHtmlToJson"
@Entry
@Component
struct Index {
@State htmlStr:string =`
<h1>h1标签</h1>
<h6>h6标签</h6>
<div>
<a href="http://www.baidu.com">a标签</a>
<span>span标签</span>
<strong>strong标签</strong>
<img src="https://img1.baidu.com/it/u=728576857,3157099301&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=313" />
<input style="color:red" placeholder="请输入..." type="number" maxlength="2" value="我是input标签"/>
</div>
<p style="margin: 10px;border: 5px solid #000;">带边框样式的</p>
`;
parseHTML = new parseHTML();
aboutToAppear(){
const result = this.parseHTML.parseHTMLtoJSON(this.htmlStr);
console.log('result',JSON.stringify(result))
}
build() {
Column(){
}
}
}
可以看到打印结果给自闭合标签添加了尾部
- 将html转换为JSON树,给特殊添加标签默认样式,解析标签上的属性。
修改一下 parseHtmlToJson.ts
文件,完整代码如下
interface NestedObject {
[key: string]: string | number| object
}
export interface VNode {
type: string;
props: {
[key: string]: string | number| object
style?:NestedObject
}; // 属性可以是字符串或对象(如样式)
text?: string; // 标签内的文本内容
children?: VNode[]; // 子节点列表
}
export class parseHTML{
selfClosingTagRegex = /<([a-zA-Z0-9-]+)([^>]*)/>/g; //匹配自闭合标签
baseFontColor: string = '#000'; //基础颜色
baseFontSize: string | number = '16';//默认字体大小
themeFontColor: string = 'blue'; //默认主题颜色 用于处理a 标签等
inlineElements = this.makeMap('text,a,abbr,acronym,applet,b,basefont,bdo,big,button,cite,del,dfn,em,font,i,ins,kbd,label,map,object,q,s,samp,script,select,small,span,strike,strong,sub,sup,tt,u,var')
constructor() {
}
// 解析标签属性
parseAttributes(attrString: string): Record<string, string | Record<string, string | number>> {
const attrs: Record<string, string | Record<string, string | number>> = {};
const attrRegex = /(\w+)="(.*?)"/g;
let match: RegExpExecArray | null;
while ((match = attrRegex.exec(attrString)) !== null) {
const [, name, value] = match;
if (name === 'style') {
// 如果是 style 属性,将其解析为对象
const styleObject: Record<string, string | number> = {};
value.split(';').forEach((style) => {
let [property, val] = style.split(':').map(s => s.trim());
if (property && val) {
console.log('valval', val)
if (val.includes('px')) {
val = this.removePxUnits(val); // 去掉 'px'
}
styleObject[this.toCamelCase(property)] = val;
// 拆分 border 属性
if (property === 'border') {
const borderParts = val.split(' ');
if (borderParts.length === 3) {
styleObject['borderWidth'] = borderParts[0];
styleObject['borderStyle'] = borderParts[1];
styleObject['borderColor'] = borderParts[2];
}
}
// 拆分 margin 属性
if (property === 'margin') {
const marginParts = val.split(' ');
switch (marginParts.length) {
case 1:
styleObject['marginTop'] =
styleObject['marginRight'] = styleObject['marginBottom'] = styleObject['marginLeft'] = marginParts[0];
break;
case 2:
styleObject['marginTop'] = styleObject['marginBottom'] = marginParts[0];
styleObject['marginRight'] = styleObject['marginLeft'] = marginParts[1];
break;
case 3:
styleObject['marginTop'] = marginParts[0];
styleObject['marginRight'] = styleObject['marginLeft'] = marginParts[1];
styleObject['marginBottom'] = marginParts[2];
break;
case 4:
styleObject['marginTop'] = marginParts[0];
styleObject['marginRight'] = marginParts[1];
styleObject['marginBottom'] = marginParts[2];
styleObject['marginLeft'] = marginParts[3];
break;
}
}
// 拆分 padding 属性
if (property === 'padding') {
const paddingParts = val.split(' ');
switch (paddingParts.length) {
case 1:
styleObject['paddingTop'] = styleObject['paddingRight'] =
styleObject['paddingBottom'] = styleObject['paddingLeft'] = paddingParts[0];
break;
case 2:
styleObject['paddingTop'] = styleObject['paddingBottom'] = paddingParts[0];
styleObject['paddingRight'] = styleObject['paddingLeft'] = paddingParts[1];
break;
case 3:
styleObject['paddingTop'] = paddingParts[0];
styleObject['paddingRight'] = styleObject['paddingLeft'] = paddingParts[1];
styleObject['paddingBottom'] = paddingParts[2];
break;
case 4:
styleObject['paddingTop'] = paddingParts[0];
styleObject['paddingRight'] = paddingParts[1];
styleObject['paddingBottom'] = paddingParts[2];
styleObject['paddingLeft'] = paddingParts[3];
break;
}
}
}
});
if (!styleObject['color']) {
styleObject['color'] = this.baseFontColor; // 默认颜色
}
if (!styleObject['fontSize']) {
styleObject['fontSize'] = this.baseFontSize; // 默认颜色
}
attrs[name] = styleObject;
} else {
attrs[name] = value;
}
}
return attrs;
}
// 将Html 转换为JSON 结构
parseHTMLtoJSON(htmlString): VNode[] {
// 使用正则表达式的替换功能来转换自闭合标签
const result = htmlString.replace(this.selfClosingTagRegex, (match, tagName, attributes) => {
// 构造结束标签
return `<${tagName}${attributes}></${tagName}>`;
});
return this.HTMLtoJSON(result)
}
// str 转换为对象
makeMap(str: string): Record<string, boolean> {
const obj: Record<string, boolean> = {};
const items: string[] = str.split(',');
for (let i = 0; i < items.length; i += 1) {
obj[items[i]] = true;
}
return obj;
}
// 改变默认样式颜色和字体大小
changeTagStyle(key: string) {
const style = {
fontSize: null,
decoration: null,
color: null,
fontWeight: null
}
switch (key) {
case 'h1':
style.fontSize = 2 * (this.baseFontSize as number)
break;
case 'h2':
style.fontSize = 1.5 * (this.baseFontSize as number)
break;
case 'h3':
style.fontSize = 1.17 * (this.baseFontSize as number)
break;
case 'h4':
style.fontSize = 1 * (this.baseFontSize as number)
break;
case 'h5':
style.fontSize = 0.83 * (this.baseFontSize as number)
break;
case 'h6':
style.fontSize = 0.67 * (this.baseFontSize as number)
break;
case 'strong':
style.fontWeight = 600
break;
case 'b':
style.fontWeight = 600
break;
case 'big':
style.fontSize = 1.2 * (this.baseFontSize as number)
break;
case 'small':
style.fontSize = 0.8 * (this.baseFontSize as number)
break;
case 's':
case 'strike':
case 'del':
style.decoration = 'LineThrough'
break;
case 'a':
style.color = this.themeFontColor
style.decoration = 'Underline'
break;
}
return style
}
// 创建对象
mergeObjects(obj1, obj2) {
return Object.keys({ ...obj1, ...obj2 }).reduce((merged, key) => {
merged[key] = obj2[key] ?? obj1[key];
return merged;
}, {});
}
//解析json
HTMLtoJSON(htmlString: string, parentStyle: Record<string, string> = {}): VNode[] {
const tagRegex = /<(\w+)(.*?)>(.*?)</\1>/gs; // 匹配成对标签及内容
const result: VNode[] = [];
const nodeStack: VNode[] = []; // 节点栈,用于管理层级关系
let lastIndex = 0;
let inlineGroup: VNode[] = []; // 存储连续的行内元素
// 处理成对标签
while (true) {
const match = tagRegex.exec(htmlString);
if (!match) break;
const [fullMatch, tagName, attrs, innerHTML] = match;
// 处理标签之前的文本
if (lastIndex < match.index) {
const text = htmlString.slice(lastIndex, match.index).trim();
if (text) {
const textNode: VNode = { type: 'span', text, props: { style: parentStyle }, };
inlineGroup.push(textNode);
}
}
const element: VNode = {
type: tagName,
props: this.parseAttributes(attrs),
children: [],
};
const style = this.changeTagStyle(tagName)
element.props.style = this.mergeObjects(element.props?.style || {}, style)
// 合并父级样式
if (element.props.style) {
element.props.style = this.mergeObjects(parentStyle, element.props.style as Record<string, string>);
} else {
element.props.style = { ...parentStyle };
}
// 如果当前标签是行内元素
if (this.inlineElements[tagName]) {
element.text = innerHTML
inlineGroup.push(element);
} else {
if (tagName == 'textarea') {
element.text = innerHTML
result.push(element);
} else {
// 如果遇到非行内元素,先把之前收集的行内元素作为一个组添加到当前父节点的children中
if (inlineGroup.length > 0) {
const inlineGroupNode: VNode = {
type: 'inline-group',
props: {},
children: [...inlineGroup]
};
if (nodeStack.length > 0) {
const parent = nodeStack[nodeStack.length - 1];
(parent.children = parent.children || []).push(inlineGroupNode);
} else {
result.push(inlineGroupNode);
}
inlineGroup = []; // 清空行内元素组
}
// 将当前标签推入栈中,作为父级节点
nodeStack.push(element);
// 递归解析子标签
const childrenHTML = innerHTML;
element.children = this.HTMLtoJSON(childrenHTML, {
fontSize: element.props?.style?.fontSize,
fontColor: element.props?.style?.fontColor,
} as Record<string, string>);
// 解析完成后,将当前标签出栈,并加入其父级节点的子节点中
nodeStack.pop();
if (nodeStack.length > 0) {
const parent = nodeStack[nodeStack.length - 1];
(parent.children = parent.children || []).push(element);
} else {
result.push(element);
}
}
}
// 更新 lastIndex
lastIndex = tagRegex.lastIndex;
}
// 处理最后的文本
if (lastIndex < htmlString.length) {
const text = htmlString.slice(lastIndex).trim();
if (text) {
const textNode: VNode = { type: 'span', text, props: { style: parentStyle }, };
inlineGroup.push(textNode);
}
}
// 如果最后还有行内元素未处理
if (inlineGroup.length > 0) {
const inlineGroupNode: VNode = {
type: 'inline-group',
props: {
style: parentStyle
},
children: [...inlineGroup]
};
if (nodeStack.length > 0) {
const parent = nodeStack[nodeStack.length - 1];
(parent.children = parent.children || []).push(inlineGroupNode);
} else {
result.push(inlineGroupNode);
}
}
return result;
}
// 替换px 单位
removePxUnits(value: string): string {
return value.replace(/(\d+)px/g, '$1'); // 将 "5px" 替换为 "5"
}
//转换为驼峰法
toCamelCase(str: string): string {
return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
}
}
看看打印结果
[ { "type": "h1", "props": { "style": { "fontSize": "32" } }, "children": [ { "type": "inline-group", "props": { "style": { "fontSize": "32" } }, "children": [ { "type": "span", "text": "h1标签", "props": { "style": { "fontSize": "32" } } } ]
}
]
},
{
"type": "h6",
"props": {
"style": {
"fontSize": "10.72"
}
},
"children": [
{
"type": "inline-group",
"props": {
"style": {
"fontSize": "10.72"
}
},
"children": [
{
"type": "span",
"text": "h6标签",
"props": {
"style": {
"fontSize": "10.72"
}
}
}
]
}
]
},
{
"type": "div",
"props": {
"style": {
}
},
"children": [
{
"type": "inline-group",
"props": {
},
"children": [
{
"type": "a",
"props": {
"href": "http://www.baidu.com",
"style": {
"decoration": "Underline",
"color": "blue"
}
},
"children": [
],
"text": "a标签"
},
{
"type": "span",
"props": {
"style": {
}
},
"children": [
],
"text": "span标签"
},
{
"type": "strong",
"props": {
"style": {
"fontWeight": 600
}
},
"children": [
],
"text": "strong标签"
}
]
},
{
"type": "img",
"props": {
"src": "https://img1.baidu.com/it/u=728576857,3157099301&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=313",
"style": {
}
},
"children": [
]
},
{
"type": "input",
"props": {
"style": {
"fontSize": "16",
"color": "red"
},
"placeholder": "请输入...",
"type": "number",
"maxlength": "2",
"value": "我是input标签"
},
"children": [
]
}
]
},
{
"type": "p",
"props": {
"style": {
"margin": "10",
"marginLeft": "10",
"marginBottom": "10",
"marginRight": "10",
"marginTop": "10",
"border": "5 solid #000",
"borderWidth": "5",
"borderStyle": "solid",
"borderColor": "#000",
"color": "#000",
"fontSize": "16"
}
},
"children": [
{
"type": "inline-group",
"props": {
"style": {
"fontSize": "16"
}
},
"children": [
{
"type": "span",
"text": "带边框样式的",
"props": {
"style": {
"fontSize": "16"
}
}
}
]
}
]
}
]
需要特别注意的是 :
- 我将块级元素下面的行内元素单独用
inline-group
分组存储,特别是块级元素下面既有行内又有块级元素必须特别处理, 方便后续在鸿蒙ArkTs 中渲染。 - 样式继承现在我只继承了 fontSize 和fontColor 。
- 在解析标签属性的时候 对常用 的
border
、padding
margin
做了相关处理拆分成独立的属性方便适配。
四、将JSON树转为鸿蒙ArkUI组件。
修改ets/pages/Index.ets 文件
import {parseHTML,VNode} from "./parseHtmlToJson"
// 行内
@Extend(Span) function SpanExtend (item: VNode) {
.fontColor(item.props?.style?.color)
.fontSize(item.props?.style?.fontSize)
.fontWeight(item.props?.style?.fontWeight)
.decoration({type:item.props?.style?.decoration?
(
item.props?.style?.decoration=='LineThrough'?TextDecorationType.LineThrough
:TextDecorationType.Underline
)
:null,color: item.props?.style?.color,})//下划线等
}
// text
@Extend(Text) function TextExtend (item: VNode) {
.fontColor(item.props?.style?.color)
.fontSize(item.props?.style?.fontSize)
.fontWeight(item.props?.style?.fontWeight)
.textOverflow({overflow:item.props?.style?.textOverflow?TextOverflow.Ellipsis:null})
.maxLines(item.props?.style?.textOverflow?(item.props.style.WebkitLineClamp as number||1):null)
}
// 块级
@Extend(Column) function ColumnExtend (item: VNode) {
.borderWidth(item.props?.style?.borderWidth)
.borderColor(item.props?.style?.borderColor)
.borderStyle(item.props?.style?.borderStyle=='solid'?BorderStyle.Solid:BorderStyle.Dashed)
.margin({
top:item.props?.style?.marginTop,
left:item.props?.style?.marginLeft,
right:item.props?.style?.marginRight,
bottom:item.props?.style?.marginBottom})
.padding({
top:item.props?.style?.paddingTop,
left:item.props?.style?.paddingLeft,
right:item.props?.style?.paddingRight,
bottom:item.props?.style?.paddingBottom})
.backgroundColor(item.props?.style?.backgroundColor)
}
@Entry
@Component
struct Index {
// 块级元素
block :string[] =['br','code','address','article','applet','aside','audio','blockquote','button','canvas','center','dd','del','dir','div','dl','dt','fieldset','figcaption','figure','footer','form','frameset','h1','h2','h3','h4','h5','h6','header','hgroup','hr','iframe','ins','isindex','li','map','menu','noframes','noscript','object','ol','output','p','pre','section','script','table','tbody','td','tfoot','th','thead','tr','ul','video'];
// 行内元素
inline:string[]=['span','a','abbr','acronym','applet','b','basefont','bdo','big','button','cite','del','dfn','em','font','i','ins','kbd','label','map','object','q','s','samp','script','select','small','strike','strong','sub','sup','tt','u','var']
onClickCallback: (event:ClickEvent,node:VNode) => void = () => { //点击事件回调
}
onChangeCallback: (value:string,node:VNode) => void = () => { //输入回调
}
@State ParseList:VNode[] =[];
@State htmlStr:string =`
<h1>h1标签</h1>
<h6>h6标签</h6>
<div>
<a href="http://www.baidu.com">a标签</a>
<span>span标签</span>
<strong>strong标签</strong>
<img src="https://img1.baidu.com/it/u=728576857,3157099301&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=313" />
<input style="color:red" placeholder="请输入..." type="number" maxlength="2" value="我是input标签"/>
</div>
<p style="margin: 10px;border: 5px solid #000;">带边框样式的</p>
`;
parseHTML = new parseHTML();
aboutToAppear(){
this.ParseList = this.parseHTML.parseHTMLtoJSON(this.htmlStr);
}
@Builder buildNode(item:VNode){
if(item.type=='inline-group'){ //行内分组元素
Text(){
ForEach(item?.children,(child:VNode)=>{
this.buildNode(child)
})
}.width('100%')
.TextExtend(item)
}else{
if(this.block.includes(item.type)){ //块级元素
Column(){
ForEach(item?.children,(child:VNode)=>{
this.buildNode(child)
})
}.ColumnExtend(item)
}else{
if(this.inline.includes(item.type)){ //行内元素
Span(item.text)
.SpanExtend(item)
.onClick((event:ClickEvent)=>{
this.onClickCallback(event,item)
})
}
if(item.type=='input'){ //输入框
TextInput({ text: item.props.value as string, placeholder: item.props.placeholder as string})
.maxLength(item.props.maxlength as number)
.fontColor(item.props?.style?.color)
.onChange((value)=>{
this.onChangeCallback(value,item)
})
.onClick((event:ClickEvent)=>{
this.onClickCallback(event,item)
})
}
if(item.type=='img'){
Image(item?.props?.src as string)
.width((item?.props?.style?.width)||'100%')
.height(200)
.onClick((event:ClickEvent)=>{
this.onClickCallback(event,item)
})
}
}
}
}
build() {
Column(){
ForEach(this.ParseList,(item:VNode)=>{
this.buildNode(item)
})
}
}
}
最后看看运行效果如下。基本上是实现了我的预期目标。
总结
本文详细介绍了关于在华为鸿蒙系统 去实现一个自定义的富文本解析插件的详细教程,其实关于具体的解析过程逻辑是相通的,不仅仅是用于鸿蒙中,在其他例如小程序等 都是可以实现的,只是具体的标签渲染细节可能有些差异。