lizheming 发布于 12月27, 2020

静态博客如何高性能插入评论

🌏 前言

我们知道,静态博客由于不带有动态功能,所以针对评论这种动态需求比较大众的做法就是使用第三方评论系统。第三方评论的本质其实就是使用 JS 去调取第三方服务接口获取评论后动态渲染到页面中。虽然它很好的解决了这个问题,但是由于需要请求接口,在体验上远比动态博客的直出效果要差很多。所以当我把博客从动态博客 Typecho 迁移到静态博客 Hugo 上来时,就一直在思考这个问题。直到我看到了 Hugo 的 getJSON 方法,发现原来静态博客也是能够像动态博客一样直出评论的。

大部分的静态博客的原理是解析存储内容的文件夹,使用一些模板语言遍历数据生成一堆 HTML 文件。而 Hugo 除了解析 Markdown 内容之外,还支持额外的数据获取方法 getJSON。由于有了 getJSON 方法的出现,我们可以实现在博客编译构建过程中动态的去获取评论接口数据,将其渲染到页面中,实现评论数据的直出效果。关于 getJSON 的更多介绍,可以查看 Hugo 文档数据模板一节。

🎃 方案

高性能方案基本思路是在需要评论数据的地方通过 getJSON 方法调用接口获取评论数据并进行模板渲染。当评论更新的时候,我们需要触发重新构建。实现这个方案依赖三个关键要素:

  1. 构建过程支持调取接口获取数据
  2. 评论服务提供 HTTP 接口返回数据
  3. 博客部署服务支持钩子触发重新构建

我的博客使用的是 Hugo 静态博客系统,如上文所说通过 getJSON 即可解决第一个问题。而我的评论服务使用的是自研的 Waline 评论系统,它提供了评论数、评论列表、最近评论等基础接口满足我们的数据获取需求。并且 Waline 提供了丰富的钩子功能,支持在评论发布的时候触发自第一方法。我的博客部署在 Vercel 上,它提供了 Deploy Hooks 功能,通过 URL 即可触发重新构建。也就是说我只要在 Waline 评论发布的钩子中调用 Vercel 的钩子 URL 触发重新构建即可解决第三个问题。

🥪 实现

我的博客上有三处地方和评论有关,分别是首页侧边栏的最近评论,文章标题下方的评论数,以及文章详情页底部的评论列表展示。

🍞 最近评论

Waline 最近评论接口:文档

{{ $walineURL := .Site.Params.comment.waline.serverURL }}
<h2 class="widget-title ">最近回复</h2>
<ul class="widget-list recentcomments">
  {{ $resp := getJSON $walineURL "/comment?type=recent" }}
  {{ range $resp }}
  <li class="recentcomments">
    <a href="{{.Site.BaseURL}}{{ .url }}">{{ .nick }}</a>:{{ .comment | safeHTML | truncate 22 }}
  </li>
  {{ end }}
</ul>

🧀 文章评论数

Waline 获取文章对应的评论数接口:文档

{{ $walineURL := .Site.Params.comment.waline.serverURL }}
{{ $count := getJSON $walineURL "/comment?type=count&url=/" .Slug ".html" }}
<a href="{{ .Permalink }}#comments" title="{{ .Title }}">
  <i class="fas fa-comment mr-1"></i>
  <span>{{- if gt $resp 0}}{{$resp}} 条评论{{else}}暂无评论{{end -}}</span>
</a>

🍯 评论列表

评论列表由于有分页的存在,不像最近评论和评论数一样简单的调用接口即可。先获取评论数,发现有评论时先获取第一页的评论,主要是用来获取总共有多少页评论。之后再从第二页开始循环获取评论数据。最终将获取到的数据全部存到 {{$scratch.Get "comments"}} 数组中,使用模板语法渲染该数组数据即可。

{{$baseUrl := .Site.Params.comment.waline.serverURL}}
{{$slug := .Slug}}
{{$count := getJSON $baseUrl "/comment?type=count&url=/" $slug ".html" }}
{{$scratch := newScratch}}
{{$scratch.Add "comments" slice}}

{{if gt $count 0}}
  {{$comments := getJSON $baseUrl "/comment?path=/" $slug ".html&page=1&pageSize=100"}}
  {{range $cmt := $comments.data}}
    {{$scratch.Add "comments" $cmt}}
  {{end}}

  {{$totalPages := $comments.totalPages}}
  {{if gt $totalPages 1}}
    {{range $page := seq 2 $totalPages}}
      {{$comments := getJSON $baseUrl "/comment?path=/" $slug ".html&pageSize=100&page=" $page}}
      {{range $cmt := $comments.data}}
        {{$scratch.Add "comments" $cmt}}
      {{end}}
    {{end}}
  {{end}}
{{end}}

<div class="vcards">
  {{range $cmt := $scratch.Get "comments"}}
  <div class="vcard" id={{$cmt.objectId}}>
    <img class="vimg" src="https://gravatar.loli.net/avatar/{{$cmt.mail}}?d=mp">
    <div class="vh">
      <div class="vhead">
        <a class="vnick" rel="nofollow" href="{{$cmt.link}}" target="_blank">{{$cmt.nick}}</a>
        <span class="vsys">{{$cmt.browser}}</span>
        <span class="vsys">{{$cmt.os}}</span>
      </div>
      <div class="vmeta">
        <span class="vtime">{{dateFormat $cmt.insertedAt "2006-01-02 03:04:05"}}</span>
        <span class="vat">回复</span>
      </div>
      <div class="vcontent" data-expand="查看更多...">
        {{$cmt.comment | safeHTML}}
      </div>
      <div class="vreply-wrapper"></div>
      <div class="vquote">
        {{range $cmt := $cmt.children}}
        <div class="vh" id="{{$cmt.objectId}}">
          <div class="vhead">
            <a class="vnick" rel="nofollow" href="{{$cmt.link}}" target="_blank">{{$cmt.nick}}</a>
            <span class="vsys">{{$cmt.browser}}</span>
            <span class="vsys">{{$cmt.os}}</span>
          </div>
          <div class="vmeta">
            <span class="vtime">{{dateFormat $cmt.insertedAt "2006-01-02 03:04:05"}}</span>
            <span class="vat">回复</span>
          </div>
          <div class="vcontent" data-expand="查看更多...">
            {{$cmt.comment | safeHTML}}
          </div>
          <div class="vreply-wrapper"></div>
        </div>
        {{end}}
      </div>
    </div>
  </div>
  {{end}}
</div>

🍳 构建触发

Waline 在评论发布、更新和删除阶段都支持自定义钩子,在钩子中触发 Vercel 的构建钩子即可完成发布评论重新构建的流程。

按照如下内容修改服务端部署的 index.js 文件,查看文档了解全部的 Waline 钩子。

const Waline = require('@waline/vercel');
const https = require('https');
const buildTrigger = _ => https.get('https://api.vercel.com/v1/integrations/deploy/xxxxx');

module.exports = Waline({
  async postSave(comment) {
    if(comment.status !== 'approved') {
      return;
    }
    buildTrigger();
  },
  async postUpdate() {
    buildTrigger();
  },
  async postDelete() {
    buildTrigger();
  }
});

🍾 后记

通过以上操作,就能在不损失用户体验的情况下实现评论数据的动态支持了。有些人可能会担心是否会在构建阶段造成超多的接口请求。这里大可不用担心,Hugo 自己会在构建的时候做接口的缓存,同 URL 的接口调用会走缓存数据而不会重新调用。

除了用户体验之外,由于只会在构建的时候触发数据的获取,针对有调用次数配额的第三方评论服务也能节省额度。当然,理论上构建次数是远小于访问次数的,所以额度节省的结论是能成立的。如果说你的构建次数要比访问次数还要大的话,那这种方法就无法节省额度了。

当然这种方式也会有带来些问题,主要是评论的更新没那么快。好在 Hugo 的构建速度非常快,一两分钟的时间也能接受。而针对用户评论的发布,则可以通过评论发布后先假插入缓解该问题。

阅读全文 »

lizheming 发布于 11月24, 2020

关于被人恶意冒用身份发送垃圾评论的声明

