站内搜索
“插件” 找到 68 条结果
Firekylin:AI 友好的博客平台
用 AI 重新折腾 Firekylin
如何制作 Figma 插件
Figma 是一款专业的在线 UI 设计工具,它因为个人使用免费、在线跨平台、多人协作、蓬勃的 Figma Community 社区组织而广受欢迎。目前设计团队都在使用 Figma 进行 UI 设计交付。
能被 SaaS 化的终将被 SaaS 化
Figma 本身是 Web 服务,其客户端也是使用 Electron 进行的封装。所以它的插件系统是前端友好型,和日常前端开发没有什么太大的区别。
插件原理
Figma 的插件也采用了双线程的架构。UI 线程能获得完整 Web 的能力,但是无法直接操作 Figma;主线程则相反,可以通过 Figma API 对数据进行操作,但无完整的 Web 能力,仅有 JS 执行以及 Figma API 支持。两者通过 postMessage 进行通信。
根据官方文章描述,通过 WebAssembly 版的 QuickJS 来实现主线程的沙箱执行,UI 线程则是通过 iframe 执行。
采用这种方案的原因主要是既想保证代码隔离,但是又希望能方便的操作 Figma 数据。
根据官博文章描述,之前也曾有尝试使用 Web 原生的沙盒 API Realms 来实现主线程的沙盒执行,但因为该 API 的一些安全漏洞还是回退回了 QuickJS 的实现。
了解了插件的原理之后,下面我就以帮助设计师同学快速插入占位图的插件 Placeholder 为例,带大家一步一步的了解如何进行 Figma 插件开发。
需求整理
在进行插件开发之前,我们捋一捋我们需要实现的功能。http://placeimg.com/ 是一个专门用来生成占位图的网站,我们将利用该网站提供的服务制作一个生成指定大小的占位图并插入到 Sketch 画板中的功能。插件会提供一个面板,可以让使用者输入尺寸、分类等可选项,同时提供插入按钮,点击后会在画板插入一张图片图层。
项目结构
![]() |
![]() |
![]() |
|---|
在 Figma 客户端中按照如上操作即可完成插件的初始化。除了默认的三个例子之外,官方也有一个示例插件的仓库,也可以参考。
https://github.com/figma/plugin-samples
Figma 插件默认推荐使用 TypeScript 开发,官方提供了完善的 TypeScript 类型支持。以默认的带 UI 的模板为例,初始化后进入文件夹 npm i 安装依赖后执行 npm run build 编译完成后点击插件即可看到效果。
.
├── README.md
├── code.js
├── code.ts
├── manifest.json
├── package-lock.json
├── package.json
├── tsconfig.json
└── ui.html
manifest.json
可以看到整体的接口和大多数 JS 项目一样,其中 manifest.json 用来记录插件的信息。manifest.json 这个文件大家可以理解为是 Figma 插件的 package.json 文件。我们来看看默认生成的 manifest.json。
{
"name": "figma-placeimg",
"id": "1117699210834344763",
"api": "1.0.0",
"main": "code.js",
"editorType": [
"figma"
],
"ui": "ui.html"
}
其中重点的是 main 和 ui 两个字段:
- main:指定插件的入口文件,该文件中的代码会运行在主进程中的沙箱里。
- ui: 指定插件的 UI 代码文件,该文件中的代码会运行在 iframe 中。实际上,UI 代码文件的内容会作为字符串传递给 figma 内置变量
__html__,在沙箱内可以通过figma.showUI(__html__)创建 iframe。
这里注意到是将UI代码文件中的内容作为字符串注入到主线程中,类似
<iframe srcdoc="__html__" />。这就导致了我们无法直接引用插件中的其他资源,所有插件内依赖的资源都需要内嵌到最终的字符串中。
ui 字段也支持指定多个文件,当指定多个文件的时候会注入 __uiFiles__ 对象来映射文件。
manifest.json 中还支持通过 menu 字段定义插件的菜单。如果不想写 UI 也可以通过parameters指定支持的指令,直接通过输入指令来操作也是可以的。更多的配置可以查看官方文档 Plugin Manifest。

