前排提醒,这个框架就是我写着玩的,如果您已经会使用vue或其他前端框架,这篇文章可能对您没有什么意义。即使您不会如上提到的框架,也不要对该框架报有过高的期待,该框架更多的是,我自己的自娱自乐。
这里还要提醒一下,该框架没有实现对css和js的支持,就是一个生成html代码的工具。
前言
为什么我要写这个玩意出来?因为我有时想用网页写一写游戏评测文章,而用html编写就可以比较方便地通过浏览器在不同网页之间跳转。如果编写markdown文章或其他方式,就比较麻烦了。
而我作为一名安卓开发者,对前端开发并不熟悉,而且对网页的要求也不高。只需要可显示不同样式的文字、图片和链接等功能,所以就不想花精力去学习前端框架。当然了,以后如果工作需要,那去学习也不可厚非。
编写该框架确实有如上的原因,但还有一个原因:我想试试看我能不能写出来。所以就经历了框架设计、改进框架不合理的代码等过程,最后有了现在代码。
使用和演示
上面说了那么多,接下来贴一下使用代码。
html {
header {
title("test")
}
body {
div {
a {
href = "https://www.baidu.com"
text = "baidu"
}
h1("这是h1")
text("text")
}
text("text")
}
}.toHtmlCode().also(::println)
// println
<html><header><title>test</title></header><body><div><a href="https://www.baidu.com">baidu</a><h1>这是h1</h1>text<img/></div>text</body></html>
可以看到,只需很简单的编写代码,就能够输出完整的html代码,而不需要手写很多标签代码。
再看看我使用该框架编写html的实际代码和最终效果。
代码
上面代码看似简单,但实际上,调用的代码一点都不少,想看实现的代码,可以直接看这个github链接,接下来是代码解析
HtmlTag
首先,需要一个类来描述html代码,所以我就创建了HtmlTag,任何一个标签都是HtmlTag的实现类。interface HtmlTag {
fun getTagString(): String
// 获取标签的属性,如id、width、height等
// Pair的first就是属性的字符串,second就是属性的值,类型设计为Any是考虑到Int、Double这些类型。如果限制为String,那每次都需要手动调用一次toString,这样做其实挺麻烦的
fun getAttributeList(): List<Pair<String, Any>>
// 转换成html代码,每个标签做好自己的转换任务,由上级调用下级的该方法进行转换,最终形成一条调用链,这样就能非常方便地生成html代码
fun toHtmlCode(): String
}
从上面的演示代码可以看到,还有html这个方法,这个方法获取到的就是一个HtmlRoot对象,看看该对象里面有什么代码
class HtmlRoot: HtmlTag {
var header: HtmlHeaderRoot? = null
var body: HtmlBodyRoot? = null
override fun getTagString(): String = TAG
override fun toHtmlCode(): String {
return generateHtmlCode(listOfNotNull(header, body))
}
override fun getAttributeList(): List<Pair<String, Any>> = emptyList()
companion object {
const val TAG = "html"
}
}
这里的generateHtmlCode是一个扩展方法,下面会提到,先把注意力放到其他地方。
HtmlBody
可以看到,该类里面,有header和body对象,header和body的代码类似,我就把body拿出来讲。
不过在看看body的代码之前,先了解一下body的基类。
HtmlBody
interface HtmlBody: HtmlTag
就一个非常简单的接口,所有和body有关的代码,都必须为该接口的实现类,包括HtmlBodyRoot本身。该类有两个直接实现类:HtmlBodySingle和HtmlBodyGroup。
HtmlBodySingle
abstract class HtmlBodySingle<T: HtmlBody>: HtmlBody {
open var body: T? = null
}
HtmlBodyGroup
abstract class HtmlBodyGroup<T: HtmlBody>: HtmlBody {
protected open var internalBodyList: MutableList<T> = ArrayList()
open fun addHtmlBody(body: T) {
internalBodyList.add(body)
}
open fun addAllHtmlBody(list: List<T>) {
internalBodyList.addAll(list)
}
open fun removeHtmlBody(body: T) {
internalBodyList.remove(body)
}
open fun clearBodyList() {
internalBodyList.clear()
}
open fun getBodyList(): List<T> {
return internalBodyList
}
}
可以看到,single只能有一个body,而group可以有多个body。为什么要这样做?因为我考虑到有些标签只能有一个子标签,如a、h这些标签,而有一些则可以多个,如body、div等。
一开始,我考虑的是,将single作为一个特殊的group标签,做一些实现,保证开发者永远只能在list里面添加一个标签,就像android的ScrollView一样。但随后想了想,还是不要这样做,因为这样会增加开发者出错的几率。
上面还有泛型这个东西,我考虑的是,有一些标签的子标签类型不需要那么宽泛,只需某些特定的标签,如table、ul、ol等,所以我就加了一个泛型上去。
现在再看看body标签的代码
class HtmlBodyRoot: HtmlBodyGroup<HtmlBody>() {
var style: HtmlStyleRoot? = null
override fun getTagString(): String = TAG
// 这里就是将style和bodyList转换成String,生成一个字符串List,传递给generateHtmlCodeByStringList方法去生成html代码
override fun toHtmlCode(): String {
val style = style?.toStyleCode()?.takeIf { it.isNotEmpty() } ?: ""
return generateHtmlCodeByStringList(listOf(style, getBodyList().bodyTagListToString()))
}
// 我的印象里,body好像没有属性,所以我直接返回一个空List
override fun getAttributeList(): List<Pair<String, Any>> = emptyList()
companion object{
const val TAG = "body"
}
}
fun <T: HtmlBody> List<T>.bodyTagListToString() = joinToString("") { it.toHtmlCode() }
可以看到,body的代码也没什么,就是一些很普通的代码。
我贴出来的代码有不少调用generateHtmlCode这个方法,来看看该方法的实现代码。
fun HtmlTag.generateHtmlCode(): String {
return generateHtmlCode("")
}
// 大部分body会调用这里的single和group的扩展方法,可以看到,这里的代码最终都会调用toHtmlCode方法
fun <T: HtmlBody> HtmlBodySingle<T>.generateHtmlCode(): String {
return generateHtmlCode(body?.toHtmlCode() ?: "")
}
fun <T: HtmlBody> HtmlBodyGroup<T>.generateHtmlCode(): String {
return generateHtmlCode(getBodyList().bodyTagListToString())
}
fun <T: HtmlHeader> HtmlHeaderGroup<T>.generateHtmlCode(): String {
return generateHtmlCode(getHeaderList().headerTagListToString())
}
fun <T: HtmlTag> HtmlTag.generateHtmlCode(htmlList: List<T>): String {
return generateHtmlCode(htmlList.tagListToString())
}
// HtmlBodyRoot就是调用这个方法生成html代码
fun HtmlTag.generateHtmlCodeByStringList(codeList: List<String>): String {
// 这里也只是将String List调用joinToString转换成一个String而已
return generateHtmlCode(codeList.joinToString(""))
}
// 所有代码最终都会调用该方法,可以直接看该方法的代码
fun HtmlTag.generateHtmlCode(value: String): String {
// 这里就会调用HtmlTag的getAttributeList方法,将它们转换成first="second"这样的代码,并在转换后的字符串前面加一个空格。如果该List为空,就直接返回空字符串。
// 为什么要加一个空格,因为如果不加空额,标签代码就会和属性代码贴在一起。如:<ahref=""/>
val attributeString = getAttributeList().takeIf { it.isNotEmpty() }?.joinToString(" ") {
"${it.first}=\"${it.second}\""
}?.let { " $it" } ?: ""
// 如果value为空,就生成闭标签的代码,否则就生成开标签的代码
return if(value.isEmpty()) {
"<${getTagString()}$attributeString/>"
} else {
buildString {
// 配合上面的single和group的generateHtmlCode扩展方法,就不难理解
// 将子body的代码包在自己的标签里面,而子body也会调用自己的子body包在自己的标签里面
// 最后一层包一层,就可以输出一串复杂的html代码
append("<${getTagString()}$attributeString>")
append(value)
append("</${getTagString()}>")
}
}
}
为什么要将它们作为扩展方法,而不是放到HtmlTag里面?因为这里面有一些方法不是每个实现类都用得上。如果将这些代码放到顶层接口里面,最终会导致每个实现类生成的class文件里面,有很多用不上的代码,所以将这些代码作为扩展方法是比较实际的。而且接口更多是起到定义规范的作用,而不是当工具类使用。
getAttributeList
再提一下getAttributeList的实现,拿a标签举例
HtmlBodyGeneralAttribute.kt
// 属性的根类是HtmlBodyGeneralAttribute,这里面包含了所有基础标签,不过我只是将开发中需要的属性加上去,后续如果还需要其他属性,可以自己加
interface HtmlBodyGeneralAttribute<T: HtmlBodyGeneralAttributeEntity> {
// 为属性提供默认实现,这样做了之后,实现类就不用手动写这些代码了
// 这里的attributeEntity就是存放这些属性的实体,如果在这里直接给这个字段编写get方法,每次调用该字段都会重写new一个对象
// 所以只能交给实现类去new,但也仅仅需要编写这一行代码,所以我认为这没有什么负担
val attributeEntity: T
// 通过字段的形式来设置属性,这样外部用起来就比较方便,而不用去调用方法
// 这些字段默认是否为null,就自己判断了,如果觉得不需要null,也可以将?去掉
var id: String?
get() = attributeEntity.id
set(value) {
attributeEntity.id = value
}
var width: String?
get() = attributeEntity.width
set(value) {
attributeEntity.width = value
}
var height: String?
get() = attributeEntity.height
set(value) {
attributeEntity.height = value
}
// 最后通过该方法生成一个Pair List
// toPairByStringValue的代码已经放在下面了,该方法会判断String Value是否为空字符串,如果是,就烦恼会一个空的Pair对象
// 这里调用listOfNotNull,所以空的Pair对象就会直接被过滤掉
fun getAttributeList(): List<Pair<String, Any>> {
return listOfNotNull(
HtmlBodyAttribute.general.ID toPairByStringValue attributeEntity.id,
HtmlBodyAttribute.general.WIDTH toPairByStringValue attributeEntity.width,
HtmlBodyAttribute.general.HEIGHT toPairByStringValue attributeEntity.height,
)
}
}
open class HtmlBodyGeneralAttributeEntity {
var id: String? = null
var width: String? = null
var height: String? = null
}
infix fun String.toPairByStringValue(value: String?): Pair<String, String>? {
return value?.takeIf { it.isNotEmpty() }?.let { this to value }
}
HtmlBodyATag.kt
// 这个类用于存放a标签需要的属性,该类需要继承HtmlBodyGeneralAttribute,并提供attibuteEntity对象类型
// 所以就写一个a的attibuteEntity继承GenernalAttributeEntity
interface HtmlBodyAAttribute: HtmlBodyGeneralAttribute<HtmlBodyAAttributeEntity> {
var href: String?
get() = attributeEntity.href
set(value) {
attributeEntity.href = value
}
var target: String?
get() = attributeEntity.target
set(value) {
attributeEntity.target = value
}
// 重点是这里,继承之后,需要重写该方法,将super的结果取出来,并放一个新的List,形成一个二维List
// 最后再调用list的flatten方法, 将二维List变成一维
// 当然了,如果觉得这种实现方法不太好,也可以自己换一种更好的实现方式
override fun getAttributeList(): List<Pair<String, Any>> {
val superList = super.getAttributeList()
val currentList = listOfNotNull(
HtmlBodyAttribute.a.HREF toPairByStringValue attributeEntity.href,
HtmlBodyAttribute.a.TARGET toPairByStringValue attributeEntity.target,
)
return listOf(superList, currentList).flatten()
}
}
class HtmlBodyAAttributeEntity: HtmlBodyGeneralAttributeEntity() {
var href: String? = null
var target: String? = null
}
// 实现HtmlBodyAAttribute接口
class HtmlBodyATag: HtmlBodySingle<HtmlBody>(), HtmlBodyAAttribute {
// 在a标签里面,只需重写这个字段,其他的都不用做,就拥有了设置id、width、href等功能
override val attributeEntity: HtmlBodyAAttributeEntity = HtmlBodyAAttributeEntity()
override fun getTagString(): String = TAG
override fun toHtmlCode(): String {
return generateHtmlCode()
}
// 这里需要override是没办法的事情,因为HtmlTag本身也有一个名称一样的方法,并且没有提供实现
// 这里调用super就可以将AAttribute的实现直接拿过来用,所以
override fun getAttributeList(): List<Pair<String, Any>> {
return super.getAttributeList()
}
companion object{
const val TAG = "a"
}
}
纯文本
从上面的HtmlBody、HtmlBodySinge和HtmlBodyGroup的代码可以看到,没有一个属性用来编写纯文本,但html想要编写纯文本的代码,只需在空白部分编写就可以显示出来。
鉴于这种情况,我编写了HtmlBodyTextTag来实现这个功能,代码非常简单
class HtmlBodyTextTag: HtmlBody {
var text: String = ""
override fun getTagString(): String = ""
override fun toHtmlCode(): String {
return text
}
override fun getAttributeList(): List<Pair<String, Any>> = emptyList()
}
如果某个标签需要纯文本的内容,就可以设置这样一个body
dsl代码
上面将body的基本架构都介绍完了,接下来写一下dsl的代码是怎么写的
htmlDSL.kt
inline fun html(action: HtmlRoot.() -> Unit): HtmlRoot {
return HtmlRoot().also {
it.action()
}
}
inline fun HtmlRoot.header(action: HtmlHeaderRoot.() -> Unit): HtmlHeaderRoot {
return HtmlHeaderRoot().also {
it.action()
header = it
}
}
inline fun HtmlRoot.body(action: HtmlBodyRoot.() -> Unit): HtmlBodyRoot {
return HtmlBodyRoot().also {
it.action()
body = it
}
}
代码还是比较简单的,想要写注释,但不知道要写什么
有关body dsl的代码还是有点多的,所以拿一部分出来讲
htmlBodyGenenralDSL.kt
// 给single扩展方法之后,在single里面就可以直接调用a了,而不需要用body = getA这种麻烦的形式
inline fun HtmlBodySingle<HtmlBody>.a(action: HtmlBodyATag.() -> Unit) {
getA.also {
it.action()
body = it
}
}
// 由于HtmlBodyRoot也是一个HtmlBodyGroup对象,所以这样写了之后,就能为body添加一个a标签
// 返回类型:返回自己本身。为什么要这样做?因为返回自己之后,就能链式调用代码
// 比如写一个a标签之后,如果不想换行去写一个text标签,就可以直接在a标签的代码后面.去调用,否则需要手动写";"才能调用
inline fun HtmlBodyGroup<HtmlBody>.a(action: HtmlBodyATag.() -> Unit): HtmlBodyGroup<HtmlBody> {
getA.also {
it.action()
addHtmlBody(it)
}
return this
}
htmlBodyGenenralGetDSL.kt
inline val getA: HtmlBodyATag
get() = HtmlBodyATag()
给group扩展之后,就不只是body可以使用,像div、li、th、td这些,也都可以通过这种方法add一个body,使用起来非常方便。
从上面还能看到,有一个getA,为什么要这样做?
可以想象一下,如果外部需要new一个A标签的对象,那就需要记住A标签的对象名称,但名称又那么长,每次写起来都挺麻烦的。而如果用这种形式,就可以让开发者无需记住对象名称,需要使用时,只需在标签前面加个get就可以拿到对应的标签对象,这不是很方便吗?
大部分标签会提供get方法,并放到GetDSL里面。
text
inline fun HtmlBodySingle<HtmlBody>.text(text: String){
getText.also {
it.text = text
body = it
}
}
var HtmlBodySingle<HtmlBody>.text: String
get() = (body as? HtmlBodyTextTag)?.text ?: ""
set(value) {
val body = body as? HtmlBodyTextTag ?: getText.also {
body = it
}
body.text = value
}
inline fun HtmlBodyGroup<HtmlBody>.text(text: String): HtmlBodyGroup<HtmlBody> {
getText.also {
it.text = text
addHtmlBody(it)
}
return this
}
text除了提供2个方法,还为single提供了text这个字段,这样如果想要给某个标签设置text,就可以通过这种方式
dsl的代码基本都是a、text标签这种形式,其他代码都是大同小异,所以其他代码我就不贴出来了。
字数统计
最后补充一个字数统计的功能,如果要统计一个网页的字数,可以使用js进行统计,但该框架没有提供js相关的api,所以写起来有点麻烦,自然地,我也不打算用js去统计字数。除了js,我还想到用正则表达式,将html的文本找出来,但试了几个正则表达式,我都没能将文本找出来。
最后就想到,直接提取body里面所有text对象,并获取text对象的文本内容,最后计算出字数。
fun HtmlRoot.getTextLength(): Int {
// 通过递归调用,就可以获取到所有text对象,获取完成后,就可以通过text里面的text字段计算字数
return body?.getTextBodyList()?.sumOf {
it.text.length
} ?: 0
}
fun HtmlBody.getTextBodyList(): List<HtmlBodyTextTag>? {
return when(this) {
is HtmlBodyTextTag -> {
arrayListOf(this)
}
is HtmlBodySingle<*> -> {
getTextBodyList()
}
is HtmlBodyGroup<*> -> {
getTextBodyList()
}
else -> null
}
}
fun HtmlBodyGroup<*>.getTextBodyList(): List<HtmlBodyTextTag>? {
val list = ArrayList<HtmlBodyTextTag>()
getBodyList().forEach {
// 这里就会调用上面的HtmlBody的getTextBodyList方法
// 如果获取到一个空对象,就不会addAll到list里面
it.getTextBodyList()?.also(list::addAll)
}
// 如果list不为空,就返回该List,否则就返回null
return list.takeIf { it.isNotEmpty() }
}
fun HtmlBodySingle<*>.getTextBodyList(): List<HtmlBodyTextTag>? {
return body?.getTextBodyList()
}
我自己没有想出来如何用正则表达式统计字数,所以抱着试一试的想法问了chatGPT,想不到还真得到我想要的代码。而且比我想象中的简单
fun getTextByHtmlCode(html: String): String {
// 移除 HTML 标签
var text = html.replace("\\<.*?\\>".toRegex(), "")
// 移除 HTML 转义字符
text = text.replace("&.*?;".toRegex(), "")
// 移除多余空格和换行符
text = text.trim { it <= ' ' }.replace("[\\s\\t]+".toRegex(), " ")
return text
}
可以看到,直接暴力匹配<>这个括号就行,不管里面有什么内容。而且我也用一段5000多文本的html页面测试过了,得到的结果时一样的,所以代码是不存在什么问题的。只不过执行效率我就不清楚了,我不知道正则表达式的替换效率是怎么样的。而我提供的代码,本质是递归调用,也好不到哪去。
从chatGPT发送的代码可以看到,最后还会将字符串转换成字符数组,这段在我看来是没必要的,所以我就去掉了。
后记
上面简单地讲解了该框架的使用方法和实现方式,其他代码麻烦看看github里面的代码。
由于该框架主要是为了方便自己,所以提供的标签和属性都不全,如果自己需要哪个标签,可以根据我编写的代码,自己做一套实现。模板代码已经写出来了,剩下的就是体力活了。