鸿蒙Next – 自己来手搓一个富文本解析器

前言

大家好,我是无言。有一阵子没有更新文章了,最近公司效益不好,行业下行,看了一些机会,基本都能进入2轮,有些甚至进行了4轮面试,但是我觉得4轮的结果和他的薪资体系不匹配。[手动狗头]。言归正传前段时间刚好做了一个社区类的鸿蒙项目,里面涉及了大量富文本展示,包括评论都是需要用富文本展示,用了官方自己的RichText,效果并不好不好,而且支持的标签很有限,所以决定自己来手搓一个富文本解析器。

我将富文本展示内容完善后放到了插件市场中,有需要的朋友可以下载下来试一试@wuyan/html_parse

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

主要有以下几个步骤

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

大致的流程图如下:

image.png

三、解析富文本内容–转换为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}>`;
    });
    console.log("result",result)
  }
}

修改ets/pages/Index.ets文件。



import  {parseHTML} from  "./parseHtmlToJson"
@Entry
@Component
struct Index {

  @State htmlStr:string =`
    

h1标签

h6标签
href="http://www.baidu.com">a标签 span标签 strong标签 src="https://img1.baidu.com/it/u=728576857,3157099301&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=313" /> style="color:red" placeholder="请输入..." type="number" maxlength="2" value="我是input标签"/>

style="margin: 10px;border: 5px solid #000;">带边框样式的

` parseHTML = new parseHTML() aboutToAppear(){ const result = this.parseHTML.parseHTMLtoJSON(this.htmlStr) console.log('result',JSON.stringify(result)) } build() { Column(){ } } }

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

image.png

  • 将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'
  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> {
    const attrs: Record> = {}
    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 = {}
        value.split('
          let [property, val] = style.split(':').map(s => s.trim())
          if (property && val) {
            console.log('valval', val)
            if (val.includes('px')) {
              val = this.removePxUnits(val)
            }
            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}>`
    })
    return this.HTMLtoJSON(result)
  }
  // str 转换为对象
  makeMap(str: string): Record {
    const obj: Record = {}
    const items: string[] = str.split(',')
    for (let i = 0
      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 = {}): 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)
      } 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)
          // 解析完成后,将当前标签出栈,并加入其父级节点的子节点中
          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')
  }
  //转换为驼峰法
  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,})

}


@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标签

h6标签
"http://www.baidu.com">a标签 span标签 strong标签 "https://img1.baidu.com/it/u=728576857,3157099301&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=313" /> "color:red" placeholder="请输入..." type="number" maxlength="2" value="我是input标签"/>

"margin: 10px;border: 5px solid #000;">带边框样式的

`; 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) }) } } }

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

image.png

总结

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

希望这篇文章能帮到你,最后我把完整代码放到了gitee上有需要的小伙伴可以自己拉下来去试一试。

最后如果觉得本篇文章还不错,欢迎点赞收藏。

本文正在参加华为鸿蒙有奖征文征文活动

阅读全文
下载说明:
1、本站所有资源均从互联网上收集整理而来,仅供学习交流之用,因此不包含技术服务请大家谅解!
2、本站不提供任何实质性的付费和支付资源,所有需要积分下载的资源均为网站运营赞助费用或者线下劳务费用!
3、本站所有资源仅用于学习及研究使用,您必须在下载后的24小时内删除所下载资源,切勿用于商业用途,否则由此引发的法律纠纷及连带责任本站和发布者概不承担!
4、本站站内提供的所有可下载资源,本站保证未做任何负面改动(不包含修复bug和完善功能等正面优化或二次开发),但本站不保证资源的准确性、安全性和完整性,用户下载后自行斟酌,我们以交流学习为目的,并不是所有的源码都100%无错或无bug!如有链接无法下载、失效或广告,请联系客服处理!
5、本站资源除标明原创外均来自网络整理,版权归原作者或本站特约原创作者所有,如侵犯到您的合法权益,请立即告知本站,本站将及时予与删除并致以最深的歉意!
6、如果您也有好的资源或教程,您可以投稿发布,成功分享后有站币奖励和额外收入!
7、如果您喜欢该资源,请支持官方正版资源,以得到更好的正版服务!
8、请您认真阅读上述内容,注册本站用户或下载本站资源即您同意上述内容!
原文链接:https://www.shuli.cc/?p=20860,转载请注明出处。
0

评论0

显示验证码
没有账号?注册  忘记密码?