插件开发
一些基本原理了解清楚之后我们就可以进行插件的开发了。首先我们需要用户点击插件菜单之后打开一个面板,该面板可以配置尺寸、分类等基础信息。
<link rel="stylesheet" href="https://unpkg.com/figma-plugin-ds@1.0.1/dist/figma-plugin-ds.css">
<style>
.content { display: flex; }
.icon--swap { animation: rotate 1s linear infinite; }
.hide { display: none; }
@keyframes rotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
<div id="app">
<div class="field">
<label for="" class="label">请输入图片尺寸:</label>
<div class="content" style="padding-left: 10px;">
<div class="input">
<input type="input" class="input__field" placeholder="宽" name="width">
</div>
<div class="label" style="flex:0;">×</div>
<div class="input">
<input type="input" class="input__field" placeholder="高" name="height">
</div>
</div>
</div>
<div class="field">
<label for="" class="label">请选择图片分类:</label>
<div class="content">
<div class="radio">
<input id="radioButton1" type="radio" class="radio__button" value="any" name="category" checked>
<label for="radioButton1" class="radio__label">全部</label>
</div>
<div class="radio">
<input id="radioButton2" type="radio" class="radio__button" value="animals" name="category" >
<label for="radioButton2" class="radio__label">动物</label>
</div>
<div class="radio">
<input id="radioButton3" type="radio" class="radio__button" value="arch" name="category" >
<label for="radioButton3" class="radio__label">建筑</label>
</div>
<div class="radio">
<input id="radioButton4" type="radio" class="radio__button" value="nature" name="category" >
<label for="radioButton4" class="radio__label">自然</label>
</div>
<div class="radio">
<input id="radioButton5" type="radio" class="radio__button" value="people" name="category" >
<label for="radioButton5" class="radio__label">人物</label>
</div>
<div class="radio">
<input id="radioButton6" type="radio" class="radio__button" value="tech" name="category" >
<label for="radioButton6" class="radio__label">科技</label>
</div>
</div>
</div>
<div class="field">
<label for="" class="label">请选择图片滤镜:</label>
<div class="content">
<div class="radio">
<input id="radioButton7" type="radio" class="radio__button" value="none" name="filter" checked>
<label for="radioButton7" class="radio__label">正常</label>
</div>
<div class="radio">
<input id="radioButton8" type="radio" class="radio__button" value="grayscale" name="filter" >
<label for="radioButton8" class="radio__label">黑白照</label>
</div>
<div class="radio">
<input id="radioButton9" type="radio" class="radio__button" value="sepia" name="filter" >
<label for="radioButton9" class="radio__label">老照片</label>
</div>
</div>
</div>
<div class="field" style="padding:0 10px;">
<div id="create" class="icon-button" style="width: 100%;">
<div class="icon icon--image"></div>
<div class="type type--small type--medium type--inverse">插入</div>
</div>
<div class="icon-button loading hide" style="width: 100%;">
<div class="icon icon--swap"></div>
</div>
</div>
</div>

