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> 截取即可。但是:

  1. <p> 不一定是顶级元素。
  2. 顶级元素不一定是 <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 infodebug.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 频繁运行也会加大渲染的开销,在调试完时要删除相关代码。

其他优化:让速度快了几十毫秒,几乎很难让人察觉。

  1. 把计算 $tagName 的方式改成:

    {{- $tagName := index (findRESubmatch `^/?([a-zA-Z0-9]+)` $s 1) 0 1 -}}
    
  2. 把用 slice 表示的标题和 self-closing 标签改成用 dict 表示(Hugo 不支持 set 就很烦)。

  3. 先尝试用 Hugo 已经截取好的字符串来操作,失败时才对全文自行截取,这样可以减少对超大字符串 split 的开销。

其他可以继续想办法的地方

  1. 在循环中对 $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) -}}