Hugo 文章摘要
说明
在我写这个文章摘要代码片段的时候,Hugo 还没有支持按照元素截取,因此 summary 是纯文本、没有格式。
现在 Hugo 已经实现了带格式的截取。目前内置实现和我实现的区别是:内置实现是按照词数截取的,直到词数满足要求就停止添加新的元素,我的实现是按照顶级元素数量截取的(以前用 Hexo 插件有这个功能,因此我实现的也是这个)。
由于 Hugo 没有开放给 html 模板足够的功能,很多数据结构有点大材小用,Hugo 内置实现远远快于用 html 模板的实现。在我电脑上测试是快了好几倍,也就是说这个文章摘要功能拖累了渲染速度。
实现
在 themes/hugo-theme-next/layouts/partials/post/body.html
的基础上修改。原本内容类似于:
{{- with .ctx -}}
{{- if or (not $.IsHome) .Params.Expand -}}
{{ .Content }}
{{- else -}}
{{ .Summary }}
{{ end }}
现在一个字符一个字符地遍历文章,然后找到前 summaryElementCount
个非标题元素截断。配置为:
params:
summaryByElement: true
summaryByElementStrideMode: true
summaryElementCount: 3
summaryDontCountHeadings: true
summaryByElement
是这个功能的总开关,summaryElementCount
是要截取元素的数量,summaryDontCountHeadings
表示排除对标题的计数,因为只有标题没有内容不太好看(尤其是在有连续几个大小标题的时候)。
summaryByElementStrideMode
表示是否严格启用按照顶级元素截取,如果不启用的话直接搜索第三个 </p>
截取即可。但是:
<p>
不一定是顶级元素。- 顶级元素不一定是
<p>
。
因此截取效果可能会有偏差,导致有些文章很长,有些文章很短。但是这种处理方式非常快,不会感受到渲染变慢。在 Hugo 还没有实现带格式摘要时可以使用这种方式过渡。
Partial 代码为:
{{- with .ctx -}}
{{- $summaryByElement := .Site.Params.SummaryByElement -}}
{{- $summaryByElementStrideMode := .Site.Params.SummaryByElementStrideMode -}}
{{- $summaryElementCount := int .Site.Params.SummaryElementCount -}}
{{- $summaryDontCountHeadings := .Site.Params.SummaryDontCountHeadings -}}
{{- if or (not $.IsHome) .Params.Expand -}}
{{ .Content }}
{{- else if not $summaryByElement -}}
{{ "<!--Builtin summary is much faster-->" | safeHTML }}
{{ .Summary }}
{{/* ! 这里本来 more 的里面不能要空格,但是 Hugo 会将这样的代码块渲染成空白。 */}}
{{/* ! 为了显示出来不得以换了一种写法。实际上代码中可以不用这么写。 */}}
{{- else if strings.Contains .RawContent (replaceRE "(\\s)" "" "<!-- more -->") -}}
{{ .Summary }}
{{- else -}}
{{- if not $summaryByElementStrideMode }}
{{- $parts := split .Content "</p>" -}}
{{- $summary := delimit (first $summaryElementCount $parts) "</p>" -}}
{{ $summary | safeHTML }}
{{- else -}}
{{- $content := .Content -}}
{{- $lastRune := "" -}}
{{- $level := 0 -}}
{{- $topElements := 0 -}}
{{- $endIndex := 0 -}}
{{- $isOpenTag := true -}}
{{- $selfClosing := slice "br" "hr" "img" "input" "link" "meta" -}}
{{- $headings := slice "h1" "h2" "h3" "h4" "h5" "h6" -}}
{{- $tagStart := 0 -}}
{{- range $i, $r := split $content "" -}}
{{- if eq $r "<" -}}
{{- $isOpenTag = true -}}
{{- $tagStart = $i -}}
{{- else if and (eq $r "/") (eq $lastRune "<") -}}
{{- $isOpenTag = false -}}
{{- else if eq $r ">" -}}
{{- $endIndex = add $i 1 -}}
{{- $tag := slicestr $content $tagStart $endIndex -}}
{{- $tag = index (split $tag " ") 0 -}}
{{- $tag = trim $tag "<>/" -}}
{{- if in $selfClosing $tag }}
{{ "<!--Skip self-closing tags-->" | safeHTML }}
{{- else -}}
{{- if $isOpenTag -}}
{{- $level = add $level 1 -}}
{{- else -}}
{{- $level = sub $level 1 -}}
{{- end -}}
{{- end -}}
{{- if eq $level 0 -}}
{{- if not (and $summaryDontCountHeadings (in $headings $tag)) -}}
{{- $topElements = add $topElements 1 -}}
{{- end -}}
{{- if ge $topElements $summaryElementCount -}}
{{- break -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- $lastRune = $r -}}
{{- end -}}
{{- $summary := slicestr $content 0 $endIndex -}}
{{ $summary | safeHTML }}
{{- end -}}
{{ end }}
{{ end }}
尝试加速渲染过程
通过 hugo --logLevel info
和 debug.Timer
函数可以对代码片段调试速度。结果发现默认渲染方式平均一次只要微秒级别,而不严格的按 <p>
摘取要 ~40 微秒,逐字符分析则是需要 ~4 毫秒。
通过将逐字符分析换成用 <
分割的字符串分析,可以将这个过程加速到亚毫秒级(~900 微秒)。以下是关键部分的新代码:
{{- $level := 0 -}}
{{- $topElements := 0 -}}
{{- $endIndex := 0 -}}
{{- $isOpenTag := true -}}
{{- range $i, $s := split $content "<" -}}
{{- if eq $i 0 -}}
{{/* The first string is guranteed to be empty. */}}
{{- continue -}}
{{- end -}}
{{- $endIndex = add $endIndex (strings.RuneCount "<") -}}
{{- $isOpenTag = true -}}
{{- if eq "/" (substr $s 0 1) -}}
{{- $isOpenTag = false -}}
{{- end -}}
{{- $tagName := index (findRE `^/?[a-zA-Z0-9]+` $s 1) 0 -}}
{{- $tagName = trim $tagName "/" -}}
{{- if not (in $selfClosing $tagName) -}}
{{- if $isOpenTag -}}
{{- $level = add $level 1 -}}
{{- else -}}
{{- $level = sub $level 1 -}}
{{- end -}}
{{- end -}}
{{- if eq $level 0 -}}
{{- if not (and $summaryDontCountHeadings (in $headings $tagName)) -}}
{{- $topElements = add $topElements 1 -}}
{{- end -}}
{{- if ge $topElements $summaryElementCount -}}
{{- $tag := index (findRE `[^>]*>` $s 1) 0 -}}
{{- $endIndex = add $endIndex (strings.RuneCount $tag) -}}
{{- break -}}
{{- end -}}
{{- end -}}
{{- $endIndex = add $endIndex (strings.RuneCount $s) -}}
{{- end -}}
{{- if ge $topElements $summaryElementCount -}}
{{- slicestr $content 0 $endIndex | safeHTML -}}
{{/* This is for debugging */}}
{{- if not $fullContent -}}
{{- $timer := debug.Timer "PostSummary.builtinSummarySuccess" -}}
{{- $timer.Stop -}}
{{- end -}}
{{- break -}}
{{- else if $fullContent -}}
{{ $content | safeHTML }}
{{- end -}}
{{- end -}} {{/* range $fullContent := slice false true */}}
虽然还是比默认渲染方式慢了几百倍,但是渲染速度从难以忍受(一篇文章加 > 4 毫秒)来到了可以接受的范围内(一篇文章加 < 1 毫秒)。
接着发现每次 split 只花了 ~5 微秒,但是整个过程花的时间比较多,应该是循环次数比较多。调试发现平均每个文章在 split 之后要进循环 50 次,平均每次花几十微秒,因为在循环中有一些很难省掉的计算逻辑,可能在这种方法下很难继续优化了。
Warning
如果发现计时反常地大,有可能是因为写了一些分支语句,导致遗漏了停止计时器的过程。
Warning
debug.Timer
频繁运行也会加大渲染的开销,在调试完时要删除相关代码。
其他优化:让速度快了几十毫秒,几乎很难让人察觉。
把计算
$tagName
的方式改成:{{- $tagName := index (findRESubmatch `^/?([a-zA-Z0-9]+)` $s 1) 0 1 -}}
把用 slice 表示的标题和 self-closing 标签改成用 dict 表示(Hugo 不支持 set 就很烦)。
先尝试用 Hugo 已经截取好的字符串来操作,失败时才对全文自行截取,这样可以减少对超大字符串 split 的开销。
其他可以继续想办法的地方:
- 在循环中对
$endIndex
增加计数需要计算当前$s
的字符数(strings.RuneCount $s
),这个过程耗时有足足 100 毫秒。但是 Hugo 中没有提供其他的手段来计算字符数了。
结果:用 hugo --templateMetrics --templateMetricsHints
来看各部分渲染的时间,在不用自定义截取方法时,body.html 渲染耗时为 24.46 ms,使用时耗费 369.59 ms。
(2025/2/16) 在判断 tag 是否为自闭 tag 之前要检查 tag 是否为空,如果为空说明本轮分割没有有效 tag。
+{{/* There's no such tag. It may be nil. */}}
+{{ if not $tagName }}
+ {{ continue }}
+{{ end }}
{{- if not (in $selfClosing $tagName) -}}