官方文档里有推荐 https://github.com/thomas-lowry/figma-plugin-ds 这个仓库,提供 Figma 的 UI 库组件,会让你的插件显的更加“原生”。
由于之前说过所有的资源都需要内嵌到 html 中,所以我使用了 CDN 地址的形式引入了样式文件。另外由于功能比较简单,这里也没有使用 React 等框架去进行开发。官方模板中有 React 模板可以参考 https://github.com/figma/plugin-samples/tree/master/webpack-react
获取图片
UI 完成之后接下来我们需要实现功能。我们需要将图片下载下来插入到 Figma 图层中。由于主线程没有网络能力,所以这部分工作需要在 UI 线程中完成,再通过 postMessage 传递回主线程中完成后续操作。具体的代码如下:
<script>
async function loadImage(url) {
const resp = await fetch('http://localhost:3000/' + url);
const buffer = await resp.arrayBuffer();
return new Uint8Array(buffer);
}
document.getElementById('create').onclick = async (e) => {
const width = parseInt(document.querySelector('input[name="width"]').value);
const height = parseInt(document.querySelector('input[name="height"]').value);
const category = document.querySelector('input[name="category"]:checked').value;
const filter = document.querySelector('input[name="filter"]:checked').value;
const loading = document.querySelector('.icon-button.loading');
e.target.classList.add('hide');
loading.classList.remove('hide');
const imgBytes = await loadImage(`https://placeimg.com/${width}/${height}/${category}/${filter}`);
parent.postMessage({ pluginMessage: { type: 'insert', bytes: imgBytes, width: width, height: height } }, '*');
loading.classList.add('hide');
e.target.classList.remove('hide');
}
</script>
由于 UI 线程是一个纯 Web 环境,当我们使用 XMLHttpRequest 或者 fetch 发送请求的时候,肯定会碰到跨域的问题。按照文档 https://www.figma.com/plugin-docs/making-network-requests/ 提供的解决办法,我们只能依靠服务端加层代理来解决。
当你的插件没有 UI 面板的时候,如何进行网络请求?按照文档所说,我们是可以设置 figma.ui.show() 的第二个参数,将其设置成 visible: false 的形式创建 iframe 获取数据。
// code.ts
function fetch(url, options) {
const html = `<script>
fetch(${url}, ${JSON.stringify(options)}).then(resp => resp.json()).then(resp => parent.sendMessage({
pluginMessage: { type: 'networkRequest', data: resp }
});
</script>`;
return new Promise(resolve => {
figma.ui.on('message', msg =>
msg.type === 'networkRequest' && resolve(msg.data)
);
figma.ui.show(html, { visible: false });
});
}
插入图片
由于只有主线程才能操作 Figma 数据,所以需要在 UI 线程 postMessage 传递数据到主线程中继续进行操作。
主线程中的步骤就比较简单了,使用 Figma API 创建好矩形并将图片填充即可完成图片的插入。
我们可以通过设置 figma.currentPage.selection 设置选中项,并使用 figma.viewport.scrollAndZoomIntoView 将刚插入的数据滚动到视野中。
figma.ui.onmessage = msg => {
if (msg.type === 'insert') {
const rectNode = figma.createRectangle();
const image = figma.createImage(msg.bytes);
rectNode.name = 'Image';
rectNode.resize(msg.width, msg.height);
rectNode.fills = [{
imageHash: image.hash,
scaleMode: 'FILL',
scalingFactor: 0.5,
type: 'IMAGE'
}];
figma.currentPage.appendChild(rectNode);
figma.currentPage.selection = [rectNode];
figma.viewport.scrollAndZoomIntoView([rectNode]);
}
figma.closePlugin();
};
除了需要显示的调用 figma.ui.show 来展示 UI 之外,在执行完插件后需要显示的调用 figma.closePlugin() 告知 Figma 进行关闭插件操作。
优化插件
上面我们实现了配置宽高然后插入一张图片。但有时候我们会先插入一个矩形占位,之后才会将其替换成图片。所以我们可以优化下操作步骤,当选中到一个矩形的时候,自动获取到它的尺寸,然后点击插入后会直接插入到该矩形中。
// code.ts
function initSelectionState() {
if (figma.currentPage.selection.length === 1 && figma.currentPage.selection[0].type === 'RECTANGLE') {
const rectNode = figma.currentPage.selection[0];
figma.ui.postMessage({ type: 'update', width: rectNode.width, height: rectNode.height });
}
}
figma.on('selectionchange', initSelectionState);
initSelectionState();
通过在主线程中监听 selectionchange 事件,我们能实时获取到当前选中的元素。我们将尺寸信息发送到 UI 线程后让其填充到输入框中称为默认值。
window.onmessage = function(e) {
if (e.data.pluginMessage.type === 'update') {
document.querySelector('input[name="width"]').value = e.data.pluginMessage.width;
document.querySelector('input[name="height"]').value = e.data.pluginMessage.height;
}
}
最后再插入的时候,我们也需要判断如果有选中矩形的话则优先使用选中的矩形,而不是新增矩形。
let rectNode: RectangleNode;
if (figma.currentPage.selection.length === 1 && figma.currentPage.selection[0].type === 'RECTANGLE') {
rectNode = figma.currentPage.selection[0];
} else {
rectNode = figma.createRectangle();
}
// const rectNode = figma.createRectangle();
插件发布
最终我们的插件的主体功能就开发完毕了。下面我们就可以进行插件的发布了。我们可以直接通过插件管理中 Publish 操作进行发布。
![]() |
![]() |
|---|
和 Chrome 插件有点类似,Figma 插件支持发布到社区,也支持发布到组织。支持发布到多个组织中。发布到组织不需要审核,但只有该组织的同学和文件可使用。发布到社区的需要由 Figma 官方审核。
插件调试
由于是 Web 技术向,所以 Figma 的插件调试非常简单。直接 Command + Shift + I 打开控制台即可。
不过比较麻烦的是热更新的支持不太好。之前页面资源需要编译到 html 问价中的方式也不太友好。所以有人就想到了** iframe 套娃**来解决 UI 的更新问题。
简单来说就是通过在 UI 线程中再嵌套一个在线页面,UI 线程作为主线程和新的 iframe 的消息中转。这样相当于将插件在线化,回到了纯 Web 开发模式了,热更新自然就没有什么问题了。
不过这仅能解决 UI 线程的热更新问题,主线程如果有变化还需要重新更新插件解决。基于上面的方案,其实我们能做的更“绝”一点。我们可以将主线程变成一个壳,具体的业务代码由 iframe 下发,通过这种方式来解决主线程的更新问题。
// ui.html
parent.postMessage({ pluginMessage: { type: 'MAIN_CODE', code: 'console.log(figma)' } });
// code.ts
figma.ui.onmessage = (msg) => {
msg.type === 'MAIN_CODE' && eval(msg.code);
}
后记
通过示例讲述了如何开发一个 Figma 插件,包含了获取 Figma 数据信息,操作 Figma 文件等双向操作。基于以上简单操作我们可以完成更多有意义的事情帮助我们更好的开发。比如快速导出多尺寸图片、导出图标自动发布到 npm 等…
以上示例代码已发布到 GitHub 中,欢迎参考。
豆瓣书影音同步 GitHub Action
2023-07-12 更新:《关于豆瓣图片无法直接使用的说明》
简介
doumark-action 是我前段时间造的一个轮子。它是一款 GitHub Action,支持在 GitHub 中同步你的豆瓣书影音数据到本地的文件或者 Notion 中。我利用它,定时同步我的豆瓣观影数据到我的博客仓库中,并利用 Hugo 读取文件数据渲染成页面,观影 是最终的效果。
使用
使用其实很简单,在你的博客仓库中新建 .github/workflows/douban.yml 文件,以观影为例添加如下内容。它实现了每小时自动抓取你的豆瓣观影记录并更新到文件中,如果发现文件有更新则触发 commit 提交。
name: douban
on:
schedule:
- cron: "30 * * * *"
jobs:
douban:
name: Douban mark data sync
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: movie
uses: lizheming/doumark-action@master
with:
id: lizheming
type: movie
format: csv
dir: ./douban
- name: Commit
uses: EndBug/add-and-commit@v8
with:
message: 'chore: update douban data'
add: './douban'
该 workflow 总共分为三步,第一步初始化 Git 仓库;第二步调用 doumark-action 同步豆瓣账号 lizheming 的 movie 类型数据到 ./douban 文件夹下,并保存为 csv 格式文件;最后一步则是当 ./douban 文件夹下有更新则调用插件提交修改。
Notion
如果是要同步到 Notion 中会稍微复杂一点。需要先准备好 Notion Token 并初始化好页面。
- 我们可以在 My Integrations 里创建机器人得到
NOTION_TOKEN。 - 电影 | 阅读 | 音乐 基于这三个模板点击右上角的 Duplicate 按钮渲染复制页面。
- 复制后的页面右上角选择右上角的 Share - Invite 将第一步创建的机器人加入,这样机器人就有权限更新你的页面数据。
# .github/workflows/douban.yml
name: douban
on:
schedule:
- cron: "30 * * * *"
jobs:
douban:
name: Douban mark data sync
runs-on: ubuntu-latest
steps:
- name: movie
uses: lizheming/doumark-action@master
with:
id: lizheming
type: movie
format: notion
dir: xxxx
notion_token: ${{ secrets.notion_token }}
其中 format 需要为 notion,dir 为 Notion 页面 ID,Notion 页面 URL 第一个随机字符即为页面的 ID。
渲染
数据已经有了,剩下的就是我们需要读取该数据源的数据,并渲染出页面。除了数据渲染之外,我还给自己增加了筛选查找的需求,所以我在头部还渲染了一些筛选项。
{{$movies := getCSV "," "douban/movie.csv" }}
{{$scratch := newScratch}}
{{$scratch.Add "genres" slice}}
{{range $idx, $movie := $movies}}
{{if ne $idx 0}}
{{$scratch.Set "genres" (union ($scratch.Get "genres") (split (index $movie 7) ","))}}
{{end}}
{{end}}
<div class="sc-ksluID gFnzgG">
<!--分类筛选-->
<div class="sc-bdnxRM jvCTkj">
<a href="javascript:void 0;" class="sc-gtsrHT kEoOHR" data-search="genres" data-method="contain" data-value="">全部</a>
{{range $genre := $scratch.Get "genres"}}
<a href="javascript:void 0;" class="sc-gtsrHT dvtjjf" data-search="genres" data-method="contain" data-value="{{$genre}}">{{$genre}}</a>
{{end}}
</div>
<!--时间筛选-->
<div class="sc-bdnxRM jvCTkj">
<a href="javascript:void 0;" class="sc-gtsrHT kEoOHR" data-search="year" data-method="equal" data-value="">全部</a>
{{range $year := (seq 2022 -1 2009)}}
<a href="javascript:void 0;" class="sc-gtsrHT dvtjjf" data-search="year" data-method="equal" data-value="{{$year}}">{{$year}}</a>
{{end}}
</div>
<!--评分筛选-->
<div class="sc-bdnxRM jvCTkj">
<a href="javascript:void 0;" class="sc-gtsrHT kEoOHR" data-search="star" data-method="equal" data-value="">全部</a>
<a href="javascript:void 0;" class="sc-gtsrHT dvtjjf" data-search="star" data-method="equal" data-value="5">五星</a>
<a href="javascript:void 0;" class="sc-gtsrHT dvtjjf" data-search="star" data-method="equal" data-value="4">四星</a>
<a href="javascript:void 0;" class="sc-gtsrHT dvtjjf" data-search="star" data-method="equal" data-value="3">三星</a>
<a href="javascript:void 0;" class="sc-gtsrHT dvtjjf" data-search="star" data-method="equal" data-value="2">二星</a>
<a href="javascript:void 0;" class="sc-gtsrHT dvtjjf" data-search="star" data-method="equal" data-value="1">一星</a>
<a href="javascript:void 0;" class="sc-gtsrHT dvtjjf" data-search="star" data-method="equal" data-value="0">零星</a>
</div>
<!--排序规则-->
<div class="sc-bdnxRM jvCTkj sort-by">
<a href="javascript:void 0;" class="sort-by-item active" data-order="time">
观影时间排序
</a>
<a href="javascript:void 0;" class="sort-by-item" data-order="rating">
网友评分排序
</a>
</div>
<!-影片列表-->
<div class="sc-dIsUp fIuTG">
{{range $idx, $movie := $movies}}
<!--排除第一行表头-->
{{if ne $idx 0 }}
<div
class="sc-gKAaRy dfdORB"
data-year="{{index $movie 9}}"
data-star="{{index $movie 8}}"
data-rating="{{index $movie 6}}"
data-genres="{{index $movie 7}}"
>
<a href="{{index $movie 5}}" target="_blank">
<div class="sc-hKFxyN HPRth">
<div class="lazyload-wrapper ">
<img class="lazy" data-src="https://dou.img.lithub.cc/movie/{{ index (findRE `\d+` (index $movie 5)) 0 }}.jpg" referrer-policy="no-referrer" loading="lazy" alt="{{index $movie 1}}" width="150" height="220">
</div>
</div>
<div class="sc-iCoGMd kMthTr">{{index $movie 1}}</div>
<div class="sc-fujyAs eysHZq">
<span class="sc-jSFjdj jcTaHb">
{{range $star := (seq 0 2 8)}}
<svg viewBox="0 0 24 24" width="24" height="24" class="sc-dlnjwi {{if gt (index $movie 6) $star}}lhtmRw{{else}}gaztka{{end}}">
<path fill="none" d="M0 0h24v24H0z"></path>
<path fill="currentColor" d="M12 18.26l-7.053 3.948 1.575-7.928L.587 8.792l8.027-.952L12 .5l3.386 7.34 8.027.952-5.935 5.488 1.575 7.928z"></path>
</svg>
{{end}}
</span>
<span class="sc-pNWdM iibjPt">{{index $movie 6}}</span>
</div>
</a>
</div>
{{end}}
{{end}}
</div>
</div>
整体的布局我使用了 Flex 布局,增加了图片懒加载。
搜索
使用 CSS 的属性选择器,可以非常简单的实现搜索的功能。事先将数据通过属性挂载在 DIV 上,通过 [data-year^=2022][data-genres*=喜剧] 就可以查询到 2022 年看过的喜剧片了!
function search(e) {
// 隐藏全部电影
document.querySelectorAll('.dfdORB').forEach(item => item.classList.add('hide'));
// 移除当前筛选项之前的选项
document.querySelector(`.dvtjjf.active[data-search="${e.target.dataset.search}"]`)?.classList.remove('active');
// 如果选择的是非全部选项,则高亮该选项
if(e.target.dataset.value) {
e.target.classList.add('active');
}
// 找到所有筛选项的值
const searchItems = document.querySelectorAll('.dvtjjf.active');
// 根据筛选值拼接 CSS 选择器,JSON 数据类型的需要使用 *=,其它的需要使用 ^=
const attributes = Array.from(searchItems, searchItem => {
const property = `data-${searchItem.dataset.search}`;
const logic = searchItem.dataset.method === 'contain' ? '*' : '^';
const value = searchItem.dataset.method === 'contain' ? `${searchItem.dataset.value}` : searchItem.dataset.value;
return `[${property}${logic}='${value}']`;
});
const selector = `.dfdORB${attributes.join('')}`;
// 找到目标元素对其进行展现操作
document.querySelectorAll(selector).forEach(item => item.classList.remove('hide'));
}
window.addEventListener('click', function(e) {
if(e.target.classList.contains('sc-gtsrHT')) {
e.preventDefault();
search(e);
}
});
排序
由于我使用了 Flex 布局,所以排序这个实行实际上是可以通过 Flex 的 order 属性来实现的。这样做的好处就是我不需要真的去修改 DOM 结构,只需要生成或者删除 CSS 就好了。
function sort(e) {
const sortBy = e.target.dataset.order;
const style = document.createElement('style');
style.classList.add('sort-order-style');
document.querySelector('style.sort-order-style')?.remove();
document.querySelector('.sort-by-item.active')?.classList.remove('active');
e.target.classList.add('active');
if(sortBy === 'rating') {
const movies = Array.from(document.querySelectorAll('.dfdORB'));
movies.sort((movieA, movieB) => {
const ratingA = parseFloat(movieA.dataset.rating) || 0;
const ratingB = parseFloat(movieB.dataset.rating) || 0;
if(ratingA === ratingB) {
return 0;
}
return ratingA > ratingB ? -1 : 1;
});
const stylesheet = movies.map((movie, idx) => `.dfdORB[data-rating="${movie.dataset.rating}"] { order: ${idx}; }`).join('\r\n');
style.innerHTML = stylesheet;
document.body.appendChild(style);
}
}
window.addEventListener('click', function(e) {
if(e.target.classList.contains('sort-by-item')) {
e.preventDefault();
sort(e);
}
});
起因
很早以前我就养成了看完电影就要上豆瓣上标记一下的习惯,并在每年年末的时候统计一下。为了满足自己的需求,很早之前我写过一款 Chrome 插件,用于统计豆瓣电影记录,具体可以看这篇文章《豆瓣电影统计插件For Chrome》。
在后来无意间知道了牧风老师开发的布克牧为,用户同步豆瓣记录数据并支持在第三方网站中挂件展示。所以我为我的博客增加了观影页面,用来展示我看过的电影。后来,每当我和朋友聊电影,想要推荐之前看过的电影给他们的时候,它也成为了重要的查找入口。
布克牧为的第三方挂件样式很好看,但筛选功能偏弱,仅支持分类的筛选。对于我有搜索和统计的需求其实没办法很好的满足。再加之最近布克牧为时长不出数据,变的不太稳定,导致我又有了重新造轮子的想法。
自从博客切换成 Hugo 之后,我对 SSG(Server Side Generate) 就非常的痴迷,连评论都是使用 SSG 的方式渲染到页面上的,具体可以查看我之前写的这篇文章《静态博客如何高性能插入评论》。于是关于这次的功能理所当然我也想使用类似的方式。
所以最开始我是写了个独立的服务,该服务会定时抓取数据并更新到数据库中,同时提供了 API 用于获取数据。在博客中则去调用该接口获取到数据后渲染页面。后来因为需要找一个第三方定义任务服务,用于定时触发抓取任务接口。更新数据后还需要调用博客的构建触发器,同时又觉得每次构建的时候都需要花时间去请求一次接口有点浪费,就一直在思考有没有其它的方式。
其实 Hugo 除了支持 JSON 接口的数据读取之外,也支持本地 CSV 文件的数据读取。直接读取从库中的表格文件获取到数据能减少不必要的网络请求,而表格文件更新的时候会自动触发 Git 操作从何触发博客的构建任务。所以最终就想到了 GitHub Action 的方案,通过免费的 GitHub Action 触发 CSV 文件的更新,最终触发构建更新。
于是乎「doumark-action」这个项目就诞生了!
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 增加了微信分享自定义相关功能;文章页目录底部增加了个人公众号的展示。由于这些内容都比较简单且定制化内容程度比较高,就不一一展示了,感兴趣的朋友可以自行查看源码查阅。
2020 岁末总结
不知不觉,2020年都要过去了。今年因为疫情的原因,感觉时间过得特别的快,一不留神,一年就这么过去了。而今年发生的很多事情也都围绕着疫情在改变着。
🚑 疫情
每当你想尝试放松的时候,你都会被工作扼住命运的喉咙。今年要说什么对我的影响最大,非疫情莫属了。当我还在家里做着疫情很快会过去的美梦的时候,不知不觉就已经被疫情专题页的工作搞的日夜颠倒了。可能是因为丁香园疫情专题页的高流量,不知道为何我们突然之间也投入了大量的人力去开发疫情专题页了。整整持续了两个多月的高强度工作让我身心俱疲。