今日,有多位同学反馈说我在他们的网站上大量发送垃圾评论。本人特此声明,该事情绝非本人所为。由于反馈过来的同学都是使用 Valine 评论系统,该评论系统是支持匿名和自定义信息评论的。我的个人信息基本是公开的,所以冒用我的身份进行垃圾评论发布是非常容易的。就我个人而言,没有时间也没有必要去做这种事情,希望各位投诉的同学保持理智。

同时我也在此告诫始作俑者,你的行为已经不仅给我本人甚至给广大博主造成了非常恶劣的影响。不管你是处于什么目的,请立即停止你无聊的行为。如果是觉得 Waline 这个项目好,那就欢迎进行 Star,如果觉得项目不好,那也请直接评论抨击我即可。你现在的行为只能显示你的无聊与幼稚。

鉴于反馈过来的用户都是 Valine 用户,个人建议使用大家可以安装 Valine-Admin 或者使用本人之前开发的 Waline 评论系统,这两个都能基于 Valine 快速的增加 Akismet 垃圾评论过滤的功能,对垃圾评论的过滤有一定的效果。

另外就是可以将垃圾评论的数据在该文章的评论中提供一下,看看 IP 和邮箱之类的能否有手段排查一下。目前投诉过来的同学因为都已经删除了评论了,所以我也没办法再追查下去。

**更新:**通过邮件通知有看到几条垃圾评论,发现 IP 和 UA 等要么未提供要么就是伪造的,无法对其进行溯源查找。

另外介于是最近才出现的事情,而且反馈过来的同学都是 Valine 的用户。有理由怀疑是因为我写了《基于 Serverless 的 Valine 可能并没有那么香》 这篇文章之后被人利用了。而且目前 LeanCloud 的服务似乎都跪了,估计大量的垃圾评论写入也有一定的原因。

阅读全文 »

lizheming 发布于 11月14, 2020

基于 Serverless 的 Valine 可能并没有那么香

声明:《关于被人恶意冒用身份发送垃圾评论的声明》

Valine 是一款样式精美,部署简单的评论系统, 第一次接触便被它精美的样式,无服务端的特性给吸引了。它最大的特色是基于 LeanCloud 直接在前端进行数据库操作而无需服务端,极大的缩减了部署流程,仅需要在静态页引入 Valine SDK 即可。

👨‍💻‍ 初识 Valine

以下是 Valine 官网提供的快速部署脚本,其中 appId 和 appKey 是你在 LeanCloud 上创建应用后对应的应用密钥。也正是基于这对密钥,Valine 在内部调用了 LeanCloud SDK 进行数据的获取,最终将数据渲染在 #vcomments 这个 DOM 上。这便是 Valine 的大概原理。

<head>
  ..
  <script src='//unpkg.com/valine/dist/Valine.min.js'></script>
  ...
</head>
<body>
  ...
  <div id="vcomments"></div>
  <script>
    new Valine({
      el: '#vcomments',
      appId: 'Your appId',
      appKey: 'Your appKey'
    })
  </script>
</body>

有同学可能会有疑问了,appId 和 appKey 都直接写在前端了,那岂不是谁都可以修改数据了?这就需要牵扯到 LeanCloud 的数据安全问题了,官方专门写了篇文档《数据和安全》 来说明这个问题。简单的理解就是针对数据设置用户的读写权限,确保正确的人对数据有且仅有正确的权限来保证数据的安全。

乍听一下,保证用户数据只读的话,感觉还是挺安全的。可事实真的如此么,让我们继续来看看。

🙅‍♂️ Valine 的问题

📖 阅读统计篡改

Valien 1.2.0 增加了文章阅读统计的功能,用户访问页面就会在后台 Counter 表中根据 url 记录访问次数。由于每次访问页面都需要更新数据,所以在权限上必须设置成可写,才能进行后续的字段更新。这样就造成了一个问题,实际上该条数据是可以被更新成任意值的。感兴趣的同学可以打开 https://valine.js.org/visitor.html 官网页面后进入控制台输入以下代码试试。试完了记得把数改回去哈~

const counter = new AV.Query('Counter');
const resp = await counter.equalTo('url', '/visitor.html').find();
resp[0].set('time', -100001).save();
location.reload();

可以看到该页面的访问统计被设置成了 -100000 了。这个问题唯一值得庆幸的是 time 字段的值是 Number 类型的,其它的值都无法插入。如果是字符串类型的话就是一个 XSS 漏洞了。

该问题有一个解决办法,就是不使用次数累加的存储方式。更改为每次访问都存储一条只读的访问记录,读取的时候使用 count() 方法进行统计。这样所有数据都是只读的,就不存在篡改的问题了。这种解决方案唯一的问题就是数据量会比较大,对查询会造成一定压力。当然如果是在基于原数据不变的情况下,只能是增加一层服务端来做修改权限的隔离了。

🧯 XSS 安全

从很早的版本开始就有用户报告了 Valine 的 XSS 问题,社区也在使用各种方法在修复这些问题。包括增加验证码,前端XSS过滤等方式。不过后来作者才明白,前端的一切验证都只能防君子,所以把验证码之类的限制去除了。

现有的逻辑里,前端发布评论的时候会将 Markdown 转换成 HTML 然后走一下前端的一个 XSS 过滤方法最后提交到 LeanCloud 中。从 LeanCloud 中拿到数据之后因为是 HTML 直接插入进行显示即可。很明显,这个流程是存在问题的。只要直接提交的是 HTML 而且拿到 HTML 之后直接进行展示的话,XSS 从根本上是无法根除的。

那有没有根本的解决办法?其实是有的。针对存储型的 XSS 攻击,我们可以使用转义编码进行解决。只要效仿早前 BBCode 的做法,提交到数据库的是 Markdown 内容。前端读取到内容对所有 HTML 进行编码后再进行 Markdown 转换后展示。

