站内搜索

“主题” 找到 45 条结果

Firekylin:AI 友好的博客平台

文章介绍了经过 AI 改造后的 Firekylin,提出其定位为“AI 友好的博客平台”。核心思路不是简单增加几个 AI 按钮,而是让 AI 贯穿写作、内容管理、外部客户端接入和二次扩展等多个环节。 文章还介绍了新版主题市场、Eta 模板引擎、Widget 数据接口和插件扩展机制,说明 Firekylin 不仅在使用层面对 AI 友好,也在开发和定制层面提供清晰接口,方便围绕主题、插件和工具能力继续扩展。最后作者总结,所谓“AI 友好”,应体现在写作辅助、内容管理和系统扩展三个方面,但博客内容的表达、采纳建议与发布决策,最终仍应由人来掌握。

用 AI 重新折腾 Firekylin

文章回顾了作者与 Firekylin 的长期渊源,并重点分享了这次借助 AI 对老项目进行大规模升级改造的过程与感受。作者将 Firekylin 从多年积累的旧技术栈中全面升级,包括基础库、React 组件写法、模板引擎以及插件体系等,认为 AI 在处理这类跨版本迁移、重复性重构和结合上下文的大量修改时表现非常高效。AI 正在改变老项目维护和升级的工作方式。开发者的角色从亲自逐行实现,更多转向明确目标、拆分需求和进行审查判断。借助 AI,那些原本因工作量过大而长期搁置的改造,如今变得更容易推进。

Eureka 主题性能优化小结

我在之前的文章 《Hugo 主题 Eureka 自定义》 中有讲到我现在用的博客主题就是 Eureka。不过主题虽然好看,但是性能跑分却比较低。遂趁着周末时间给优化了一下,遂有本文。

打开控制台看了下资源的加载,之前没注意,这会才发现首页竟然后 10M 这么多资源要加载,怪不得性能不好呢。

JS 资源

JS 资源中大头是 FontAwesome,主题中直接使用了引用了所有图标的集成版地址 @fortawesome/fontawesome-free/js/all.min.js,该资源有 1.2M。但其实在主题中根本没有使用到如此之多的图标,完全可以按需加载优化。

参考《Using Font Awesome Icons in Hugo》 中的优化方法。通过关键词查找收集了主题中用到的图标,下载下来后通过模板语法直接在构建阶段把所有的 SVG 内联到 HTML 中。

不过我发现在首页中会有大量的重复图标,使用该方法后会有重复的 SVG 内容内联到 HTML 中。所以我再上述方法的基础之上再次尝试优化,将所有的 SVG 图标合并到一个文件中,每个使用的地方使用 <use href="#<icon>" /> 来进行引用。

首先我们还是像之前那样,把所有的图标下载下来。区别是通过 <symbol> 将图标转成图元,方便后续使用 <use> 进行复用。

// deno run --allow-net --allow-write fontsvg.ts
import * as path from "https://deno.land/std/path/mod.ts";
const __dirname = new URL('.', import.meta.url).pathname;

const icons = [
  "calendar-alt",
  "calendar",
  "star-half-alt",
  "comment",
  "clock",
  "bars",
  "search",
  "moon",
  "sun",
  "adjust",
  "globe",
  "th-list",
  "folder",
  "caret-right",
  "edit",
  "user",
  "pen",
  "book",
  "rss"
];

