鸿蒙5.0开发手写一个【富文本解析器】

实现过程
一、准备工作
  • 安装好最新[DevEco Studio]开发工具,创建一个新的空项目。
二、整体思路

主要有以下几个步骤

  • 正则处理自闭合标签例如img 、input 等,方便后续处理。
  • 递归解析标签,并且判断处理特殊标签给他们加上默认样式 例如 h1~h6 以及 strong 、b、big、a、s、等标签。
  • 解析标签上的 style样式、以及其他属性样式等。
  • 利用[@Builder装饰器]自定义构建函数 递归构造解析生成对应的ArkUI。
  • 利用[@Extend装饰器]定义扩展组件样式,方便一些样式的集成共用。

大致的流程图如下:

1

三、解析富文本内容–转换为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(){

    }
  }
}

可以看到打印结果给自闭合标签添加了尾部

2

  • 将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 。
  • 在解析标签属性的时候 对常用 的 borderpadding 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)
      })
    }
  }
}

最后看看运行效果如下。基本上是实现了我的预期目标。

4

总结

本文详细介绍了关于在华为鸿蒙系统 去实现一个自定义的富文本解析插件的详细教程,其实关于具体的解析过程逻辑是相通的,不仅仅是用于鸿蒙中,在其他例如小程序等 都是可以实现的,只是具体的标签渲染细节可能有些差异。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值