function encodeForHTML(str){
  return ('' + str)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')    
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;')
    .replace(/\//g, '&#x2F;');
};

由于 Serverless 攻击者是可以直达存储阶段,所以数据存储之前的一切防范是无效的,只能在读取展示过程处理。由于所有的 HTML 转义后无法解析,Markdown 相当于我们根据自定义的语法解析成 HTML,保证转换后的 HTML 没有被插入的机会。

不过这个方法存在一个问题,那就是对老数据存在不兼容。因为这相当于修改了存储和展示的规则,而之前一直存储的都是 HTML 内容,修复后之前的数据将无法展示 HTML 样式。而为了能在存储的还是 HTML 情况下规避 XSS 安全问题,唯一的办法就是增加服务端中间层。存储阶段增加一道阀门,将转义阶段提前至存储阶段,保证新老数据的通用。

🖼 隐私泄露

说完了存储的问题,我们再来看看读取的问题。攻击者除了可以直达存储,也可以直达读取,当一个数据库的字段开放了读取权限后,相当于该字段的内容对攻击者是透明的。

在评论数据中,有两个字段是用户比较敏感的数据,分别是 IP 和邮箱。灯大甚至专门写了一篇文章来批判该问题 《请马上停止使用Valine.js评论系统,除非它修复了用户隐私泄露问题》。甚至掘金社区在早期使用 LeanCloud 的时候也暴出过泄露用户手机号的安全问题。

为了规避这个问题,Valine 作者增加了 recordIP 配置用来设置是否允许记录用户 IP。由于是 Serverless,目前能想到的也只是不存储的方式解决了。不过该配置项会存在一个问题,就是该配置项的配置权在网站,隐私的问题是评论者遇到的,也就是说评论者是无权管理自己的隐私的。

除了这个矛盾点之外,还有就是邮箱的问题。邮箱本质上只需要返回 md5 用来获取 Gravatar 头像即可。但是由于无服务端的限制,只能返回原始内容由前端计算。而邮箱我们又需要获取到原始值,方便做评论回复邮件通知功能。所以我们也不能不存储,或者存储 md5 后的值。

该问题的解决方案只能是增加一层服务端,通过服务端过滤敏感信息解决这个问题。

🎊 Waline!

基于以上原因,我们发现只有增加一层服务端中间层才能很好的解决 Valine 的安全问题,所以 Waline 横空出世了!Waline 与 Valine 最大的不同就是增加了服务端中间层,解决 Valine 暴露出来的安全问题。同时基于服务端的特性,提供了邮件通知、微信通知、评论后台管理、LeanCloud, MySQL, MongoDB, SQLite, PostgreSQL 多存储服务支持等诸多特性。不仅如此,Waline 默认使用 Vercel 部署,实现完全免费部署!

Waline 最初的目标仅仅是为 Valine 增加上服务端中间层。但是由于作者不知为何从 1.4.0 版本开始只推送编译后的文件到 Github 仓库中,源文件停止更新。导致我只能连带前端也实现一遍。当然前端的很多代码和逻辑为了和 Valine 的配置保持一致都有参考 Valine,甚至在名字上,我也是从 Valine 上衍生的,让大家能明白这个项目是 Valine 的衍生版。

📔 后记

Serverless 的概念火了非常多年,但技术没有银弹,我们在看到它的优点的同时,也要正视它所带来的问题。而 Serverless 自己可能也意识到了这个问题,从早期的无服务端慢慢转向了无服务器,更偏向 BaaS 了。不过由于 Valine 没有开放源代码,所以上面说的一些问题和解决方法只能等待作者自己发现这件事了。

阅读全文 »

lizheming 发布于 11月04, 2020

Hugo 之旅

之前写了篇文章《博客迁移至 Hugo》,提了下使用 Typecho 多年后越发感受到运维的成本之高后,将博客迁移到了静态博客程序 Hugo 下。使用 Vercel + Github 可以免费搭建高性能博客,绑定域名还能自动帮忙创建 SSL 证书。当然偷懒的话也可以直接使用默认分配的二级域名。

搭建

创建 Hugo 博客

点击上面的按钮快速抵达创建页面,未登录的会需要登录,这块直接使用 Github 登录即可。登录后第一步会让你选择 Vercel 账号,这里直接选择 Personal Account 即可。之后会让你输入仓库名称,Vercel 会自动帮你创建并初始化该仓库。如果你的仓库不想让其它人看的话,这里可以勾选 Private Git Repository 创建私有仓库。

下一步这块会让你输入 Vercel 中项目的名称和一些配置。这里需要注意一下,官方提供的默认 Hugo 编译命令会把草稿文章也生成出来。需要在 BUILD COMMAND 那打开 OVERRIDE 按钮后输入 hugo --gc 进行覆盖。

稍等片刻之后,你就可以看到飘着满屏的彩带庆祝你创建博客成功叻!点击 Visit 按钮你就可以看到你的博客的样子了。

由于是静态博客,你所有的文章都会存储在你刚才新建的仓库中。你可以选择将仓库下来修改后提交,也可以利用 Github 的在线编辑功能在线修改提交。提交之后 Vercel 会自动触发更新,重新构建并更新你的博客。

配置

默认每次提交 Vercel 构建完成之后都会把构建后的地址评论在你的 Github 提交下。你可以通过设置关闭该功能。另外默认 Vercel 指定的 Hugo 版本比较老了,在 Markdown 编译过程中会发生一些异常的行为。我们可以通过配置指定最新的 Hugo 版本进行编译。在项目根目录下新建 vercel.json 文件,并加入以下内容。

{
  "github": {
    "silent": true
  },
  "build": {
    "env": {
      "HUGO_VERSION": "0.78.1"
    }
  }
}

Hugo 博客本身的配置都在 config.toml 下。默认情况下 Hugo 生成的 URL 都是 /posts/hello-world/ 这种格式,不过之前做动态博客为了做伪静态,一般都将路由设置成了 /hello-world.html 这种格式。这种时候就需要在 config.toml 中增加 uglyurls 配置。

uglyurls = true
[permalinks]
  posts = ":slug"

后面的 permalinks 配置主要是用来去除文章的 /posts 前缀的。除此之外,默认的配置文章中的 HTML 是会被转义的,对于我这种偶尔会在 Markdown 中写 HTML 的人来说操作有点多余。这时候可以在配置中定义它不转义。

[markup.goldmark.renderer]
  unsafe= true

域名

使用 Vercel 搭建的网站,它会默认提供一个 *.vercel.app 的二级域名,你可以直接使用这个域名访问网站。如果你想要绑定自己的域名,也可以在后台设置。进入网站后选择自己的项目,选择 Settings - Domains 进入域名配置界面,在输入框中添加自己的域名。它会提示你需要给域名增加 A 记录或者是 CNAME 解析。按照提示添加后后台会自动检测是否生效。

生效的时间视 DNS 服务器的生效时间而定,我这边使用 DNSPod 还挺快的,大概 30 秒之内就生效了。生效后 Vercel 会自动帮我们申请配置 SSL 证书,我们全然不用操心证书的问题。等待片刻之后我们就能直接使用新域名进行访问了。

图片

默认所有的静态资源都放在 static/ 目录下。你可以将图片放在该目录下,例如 static/hello-world.jpg。在文章中则直接使用 /hello-world.jpg 地址引用即可。

由于 Github 仅有单文件小于 100M 的限制,Vercel 会将所有的资源部署到自己服务器上。所以使用仓库存储的方式会非常方便和安全,而且还不损失速度。唯一美中不足的是,由于是 HTTP 路径,在本地编写文章的时候会不方便。如果是使用 Typora 的话可以点击标签栏 格式 - 图像 - 设置图片根目录 将目录指定到 static/ 目录解决。VSCode 的话暂时没有倒腾出来。

其实比较好的方案是建议大家创建一个文件夹,将该篇文章和它所用到的图片都归置到一块。使用类似组件化的思路管理内容,会更加方便后续的修改。

除了使用本地存储之外,你也可以选择使用第三方的存储服务。免费服务的话可以试试 又拍云 提供的联盟计划,按照要求申请下即可。其他家的存储服务要么免费时间有限,要么有部分收费功能。第三方存储的好处在于带有 CDN 加速,在速度上会很方便。但目前 Vercel 的速度我觉得还挺不错的,所以最终还是选择了直接存仓库。

评论

使用静态博客之后,评论则只能选择第三方评论系统了。鉴于国情,国内的第三方评论服务都已名存实亡。目前比较知名的 Disqus , HyperComments 以及 来必力 都是国外的服务。HyperComments 是付费的服务就不多做讨论,Disqus 是老牌服务,提供了强大的 API 对开发者较为友好。来必力则是对国内用户友好,集成了很多国内的社交账号登录。

除了专门提供评论的第三方服务之外,也有一部分是选择自建评论服务。其中比较知名的是 Valine 和 Gitalk 以及 Staticman,isso。Staticman 提供了强大的 API 但是缺少界面,isso 则是需要服务器部署,偏离了我使用静态博客的初衷就是不想维护服务器的目的,所以这两者都不多做讨论。Gitalk 是使用 Github issue 进行评论数据存储的评论脚本,适合纯技术博客和极客使用。Valine 则是基于LeanCloud Serverless 云存储进行评论数据存储的评论脚本,同时它还带有漂亮的外观,适合各类人群使用。

博客站点上与评论相关的地方一般有三个地方:

  1. 首页显示最近评论
  2. 文章下显示当前文章评论数
  3. 文章底部显示该文章的评论列表以及输入框

最开始我选择了 Disqus 作为本站的评论系统。但是它有个问题国内正常无法访问。后来我发现 Hugo 的模板中是可以使用 getJSON 方法调取接口的。而 Vercel 的机器本身就在海外,那我实际上可以在博客编译阶段就获取到输入写到页面中。最近评论和评论数的显示还是比较简单的,而评论列表的显示则需要折腾一下了。不过 Disqus API 的使用频率是 1000 次/小时,当短时间部署频繁的话可能会有超过的风险。而且发布评论的接口没办法走该逻辑,还是需要一个代理服务。

最终让我决定不使用 Disqus 的原因是它的评论没办法让评论者输入网站地址,评论列表中的评论者昵称点击也无法跳转到用户输入的网站中。这对于有着大量的老式评论数据的我来说是不太能接受的。所以基于这些种种原因,我最后又将评论迁移到了 Valine 下。

我对 Valine 的主要问题在于两点:

  1. 早前使用 Valine 的时候发现了大量的 XSS 漏洞
  2. 荒野无灯大大反馈的“泄露用户隐私问题”

以上两个是非常重要的安全问题,不过我看 XSS 的问题作者已经修复,实际上对用户输入的所有 HTML 内容进行转义可以规避该问题。而灯大反馈的 IP 泄露的问题,作者也通过不记录 IP 来规避该问题。这两个问题造成的原因其实还是因为 Serverless 。因为我想直接在编译的时候将相关数据静态化,所以我势必是需要增加一个服务端来帮我进行接口的封装,最后也能规避这些问题。

不过 Valine 不知因为什么原因不开放源码,只在 Github 提供编译后代码,这点除了让我比较无语之外,改造工作也比较慢。本来我只需要将它 UI 中调用 LeanCloud API 的逻辑替换成我的接口逻辑即可。但是因为没有源码,只能自己重新制作 UI 了。

更新:基于 Valine 衍生的带后端评论系统已经完成 https://github.com/lizheming/waline 已切换至该评论系统。2020/11/08

搜索

缺少服务端之后,静态博客的搜索功能也无法自己完成。目前比较知名的是第三方搜索服务 Agolia,提供了免费的文章索引和搜索的功能。除了使用第三方服务之外,还发现有一种比较简单的做法就是将所有数据生成到一个文件中,前端下载该文件进行搜索结果展示。这里头比较知名的是 fuse,它是一个 JS 模块,将输入传入之后它能帮我们快速的匹配到命中结果。

目前本站使用的是后一种方法,该方法的优点是不依赖第三方服务,自己就能完成搜索功能。缺点是第一次搜索的时候需要下载完整的数据,对文章比较多的网站用户鸭梨会比较大。使用第二种方法第一步是需要先创建全量的数据索引,通过以下配置告知 Hugo 编译时创建 index.json 索引文件。

[outputs]
  home = ["HTML", "RSS", "JSON"]

当然我们还需要为新的数据编写生成的模板文件,在 layouts/ 下增加 index.json 文件,并加入以下内容:

{{- $.Scratch.Add "index" slice -}}
{{- range .Site.RegularPages -}}
    {{- $.Scratch.Add "index" (dict "title" .Title "contents" .Plain  "summary" .Summary "permalink" .Permalink "date" (.Date.Format "2006年01月02日")) -}}
{{- end -}}
{{- $.Scratch.Get "index" | jsonify -}}

其中 dict后的字典可以添加多个字段,视你 JS 渲染脚本中需要的字段而定。完成这些之后 Hugo 编译就会生成 index.json 文件了。

最后我们需要增加一个搜索页面,该页面会先加载 index.json 然后使用 fuse 进行数据查找,最后渲染成 HTML 输出搜索结果。具体的代码可以直接查看源码 https://imnerd.org/search.html?q=hugo 参考本站的搜索页。

//以下为示例代码
let fuse;
async function search(text) {
  if(!fuse) {
    const indexData = await fetch('/index.json', {method: 'GET'}).then(resp => resp.json());
    fuse = new Fuse(indexData, {...});
  }
  const result = fuse.search(text);
  renderHTML(result, '#app');
}

其它

Hugo 静态博客能提供给我们发挥的空间非常多,比如说我参考 屈屈的博客 增加了一个博客文章发布 >180 天的话,就在文章详情页黄条提醒:

提醒:本文最后更新于 313 天前,文中所描述的信息可能已发生改变,请谨慎使用。

正常来说由于我没有服务端,所以只能使用前端来计算当前时间与发布时间的时间差。但其实转念一想,编译时也是可以拿到时间差的,只是这个时间差无法随着时间的变化而变化。不过我只要每天编译一次,就能解决变化的问题了。在 Vercel 项目 Settings - Git - Deploy Hooks 中你可以输入 Hook 名称和触发分支创建一个 Hook URL。只要访问该 URL 就可以触发 Vercel 更新博客。

{{ if eq .Type "posts" -}}
	{{ $ageDays := div (sub now.Unix .Date.Unix) 86400 }}
	{{ if gt $ageDays 180 }}
		<p class="expired-tips"> 提醒:本文最后更新于&nbsp;{{$ageDays}}&nbsp;天前,文中所描述的信息可能已发生改变,请谨慎使用。</p>
	{{ end }}
{{ end }}

而在 Vercel 的 Marketplace 中,有一个 EasyCron 的服务提供了定时任务的功能。我在上面设置了每天零点访问 Hook URL 触发博客更新,就这样解决每天需要更新时间的问题。

另外屈屈博客中的“查看本文Markdown版本”也是个不错的功能,在 Hugo 中也可以实现,本质是在编译的时候顺便生成一份 .md 的 Markdown 文件即可。

首先我们需要在 layouts/ 下增加 single.md 文件,表示的是当文章页(Single)需要导出 .md 的数据的时候使用该模板。模板内容不用填写,为空即可。然后在 config.toml 中新增 .md 文件类型,并告知 Hugo 文章需要增加编译 Markdown 文件类型。

[mediaTypes]
  [mediaTypes."text/plain"]
    suffixes = ["md"]

[outputFormats.MarkDown]
  mediaType = "text/plain"
  isPlainText = true
  isHTML = false

[outputs]
  page = ["HTML", "MarkDown"]

阅读全文 »

lizheming 发布于 11月01, 2020

Typecho 评论导出至 Valine 插件

前几天我为了降低成本把博客迁移到了 Hugo,具体可见《博客迁移至 Hugo》。文章我基于前人的脚本自制了 Export2Hugo 插件进行导出,但是发现评论居然没有相关的同学折腾过。大部分都是使用了 ByeTyp 和 TypExport 插件将数据导出成 WordPress ,然后再通过 Disqus 桥接到 Valine,非常的麻烦。

Valine 使用的 LeanCloud 存储服务是支持文件导入的,于是我写了个 Node.js 脚本将 Typecho 数据导出成了支持导入的文件格式完成了导入。基于该脚本的逻辑我将其转成了 Typecho 插件 Export2Valine 方便大家迁移。

插件使用非常简单,启用插件后在左上角 控制台 菜单中会新增 评论导出 选项。点击后直接点击导出 LeanCloud JSON 文件按钮稍等片刻就可以拿到导出的 JSON 文件了。

在 Leancloud 后台新建应用后,选择 存储-导入导出-数据导入。Class 名称输入Comment,数据文件选择刚才导出的 JSON 文件。点击导入按钮即可等待导入成功。导入的时间会随着评论数的增加而增加,如果评论数比较多的请耐心等待提示。

这里需要注意两点:

  1. LeanCloud 应用中如果已经存在了 Comment 表的话需要删除,否则数据不会导入。

  2. 插件导出的评论对应的文章 URL 格式为 /{slug}.html,为了能让文章和评论数据正确映射,需要将你的文章设置成对应的格式才能正确显示评论。以 Hugo 为例,在 config.toml 中需要配置:

    uglyurls = true
    [permalinks]
      posts = ":slug"
    

下载地址:Github

阅读全文 »

lizheming 发布于 11月01, 2020

Typecho 迁移至 Hugo 插件

前几天我为了降低成本把博客迁移到了 Hugo,具体可见《博客迁移至 Hugo》。在迁移的过程中找到了 @eallion 的这篇文章《Typecho 迁移到 Hugo》 ,里面使用一个 PHP 脚本将数据导出成 Markdown 文件存储到本地。当时使用了下发现有这么一些不足的地方:

  1. 因为不是插件,所以需要人工编辑配置数据库信息
  2. 脚本会把所有的 Markdown 文件生成在 Typecho 根目录下
  3. 会导出一些附件信息等额外的文件

所以为了让导出更加方便,我参考了 panxiahai/TypExport 该插件将之前的脚本整理成了插件 Export2Hugo。插件会直接将文件按照 Hugo 的格式存储后以 ZIP 压缩文件的形式下载到本地。

使用方法很简单,启用插件后在左上角 控制台 菜单中会新增 导出至Hugo 选项。点击后直接点击开始导出!按钮稍等片刻就可以拿到导出的 ZIP 文件了。

需要注意 Mac 下可能双击无法打开该压缩文件,可以在终端试试使用 unzip 命令进行解压。

下载地址:Github

阅读全文 »

lizheming 发布于 10月31, 2020

博客迁移至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 之后在快捷方式上没有网页的好用,之后再考虑做个单独的发布页吧。

阅读全文 »

lizheming 发布于 10月06, 2020

Drone 自定义 UI

Drone 是一款开源的 CI/CD 工具,基于容器提供了强大的插件系统。多年前我有写过《基于Docker的CI工具——Drone》中有详细的介绍它的优点。Drone 采用的是 Server/Agent 架构,Server 端用来处理请求派发任务给 Agent,最终在 Agent 上执行任务。

Drone 整体是使用 Golang 写的,drone/drone-ui 是它的前端页面仓库,采用 Vue.js 进行开发(很早之前是使用 React 进行开发的)。前后端分离的项目,比较正常的中间会使用 NGINX 之类的 Web Server 进行桥接,用户通过 Web Server 访问前端页面,然后页面在访问 Web Server 反代后的接口。不过 Drone Server 端直接是使用的 Golang 自己起的服务,而 Golang 又是一种需要编译的语言。为了能让 Server 编译后还是单文件,作者特地写了一款工具 bradrydzewski/togo 用来将静态资源编译成 Golang 代码。编译出来的结果本质就是文件路由和内容的哈希表,可以在官方仓库中一窥究竟。

将编译后生成的 Golang 文件提交到仓库之后,就可以在 Server 中使用模块的形式将其加载进来,剩下的就是在 Server 中根据路由获取内容返回了。这种做法在开发上会比较麻烦,不过对使用的人来说倒是方便很多了。不过由于静态资源被编译进了执行文件中,所以我们如果要自定义前端界面的话,就需要按照这个流程重新构建编译 Server 执行文件了。

构建前端模块

首先我们需要针对 drone/drone-ui 原始仓库进行 Fork,在新的仓库中根据你们的需求进行前端代码的修改。在 RADME 中介绍了如何在开发环境中进行开发。如果改动不大的话,可以在每次 Drone 官方发布版本的时候根据上游仓库提交 Pull Request 进行需求合并。执行 npm run build 会在 dist/files 目录生成最终需要的前端静态资源。

前端资源备好之后需要安装 bradrydzewski/togo 将静态资源嵌到 Golang 代码中。如果没有安装 Golang 的话需要先安装 Golang。另外 Golang 的全局 bin 目录需要配置到 PATH 环境变量中,否则编译时会提示找不到该命令。

go get github.com/bradrydzewski/togo
cd dist
go generate dist.go

注: go generate 是利用注释快速执行脚本的一种方式。本质上是执行了 dist.go 文件中的 togo http -package dist -output dist_gen.go 这条命令。

最后将编译生成的 dist_gen.go 文件添加到仓库中提交,完成前端模块的构建。接下来我们需要重新构建 Server 执行文件。

构建执行文件

Server 执行文件的仓库是在 drone/drone,我们需要找到依赖了 github.com/drone/drone-ui 模块的文件,并将其替换成我们 Fork 的新仓库地址 xxx.com/xxx/drone-ui。主要有 ./handler/web/{logout,pages,web}.go 三个文件需要被替换。

go get -v -insecure xxx.com/xxx/drone-ui
sed -i '' 's/github.com\/drone\/drone-ui/xxx.com\/xxx\/drone-ui/' ./handler/web/{logout,pages,web}.go

注: 针对这种场景,Golang 官方的模块管理中其实是支持 replace 方式用来将 A 模块替换成 B 模块的,不过我当时没有实验成功,就还是使用了 sed 的方式。

go mod edit -replace=github.com/drone/drone-ui=xxx.com/xxx/drone-ui

之后我们就可以执行 go build 对其进行构建了。我们并没有对该项目进行修改,只是针对它依赖的前端模块进行处理。所以我的想法是当 drone-ui 仓库发生变更的之后,执行 CI 流水线将 Server 仓库克隆下来修改后执行镜像构建并上传到镜像仓库中。

CI 执行当然是选择 Drone 啦,用 Drone 去构建 Drone 听起来就很酷!默认 Drone 会把当前仓库克隆下来,但实际上我们不需要克隆当前仓库,当前仓库是被主仓库依赖的模块。我们真正需要下载的是 drone/drone 主仓库。

clone: 
  disable: true

steps:
- name: clone
  image: alpine/git
  commands:
  - git clone https://github.com/drone/drone.git .
  - git checkout ${DRONE_TAG}

trigger:
  event:
  -tag

在 Drone 的配置中,设置 disable: false 即可实现不克隆当前仓库。然后自己在单独增加 git clone 的步骤。我们将仓库克隆到当前目录中,并根据当前 git tag 的版本号切换 Server 仓库的版本。这样保证最后编译出来的镜像同版本号和上游不会有其它差异。

- name: build
  image: golang:1.14.4
  commands:
  - go get -v -insecure xxx.com/xxx/drone-ui
  - sed -i '' 's/github.com\\/drone\\/drone-ui/xxx.com\\/xxx\\/drone-ui/' ./handler/web/{logout,pages,web}.go
  - sh scripts/build.sh
  environment:
    GOARCH: amd64
    GOOS: linux

接下来这段构建命令除了增加前端模块依赖替换之外,其它的都是从上游 Server 仓库 中搬运过来的。上游构建中还有 ARM, ARM64 架构版本的构建,由于我这里并不需要,就不增加构建时间了。

之后我们再像官方一样,增加 Docker 镜像构建上传的步骤即可完成最终镜像的创建。使用的时候使用该镜像即可。

后记

同样是使用 Drone 搭建,官方针对 Github 搭建的 https://cloud.drone.io 在未登录的情况下还会自带一个登录页。原理是 Server 服务在 pages.go 中判断接入域名为 "cloud.drone.io" 的话会展示位于 handler/web/landingpage/index.html 的静态页。如果有门户页的需求的话可以针对这些文件进行对应的修改。

阅读全文 »

lizheming 发布于 10月06, 2020

Sketch 插件导出切片

Sketch 作为流行的 UI 设计软件,除了设计之外,还承担了设计与开发之间沟通的桥梁作用。通过 Sketch 导出的在线标注能够节省很多沟通的成本。除了标注之外还有个比较重要的功能就是切图的导出。Sketch 中如果要导出一张切图,需要将其标记为切片(Slice)。在 Sketch 中切片的标记是多种多样的,针对不同的切片标记插件需要处理的逻辑也有细微的差别。下面我们就来看看不同的切片操作在插件中应该如何导出吧。

注: Ctrl + Shift + K 可以在 Sketch 中调出插件脚本运行的 Playground,可以方便的调试代码。

图层及编组切片

这种是最普通的方式了,当我们想要将某个图层导出成图片的时候,就会为该图层设置导出选项。导出选项中我们可以设置多种导出尺寸和格式,在左侧图层面板中设置了导出选项的图层会增加类似刻刀的图标标记。

Sketch API 提供了 sketch.export() 方法帮助我们在切片中导出切片图层。设置了导出选项的图层,图层属性会带有 exportFormats 属性,我们可以根据它判断是否是需要导出切图的图层。

const sketch = require("sketch/dom");
const artboard = sketch.getDocuments()[0].pages[0].layers[0];

const exportLayers = artboard.layers.filter(layer => layer.exportFormats.length);
exportLayers.forEach(layer => sketch.export(layer, {
  scales: 1, 
  formats: 'svg', 
  output: `~/Desktop/Sketch-Export-Demo` 
}));

编组带切片图层

除了为图层设置导出项之外,我们还可以专门添加切片图层来导出图片。切片图层会将所有与该切片图层同级的图层叠加后产生的图片进行导出。同时它不依赖素材图层,在尺寸设置上更加自由。

理论上这种情况使用 sketch.export() 方法也是没有问题的。不过这种情况下会像示例图一样,切片导出会把父级的白色背景色也导出出来,然而大部分情况下我们需要的其实只是透明图层。

这时候要导出不带背景色的图片的话,需要将切片图层和同级元素都放在一个编组里,这时候切片图层导出会多出一个 Export group contents only 的选项,中文译为仅导出编组内内容。当它被选中后,由于父级背景色不属于编组就会被排除了。

所以我们需要使用代码实现编组,勾选配置和导出三件事情。我们使用 sketch.Group 实例化了一个与画板等大的编组,并将同级的图层复制了一份放到了新创建的编组里。这里需要注意的是图层的坐标是相对的,在编组内的坐标会基于编组图层本身进行偏移。所以我们需要基于新的编组图层位置重新计算复制图层的位置,嗯,小学减法操练起来~

至于勾选配置这件事,我似乎没有找到 JavaScript API 能干这个事情,只能通过 sketchObject 属性获取到 OC 对象调用 Native 的方法设置了。至于为什么 setLayerOptions() 的参数是 2,我要说是因为要设置的是第 2 个选项你信么(掩面…

const sketch = require('sketch/dom');
const artboard = sketch.getDocuments()[0].pages[0].layers[0];

const duplicateLayers = artboard.layers.map(layer => {
  const copy = layer.duplicate();
  copy.frame = new sketch.Rectangle(
    layer.frame.x - artboard.frame.x, 
    layer.frame.y - artboard.frame.y, 
    layer.frame.width, 
    layer.frame.height
  );
  return copy;
});
const group = new sketch.Group({
  name: '切片编组',
  parent: artboard,
  frame: artboard.frame,
  layers: duplicateLayers
});

const slice = group.layers.find(layer => layer.type === sketch.Types.Slice);
slice.sketchObject.exportOptions().setLayerOptions(2);

sketch.export(slice, {scales: 2, formats: 'png', output: '~/Desktop/Sketch-Export-Demo'});
group.remove();

控件内切片图层

上面说的都是画板本身的图层设置成切片的配置。除了上文说到的元素之外,在 Sketch 中还存在着控件(Symbol)元素。它可以类比为代码中的基类,每一个控件可以实例化出一个控件实例,实现控件一处修改,处处生效的特性,让 UI 设计更加的工程化。控件还有类似代码中变量的覆盖层概念,支持将控件中的某个元素配置化,每个实例配置不同的覆盖层满足不同控件实例求同存异的需求。

正常情况下插件只能拿到画板下的图层,也就是只能拿到最终的控件实例。如果在控件中包含切片的话(如上图),普通方法是无法获取的。这时候就需要使用上图的“解绑”这个功能,对应到代码的话就是 layer.detach() 方法。点击解绑之后,控件实例就会转换成普通的编组图层,里面会包含一份控件所有元素的复制。这样我们就能按照之前的流程进行处理了。

const sketch = require('sketch/dom');
const artboard = sketch.getDocuments()[0].pages[0].layers[0];

artboard.layers.forEach(layer => {
  if(layer.type !== sketch.Types.SymbolInstance) {
    return;
  }

  //为了不影响原图层,使用 duplicate() 方法复制一份图层出来再使用 detach() 进行解绑
  const symbolGroup = layer.duplicate().detach({recursively: false});
  
  const exportLayers = symbolGroup.layers.filter(layer => layer.exportFormats.length);
  exportLayers.forEach(layer => sketch.export(layer, {
    scales: 1, 
    formats: 'svg', 
    output: '~/Desktop/Sketch-Export-Demo'
  }));
  
  //最后操作完成后将复制图层删除
  symbolGroup.remove();
});

控件画板为切片

在之后的使用中,我们发现也会存在直接给控件画板设置成切片导出的操作。这种情况下我们直接对控件实例进行解绑会发现解绑后的编组并没有标记成切片,甚至还会出现其他的一些情况。从下图可以看到不仅解绑后的编组丢失了切片标记,控件尺寸也发生了变化。原始是 32×32 的控件,经过解绑之后尺寸变成了 26×26,周边填充的留白消失了。这是因为看到的留白本质是画板尺寸撑起来的,解绑相当于对控件内的所有图层的拷贝,然而并不包括画板。所以画板的切片属性,以及因为画板尺寸带来的留白等特性都丢失了。

解决的办法也很简单,我们可以通过 sketch.getSymbolMasterWithID() 方法获取到控件,判断控件本身有切片标记的话特殊处理一下。刚才我们说了,控件解绑后肯定是没办法获取到尺寸了,所以我们需要换个思路。由于整个控件是切片,所以我们其实是不需要去观察控件内部是否存在切片导出图层,也就不需要像上面那么复杂去做解绑的操作。通过额外增加一个切片图层,补充上控件丢失的切片标记信息。剩下来的事情其实就和前文“编组带切片图层”一节是一样的了。

const sketch = require('sketch/dom');
const document = sketch.getDocuments()[0];
const artboard = document.pages[0].layers[0];

artboard.layers.forEach(layer => {
  if(layer.type !== sketch.Types.SymbolInstance) {
    return;
  }

  const master = document.getSymbolMasterWithID(layer.symbolId);
  if(!master?.exportFormats.length) {
    return;
  }

  const instance = layer.duplicate();
  instance.frame = new sketch.Rectangle(0, 0, layer.frame.width, layer.frame.height);
  const slice = new sketch.Slice({
    name: layer.name + '_Slice', 
    frame: new sketch.Rectangle(0, 0, layer.frame.width, layer.frame.height),
    exportFormats: [
      {size: '1x', fileFormat: 'svg'}
    ]
  });
  slice.sketchObject.exportOptions().setLayerOptions(2);
  const group = new Group({
    name: layer.name + '_Group',
    parent: layer.parent,
    frame: layer.frame,
    layers: [
      slice,
      instance
    ]
  });

  sketch.export(slice, {scales: '1', formats: 'svg'});
  group.remove();
});

这里我们分别创建了当前图层的复制层和与控件等大的切片图层,最后在当前图层的位置实例化了一个编组巧妙的将前两者包裹住再导出切片。可能会有同学疑问,既然已经不需要解绑来获取控件内部的切片,那为什么不在判断该控件实例需要切片导出的时候直接设置该控件实例的导出项呢?

感兴趣的同学可以试试,你会发现在这种情况下控件实例的导出项也会和我们最开始说的一样丢失画板的留白的。可以想象到 Sketch 内部本身的导出逻辑可能和我们解绑操作差不多。通过等大的切片图层,我们能很好的将控件画板带来的留白保存下来。而冗余的再套了一层编组,则是为了解决前文说的切片导出会附带同层级的背景问题。

后记

上述列出来的切片情况基本包含了大部分设计师的切片导出习惯,控件切片在进行解绑后可以回归到图层、编组、切片的逻辑中。按照上述逻辑递归所有图层可以完成所有切片图层的导出。为了更好的帮助大家理解,以上代码都是真实代码,可以直接在 Sketch 的代码编辑器中运行。

通过以上的例子可以看到,Sketch API 操作真的就是 JavaScript 语法,一点 OC 的东西都没有,对前端工程师非常友好。不过 sketch.export() 方法目前封装的不是非常的完美,在导出组件库中的控件时会存在导出图片空白的情况。这时候只能使用 OC 的方法进行导出了,希望能在之后的版本中修复该问题。

function nativeExport(layer, {format, scale, filename}) {
  const output = MSExportRequest.exportRequestsFromExportableLayer(layer.sketchObject).firstObject();
  output.format = format;
  output.scale = scale;
  return context.document.saveExportRequest_toFile(output, options.filePath);
}

参考资料:

  1. 《Set "Export group contents only"》
  2. 《手把手教你写一个批量切图sketch插件》

阅读全文 »

lizheming 发布于 10月01, 2020

如何制作 Sketch 插件

Sketch 是近些年比较流行的 UI 设计软件,它比起之前常用的 Illustrator 或者 Photoshop 比较好的地方在于小巧功能简单但足够,同时对 Mac 的触摸板支持更加友好。另外它的插件系统也要比 Adobe 更加友好,大量的插件帮助我们解决协同和效率上的问题。

Sketch 插件最大的好处在于可以直接使用 JavaScript 进行开发,并提供了许多配套的开发工具。下面我就以帮助设计师同学快速插入占位图的插件 Placeholder 为例,带大家一步一步的了解如何进行 Sketch 插件开发。

在进行插件开发之前,我们需要了解一些基础的知识。Sketch 是一套原生 Objective-C 开发的软件,它之所以能支持使用 JS 开发,是因为它使用 CocoaScript 作为插件的开发语言。它就像是一座桥(Bridge),能让我们在插件中写 OC 和 JS,然后 Sketch 将基础方法进行了封装,实现了一套 JavaScript API,这样我们就能使用 JS 开发 Sketch 插件了。

注: 关于如何开发插件,官方提供了一份入门教程《Create a plugin》,在阅读下文之前,也可以花 2~3min 先看看这篇官方教程,内容比较简短。

需求整理

在进行插件开发之前,我们捋一捋我们需要实现的功能。http://placeimg.com/ 是一个专门用来生成占位图的网站,我们将利用该网站提供的服务制作一个生成指定大小的占位图并插入到 Sketch 画板中的功能。插件会提供一个面板,可以让使用者输入尺寸、分类等可选项,同时提供插入按钮,点击后会在画板插入一张图片图层。

使用 skpm 初始化项目

skpm 是 Sketch 官方提供的插件管理工具,类比于 Node.js 中的 npm。它集插件的创建、开发、构建、发布等多项功能于一体,我们在很多场景都需要使用它。安装的话比较简单,直接使用 npm 全局安装即可。

npm install -g skpm

按照官方教程,安装完毕之后我们就可以使用 skpm create 命令来初始化项目目录了。当然 skpm 是支持基于模板初始化的,官方仓库也列举了一些模板,我们可以使用 --temlate 来指定模板进行初始化。不过处于教学的目的,我这里就还是使用官方默认的模板创建了。

➜  ~ skpm create sketch-placeimg
✔ Done!


To get started, cd into the new directory:
  cd sketch-placeimg

To start a development live-reload build:
  npm run start

To build the plugin:
  npm run build

To publish the plugin:
  skpm publish

skpm 内部会使用 webpack 进行打包编译,运行 npm run build 会生成 sketch-placeimg.sketchplugin 目录,该目录就是最终的插件目录。双击该目录,或者将该目录拖拽到 Sketch 界面上就成功安装插件了。和 webpack --watch 类似,运行 npm run watch 的话对监听文件变化实时编译,在开发中非常有帮助。

注: 不要使用 npm start 进行开发,它携带的 --run 命令会使得构建速度特别慢。虽然它带 Live Reload 功能会很方便,但在官方未修复该问题前还是不建议大家使用。

项目结构入门

创建好的模板目录结构如下,为了帮助大家理解,我们来简单的介绍下这些目录和文件。

.
├── README.md
├── assets
│   └── icon.png
├── sketch-assets
│   └── icon.sketch
├── sketch-placeimg.sketchplugin
│   └── Contents
│       ├── Resources
│       │   └── icon.png
│       └── Sketch
│           ├── manifest.json
│           ├── my-command.js
│           └── my-command.js.map
├── node_modules
├── package.json
└── src
    ├── manifest.json
    └── my-command.js

package.json

和大多数 JS 项目一样,skpm 创建的项目中也会有 package.json 文件。该文件除了像之前一样记录了项目的依赖和快捷命令之外,还增加了 skpm 字段用来对 skpm 进行配置,默认的值如下。

{
  ...
  "skpm": {
    "name": "sketch-placeimg",
    "manifest": "src/manifest.json",
    "main": "sketch-placeimg.sketchplugin",
    "assets": [
      "assets/**/*"
    ],
    "sketch-assets-file": "sketch-assets/icons.sketch"
  },
  ...
}

这里指定了该插件的名称为 sketch-placeimg,插件的 manifest 文件为 src/manifest.json。main 表示的是最终生成的插件目录名称。assets 则表示的插件依赖的图片等相关素材,在编译的时候会将命中该配置的文件拷贝到 <main>/Contents/Resources 目录下。

manifest.json

manifest.json 这个文件大家可以理解为是 Sketch 插件的 package.json 文件。我们来看看默认生成的 manifest.json。

{
  "$schema": "https://raw.githubusercontent.com/sketch-hq/SketchAPI/develop/docs/sketch-plugin-manifest-schema.json",
  "icon": "icon.png",
  "commands": [
    {
      "name": "my-command",
      "identifier": "sketch-placeimg.my-command-identifier",
      "script": "./my-command.js"
    }
  ],
  "menu": {
    "title": "sketch-placeimg",
    "items": [
      "sketch-placeimg.my-command-identifier"
    ]
  }
}

看到 $schema 就有 JSON Schema 那味了,它对应的 JSON 文件地址告诉我们可以在里面配置那些字段。其实最重要的其实就是上面列出来的 commands 和 menu 两个字段。

commands 标记了插件有哪些命令,这里只有一个命令,命令的名称(name)是 my-command,该命令的 ID(identifier)为 sketch-placeimg.my-command-identifier,对应的执行脚本为 ./my-command.js。

menu 则标记了该插件的导航菜单配置,比如示例这里它指定了该插件在插件菜单中的名称(title)为 sketch-placeimg,并拥有一个子菜单,对应的是 ID 为sketch-placeimg.my-command-identifier的命令。通过这个 ID,菜单的行为就和执行脚本关联起来了。

appcast.xml

manifest.json 默认的示例中有两个比较重要的字段没有配置,那就是 version 和 appcast。version 很明显就是用来表示当前插件的版本的。而 appcast 它的值是一个 XML 的 URL 地址,该 XML 里面包含了该插件所有的版本以及该版本对应的下载地址。Sketch 会将 version 对应的版本和 appcast 对应的 XML 进行对比,如果发现有新的版本了,会使用该版本对应的下载地址下载插件,执行在线更新插件。一个 appcast.xml 文件大概是这样的格式。

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0">
  <channel>
    <item>
      <enclosure url="https://github.com/lizheming/sketch-placeimg/releases/download/v0.1.1/sketch-placeimg.sketchplugin.zip" sparkle:version="0.1.1"/>
    </item>
    <item>
      <enclosure url="https://github.com/lizheming/sketch-placeimg/releases/download/v0.1.0/sketch-placeimg.sketchplugin.zip" sparkle:version="0.1.0"/>
    </item>
  </channel>
</rss>

如果是通过 skpm publish 命令去发布插件的话,会自动在根目录生成一个 .appcast.xml 文件。当然按照官方文档 《Update a plugin》 所说,你也可以手动生成。

resource

从上面的内容我们可以知道,skpm 会通过 package.json 中指定的 manifest 文件读取所有 commands 对应的 script 文件作为编译入口文件,将这些文档编译打包输出到 <main>/Contents/Sketch 目录。所有的 assets 配置对应的文件会拷贝到 <main>/Contents/Resources 目录中。最终完成插件的生成。

换句话来说只想要走 webpack 打包编译的话就必须是插件的命令才行。如果有一些依赖的非插件类资源,比如插件嵌入的 HTML 页面依赖的 JS 文件想要走编译的话,就需要使用 resource 这个配置了。resource 配置中配置的文件会走 webpack 的编译打包,并输出到 <main>/Contents/Resources 目录中。

插件开发

一些基本原理了解清楚之后我们就可以进行插件的开发了。首先我们需要用户点击插件菜单之后打开一个面板,该面板可以配置尺寸、分类等基础信息。

Sketch 插件中我们可以使用原生写法进行面板的开发,但是这样写起 UI 来说比较麻烦,而且对前端同学来说入门比较高。所以一般大家都会采用 WebView 加载网页的形式进行开发。原理基本上等同于移动端采用 WebView 加载网页一样,客户端调用 WebView 方法加载网页,通过实例的 webContents.executeJavaScript()方法进行插件到网页的通信,而网页中则使用被重定义的 window.postMessage 与插件进行通信。

sketch-module-web-view

想要在插件中加载网页,需要安装 Sketch 封装好的 sketch-module-web-view 插件。

npm install sketch-module-web-view --save-dev
// src/my-command.js
import BrowserWindow from 'sketch-module-web-view';
export default function() {
  const browserWindow = new BrowserWindow({
    width: 510,
    height: 270,
    resizable: false,
    movable: false,
    alwaysOnTop: true,
    maximizable: false,
    minimizable: false
  });
  browserWindow.loadURL(require('../resources/webview.html'))
}

当你做完这些你会发现点击插件菜单后什么都没有发生,这是因为还需要更改一下配置。大家可以看到我们最后是使用了 require() 引入了一个 HTML 文件,而官方默认的模板是没有提供 HTML 引入的支持的,所以我们需要为 HTML 文件增加对应的 webpack loader。

我们这里需要的是 html-loader 和 @skpm/extract-loader 两款 Loader。前者是用来解析处理 HTML 中存在的包括 <link /> 或者 <img /> 之类的 HTML 代码中可能存在的资源关联情况。而后者则是用来将 HTML 文件拷贝到 <main>/Contents/Resources 目录并返回对应的 file:/// 格式的文件路径 URL,用来在插件中进行关联。

npm install html-loader @skpm/extract-loader --save-dev

Sketch 插件官方为我们自定义 webpack 配置也预留好了入口,在项目根目录中创建 webpack.skpm.config.js 文件,它导出的方法接收的参数中第一个则是插件最终的 webpack 配置,我们直接在这基础上进行修改即可。

// webpack.skpm.config.js
module.exports = function (config, entry) {
  config.module.rules.push({
    test: /\.html$/,
    use: [
      { loader: "@skpm/extract-loader" },
      {
        loader: "html-loader",
        options: {
          attributes: {
            list: [
              { tag: 'img', attribute: 'src', type: 'src' },
              { tag: 'link', attribute: 'href', type: 'src' }
            ]
          }
        }
      }
    ]
  });
}

html-loader 插件在新版里对配置格式做了一些修改,所以之前很多老的教程中的配置都会报错。当然如果你有更多的插件需求也可以按照这个流程往配置对象中添加。之后我们再执行 npm run watch,点击菜单就可以看到我们预期的页面了。

注: 官方是提供了一套带有 sketch-module-web-view 模块的模板的,这里只是为了能更清楚的给大家解释清楚插件的原理和流程所以和他家一步一步的进行说明。真实的开发场景中建议大家直接使用以下命令进行快速初始化。

skpm create <plugin-name> --template=skpm/with-webview

React 的集成

面板这块我准备使用 React 进行开发,主要是有 React Desktoop 这个 React 组件,能够很好的在 Web 中模拟 Mac OSX 的 UI 风格(虽然也就几个表单没什么好模拟的就是了)。

令人开心的是 skpm 默认的 webpack 配置已经增加了 React 的支持,所以我们不需要额外的增加 webpack 的配置,只需要把 React 相关的依赖安装好就可以进行开发了。

npm install react react-dom react-desktop --save-dev

增加 webview.js 入口文件。由于该文件需要走 webpack 编译,但是又不是插件命令的执行文件,所以我们需要像上文说的,将入口文件加入到 package.json 的 skpm.resources 配置中。

// package.json
{
  "skpm": {
    "resources": [
      "resources/webview.js"
    ]
  }
}

// resources/webview.js
import React from 'react';
import ReactDOM from 'react-dom';

function App() {
  return (<>
    <p>Hello World!</p>
    <hr />
    via: <em>@lizheming</em>
  </>)
}

ReactDOM.render(<App />, document.getElementById('app'));

webview.html 也需要改造一下,引入 JS 入口文件。这里需要注意一下 ../resource_webview.js 这个引用文件地址,这是 JS 入口文件编译后最终的文件地址。主要是因为 HTML 文件最终会生成到 <name>.sketchplugin/Resources/_webpack_resources 目录下,而 JS 入口文件会将 / 分隔符替换成 _ 分隔符,生成在 <name>.sketchplugin/Resources 目录下。

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <title>PlaceIMG</title>
  </head>
  <body>
    <div id="app"></div>
    <script src="../resources_webview.js"></script>
  </body>
</html>

注:

  1. HTML 文件生成到 _webpack_resources 配置
  2. JS 入口文件生成到 Resource 目录配置

面板开发

流程打通了之后接下来我们可以专心进行面板的开发了。面板开发这块就不多描述了,无非就是前段页面的编写而已,最后插件面板大概是长这样子的。

-_-||嗯,其实我就是想和大家讲下流程硬上 React 的…

选择完毕点击插入后,调用 postMessage() 方法将最终的配置传递给插件。

//resources/webview.js
import React, {useReducer} from 'react';

function App() {
  const [{width, height, category, filter}, dispatch] = useReducer(
    (state, {type, ...payload}) => ({...state, ...payload}),
    {width: undefind, height: undefined, category: 'any', filter: 'none'}
  );
  const onInsert = _ => postMessage('insert', width, height, category, filter);
  return (
    <button onClick={onInsert}>插入</button>
  );
}

注: Web 原生的 postMessage() 方法的语法为 postMessage(message, targetOrigin, [transfer])。事件名称和事件参数都应该序列化之后通过 message 参数传入。

Sketch 插件中的 postMessage() 方法是注入方法,它对原生的方法进行了复写,所以参数格式上会与原生的不一样。注入方法的实现可参见 sketch-module-web-view 代码。

在插件中,我们监听 insert 事件,获取到用户选择的配置之后给生成图片图层插入到画板中。

//src/my-command.js
import sketch, { Image, Rectangle } from 'sketch/dom';
import BrowserWindow from 'sketch-module-web-view';

export default function() {
  const browserWindow = new BrowserWindow({...});
  browserWindow.webContents.on('insert', function(width, height, category, filter) {
    const url = 'https://placeimg.com/' + [width, height, category, filter].join('/');
    new Image({
      image:  NSURL.URLWithString(url),
      parent: getSelectedArtboard(),
      frame: new Rectangle(0, 0, width, height),
    });
    return browserWindow.close();
  });
}

插件发布

最终我们的插件的主体功能就开发完毕了。下面我们就可以进行插件的发布了。我们可以直接使用 skpm publish 进行发布,它需要你通过 skpm publish --repo-url 或者是 package.json 中的 repository 字段为插件指定 Github 仓库地址。

在 Personal Access Token 页面为 skpm 申请新的 Token,记得勾选上 repo 操作的权限。使用 skpm login <token> 进行登录之后,skpm 就获得了操作项目的权限。

最后通过 skpm publish <version> 就可以成功发布了。如前文所说,发布后会在项目目录创建 .appcast.xml 文件,同时会发布一条对应版本的 Release 记录,提供插件的 zip 包下载地址。执行完 publish 操作后,如果发现你的插件还没有在插件中心仓库中列出来,还会询问你是否提交个 PR 把自己的插件增加上。

当然如果你的插件不方便发布到 Github 上,也可以使用前文所说的手工发布,执行 skpm build 后对生成的 <name>.sketchplugin 目录进行打包即可。

插件调试

上文的示例插件比较简单,所以没有使用特别多的调试手段。在官方教程《Debug a plugin》中描述了多种可以进行调试的方式。用的比较多的还是日志调试方式,可以使用系统的 Console.app 查看日志,也可以使用 skpm log -f 插件日志。

文档里说的大部分是插件的调试,WebView 内的前端代码调试会更简单一点。WebView 窗体右键审查元素即可使用 Safari 的开发者工具进行调试了。

注: 插件本身的代码本质是客户端代码,WebView 本质是前端代码,所以两者的调试和日志输出位置都是有区别的,这里要注意区分。

后记

以上就是开发 Sketch 的一些基础知识和简单流程,其它的就是多去看一下 Sketch API 文档了。不过在实际的使用中 Sketch 的这套 JavaScript API 并不是非常完美,部分功能可能还暂时需要使用原生 API 区别。这时候可以多 Google 一下,能找到很多前人的实现,节省自己的工作量。

本文主要是介绍了一套 JavaScript API + WebView 的偏前端的开发方式,代码我都已经放到 Github 上 https://github.com/lizheming/sketch-placeimg,大家可以自行查阅和下载。除了这种方式之外,我们也可以使用 OC + WebView 甚至是纯 OC 客户端的方式去开发插件。使用纯客户端开发的话性能会比 JavaScript API 的形式好一点,但是对于不了解 OC 开发的前端同学来说上手难度还是比较高的。

除了 Sketch 之外,Figma 也是一款非常棒的 UI 设计软件。它基于 Web 开发,天生跨平台,更提供了更加易用的协作模式,解决 UI 开发中的多人协作问题。感兴趣的同学也可以去了解一下。

参考资料:

  1. 《Sketch插件开发总结》

阅读全文 »