const baseUrl = 'https://cdn.jsdelivr.net/gh/FortAwesome/Font-Awesome@5.x/svgs/solid';
const toDefs = (id: string, svgText: string) => svgText
  .replace(/<svg.+viewBox=['"](\d+) (\d+) (\d+) (\d+)[^>]+>/, `<symbol id="${id}" viewBox="$1 $2 $3 $4">`)
  .replace('</svg>', '</symbol>')
  .replace('<path', '<path fill="currentColor"')
  .replace(/<!--.+?-->/, '');
const iconTexts = await Promise.all(icons.map(async icon => {
  const text = await fetch(`${baseUrl}/${icon}.svg`).then(resp => resp.text());
  const match = text.match(/(viewBox="\d+ \d+ \d+ \d+")/);
  if(!match) {
    throw Error('match error');
  }
  Deno.writeTextFile(path.join(__dirname, `./fontawesome/${icon}.svg`), `<svg ${match[1]}><use href="#${icon}" /></svg>`);
  return toDefs(icon, text);
}));
Deno.writeTextFile(path.join(__dirname, './fontawesome/all.svg'), `<svg width=0 height=0 viewBox="0 0 0 0">${iconTexts.join('\r\n')}</svg>`);

之后我们需要在 header 中加载 all.svg。在主题 header.html 开头增加如下代码:

{{ $svg := resources.Get (print "fontawesome/all.svg") }}
{{ $svg.Content | safeHTML }}

还是像引文中的方式一样,我们定义一个 Partial,所有使用的地方可以直接使用这个 Partial 内联图标 SVG。

<!--layouts/partials/fontawesome.html-->
<span class="inline-svg svg-inline--fa fa-w-14 {{.class}}">
  {{ $svg := resources.Get (print "fontawesome/" .icon ".svg") }}
  {{ $svg.Content | safeHTML }}
</span>

最后我们在使用的地方只需要使用如下 partial 命令即可完成图标的嵌入。修改 calendar 为对应的图标名称可以实现内嵌对应的图标。

{{ partial "fontawesome.html" (dict "icon" "calendar") }}

这么优化之后首页 HTML 文档的体积有着显著的改善,从 89.7k 降低至 77.3k。不过由于内联的图标都变成了不重复的内容,压缩率反而降低了,这倒是我没有想到的。Vercel 使用的是 Brotil 压缩方式,原本基于引文的方式压缩后的体积是 20.4k,优化后压缩后的体积反而增加到了 22.1k。不过 2k 不到的体积增长,倒是还能接受。

解决了大头之后,JS 资源还剩下 highlight.min.js 和 eureka.min.js,前者是代码高亮使用,后者是主题对应的 JS 脚本。由于我在首页实际上是没有代码高亮的需求,所以我将 highlight.js 相关的资源做了判断,仅在详情页的时候再做加载。

而针对 eureka.min.js 这种小文件,我们可以考虑将其内联到 HTML 中减少一个请求。不过该优化在 HTTP/2 场景并不是一个最佳实践,诸君请适度使用。

{{- $eurekaJS := resources.Get "js/eureka.js" | resources.ExecuteAsTemplate "js/eureka.js" . | minify }}
<script defer src="data:application/javascript;base64,{{ $eurekaJS.Content | base64Encode }}"></script>

最后其实还剩下百度统计的请求资源,这个参考以下两篇文章也是可以做类似的优化的,虽然两篇文章讲的都是 Google Analytics 但是原理都差不太多。不过目前这种程度我也能接受了,就没有再继续尝试下去,之后有空再参考优化一下。

CSS 资源

CSS 资源中大头是 eureka.min.css,高达 4M 的体积一看就知道它用了原子类 CSS 库 TailWind(笑哭。毕竟正经人谁能写出 4M 的 CSS 文件,特别还是这么简单的一款主题。

我对原子类 CSS 写法一直不太感冒的原因主要有两点,一个是本质它把 CSS 的功能转嫁到了 HTML class 属性上,看着那些纷繁复杂又臭又长的 class 令人脑壳疼。再一个就是因为它的体积问题。

好在 TailWind CSS 提供了优化选项,通过遍历配置中的文件查找所有可能用到的 class 节省体积。具体的话可以参考文档。由于是静态分析 class,所以不能出现动态拼接,也不能出现变量之类的替代。

将配置开启之后,eureka.min.css 文件从初始的 4M 优化成了 21.4k,Brotil 压缩后体积是 5.2k,整个人都神清气爽了有没有。

初次之外,网站还加载了一款代码高亮主题 solarized-light.min.css 以及一款自定义字体。代码高亮样式则按照 JS 优化策略一样,仅针对详情页再加载。而自定义字体我看了下会加载一款中文的 Web Font,用于提供给全站使用。所以也没有做动态切片等体积优化处理,每次都会加载 2M 的字体资源。考虑到该需求是纯美化场景,系统默认的衬线体也还可以,遂直接将该自定义字体移除解决。

图片资源

图片也是比较中的资源加载灾区,有 3M 的图片资源加载。由于之前没有特别在意这块,很多场景为了方便直接原图就放上来了,也没有做图片的处理。所以这次就使用常规的图片资源处理手段对图片进行了优化处理,主要是图片压缩以及 LazyLoad。

图片处理这块就是很正常的手段了,没有什么值得说的。图片压缩主要是使用了 https://tinypng.com,LazyLoad 则是用了苏卡卡推荐的 vanilla-lazyload。

除了体积的优化之外,图片还可以对它进行格式和尺寸进行优化。现在比较推荐使用 <picture> 的写法渲染图片,内部存放不同的格式的图片,浏览器会根据是否支持选择对应的格式展示。

<picture>
  <source srcset="image.webp" type="image/webp">
  <img src="image.jpg" alt="my image">
</picture>

剩下的就是如何获取 webp 的图片了,网上有比较多使用 cwebp 手动转成 webp 图片的教程,我就不多赘述了。除此之外,Hugo 本身似乎也支持做个格式的转换(https://discourse.gohugo.io/t/image-conversion-without-resizing/32429)。

{{ $i := resources.Get "image.jpg" }}
{{ $resizeOptions := printf "%dx%d webp" $i.Width $i.Height }}
{{ $i = $i.Resize $resizeOptions }}

最后一种方式,也是我比较推荐的方式,是使用外部的 CDN 存储服务。这些外部服务都会有通过 URL 动态转换和裁剪的能力。

除了更好的格式,对图片的裁剪也很重要。实际上 Hugo 本身也有非常多的图片处理方法用于图片优化,主要是裁剪和滤镜。我们可以利用 Hugo 本身的功能,也可以使用外部 CDN 存储服务,基于他们的动态裁剪能力来实现。

不过我的只是我的文章中图片地址五花八门,有本地的也有各种外部 CDN 的,使用那种方式都比较麻烦。所以暂时就没有处理格式的事情了, 如果有需要的可以参考一下。

2022-07-02 更新

最终我采用了外部 CDN 的方式,将博客中所有的外链图片重新抓取下来整理上传到又拍云,并对文章图片进行了整体的清洗,一些老图无法访问的就直接指向一个 404 的图片了。

同时开启了又拍云的 Webp 自适应功能,无需修改图片链接地址,又拍云 CDN 会自动根据浏览器是否支持来返回 Webp 图片,轻松全站支持 Webp 图片访问。最终的优化效果也非常明显,整体图片体积再次缩小了 3 倍左右。

总结

在各种优化之下,最终首页的资源加载从之前的 10M 缩减到了现在的 361k,加载速度已经令我比较满意了。之后我再抽空处理下整站的图片资源。

Hugo 主题 Eureka 自定义

今天有网友邮件我咨询我现在的主题 Eureka 的一些自定义配置,他想参考一下。由于我的博客仓库是私有的,所以就写一篇文章简单整理一下。

Eureka 是前段时间群友推荐给我的,纯白的朴素风格同时提供了暗色模式瞬间我就喜欢上了。将其 clone 到 Hugo 博客目录 themes/hugo-eureka 下,config.toml 中配置 theme = "hugo-eureka" 即可使用上该款主题。为了方便主题的更新,我将我所有自定义的模板都放在了 layouts 目录下。Hugo 会将主题目录和 layouts 目录下的文件进行合并,并优先使用 layouts 目录中的同名文件。这样之后我只需要单纯的更新 thtmes/hugo-eureka 目录即可。

首页

相较于 Eureka 主题的默认首页,我个人还是比较喜欢传统博客的两栏布局,左侧显示模块列表,右侧显示文章列表,所以我需要自定义首页模板。拷贝以下内容创建 layouts/index.html 文件即可实现同款。

{{ define "main" }}
<div class="pl-scrollbar">
  <div class="w-full max-w-screen-xl lg:px-4 xl:px-8 mx-auto">
    <div class="max-w-screen-xl mx-auto" style="padding-top: 3rem">
      <div class="bg-local bg-cover">
        <img class="day" src="/banner-day.png" />
        <img class="dark" src="/banner.png" />
      </div>
    </div>
    <!-- <article class="mx-6 my-7">
      <h1 class="font-bold text-3xl text-primary-text"></h1>
    </article> -->
    <div class="grid grid-cols-2 lg:grid-cols-8 gap-4 lg:pt-12">
      <div class="col-span-2 sidebar">
        <div class="widget bg-secondary-bg rounded p-6">
          <h2 class="widget-title">最新文章</h2>
          <ul class="widget-list">
            {{- $recent := default 5 .Site.Params.numberOfRecentPosts }}
            {{- $posts := where (where .Site.RegularPages "Permalink" "!=" .Permalink) "Type" "in" .Site.Params.mainSections }}
            {{- range first $recent $posts }}
            <li>
              <a href="{{ .RelPermalink }}" class="nav-link">{{ .Title }}</a>
            </li>
            {{- end }}
          </ul>
        </div>
        <div class="widget bg-secondary-bg rounded p-6">
          {{ $walineURL := .Site.Params.comment.waline.serverURL }}
          <h2 class="widget-title ">最近回复</h2>
          <ul class="widget-list recentcomments">
            {{ $resp := getJSON $walineURL "/comment?type=recent&count=10" }}
            {{ range first 10 $resp }}
            <li class="recentcomments">
              <a href="{{.Site.BaseURL}}{{ .url }}">{{ .nick }}</a>:{{ .comment | safeHTML | truncate 22 }}
            </li>
            {{ end }}
          </ul>
        </div>
        <div class="widget bg-secondary-bg rounded p-6">
          <h2 class="widget-title">友情链接</h2>
          <ul class="widget-list">
            {{ range .Site.Menus.friends }}
            <li>
              <a href="{{ .URL }}">{{ .Name }}</a>
            </li>
            {{ end }}
          </ul>
        </div>
        <div class="widget bg-secondary-bg rounded p-6">
          <h2 class="widget-title">管理</h2>
          <ul class="widget-list">
            <li>
              <a href="/admin">🛠 后台管理</a>
            </li>
            <li>
              <a href="{{ .Site.Params.comment.waline.serverURL }}/ui">💬 评论管理</a>
            </li>
          </ul>
        </div>
      </div>
      <div class="col-span-2  lg:col-span-6 bg-secondary-bg rounded px-6 py-8">
        <div class="bg-secondary-bg rounded overflow-hidden px-4 divide-y">
          {{ range .Paginator.Pages }}
          <div class="px-2 py-6">
              {{ partial "components/summary-plain.html" . }}
          </div>
          {{ end }}
        </div>
        {{ template "_internal/pagination.html" . }}
      </div>
    </div>
  </div>
</div>
{{ end }}

其中顶部还增加了一组暗色模式切换的横幅图片,添加以下 CSS 内容至 layouts/partials/custom-head.html 文件中,不存在的话需要新建。

.widget + .widget {
  margin-top: 1rem;
}
.widget-title {
  font-weight: bold;
  margin-bottom: 1rem;
}
.widget-list li {
  font-size: 0.9rem;
}
.bg-cover img {
  opacity: 1;
  transition: all .5s ease-in-out;
}
.bg-cover img.dark {
  opacity: 0;
  height: 0;
}
.dark .bg-cover img.day {
  opacity: 0;
  height: 0;
}
.dark .bg-cover img.dark {
  opacity: 1;
  height: auto;
}

左侧的模块中,评论是使用了本人自研的 Waline 评论系统并进行了一定的改造,具体可参见我之前的文章《静态博客如何高性能插入评论》。当然也可以直接使用 Waline 自带的最近评论挂件。

友情链接则是在 config.toml 中按照如下格式进行配置的。

[[menu.friends]]
name = "童欧巴博客"
url = "https://hungryturbo.com/"
weight = 20

[[menu.friends]]
identifier = "QingXu"
name = "QingXu"
url = "https://blog.qingxu.live"
weight = 19

[[menu.friends]]
identifier = "蜘蛛抱蛋"
name = "蜘蛛抱蛋"
url = "https://blog.zzbd.org/"
weight = 18

后台管理则是使用了 forestry 提供的服务,它支持提供在线后台进行文章、页面和其它配置的管理。评论管理则是链接到了 Waline 服务的后台面板中。

Metadata

Eureka 主题的文章 metadata 显示分为列表页和详情页两个,分别对应 post_metadata.html 和 post_metadata_full.html 两个文件。我们在 layouts/partials/ 目录下新建这两个文件用来覆盖主题默认的文件。

2021-03-13 更新: 更新后的 Eureka 统一使用了 components/post-metadata.html 显示文章的 metadata,代码和之前的 layouts/partials/post_metadata.html 是一致的。

{{/* layouts/partials/components/post_metadata.html */}}
<div class="flex flex-wrap flex-row items-center my-2 text-tertiary-text">
  <div class="mr-6 my-2">
      <i class="fas fa-calendar mr-1"></i>
      <span>{{ .Date.Format (.Site.Params.dateFormat | default "2006-01-02") }}</span>
  </div>

  {{- $slug := printf "/%s.html" .Slug}}
  {{- $commentsData := (partialCached "utils/get-comments.html" .)}}
  
  {{- $comments := slice }}
  {{- range where $commentsData "url" "==" $slug}}
    {{$comments = $comments | append .}}
  {{- end}}
  {{- $count := len $comments}}
  <div class="mr-6 my-2">
    <a href="{{ .Permalink }}#waline-comments" title="{{ .Title }}">
      <i class="fas fa-comment mr-1"></i>
      <span>{{- if gt $count 0}}{{$count}} 条评论{{else}}暂无评论{{end -}}</span>
    </a>
  </div>

  <div class="mr-6 my-2">
      <i class="fas fa-clock mr-1"></i>
      <span>{{ i18n "readingTime" . }}</span>
  </div>
  
  {{ with .GetTerms "categories" }}
  <div class="mr-6 my-2">
      <i class="fas fa-folder mr-1"></i>
      {{ range $index, $value := . }}
      {{ if gt $index 0 }}
      <span>, </span>
      {{ end -}}
      <a href="{{ .Permalink }}" class="hover:text-eureka">{{ .LinkTitle }}</a>
      {{ end }}
  </div>
  {{ end }}

  {{ with .GetTerms "series" }}
  <div class="mr-6 my-2">
      <i class="fas fa-th-list mr-1"></i>
      {{ range $index, $value := . }}
      {{ if gt $index 0 }}
      <span>, </span>
      {{ end -}}
      <a href="{{ .Permalink }}" class="hover:text-eureka">{{ .LinkTitle }}</a>
      {{ end }}
  </div>
  {{ end }}
</div>

post_metadata.html 主要是增加了评论条数的显示,而 post_metadata_full.html 中还增加了 Markdown 原文链接的显示。关于如何生成 Markdown 原文链接,可以参考我之前的文章《Hugo 之旅》。

{{/* layouts/partials/post_metadata_full.html */}}
<div class="flex flex-wrap flex-row items-center my-2 text-tertiary-text">
  <div class="mr-6 my-2">
      <i class="fas fa-calendar mr-1"></i>
      <span>{{ .Date.Format (.Site.Params.dateFormat | default "2006-01-02") }}</span>
  </div>

  {{$resp := getJSON "https://imerd.comment.lithub.cc/comment?type=count&url=/" .Slug ".html" }}
  <div class="mr-6 my-2">
    <a href="{{ .Permalink }}#waline-comments" title="{{ .Title }}">
      <i class="fas fa-comment mr-1"></i>
      <span>{{- if gt $resp 0}}{{$resp}} 条评论{{else}}暂无评论{{end -}}</span>
    </a>
  </div>

  {{ if eq .Type "posts" -}}
  {{ with .OutputFormats.Get "MarkDown" -}}
  <div class="mr-6 my-2">
    <a href="{{ .Permalink }}">
      <i class="fas fa-book mr-1"></i>
      <span>阅读Markdown格式</span>
    </a>
  </div>
  {{- end }}
  {{ end }}

  <div class="mr-6 my-2">
    <a href="{{ .Permalink }}">
      <i class="fas fa-pen mr-1"></i>
      <span>{{ .WordCount }} 字</span>
    </a>
  </div>

  <div class="mr-6 my-2">
      <i class="fas fa-clock mr-1"></i>
      <span>{{ i18n "readingTime" . }}</span>
  </div>
  
  {{ with .GetTerms "categories" }}
  <div class="mr-6 my-2">
      <i class="fas fa-folder mr-1"></i>
      {{ range $index, $value := . }}
      {{ if gt $index 0 }}
      <span>, </span>
      {{ end -}}
      <a href="{{ .Permalink }}" class="hover:text-eureka">{{ .LinkTitle }}</a>
      {{ end }}
  </div>
  {{ end }}

  {{ with .GetTerms "series" }}
  <div class="mr-6 my-2">
      <i class="fas fa-th-list mr-1"></i>
      {{ range $index, $value := . }}
      {{ if gt $index 0 }}
      <span>, </span>
      {{ end -}}
      <a href="{{ .Permalink }}" class="hover:text-eureka">{{ .LinkTitle }}</a>
      {{ end }}
  </div>
  {{ end }}
</div>

搜索框

搜索也是博客比较重要的功能,为了方便我在顶部增加了搜索框。创建 layouts/partials/header.html 文件用来覆盖默认的头部模板。

{{/* layouts/partials/header.html */}}
<script>
    let storageColorScheme = localStorage.getItem("lightDarkMode")
    {{- if eq .Site.Params.colorScheme "light" }}
    if ((storageColorScheme == 'Auto' && window.matchMedia("(prefers-color-scheme: dark)").matches) || storageColorScheme == "Dark") {
        document.getElementsByTagName('html')[0].classList.add('dark')
    }
    {{- else if eq .Site.Params.colorScheme "dark" }}
    if ((storageColorScheme == 'Auto' && window.matchMedia("(prefers-color-scheme: light)").matches) || storageColorScheme == "Light") {
        document.getElementsByTagName('html')[0].classList.remove('dark')
    }
    {{- else }}
    if (((storageColorScheme == 'Auto' || storageColorScheme == null) && window.matchMedia("(prefers-color-scheme: dark)").matches) || storageColorScheme == "Dark") {
        document.getElementsByTagName('html')[0].classList.add('dark')
    }
    {{- end }}
</script>
<nav class="flex items-center justify-between flex-wrap px-4 py-4 md:py-0">
    <a href="{{ "/" | relLangURL }}" class="mr-6 text-primary-text text-xl font-bold">{{ .Site.Title }}</a>
    <button id="navbar-btn" class="md:hidden flex items-center px-3 py-2" aria-label="Open Navbar">
        <i class="fas fa-bars"></i>
    </button>

    <div id="target"
        class="hidden block md:flex md:flex-grow md:justify-between md:items-center w-full md:w-auto text-primary-text z-20">
        <div class="md:flex md:h-16 text-sm md:flex-grow pb-4 md:pb-0 border-b md:border-b-0">
            {{- $relPermalink := .RelPermalink }}
            {{- range .Site.Menus.main }}
            {{- $url := .URL | relLangURL }}
            <a href="{{ $url }}" class="block mt-4 md:inline-block md:mt-0 md:h-(16-4px) md:leading-(16-4px) box-border md:border-t-2 md:border-b-2 {{ if hasPrefix $relPermalink $url }} selected-menu-item {{ else }} border-transparent {{ end }} mr-4">{{ .Name }}</a>
            {{- end }}
        </div>

        <div class="flex">
            <div class="search-container relative pt-4 md:pt-0">
                <div class="search">
                    <form role="search" class="search-form" action="/search.html" method="get">
                    <label>
                        <input name="q" type="text" placeholder="搜索 ..." class="search-field">
                    </label>
                    <button>
                        <i class="fas fa-search"></i>
                    </button>
                    </form>
                </div>
            </div>

            <div class="relative pl-4 pt-4 md:pt-0">
                <div class="cursor-pointer hover:text-eureka" id="lightDarkMode">
                    {{- if eq .Site.Params.colorScheme "dark" }}
                    <i class="fas fa-moon"></i>
                    {{- else if eq .Site.Params.colorScheme "light" }}
                    <i class="fas fa-sun"></i>
                    {{- else }}
                    <i class="fas fa-adjust"></i>
                    {{- end }}
                </div>
                <div class="fixed hidden inset-0 opacity-0 h-full w-full cursor-default z-30" id="is-open">
                </div>
                <div class="absolute flex flex-col left-0 md:left-auto right-auto md:right-0 hidden bg-secondary-bg w-48 rounded py-2 border border-tertiary-bg cursor-pointer z-40"
                    id='lightDarkOptions'>
                    <span class="px-4 py-1 hover:text-eureka" name="Light">{{i18n "light"}}</span>
                    <span class="px-4 py-1 hover:text-eureka" name="Dark">{{i18n "dark"}}</span>
                    <span class="px-4 py-1 hover:text-eureka" name="Auto">{{i18n "auto"}}</span>
                </div>
            </div>

            {{- if .IsTranslated }}
            <div class="relative pt-4 pl-4 md:pt-0">
                <div class="cursor-pointer hover:text-eureka" id="languageMode">
                    <i class="fas fa-globe"></i>
                    <span class="pl-1">{{ .Language.LanguageName }}</span>
                </div>
                <div class="fixed hidden inset-0 opacity-0 h-full w-full cursor-default z-30" id="is-open-lang">
                </div>
                <div class="absolute flex flex-col left-0 md:left-auto right-auto md:right-0 hidden bg-secondary-bg w-48 rounded py-2 border border-tertiary-bg cursor-pointer z-40"
                    id='languageOptions'>
                    <a class="px-4 py-1 hover:text-eureka" href="{{ .Permalink }}">{{ .Language.LanguageName }}</a>
                    {{- range .Translations }}
                    <a class="px-4 py-1 hover:text-eureka" href="{{ .Permalink }}">{{ .Language.LanguageName }}</a>
                    {{- end }}
                </div>
            </div>
            {{- end }}
        </div>
    </div>

    <div class="fixed hidden inset-0 opacity-0 h-full w-full cursor-default z-0" id="is-open-mobile">
    </div>

</nav>
<script>
    let element = document.getElementById('lightDarkMode')
    {{- if eq .Site.Params.colorScheme "light" }}
    if (storageColorScheme == 'Auto') {
        element.firstElementChild.classList.remove('fa-sun')
        element.firstElementChild.setAttribute("data-icon", 'adjust')
        element.firstElementChild.classList.add('fa-adjust')
        document.addEventListener('DOMContentLoaded', () => {
            switchMode('Auto')
        })
    } else if (storageColorScheme == "Dark") {
        element.firstElementChild.classList.remove('fa-sun')
        element.firstElementChild.setAttribute("data-icon", 'moon')
        element.firstElementChild.classList.add('fa-moon')
    }
    {{- else if eq .Site.Params.colorScheme "dark" }}
    if (storageColorScheme == 'Auto') {
        element.firstElementChild.classList.remove('fa-moon')
        element.firstElementChild.setAttribute("data-icon", 'adjust')
        element.firstElementChild.classList.add('fa-adjust')
        document.addEventListener('DOMContentLoaded', () => {
            switchMode('Auto')
        })
    } else if (storageColorScheme == "Light") {
        element.firstElementChild.classList.remove('fa-moon')
        element.firstElementChild.setAttribute("data-icon", 'sun')
        element.firstElementChild.classList.add('fa-sun')
    }
    {{- else }}
    if (storageColorScheme == null || storageColorScheme == 'Auto') {
        document.addEventListener('DOMContentLoaded', () => {
            switchMode('Auto')
        })
    } else if (storageColorScheme == "Light") {
        element.firstElementChild.classList.remove('fa-adjust')
        element.firstElementChild.setAttribute("data-icon", 'sun')
        element.firstElementChild.classList.add('fa-sun')
    } else if (storageColorScheme == "Dark") {
        element.firstElementChild.classList.remove('fa-adjust')
        element.firstElementChild.setAttribute("data-icon", 'moon')
        element.firstElementChild.classList.add('fa-moon')
    }
    {{- end }}

    document.addEventListener('DOMContentLoaded', () => {
        getcolorscheme();
        switchBurger();
        {{- if .IsTranslated }}
        switchLanguage()
        {{- end }}
    });
</script>

大部分的内容都是 Eureka 主题提供的,除了增加了 #search-container 搜索框部分。为了让搜索框更美观一点,我在 layouts/partials/custom-head.html 中自定义了一些样式。

.search-container {
  margin-top: -0.3rem;
}
.search-container .search {
  border: 1px solid #e2e8f0;
  border-radius: 4px;
}
.search-container input {
  padding-left: 1rem;
  line-height: 2rem;
  outline: none;
  background: transparent;
}
.search-container button {
  font-size: 0.8rem;
  margin-right: 0.5rem;
  color: #e2e8f0;
}

最终搜索框跳转至单独的搜索页面。关于如何给 Hugo 博客添加搜索功能,可查看我之前的文章 《Hugo 之旅》。我这边提供一下我的搜索结果页模板。

{{/* layouts/_default/search.html */}}
{{ define "main" }}
<div class="w-full max-w-screen-xl lg:px-4 xl:px-8 mx-auto">
  <article class="mx-6 my-8">
      <h1 id="search-count" class="font-bold text-3xl text-primary-text"></h1>
  </article>
  <div id="search-result" class="bg-secondary-bg rounded overflow-hidden px-4 divide-y"> 
    
  </div> 
</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/fuse.js/3.2.0/fuse.min.js"></script>
<script>
  document.addEventListener('DOMContentLoaded', async () => {
    const qs = new URLSearchParams(location.search);
    const searchResult = document.querySelector('#search-result');
    const searchCount = document.querySelector('#search-count');

    const fuseOptions = {
      shouldSort: true,
      includeMatches: true,
      threshold: 0.0,
      tokenize: true,
      location: 0,
      distance: 100,
      maxPatternLength: 32,
      minMatchCharLength: 1,
      keys: [{
          name: "title",
          weight: 0.8
        },
        {
          name: "summary",
          weight: 0.5
        },
        {
          name: "tags",
          weight: 0.3
        },
        {
          name: "date",
          weight: 0.3
        },
      ]
    };

    let fuse = null

    async function getFuse() {
      if (fuse == null) {
        const resp = await fetch('/index.json', {
          method: 'get'
        })
        const indexData = await resp.json()
        fuse = new Fuse(indexData, fuseOptions);
      }
      return fuse
    }

    function render(items) {
      console.log(items);
      return items.map(item => {
        item = item.item
        return `
<div class="px-2 py-6">
  <div class="flex flex-col-reverse lg:flex-row justify-between">
    <div class="w-full lg:w-2/3">
      <div class="my-2">
        <div class="mb-4">
          <a href="${item.permalink}" class="font-bold text-xl hover:text-eureka">${item.title}</a>
        </div>
        <div class="content">
          ${item.summary}
          <p class="more">
            <a href="${item.permalink}" title="${item.title}">阅读全文<span class="meta-nav">→</span></a>
          </p>
        </div>
      </div>
      <div class="flex flex-wrap flex-row items-center my-2 text-tertiary-text">
        <div class="mr-6 my-2">
          <i class="fas fa-calendar mr-1"></i>
          <span>${item.date}</span>
        </div>
        <div class="mr-6 my-2">
          <a href="${item.permalink}#waline-comments" title="${item.title}">
            <i class="fas fa-comment mr-1"></i>
            <span>${item.comments > 0 ? item.comments + ' 条评论' : '暂无评论'}</span>
          </a>
        </div>
        <div class="mr-6 my-2">
          <i class="fas fa-clock mr-1"></i>
          <span>${item.time} 分钟阅读时长</span>
        </div>
      </div>  
    </div>
    <div class="w-full lg:w-1/3 mb-4 lg:mb-0 lg:ml-8">
      ${item.featuredImage ? `<img src="${item.featuredImage}" class="w-full" alt="Featured Image">` : ''}
    </div>
  </div>
</div>`;
      }).join('');
    }

    function updateDOM(html, keyword, number) {
      document.title = document.title.replace(/包含关键词.*?文章/, `包含关键词 ${keyword} 的文章`)
      searchResult.innerHTML = html
      searchCount.innerHTML = `共查询到 ${number} 篇文章`
    }

    async function search(searchString) {
      console.log(searchString);
      let result = [];
      if(searchString) {
        const fuse = await getFuse()
        result = fuse.search(searchString)
      }
      const html = render(result)
      updateDOM(html, searchString, result.length)
    }

    document.querySelectorAll('input[name="q"]').forEach(el => el.value = qs.get('q'));
    search(qs.get('q') || '')

    window.blogSearch = function(keyword) {
      if(!keyword) {
        return;
      }

      history.pushState('', '', location.pathname + '?q=' + encodeURIComponent(keyword));
      document.querySelectorAll('input[name="q"]').forEach(el => el.value = keyword);
      search(keyword);
    }
  })
</script>
{{ end }}

归档

之前使用 Typecho 的时候有一个归档插件会按照年月列表展示文章,所以我在 Hugo 中按照之前的格式实现了一下。按照如下内容新建 layouts/_default/archive.html 文件,并新建文章 content/日志.md,文章内容为空即可,在文章的 meta 数据中指定 layout: archive 来映射到该模板。

{{/* layouts/_default/archive.html */}}
{{ define "main" }}
{{ $hasToc := and (in .TableOfContents "<li>" ) (.Params.toc) }}
{{ $hasSidebar := or ($hasToc) (.Params.series) }}
<div class="grid grid-cols-2 lg:grid-cols-8 gap-4 lg:pt-12">
    <div
        class="col-span-2 {{ if not $hasSidebar }} {{- print "lg:col-start-2" -}} {{ end }} lg:col-span-6 bg-secondary-bg rounded px-6 py-8">
        <h1 class="font-bold text-3xl text-primary-text">{{ .Title }}</h1>

        <div class="flex flex-wrap flex-row items-center my-2 text-tertiary-text">
          <div class="mr-6 my-2">
              <i class="fas fa-calendar mr-1"></i>
              <span>{{ .Date.Format (.Site.Params.dateFormat | default "2006-01-02") }}</span>
          </div>
        </div>
        
        {{ $featured := partial "utils/get-featured" . }}
        {{ with $featured }}
        <div class="my-4">
            {{ . }}
        </div>
        {{ end }}

        <div class="content">
          <script type='text/javascript' src="https://lib.baomitu.com/jquery/1.11.1/jquery.min.js"></script>
          <style type="text/css">.car-collapse .car-yearmonth { cursor: s-resize; } </style>
          <script type="text/javascript">
            /* <![CDATA[ */
              jQuery(document).ready(function() {
                jQuery('.car-collapse').find('.car-monthlisting').hide();
                jQuery('.car-collapse').find('.car-monthlisting:first').show();
                jQuery('.car-collapse').find('.car-yearmonth').click(function() {
                  jQuery(this).next('ul').slideToggle('fast');
                });
                jQuery('.car-collapse').find('.car-toggler').click(function() {
                  if ( '展开全部' == jQuery(this).text() ) {
                    jQuery(this).parent('.car-container').find('.car-monthlisting').show();
                    jQuery(this).text('折叠全部');
                  }
                  else {
                    jQuery(this).parent('.car-container').find('.car-monthlisting').hide();
                    jQuery(this).text('展开全部');
                  }
                  return false;
                });
              });
            /* ]]> */
          </script>
          <div class="car-container car-collapse">
            <a href="#" class="car-toggler">展开全部</a>
            
            <ul class="car-list">
              {{ range (.Site.RegularPages.GroupByDate "01月 2006") }}
              <li>
                <span class="car-yearmonth">{{ .Key }} <span title="Post Count">({{ len .Pages }})</span></span>
                <ul class="car-monthlisting">
                  {{ range .Pages }}
                  <li>
                    {{ .Date.Format "02"}}: <a href="{{ .Permalink }}">{{ .Title }} </a> <!--<span title="Comment Count">(0)</span>-->
                  </li>
                  {{ end }}
                </ul>
              </li>
              {{ end }}
            </ul>
          </div>
        </div>
    </div>
</div>
{{ end }}

统计

屈屈的博客中还有一个统计页面,我觉得挺有意思的,于是也在我的博客中复刻了一下。按照如下内容新建 layouts/_default/stats.html 文件,并新建文章 content/统计.md,文章内容为空即可,在文章的 meta 数据中指定 layout: stats 来映射到该模板。

{{/* layouts/_default/stats.html */}}
{{ define "main" }}

{{- $.Scratch.Add "stats" slice -}}
{{- range .Site.RegularPages -}}
    {{- $.Scratch.Add "stats" (dict "title" .Title "slug" .Slug "year" (.Date.Format "2006") "month" (.Date.Format "2006-01") "hour" (.Date.Format "15") "week" (.Date.Format "Monday") "count" .WordCount) -}}
{{- end -}}

{{ $hasToc := and (in .TableOfContents "<li>" ) (.Params.toc) }}
{{ $hasSidebar := or ($hasToc) (.Params.series) }}
<style>
.chart {
  margin-top: 15px;
  width: 100%;
  height: 350px;
}
</style>
<div class="grid grid-cols-2 lg:grid-cols-8 gap-4 lg:pt-12">
    <div
        class="col-span-2 {{ if not $hasSidebar }} {{- print "lg:col-start-2" -}} {{ end }} lg:col-span-6 bg-secondary-bg rounded px-6 py-8">
        <h1 class="font-bold text-3xl text-primary-text">{{ .Title }}</h1>

        <div class="flex flex-wrap flex-row items-center my-2 text-tertiary-text">
          <div class="mr-6 my-2">
              <i class="fas fa-calendar mr-1"></i>
              <span>{{ .Date.Format (.Site.Params.dateFormat | default "2006-01-02") }}</span>
          </div>
        </div>
        
        {{ $featured := partial "utils/get-featured" . }}
        {{ with $featured }}
        <div class="my-4">
            {{ . }}
        </div>
        {{ end }}

        <div class="content">
          {{ .Content }}
        </div>
    </div>

    {{ if $hasSidebar}}
    <div class="col-span-2">
        {{ if .GetTerms "series" }}
        {{ partial "components/post-series.html" . }}
        {{ end }}
        {{ if $hasToc }}
        {{ partial "components/post-toc.html" . }}
        {{ end }}
    </div>
    {{ end }}
</div>




<script src="https://lib.baomitu.com/echarts/5.0.0/echarts.min.js"></script>
<script>
const data = {{- $.Scratch.Get "stats" -}};
function showChart(id, title, type, d) {
  var chart = echarts.init(document.getElementById(id));
  var xData = [];
  var yData = [];
  d.forEach(function(item) {
      xData.push(item[0]);
      yData.push(item[1]);
  });
  var option = {
    title : { text : title },
    tooltip : { trigger : 'axis' },
    xAxis : [ { type : 'category', data : xData } ],
    yAxis : [ { type : 'value' } ],
    grid : { x : 35, y : 45, x2 : 35, y2 : 35 },
    series : [ { 
      type : 'bar',
      name : type,
      data : yData,
      markLine : {
        data : [ {
          type : 'average',
          name : '平均值'
        }],
        itemStyle : {
          normal : {
            color : '#4087bd'
          }
        }
      },
      itemStyle : {
        normal : {
          color : '#87cefa'
        }
      }
    }]
  };
  chart.setOption(option);
}

window.addEventListener('load', function() {
  basicInfo();
  yearStats();
  monthStats();
  hourStats();
  weekStats();
});
function basicInfo() {
  const articles = {{ len (where .Site.RegularPages "Section" "posts") }};
  const pages = data.length - articles;
  const comments = data.reduce((count, article) => count + article.comments, 0);
  const words = data.reduce((count, article) => count + article.count, 0);
  document.querySelector('#basic-info').innerHTML = `
    <span>文章:<strong><a href="/">${articles}</a></strong> 篇</span>;<span>页面:<strong><a href="/">${pages}</a></strong> 篇</span>;<span>总字数:<strong>${words}</strong></span>;
  `;
};

function yearStats() {
  const yearGroup = {};
  data.forEach(article => {
    const year = parseInt(article.year);
    if(!yearGroup.hasOwnProperty(year)) {
      yearGroup[year] = 0;
    }
    yearGroup[year] += 1;
  });

  const d = [];
  for(let i = 2009; i <= (new Date().getFullYear()); i++) {
    d.push([i, yearGroup[i] || 0]);
  }
  showChart('year-stat', '文章数 - 按年统计', '文章数', d);
}
function monthStats() {
  const monthGroup = {};
  data.forEach(article => {
    if(!monthGroup.hasOwnProperty(article.month)) {
      monthGroup[article.month] = 0;
    }
    monthGroup[article.month] += 1;
  });
  const d = [];
  for(let year = 2009; year <= (new Date().getFullYear()); year++) {
    for(let month = 1; month < 13; month++) {
      const text = `${year}-${month < 10 ? '0' + month : month}`;
      d.push([text, monthGroup[text] || 0]);
    }
  }
  showChart('month-stat', '文章数 - 按月统计', '文章数', d);
}
function hourStats() {
  const hourGroup = {};
  data.forEach(article => {
    const hour = parseInt(article.hour);
    if(!hourGroup.hasOwnProperty(hour)) {
      hourGroup[hour] = 0;
    }
    hourGroup[hour] += 1;
  });
  const d = [
    ['00:00-01:00'],
    ['01:00-02:00'],
    ['02:00-03:00'],
    ['03:00-04:00'],
    ['04:00-05:00'],
    ['05:00-06:00'],
    ['06:00-07:00'],
    ['07:00-08:00'],
    ['08:00-09:00'],
    ['09:00-10:00'],
    ['10:00-11:00'],
    ['11:00-12:00'],
    ['12:00-13:00'],
    ['13:00-14:00'],
    ['14:00-15:00'],
    ['15:00-16:00'],
    ['16:00-17:00'],
    ['17:00-18:00'],
    ['18:00-19:00'],
    ['19:00-20:00'],
    ['20:00-21:00'],
    ['21:00-22:00'],
    ['22:00-23:00'],
    ['23:00-24:00']
  ].map((item, key) => {
    item[1] = hourGroup[key] || 0;
    return item;
  });
  showChart('hour-stat', '文章数 - 按时段统计', '文章数', d);
}
function weekStats() {
  const weekGroup = {};
  data.forEach(article => {
    if(!weekGroup.hasOwnProperty(article.week)) {
      weekGroup[article.week] = 0;
    }
    weekGroup[article.week] += 1;
  });
  const d = [
    ['星期一', weekGroup.Monday],
    ['星期二', weekGroup.Tuesday],
    ['星期三', weekGroup.Wednesday],
    ['星期四', weekGroup.Thursday],
    ['星期五', weekGroup.Friday],
    ['星期六', weekGroup.Saturday],
    ['星期日', weekGroup.Sunday]
  ];
  showChart('weekday-stat', '文章数 - 按星期几统计', '文章数', d);
}
</script>

{{ end }}

其它

除了以上这些,我的博客中改动最大的当属评论这块,但这块定制型比较高,一般玩家就不推荐了,感兴趣的还是去看我之前的《静态博客如何高性能插入评论》一文。除此之外,我还修改了 footer.html 修改了底部显示文案,增加了网页统计脚本;基于自研的 wxhermit 增加了微信分享自定义相关功能;文章页目录底部增加了个人公众号的展示。由于这些内容都比较简单且定制化内容程度比较高,就不一一展示了,感兴趣的朋友可以自行查看源码查阅。

博客迁移至Hugo

我的博客从09年开始到现在已经度过了十一个年头,虽然更新的频率变慢了,但也还是一直在坚持输出力求能够帮助到大家。本站最早使用过 ASP 的 PJBlog 到之后大名鼎鼎的 WordPress,之后在 2011年 的时候迁到了 Typecho 一直到现在。

因为多年的数据和主题修改沉淀,我一直懒得更换程序,甚至是自己开发的 Node.js 博客系统 Firekylin 也没有激发我把主站迁移过去。不过随着网络环境的不稳定性,我也在慢慢寻找一些比较靠谱的备份和快速迁移的方式来降低博客的运维成本。

数据库和Docker

现在最方便的部署方式就是容器了,所以我第一个想到的就是将博客容器化。因为 Typecho 可能会有图片上传等用户数据产生,所以比较合理的镜像方式是为 Typecho 程序制作镜像,同时将用户数据挂载到本地。

数据库则选择购买了阿里云专门的数据库服务,降低数据库这块的人工维护成本。这样维护的成本就变成了只要定时备份用户上传文件即可。但是随之又带来了新的问题。

数据库服务贵不好用

首当其冲的就是数据库服务的成本问题,最低配版套餐都要好几百,基本等同于一台主机的价钱。人工运维成本是降低了,但是金钱运维成本直接加倍!而且跨主机商只能使用外网连接,虽然都是同地区服务,但是连接意外的慢。导致主站打开需要好几秒,大大降低了用户体验。

机器性能要求高

然后容器对机器的性能也有一定要求。我一直在使用 1核2G 的低配机器维护着我的网站。由于我维护的站点比较多,使用上容器之后发现机器扛不住。换成了 2核4G 之后好一点了,但是金钱运维成本再次加倍!

镜像构建困难

最后其实是容器构建这块的成本。因为手上很多的老站都是使用 PHP 开发的,之前只是单纯的用这些程序。现在为了制作镜像,需要去看程序的基础依赖。

另外还有一些老程序是破解版的,不太适合放 Docker Hub 上,寻找一个可以让我托管私有镜像的地方也废了我不少时间。之前一直没关注过这块,很早之前知道的几个做容器服务的要么不做了,要么就是改成收费了。后来发现其实 Github 就能直接托管镜像,这才方便了点。

总的来说容器的虽然降低了迁移的成本,但是目前看来性价比不高,想要更好的服务势必要投入更多的资金才行,对我来说有点舍本逐末。

静态部署

同时为了降低我的其它站的运维成本,考虑之后决定将一些不使用的站进行静态博客处理。保留了存档的功能,同时降低了维护成本。主要是现在有很多的免费服务支持静态网站部署。比如我之前在用的 Vercel,很好的提供了对静态博客的支持。在挑选程序的时候发现 Hugo 上我现在在用的主题已经有人实现了 TwentyFourteen,这个主题是我很早之前从 WordPress 的默认主题上迁移过来的。性能非常好。遂就使用它将其他的一些站进行了迁移。

只需要在 Vercel 上使用 Import Template 的形式创建项目即可。轻轻一点它便会自动帮你创建 Github 仓库,分配域名,添加证书,最后自动部署上线。然后我再把仓库克隆下来,把导出的旧数据挪进仓库提高一下即可。Vercel 会自动触发更新的流程。

不管是人力成本,金钱成本,还有数据备份成本上来看,这种方案都达到了最低方案。特别在 Github 给个人开放了无限的私有仓库之后,你连数据隐秘性都不需要考虑了。所以在迁移了四五个老站之后,我看着剩下来的主站,决定一不做二不休一块迁移得了。

后记

关于如何进行迁移我之后再单独写一篇文章来讲。迁移之后运维成本直接消失,不仅构建快而且页面访问速度也快,一个字香!之前 Typecho 的 Markdown 格式一直不支持表格语法,换了之后没有这些问题了。不过也不是说没有缺点,之前其实用 Typecho 的后台写文章非常方便,现在换成 VSCode 之后在快捷方式上没有网页的好用,之后再考虑做个单独的发布页吧。

What's New in JavaScript

前几天 Google IO 上 V8 团队为我们分享了《What's New in JavaScript》主题,分享的语速很慢推荐大家可以都去听听就当锻炼下听力了。看完之后我整理了一个文字版帮助大家快速了解分享内容,嘉宾主要是分享了以下几点:

  1. JS 解析快了 2 倍
  2. async 执行快了 11 倍
  3. 平均减少了 20% 的内存使用
  4. class fileds 可以直接在 class 中初始化变量不用写在 constructor 里
  5. 私有变量前缀
  6. string.matchAll 用来做正则多次匹配
  7. numeric seperator 允许我们在写数字的时候使用 _ 作为分隔符提高可读性
  8. bigint 新的大数字类型支持
  9. Intl.NumberFormat 本地化格式化数字显示
  10. Array.prototype.flat(), Array.prototype.flatMap() 多层数组打平方法
  11. Object.entries() 和 Object.fromEntries() 快速对对象进行数组操作
  12. globalThis 无环境依赖的全局 this 支持
  13. Array.prototype.sort() 的排序结果稳定输出
  14. Intl.RelativeTimeFormat(), Intl.DateTimeFormat() 本地化显示时间
  15. Intl.ListFormat() 本地化显示多个名词列表
  16. Intl.locale() 提供某一本地化语言的各种常量查询
  17. 顶级 await 无需写 async 的支持
  18. Promise.allSettled() 和 Promise.any() 的增加丰富 Promise 场景
  19. WeakRef 类型用来做部分变量弱引用减少内存泄露

Async 执行比之前快了11倍

开场就用 11x faster 数字把大家惊到了,也有很多同学好奇到底是怎么做到的。其实这个优化并不是最近做的,去年11月的时候 V8 团队就发了一篇文章 《Faster async functions and promises》,这里面就非常详尽的讲述了如何让 async/await 优化到这个速度的,其主要归功于以下三点:

  • TurboFan:新的 JS 编译器
  • Orinoco:新的 GC 引擎
  • Node.js 8 上的一个 await bug

2008年 Chrome 出世,10年 Chrome 引入了 Crankshaft 编译器,多年后的今天这员老将已经无法满足现有的优化需求,毕竟当时的作者也未曾料想到前端的世界会发展的这么快。关于为何使用 TurboFan 替换掉 Crankshaft,大家可以看看《Launching Ignition and TurboFan》,原文中是这么说的:

Crankshaft 仅支持优化 JavaScript 的一部分特性。它并没有通过结构化的异常处理来设计代码,即代码块并没有通过try、catch、finally等关键字划分。另外由于为每一个新的特性Cranksshaft都将要做九套不同的框架代码适应不同的平台,因此在适配新的Javascript语言特性也很困难。还有Crankshaft框架代码的设计也限制优化机器码的扩展。尽管V8引擎团队为每一套芯片架构维护超过一万行代码,Crankshaft也不过为Javascript挤出一点点性能。

via:《Javascript是如何工作的:V8引擎的内核Ignition和TurboFan》

而 TurboFan 则提供了更好的架构,能够在不修改架构的情况下添加新的优化特性,这为面向未来优化 JavaScript 语言特性提供了很好的架构支持,能让团队花费更少的时间在做处理不同平台的特性和编码上。从原文的数据对比中就可以看到,仅仅是换了个编译器优化就在 8 倍左右了…… 给 V8 的大佬们跪下了。

而 Orinoco 新的 GC 引擎则是使用单独线程异步去处理,让其不影响 JS 主线程的执行。至于最后说的 async/await 的 BUG 则是让 V8 团队引发思考,将 async/await 原本基于 3 个 Promise 的实现减少成 2 个,最终减少成 1 个!最后达到了写 async/await 比直接写 Promise 还要快的结果。

我们知道 await 后面跟的是 Promise 对象,但是即使不是 Promise JS 也会帮我们将其包装成 Promise。而在 V8 引擎中,为了实现这个包装,至少需要一个 Promise,两个微任务过程。这个在本身已经是 Promise 的情况下就有点亏大发了。而为了实现 async/await 在 await 结束之后要重新原函数的上下文并提供 await 之后的结果,规范定义此时还需要一个 Promise,这个在 V8 团队看来是没有必要的,所以他们建议规范去除这个特性。

最后的最后,官方还建议我们:多使用 async/await 而不是手写 Promise 代码,多使用 JavaScript 引擎提供的 Promise 而不是自己去实现。

Numeric Seperator

随着 Babel 的出现,JS 的语法糖简直不要太多,Numeric Seperator 算是一个。简单的说来它为我们手写数字的时候提供给了分隔符的支持,让我们在写大数字的时候具有可读性。

其实是个很简单的语法糖,为什么我会单独列出来说呢,主要是因为它正好解决了我之前一个实现的痛点。我有一个需求是一堆文章数据,我要按照产品给的规则去插入广告。如图非红框是文章,红框处是广告。由于插入规则会根据产品的需(心)求(情)频繁变化,所以我们最开始使用了两个变量进行标记:

const news = [1, 3, 5, 6, 7, 9, 10, 11];
const ads = [2, 4, 8, 12];

当位置发生变化的时候我们就需要同时对两个变量进行修改,这样导致了维护上的成本。所以我想了一个办法,广告的位置标记为 1,文章的位置标记为 0,使用纯二进制的形式来表示个记录,这样子就变成了:

+---+---+---+
| 0 | 1 | 0 |
+---+---+---+
| 1 | 0 | 0 |
+---+---+---+
| 0 | 1 | 0 |
+---+---+---+
| 0 | 0 | 1 |
+---+---+---+

1 011 010 100 010 001
// 首位为常量 1
// 2-4 位记录一行多少条
// 后续按照新闻和广告的位置进行记录

最后我们使用一个变量 0b1011010100010001 就完成了两种信息的记录。这样做将很多数据集成在了一起解决了我们之前的问题,但是它带来了新的问题,大家也可以看到注释中按照空格劈开的话大家看的还比较明白,但是在段头将空格去除之后在阅读程度上就造成了非常大的困难了。而数字分隔符这个语法糖正好就能解决这个问题,0b1_011_010_100_010_001 这样阅读起来就好很多了。

Promise

虽然在大部分的场景 async/await 都可以了,但是不好意思 Promise 有些场景还是不可替代的。Promsie.all() 和 Promise.race() 就是这种特别的存在。而 Promise.allSettled() 和 Promise.any() 则是新增加的方法, 相较于它们的前辈,这俩拥有忽略错误达到目的的特性。

我们之前有一个需求,是需要将文件安全的存储在一个存储服务中,为了灾备我们其实有两个 S3,一个 HBase 还有本地存储。所以每次都需要诸如以下的逻辑:

for(const service of services) {
  const result = await service.upload(file);
  if(result) break;
}

但其实我并不关心错误,我的目的是只要保证有一个服务最终能执行成功即可,所以 Promise.any() 其实就可以解决这个问题。

await Promise.any( services.map(service => service.upload(file)) );

Promise.allsettled() 和 Promise.any() 的引入丰富了 Promise 更多的可能性。说不定以后还会增加更多的特性,例如 Promise.try(), Promise.some(), Promise.reduce() ...

WeakRef

WeakRef 这个新类型我最开始是不太理解的,毕竟我总感觉 Chrome 已经长大了,肯定会自己处理垃圾了。然而事情并没有我想的那么简单,我们知道 JS 的垃圾回收主要有“标记清除”和“引用计数”两种方法。引用计数是只要变量多一次引用则计数加 1,少一次引用则计数减 1,当引用为 0 时则表示你已经没有利用价值了,去垃圾站吧!

在 WeakRef 之前其实已经有两个类似的类型存在了,那就是 WeakMap 和 WeakSet。以 WeakMap 为例,它规定了它的 Key 必须是对象,而且它的对象都是弱引用的。举个例子:

//map.js
function usageSize() {
  const used = process.memoryUsage().heapUsed;
  return Math.round(used / 1024 / 1024 * 100) / 100 + 'M';
}

global.gc();
console.log(usageSize()); // ≈ 3.23M

let arr = new Array(5 * 1024 * 1024);
const map = new Map();

map.set(arr, 1);
global.gc();
console.log(usageSize()); // ≈ 43.22M

arr = null;
global.gc();
console.log(usageSize()); // ≈ 43.23M
//weakmap.js
function usageSize() {
  const used = process.memoryUsage().heapUsed;
  return Math.round(used / 1024 / 1024 * 100) / 100 + 'M';
}

global.gc();
console.log(usageSize()); // ≈ 3.23M

let arr = new Array(5 * 1024 * 1024);
const map = new WeakMap();

map.set(arr, 1);
global.gc();
console.log(usageSize()); // ≈ 43.22M

arr = null;
global.gc();
console.log(usageSize()); // ≈ 3.23M

分别执行 node --expose-gc map.js 和 node --expose-gc weakmap.js 就可以发现区别了。在 arr 和 Map 中都保留了数组的强引用,所以在 Map 中简单的清除 arr 变量内存并没有得到释放,因为 Map 还存在引用计数。而在 WeakMap 中,它的键是弱引用,不计入引用计数中,所以当 arr 被清除之后,数组会因为引用计数为0而被回收掉。

正如分享中所说,WeakMap 和 WeakSet 足够好,但是它要求键必须是对象,在某些场景上不太试用。所以他们暴露了更方便的 WeakRef 类型。在 Python 中也存在 WeakRef 类型,干的事情比较类似。其实我们主要注意 WeakRef 的引用是不计引用计数的,就好理解了。例如 MDN 中所说的引用计数没办法清理的循环引用问题:

function f(){
  var o = {};
  var o2 = {};
  o.a = o2; // o 引用 o2
  o2.a = o; // o2 引用 o

  return "azerty";
}

f();

如果试用 WeakRef 来改写,由于 WeakRef 不计算引用计数,所以计数一直为 0,在下一次回收中就会被正常回收了。

function f() {
  var o = new WeakRef({});
  var o2 = o;
  o.a = o2;

  return "azerty";
}

f();

在之前的一个多进程需求中,我们需要将子进程中的数据发送到主进程中,我们使用的方式是这样写的:

const metric = 'event';
global.DATA[metric] = {};

process.on(metric, () => {
  const data = global.DATA[metric];
  delete global.DATA[metric];
  return data;
});

代码就看着比较怪,由于 global.DATA[metric] 作为强引用,如果直接在事件中 return global.DATA[metric] 的话,由于存在引用计数,那么这个全局变量是一直占用内存的。此时如果使用 WeakRef 改写一下就可以减少 delete 的逻辑了。

const metric = 'event';
global.DATA[metric] = new WeakRef({});

process.on(metric, () => {
  const ref = global.DATA[metric];
  if(ref !== undefined) {
    return ref.deref();
  }
  return ref;
});

后记

除了我上面讲的几个特性之外,还有很多其他的特性也非常一颗赛艇。例如 String.matchAll() 让我们做多次匹配的时候再也不用写 while 了!Intl 本地化类的支持,让我们可以早日抛弃 moment.js,特别是 RelativeTimeFormat 类真是解放了我们的生产力,不过目前接口的配置似乎比较定制化,不知道后续的细粒度需求支持情况会如何。

参考资料:

Firekylin 如何自定义首页

之前用 WordPress 和 Typecho 的时候有个功能特别好用,那就是自定义首页。用户可以将某个页面指定成首页,而页面又可以通过自定义模板来个性化。最终达到了个性化首页的目的。这么棒的功能当然 Firekylin 也要支持啦,下面就和大家说说在 Firekylin 中如何实现个性化首页功能。

页面自定义模板

0.15.0 版本新增自定义模板的功能,将自定义模板放置在主题目录的 template 文件夹下,程序就会识别该自定义模板。模板的内容随意,可以是纯静态的 HTML 内容,也可以是 nunjucks 语法。后台新建页面右侧可以选择你设定的自定义模板,选择该模板之后程序就会使用它替换默认的 page.html 模板渲染该页面。

选择自定义模板

自定义网站首页

设置好自定义页面之后,在 设置-阅读设置 中可以选择某个页面作为首页。这样就完成了自定义首页的配置。设置了页面为自定义首页之后如果想要再次访问原来的首页,可以使用 xxx.com/post/list 地址来访问。

选择自定义页面

自定义错误页

是不是已经对 404 页面枯草的报错感到厌烦了?0.15.0 同时增加了自定义错误页的功能。在主题目录的 error 文件夹下,放置对应错误号的模板程序就会识别以该模板渲染错误页。例如 www/theme/firekylin/error/404.html 即是自定义 404 页面文件路径。

有了这些自定义功能,我相信爱折腾小伙伴的博客会越来越漂亮了。

PMBlog 转火麒麟

别院以前一直用的是我三年前开发的静态博客系统 PMBlog。这款程序(确切的说只能叫脚本)我自己还是比较喜欢的,插件主题什么该有的都有。不过这两年维护的时间越发的少了起来,加上最近参与到另一款基于 Node.js 的博客程序 Firekylin 的开发中,遂起了心想把这个静态博客切过来。

firekylin 有提供 WXR 导入功能,WXR 是 WordPress eXtend Rss 的简称,基本上可以算是博客程序数据转换的通用格式了。所以我只要把 PMBlog 的数据转换成 WXR 文件就好啦,为此特地将其写成了插件 Export2WXR。将插件拷贝到 plugins 目录下后再次编译会提示下载一个 .xml 文件。下载下来之后直接进入到 Firekylin 后台设置-导入数据将文件导入即可。

制作插件的过程主要是将数据转换成 XML 的过程,由于用的别人的 wxr 生成代码以及数据的不对应导致转换过程非常颠婆,具体就不多表了。由于之前就使用了 Disqus 的社交评论,新程序的文章地址和原来的略有不同,需要使用 Disqus 的 URL Mapper 工具对原来的文章网址进行重映射,将文章 URL 列表下下来之后用文本编辑器的正则批量替换基本上就能搞定了,这里给一个示例:

Find: http://blog.imnerd.org/(.+?)$
Replace: http://blog.imnerd.org/$1, http://blog.imnerd.org/post/$1

Disqus 的服务还是比较稳定的,不像之前帮 75team 转多说评论一样,各种导入导出功能失败,批量删除失败,数据太多失败,缓存抽风失败。只能说毕竟在国内,不赚钱的项目是没人管的,以后还是老老实实的用着 Disqus 吧。

博客程序切过来之后希望能像月影大大那样激发热情高产起来!哈哈!

Tumblr整站图片下载

前言

前几天闲着无聊没事(其实是一大堆事摆在面前作死不愿意做)把Python的入门教程又看了一遍,感觉这样做好没有效率啊,遂想拿个东西练练手。Python做好的练手项目就是爬虫了(我也不知道从哪里看到的,反正就有这么一说),正好前两天看了Tumblr的一个图片博客,然后网速渣到爆表,遂想干脆给整站图片都抓下来好了。

说干就干篇

基本的思路非常简单啦,无非就是把页面抓下来然后解析里面的IMG标签得到图片地址最后下载下来就好叻。感谢Tumblr没有对IP访问频率进行限制让我少做了不少工作,ありがとう~

首先要说明Python版本啦,我的版本是Python3.4.1,对2.x不兼容。不过索性代码也非常的简单,无非就是urllib库的问题,稍加修改也是可以转换过去的,这个我就不多表述啦。另外就是我的代码是以http://triketora.tumblr.com/这个博客为基准的,因为每个博客的DOM标签可能都不太一样,所以没法做到代码通用,需要根据博客的主题做相应的修改。

页面的抓取我是直接用的urllib.request库,简单粗暴而且非常有效。页面DOM的解析我使用的当然是大名鼎鼎的BeautifulSoup了。至于BeautifulSoup的安装同样不多做表述,其中文文档可参见Beautiful Soup 4.2.0 文档。至于最后一步的图片下载我使用的是urllib.request.urlretrieve函数,这函数简直就是爬虫利器啊有么有,简单快捷高效的就把图片给抓取下来了,简直好顶赞!

实战代码篇

#! /usr/bin/python3
import os, sys, urllib
from urllib import request, parse
from bs4 import BeautifulSoup

def basename(url):
    return os.path.basename(parse.urlparse(url).path)

def download(url, file):
    '''download file'''
    colWidth = 50
    __perLen = 0
    def reporthook(a, b, c):
        nonlocal __perLen
        per = (100*a*b)/c
        if per>100: per=100
        realCol = int(per/100 * colWidth)
        down = '='*(realCol-1)
        undown = ' '*(colWidth-realCol)
        process = '['+down+'>'+undown+']'
        per = '{:.2f}%'.format(per)
        print('\b'*__perLen, per+' '+process, end=' ', flush=True)
        __perLen = len(per)+len(process)+4

    print('Downloading URL File:\t%s' % (url))
    urllib.request.urlretrieve(url, file, reporthook)
    print('Downloaded!')

def downImages(post):
    for image in post.find_all('img'):
        filename = basename(image['src'])
        if(os.path.exists(filename)):
            print(filename+' exists!')
            continue
        download(image['src'], filename)

def spider(url):
    global start
    print("It has ready to parse URL: "+url)
    html = BeautifulSoup( request.urlopen(url).read() )
    for post in html.select(".post"):
        iframe = post.find('iframe')
        if(iframe):
            if 'photoset' not in iframe['class']: continue #! True Video Post
            iframe = BeautifulSoup( request.urlopen(iframe['src']).read() )
            downImages(iframe)
        else: downImages(post)

    start += 1
    spider('%s/page/%d' % (URL, start))

if(len(sys.argv) <= 1): sys.exit('no tumblr blog url')
_, URL, *start = sys.argv
start = int( start[0] if len(start)>0 else 1 )
spider('%s/page/%d' % (URL, start))

HOW TO USE?

首先保证你安装了Python3,以及BeautifulSoup库,复制以上代码到Spider.py文件。然后命令行输入:

Python3 Spider.py http://triketora.tumblr.com/

你还可以指定起始下载页数,比如:

Python3 Spider.py http://triketora.tumblr.com/ 30

将会从第30页开始下载。

友情提示

默认下载的地方是Spider.py的同级目录,所以找个大点的位置放它哦。

作者有话说之还不能完结篇

怎么说呢,这是我写的第一个Python爬虫代码,甚至是第一个真正意义上的Python代码,所以看起来会有点糙啦,欢迎各位吐槽。而且如何做到代码通用也是一个让我值得思考的问题,目前的思路颇为烦恼啊。

以上!

移植主题TwentyFourteen

唔,前两天有人求WordPress主题,交流一番之后知道了WordPress居然又发新的默认主题了!之前那款TwentyTwelve也很是不错的说,看了下新发的TwentyFourteen,我也很是喜欢,果断就开始了扒皮行动。话说我也是现在才知道WordPress的默认主题对应着年份的,这么后知后觉真心好忧桑。至于TwentyThirteen那款默认主题由于个人不喜欢甚至觉得有些难看而自动过滤掉了。

在跟各种神奇的WordPress语法大干一场后,抄袭移植就基本完成了。秉承着尽量忠于原主题的原则(说的好像自己扒皮多有原则似的,→_→),主题的资源文件当然是毫不犹豫的是从原主题拷贝过来的啦。发现WordPress现在真是各种高度集成,乱七八糟什么样的函数都有,通过函数能做大部分的事情。同时今天看到一句话“Typecho就是早期整合度不高的WordPress”,对此也深表同意。原来做WordPress主题跟Typecho一样,也就是header index footer sidebar post page comments functions几个文件而已。现在的WordPress主题动不动就是十几个模板文件,各种嵌套引用,头都大了。以及觉得Typecho果然还是有不足的地方啊,好几个需求都没办法直接实现,有些甚至没法实现,只能各种“黑魔法”。

虽然花了我一些时间去移植,但是不保证完全没有问题啦,有问题的同学请留言提出来,我会尽我所能解决的。最后放上页面预览和下载地址:

TwentyFourteen Theme

更新:

本主题更新至1.1版修复了WP官方的定宽(最大只能显示1260px)的问题,以及搜索页面的一些排版问题。

下载地址:BOX | 百度云(提取码:hvkx)