探索实现一个轻量可控的HTML iOS解析渲染器
本文涉及到的实例代码在这里: SimpleHTMLParser
背景
随着互联网的发展HTML数据的展示早已经超出了浏览器,可以在各移动终端平台进行展示与渲染。HTML可以看成是这些终端平台的脚本语言。常见的移动终端平台都内置了原生的引擎可以实现HTML的解析,这里也引发了一个问题,以iOS平台为例,将HTML数据解析成富文本对象的过程是比较耗时的,而官方的API说明了这个过程只能在主线程中进行。为什么这么耗时呢?就算加载一段如下HTML文本:
<p>段落展示</p>
解析器会加载JS以及CSS引擎为HTML加载做准备,实际上在我们的项目中需要展示的HTML是有限的,基本上也很少使用JS与CSS去控制,大多数情况下都是换行、段落、文本颜色字体等轻量的操作。
思考
高效快速的实现业务的落地,切合业务实际应用场景去实现效果。使用了很长一段时间的系统API来实现HTML的解析,随着用户量与消息量与各种不可控的数据增加,带来的某些场景下的卡顿已经不可忽视了。在这样的背景下需要对HTML的解析做出优化,但是系统API可配置可优化的空间实在有限,思考能否自己实现一个轻量的HTML解析器呢?
实现方案
要实现HTML的展示分为两个阶段,第一步是解析,第二步是渲染。 解析可以通过XML或者扫描的方式可以实现HTML的解析,由于HTML比起XML严格的定义更随意,所以接下来谈谈通过扫描的方式来实现HTML的解析。 使用以下的样本说明解析HTML的实现原理:
正常文字。
</p><em>13123<h1 style='color: #00ff00;'>sdfasfa</h1></em><br/>
第一步:
创建一个解析上下文,用于记录解析所必要的数据,这里必要的数据主要有两个,当前解析的位置(游标)以及解析时的栈(用于处理递归解析)
class SimpleHTMLParserContext {
/// 是否支持自闭合标签<br/>、<img>等,默认支持
var isSupportSelfClosingTag = true
var rawHTML = ""
var cur = 0
var parseStack: [SimpleHTMLElement] = .init()
}
第二步:
创建一个根节点,然后通过递归的方式解析子节点
let rootElement = SimpleHTMLElement.init()
rootElement.type = .root
rootElement.rawHTML = html
rootElement.defaultTextColor = defaultFontColor
rootElement.defaultFontSize = defaultFontSize
将根节点压入栈,即之后解析的为其子节点
self.context.pushElement(rootElement)
接着,开始解析根节点的子节点,将需要被解析HTML原始数据
rootElement.children = self.parseChildren()
第二步:
说干就干!接下来谈谈parseChildren
private func parseChildren() -> [SimpleHTMLElement] {
var children = [SimpleHTMLElement]()
while !self.context.isEnd {
guard let source = self.context.readCurrentSource(), source.count > 0 else { break }
var element: SimpleHTMLElement? = nil
/// 标签的开头
if source.hasPrefix("<") {
if source.hasPrefix("</") {
/// 结束标签
let startCur = self.context.cur
element = self.parseElement(.tail)
let endCur = self.context.cur
if let element = element {
let subString = self.context.rawHTML.subString(start: startCur, length: endCur - startCur)
element.rawHTML = String(subString)
}
/// 解析完当前标签,将当前标签从栈顶弹出
self.context.popElement()
break
} else {
/// 开始标签
let startCur = self.context.cur
element = self.parseElement(.head)
if let element = element {
if !element.isSelfClosingTag && element.type.canLayEggs() {
self.context.pushElement(element)
element.children = self.parseChildren()
}
}
let endCur = self.context.cur
let subString = self.context.rawHTML.subString(start: startCur, length: endCur - startCur)
element?.rawHTML = String(subString)
}
} else {
/// 不是标签的开头,则解析成纯文本数据
element = self.parseTextElement()
}
if let element = element {
children.append(element)
}
}
return children
}
1.判断起始位置非空格的第一个字符,如果是<
或者是</
则表示解析到标签的开始或者结束,如果不是则将接下来的一段字符串作为一个纯文本的数据进行解析,并生成一个TextElement
节点,解析纯文本节点的方法如下:
private func parseTextElement() -> SimpleHTMLElement? {
guard let parent = self.context.topElement(), let source = self.context.readCurrentSource() else { return nil }
/// 匹配所有的字符,除了'<', '>'
guard let regularExp = compileRegularExpression("^[\\s\\S]([^<>])*"),
let result = regularExp.firstMatch(in: source, options: .init(rawValue: 0), range: .init(location: 0, length: source.count)) else { fatalError() }
var text = source.subString(range: result.range)
self.context.advance(by: result.range)
/// 路过异常的标签数据
while let source = self.context.readCurrentSource(),
source.hasPrefix("<<") || source.hasPrefix(">>") || source.hasPrefix("<>") || source.hasPrefix("><") {
text.append(source.subString(start: 0, length: 1))
self.context.advancd(by: 1)
}
return .buildTextElement(text, parent: parent)
}
2.如果判断开头是<
则表示开始解析一下新的节点,此时进入常规节点解析方法,如下:
private func parseElement(_ location: SimpleHTMLElementLocation) -> SimpleHTMLElement? {
var element: SimpleHTMLElement? = nil
guard let parent = self.context.topElement(), let source = self.context.readCurrentSource() else { return nil }
/// 匹配'<'(开始标签)或'</'(结束标签)开头的字符串,中间不能有空格,也不能出现'/','>','<','='
guard let regularExp = compileRegularExpression("^<\\/?([a-z][^\t\r\n /><=]*)") else { fatalError() }
guard let result = regularExp.firstMatch(in: source, options: .init(rawValue: 0), range: .init(location: 0, length: source.count)) else { return nil }
let tagContent = source.subString(range: result.range)
self.context.advance(by: result.range)
let tagName = readTagName(from: tagContent, location: location)
if location == .head {
/// 开始解析标签属性
element = .init()
element?.parent = parent
let attributes = self.parseAttributes()
self.context.trimLeftSpace()
var isSelfClosing = false
var validTag = false
/// 属性解析完成,判断开始标签是否解析正常
if let source = self.context.readCurrentSource() {
if source.hasPrefix(">") {
isSelfClosing = false
validTag = true
self.context.advancd(by: 1)
} else if source.hasPrefix("/>") {
isSelfClosing = true
validTag = true
self.context.advancd(by: 2)
}
}
if validTag {
element?.attributes = attributes
element?.isSelfClosingTag = isSelfClosing
element?.tagName = tagName
element?.type = getSimpleHTMLElementTagType(by: tagName)
}
} else {
/// 开始解析结束标签
self.context.trimLeftSpace()
if let source = self.context.readCurrentSource() {
if tagName == parent.tagName && source.hasPrefix(">") {
/// 结束标签的名称与当前正在解析的标签名称一致,正常结束
parent.isSelfClosingTag = false
self.context.advancd(by: 1)
} else {
/// 异常的标签,将数据作为纯文本展示
element = .buildTextElement(tagContent, parent: parent)
}
}
}
return element
}
通过正则表达式匹配标签的开头,解析到标签的名称,同时继续解析标签的属性直到开始标签结束符>
,解析的具体看一下代码就明白了很简单,解析属性也一样的简单。解析完开始标签需要判断是否为自闭会标签,如果不是自闭合标签则需要递归解析子标签,当解析到</
时表示解析到结束标签,判断当前栈顶的标签(正在解析的标签)名称是否与结束一样,同时将栈顶标签元素弹出,此时一轮标签解析完成,判断是否解析完所有的HTML,如果没有则重复上面的逻辑直到HTML解析到最后一个字符。 整个逻辑总结下来就4步:
1.解析标签的开头与属性
2.解析子标签
3.解析结束标签
4.重复上面的操作
渲染
解析完成了就是将解析后的AST翻译生成对应平台的富文本对象,在iOS平台里是NSAttributedString
,富文本对象也是一棵树,将对应的AST翻译过来就可以了。HTML解析后的标签节点可以分为两类,渲染标签
与功能标签
。比如:
<strong>123</strong>
在解析之后会生成两个节点strong
与text
节点,strong
是功能节点,它本身不参与渲染,而是在解析的时候生成的,目的是对其子节点增加加粗的功能,text
则需要进行渲染。HTML的属性是可以继承的,即父节点的样式会被子节点继承,有了以上的原则,生成富文本对象就很简单了,下面是文本节点的富文本对象创建逻辑:
let textRender: SimpleHTMLElementRender = {
let range: NSRange = .init(location: 0, length: $0.value.count)
let attributedString = NSMutableAttributedString.init(string: $0.value)
let fontSize = $0.getFontSize()
var font = UIFont.systemFont(ofSize: fontSize)
var traits = font.fontDescriptor.symbolicTraits
if $0.getIsItalic() { traits.insert(.traitItalic) }
if $0.getIsBold() { traits.insert(.traitBold) }
if let nFontDescriptor = font.fontDescriptor.withSymbolicTraits(traits) {
font = UIFont.init(descriptor: nFontDescriptor, size: fontSize)
}
attributedString.addAttribute(.font, value: font, range: range)
if let textColor = $0.getTextColor() {
attributedString.addAttribute(.foregroundColor, value: textColor, range: range)
}
if traits.contains(.traitItalic) {
attributedString.addAttribute(.obliqueness, value: 0.2, range: range)
}
if let linkUrl = $0.getLinkUrl() {
attributedString.addAttribute(.link, value: linkUrl, range: range)
}
if $0.containsInParagraph() {
let paragraphStyle = NSMutableParagraphStyle.init()
paragraphStyle.alignment = .natural
paragraphStyle.lineSpacing = 0
paragraphStyle.paragraphSpacing = 12
attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: range)
attributedString.append(NSAttributedString.init(string: "\n"))
}
return attributedString
}
这里只对有限的属性进行了解析与生成,满足了当下的业务场景即可,有兴趣的小伙伴可以自己扩展。
下面是br
标签创建富文本的代码:
let brRender: SimpleHTMLElementRender = { _ in
return NSAttributedString.init(string: "\n")
}
下面是img
标签创建富文本的代码:
let imgRender: SimpleHTMLElementRender = {
guard let imgUrl = $0.getImgUrl(),
let url = URL.init(string: imgUrl),
let data = try? Data.init(contentsOf: url),
let image = UIImage.init(data: data) else { return .init(string: "[img]")}
let attachment = NSTextAttachment.init()
attachment.image = image
let imgAttributedString = NSAttributedString.init(attachment: attachment)
return imgAttributedString
}
由于这里解析创建可以在子线程中进行,则直接对图片进行了简单的处理,实际项目当中这一块的实现是被替换的,有兴趣的小伙伴也可以自己探索通过继承NSTextAttachment
将图片的生成与展示封闭在内部进行。 最后看一下测试样本的效果:

总结
解析渲染HTML总共分成两个大步骤:
1.解析: 通过逐步解析消耗的方式从头解析到尾,标签递归解析标签完成整个AST的构建。
2.渲染: 通过将标签分成两类构建出用于平台渲染的富文本对象
轻量级的HTML渲染只是一个探索,也许你还有更好的方案也可以一起分享讨论。
公众号: 程序猿搬砖