看看我当时发的状态,真是太丧了。印象比较深的是有一天搞到凌晨3点才休息,结果8点的时候就被领导电话叫起来说是线上有 Bug 赶紧看看。我… 😓真是棒呢!

截两个图留存下被大 Boss 直接跟项目带来的恐惧,名字就不留了,看头像懂得人应该都懂。
好在三月之后慢慢开始复工,回到公司之后状态慢慢的就恢复过来了。甚至感觉还能再来个疫情呢(大雾! 现在回过头来不得不感慨,之前一天 50 多次上线,每次上线都没走 QA 就上了,我头是有多铁啊!全程面向微信开发,产品微信发需求,设计微信发设计图,开发微信发上线记录。我们都有美好的未来……个鬼啊!
🌎 Drone
看过我之前文章的同学就知道,Drone 是我非常喜欢的一款 CI/CD 的工具。它的可扩展性非常高,适合用来在企业内部进行 CI/CD 服务的接入和部署推广。奈何内部环境限制,Gitlab 版本过低,网段隔离导致推广起来还挺费劲的。
不过两年后环境发生了很大的变化,Gitlab 版本升级上来了,网段隔离的问题也发现了解决方案。顺顺利利的就部署了起来。而且老板也想统一推广 CI/CD 这块,正好顺着这个风推广给大家了。
为了帮助大家能快速的接入 Drone,也开发了很多内部服务相关的插件,包括内部项目、容器上线的,内部 IM 消息通知的插件。而且高兴的是,除了我之外,公司内还有其它团队的同学也有在使用 Drone,写的插件对他们也很有帮助。
自己一直想推的事情总算有一点小小的进展,而且发现还有同好,真是很开心呢。
👩🎨 设计云
设计云是今年团队因为蓝湖收费产出的一款类似于蓝湖的设计交付工具。后续转手到了我这里进行开发维护。它给我最大的收获是打开了 Sketch 插件开发的大门,原来开发一款 Sketch 插件其实没有想象中的那么困难。
当然开始的时候还是非常难的,经常碰到问题需要去请教之前做的小伙伴。不过后来基本就驾轻就熟了。然后反向回馈 Sketch 插件社区一些项目中反馈到的问题。成为了插件核心插件 skpm/skpm 的贡献者,想想都还挺激动的呢。
另外我负责了这个项目包括前端、服务端、客户端上的全部重构,所以这块能总结的东西其实也非常多。我是特别喜欢将自己的知识总结成文字分享给大家的,所以那段时间也一连产出了好些文章,都是从这个项目中反馈出来的经验。
🙏 司徒正美
4月1日,惊闻正美老师过逝的消息,一度还以为是假消息,后来经过正美老师的室友确认。正美老师是在前端圈非常有技术声望的人,他的离世震惊了圈内人士。
我和正美老师的交集在于我在业务上使用了正美老师之前开发的类 React 框架 anu.js。选择这个库的原因有以下几点:
- 满足了我们的 React IE 兼容性要求
- 它比较小巧,代码清晰易懂我们自己维护也不费劲
- 正美老师个人在前端框架这块的技术声望
后续该框架也在我们的项目中扮演越来越重要的角色,我也不遗余力的在推广其他业务的小伙伴有类似兼容性需求的时候使用该框架。而正美老师的突然离世则让该框架成了没爹的孩子。
为了保证业务的可维护性,以及不让正美老师的遗作就这么销声匿迹,我慢慢开始了 anujs 项目的权限申请,主要是 npm 模块的权限申请以及 Github 仓库的权限申请。这些由于账号主人的离世,都只能去邮件和网站管理沟通了。
在经历了一段时间的等待之后,5月1日我成功获得了 anujs 的 npm 模块的发布权限。而 Github 的权限则非常可惜的没有申请下来,最后我们采用了 fork 的方式继续维护。
目前我们项目组有一位对 React 框架有经验的同学在负责维护该框架,主要是一些日常的 Bug 修复。感谢正美老师为我们带来这么好的作品,也愿他在天堂安息。
🌋 垃圾评论
2020年的年末,我不是很开心,因为……我被网暴了。起因是我发布了一篇 《基于 Serverless 的 Valine 可能并没有那么香》 的文章。文章里描述了一款第三方评论工具 Valine 存在的一些安全问题,然后在文末介绍了我为了解决该问题开发的高度兼容 Valine 的评论系统 Waline。
也不知是哪位无聊之人使用我的昵称和邮箱在全网使用 Valine 的博客中套用我的身份给它们发送了大量该文章和 Waline 系统的垃圾评论广告。导致大量的博主到我的博客上投诉甚至谩骂我。
这本是有人利用了 Valine 系统本身的漏洞问题制作的一场恶作剧,本来解释一下大家应该也都能理解就这么过去了。不过其中有一位用户说什么也不相信这不是我本人干的。在我的博客上疯狂的辱骂我。本来他发些垃圾评论我觉得也没什么,大不了定时清理下数据就好了。但是他开始回复我的博客的其它评论。而评论是有回复通知的,这无疑对其它博主造成了困扰。
为了阻止他,我紧急增加了发送频率限制、关键词过滤、IP黑名单等常见的反垃圾评论操规则。当他发现我有 IP 黑名单之后,还会尝试换一些 IP 来操作。好在经过几次的 IP 黑名单完善之后,慢慢的发的也就少很多了。不过后续更过分的事情又来了,他又顺着我的友链列表,去到我的友链博客下面使用我的信息伪造我的发言。真是可笑的事情啊,屠龙者终成恶龙!
💻 后记
其实今年发生的事情非常多,组织也发生了很大的变化,但有些事情真的无法用言语表达出来,就让它默默的存在我心里吧。新的一年 Flag 就不立了,希望在新的一年里能够在技术上有更好的突破,折腾一些更有意思的东西吧。
Typecho 评论导出至 Valine 插件
前几天我为了降低成本把博客迁移到了 Hugo,具体可见《博客迁移至 Hugo》。文章我基于前人的脚本自制了 Export2Hugo 插件进行导出,但是发现评论居然没有相关的同学折腾过。大部分都是使用了 ByeTyp 和 TypExport 插件将数据导出成 WordPress ,然后再通过 Disqus 桥接到 Valine,非常的麻烦。
Valine 使用的 LeanCloud 存储服务是支持文件导入的,于是我写了个 Node.js 脚本将 Typecho 数据导出成了支持导入的文件格式完成了导入。基于该脚本的逻辑我将其转成了 Typecho 插件 Export2Valine 方便大家迁移。
插件使用非常简单,启用插件后在左上角 控制台 菜单中会新增 评论导出 选项。点击后直接点击导出 LeanCloud JSON 文件按钮稍等片刻就可以拿到导出的 JSON 文件了。

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

这里需要注意两点:
-
LeanCloud 应用中如果已经存在了
Comment表的话需要删除,否则数据不会导入。 -
插件导出的评论对应的文章 URL 格式为
/{slug}.html,为了能让文章和评论数据正确映射,需要将你的文章设置成对应的格式才能正确显示评论。以 Hugo 为例,在config.toml中需要配置:uglyurls = true [permalinks] posts = ":slug"
下载地址:Github
Typecho 迁移至 Hugo 插件
前几天我为了降低成本把博客迁移到了 Hugo,具体可见《博客迁移至 Hugo》。在迁移的过程中找到了 @eallion 的这篇文章《Typecho 迁移到 Hugo》 ,里面使用一个 PHP 脚本将数据导出成 Markdown 文件存储到本地。当时使用了下发现有这么一些不足的地方:
- 因为不是插件,所以需要人工编辑配置数据库信息
- 脚本会把所有的 Markdown 文件生成在 Typecho 根目录下
- 会导出一些附件信息等额外的文件
所以为了让导出更加方便,我参考了 panxiahai/TypExport 该插件将之前的脚本整理成了插件 Export2Hugo。插件会直接将文件按照 Hugo 的格式存储后以 ZIP 压缩文件的形式下载到本地。
使用方法很简单,启用插件后在左上角 控制台 菜单中会新增 导出至Hugo 选项。点击后直接点击开始导出!按钮稍等片刻就可以拿到导出的 ZIP 文件了。

需要注意 Mac 下可能双击无法打开该压缩文件,可以在终端试试使用 unzip 命令进行解压。
下载地址:Github
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 的静态页。如果有门户页的需求的话可以针对这些文件进行对应的修改。
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);
}
参考资料:




