前言
大家好,我是无言。有一阵子没有更新文章了,最近公司效益不好,行业下行,看了一些机会,基本都能进入2轮,有些甚至进行了4轮面试,但是我觉得4轮的结果和他的薪资体系不匹配。[手动狗头]。言归正传前段时间刚好做了一个社区类的鸿蒙项目,里面涉及了大量富文本展示,包括评论都是需要用富文本展示,用了官方自己的RichText,效果并不好不好,而且支持的标签很有限,所以决定自己来手搓一个富文本解析器。
我将富文本展示内容完善后放到了插件市场中,有需要的朋友可以下载下来试一试@wuyan/html_parse
实现过程
一、准备工作
- 安装好最新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标签
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(){
}
}
}
可以看到打印结果给自闭合标签添加了尾部
- 将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}>${tagName}>`
})
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 。
- 在解析标签属性的时候 对常用 的
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,})
}
@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)
})
}
}
}
最后看看运行效果如下。基本上是实现了我的预期目标。
总结
本文详细介绍了关于在华为鸿蒙系统 去实现一个自定义的富文本解析插件的详细教程,其实关于具体的解析过程逻辑是相通的,不仅仅是用于鸿蒙中,在其他例如小程序等 都是可以实现的,只是具体的标签渲染细节可能有些差异。
希望这篇文章能帮到你,最后我把完整代码放到了gitee上有需要的小伙伴可以自己拉下来去试一试。
最后如果觉得本篇文章还不错,欢迎点赞收藏。
本文正在参加华为鸿蒙有奖征文征文活动
阅读全文
下载说明:
1、本站所有资源均从互联网上收集整理而来,仅供学习交流之用,因此不包含技术服务请大家谅解!
2、本站不提供任何实质性的付费和支付资源,所有需要积分下载的资源均为网站运营赞助费用或者线下劳务费用!
3、本站所有资源仅用于学习及研究使用,您必须在下载后的24小时内删除所下载资源,切勿用于商业用途,否则由此引发的法律纠纷及连带责任本站和发布者概不承担!
4、本站站内提供的所有可下载资源,本站保证未做任何负面改动(不包含修复bug和完善功能等正面优化或二次开发),但本站不保证资源的准确性、安全性和完整性,用户下载后自行斟酌,我们以交流学习为目的,并不是所有的源码都100%无错或无bug!如有链接无法下载、失效或广告,请联系客服处理!
5、本站资源除标明原创外均来自网络整理,版权归原作者或本站特约原创作者所有,如侵犯到您的合法权益,请立即告知本站,本站将及时予与删除并致以最深的歉意!
6、如果您也有好的资源或教程,您可以投稿发布,成功分享后有站币奖励和额外收入!
7、如果您喜欢该资源,请支持官方正版资源,以得到更好的正版服务!
8、请您认真阅读上述内容,注册本站用户或下载本站资源即您同意上述内容!
原文链接:https://www.shuli.cc/?p=20860,转载请注明出处。
1、本站所有资源均从互联网上收集整理而来,仅供学习交流之用,因此不包含技术服务请大家谅解!
2、本站不提供任何实质性的付费和支付资源,所有需要积分下载的资源均为网站运营赞助费用或者线下劳务费用!
3、本站所有资源仅用于学习及研究使用,您必须在下载后的24小时内删除所下载资源,切勿用于商业用途,否则由此引发的法律纠纷及连带责任本站和发布者概不承担!
4、本站站内提供的所有可下载资源,本站保证未做任何负面改动(不包含修复bug和完善功能等正面优化或二次开发),但本站不保证资源的准确性、安全性和完整性,用户下载后自行斟酌,我们以交流学习为目的,并不是所有的源码都100%无错或无bug!如有链接无法下载、失效或广告,请联系客服处理!
5、本站资源除标明原创外均来自网络整理,版权归原作者或本站特约原创作者所有,如侵犯到您的合法权益,请立即告知本站,本站将及时予与删除并致以最深的歉意!
6、如果您也有好的资源或教程,您可以投稿发布,成功分享后有站币奖励和额外收入!
7、如果您喜欢该资源,请支持官方正版资源,以得到更好的正版服务!
8、请您认真阅读上述内容,注册本站用户或下载本站资源即您同意上述内容!
原文链接:https://www.shuli.cc/?p=20860,转载请注明出处。
评论0