站内搜索
“plugin” 找到 70 条结果
Firekylin:AI 友好的博客平台
如何制作 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 中,欢迎参考。
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
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);
}
参考资料:
如何制作 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>
注:
面板开发
流程打通了之后接下来我们可以专心进行面板的开发了。面板开发这块就不多描述了,无非就是前段页面的编写而已,最后插件面板大概是长这样子的。

-_-||嗯,其实我就是想和大家讲下流程硬上 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 开发中的多人协作问题。感兴趣的同学也可以去了解一下。
参考资料:
使用 React 为 Chimee 开发插件
Chimee 是由奇舞团开源的一套可扩展的H5组件化播放器框架。由于前段时间业务有视频播放的需求所以使用了它,并基于它提供的插件系统之上开发了一系列的插件,其中最复杂的是控制条插件。由于默认的样式无(实)法(在)满(是)足(太)设(难)计(看)需(了)求(!),所以我们重新开发了一套 lizheing/chimee-plugin-controlbar 并总结一些心得,希望对大家有帮助。
Chimee 插件规范
开篇之前我们先简单的了解下如何开发一款 Chimee 的插件,文档 提供了一个非常简单的示例告诉我们大概的流程。
const plugin = {
// 插件名为 controller
name: 'controller',
// 插件实体为按钮
el: '<button>play</button>',
data: {
text: 'play'
},
methods: {
changeVideoStatus () {
this[this.text]();
},
changeButtonText (text) {
this.text = text;
this.$dom.innerText = this.text;
}
},
// 在插件创建的阶段,我们为插件绑定事件。
create () {
this.$dom.addEventListener('click', this.changeVideoStatus);
},
// 插件会在播放暂停操作发生后改变自己的文案及相应的行为
events: {
pause () {
this.changeButtonText('play');
},
play () {
this.changeButtonText('pause');
}
}
};
// 安装插件
Chimee.install(plugin);
const player = new Chimee({
// 播放地址
src: 'http://cdn.toxicjohann.com/lostStar.mp4',
// dom容器
wrapper: '#wrapper',
// 使用插件
plugin: ['controller'],
});
通过以上示例代码我们可以发现 Chimee 的插件的规则主要有以下几点:
- 插件需要暴露一个对象,并带有
name属性用来标记插件名称,方便后续实例化的时候指定。 - HTML 模板相关的内容需要放在
el属性中,在其它方法中可以通过this.$dom访问到 DOM 对象。 data属性用来存放数据,methods属性可以用来存放方法。两个属性中的数据可以直接通过this.xx的形式访问到。events()属性可以放置一些播放器的事件回调,Chimee 会自动做事件的绑定。
从以上发现的几点来看,我们可以大致估摸出插件的简单逻辑。无非就是将 data 和 methods 中的数据绑定到 this 上,然后插入 el 的 HTML 到页面中,然后执行一些对应的生命周期方法。
听起来似乎比较 Vue?虽然写法上是这样的,但是明眼人能看出来其实这个流程还是 DOM 操作,所以必然会碰上原生 DOM 开发的两个问题:
data虽然能存放数据,但是数据无法直接映射到el的 HTML 模板中,导致还是需要回归原本的 DOM 操作。- 事件绑定需要等待 DOM 生成后在生命周期中进行手动的绑定,又回到了 DOM 操作时一堆
addEventListener的年代了。
由于我们的业务是使用 React 开发的,所以就想是否可以通过引入 React 来解决上述 DOM 操作的问题。
React 开发
使用 React 开发虽然能解决上段提到的两个问题,但是插件本身的配置以及生命周期却是不太好在 React 中实现。要想实现插件即组件,组件即插件的场景需要对现有的一些生命周期进行映射,这成本就有点高了。所以最终退而求其次,只有 DOM 这块使用 React,外层依旧是正常的 Chimee 插件的配置。
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import Context from './Context';
export default {
Context,
name: 'plugin-demo',
el: `<div class="plugin"></div>`,
create() {
ReactDOM.render(
<Context.Provider value={this}>
<App />
</Context.Provider>,
this.$dom
);
}
};
Context 解决访问插件方法问题
而为了解决 React 组件内部需要获取 Chimee 的方法的问题,我们通过 Context 的方式将 this 这个 Chimee 插件实例传入,内部通过 Context 获取到实例之后就可以为所欲为了。
import React, { useContext, useState, useEffect } from 'react';
import Context from '../Context';
export default function () {
const ctx = useContext(Context);
const [cur, setCur] = useState(0);
useEffect(() => {
ctx.$on('timeupdate', () => setCur(ctx.currentTime));
}, []);
return (
<div className="play--time">{cur}</div>
)
}
不得不感谢 React Hooks 的出现,让 Context 的 API 变的如此的简单。如果要像之前一样写 <Context.Consumer /> 然后再来个 render props 估计也是够烦的。当然使用 Context 只是为了方便在组件层级比较深的情况下数据的透传,这也是 Context 的本来意义。如果插件的组件层级不深直接使用 Props 传递也是可以的。
事件监听解决外部数据触发更新问题
目前这套插件的模式已经能够满足大部分日常插件的开发了,不过碰到了异步就有点问题了。我们知道的是插件的配置在 new Chimee() 那一刻就已经固定下来了,外部的数据的更新并不会影响内部的配置,也就无法造成插件的更新渲染,毕竟目前 Chimee 没有一个类似 componentWillReceiveProps() 的生命周期。如果 Chimee 自己本身配置都无法更新上,就更遑论触发 React 的更新了。
+--------+ +--------+ +---------------+ +-----------------+
| config +---->+ Chimee +---->+ Chimee Plugin +---->+ React Component |
+--------+ +----+---+ +---------------+ +-----------------+
×
+----------------+ |
| updated config +--×
+----------------+
回到具体的业务场景上,我们有一个需求是控制条需要显示下一条视频的标题和封面图,而这个数据是在业务代码中通过接口获取得到的,所以在最开始实例化播放器的时候我们是没办法传入该数据。最后我们决定用事件监听的方式解决该问题,通过自定义事件进行跨组件层级的数据传递。
+--------+ +--------+ +---------------+ +-----------------+
| config +---->+ Chimee +---->+ Chimee Plugin +---->+ React Component |
+--------+ +--------+ +---------------+ +--------+--------+
^
+----------------+this.$emit('customEvent')+-----------------+--------+
| updated config +------------------------>+ this.$on('customeEvent') |
+----------------+ +--------------------------+
多插件先后问题
随着业务越来越复杂,播放器在同一事件要处理的事情越来越多,例如我们碰到的片尾需要先播放片尾广告,广告播放完之后显示倒计时,倒计时后再触发播放下一条的动作。如何在同一个事件中保证三者的顺序成了本次需求的关键。
好在当初 Chimee 在设计之初就已经考虑到了这个问题。先执行的回调只要返回 false 即可阻止整个事件,当该插件完成任务后只要重新触发事件即可。
import React, {useEffect} from 'react';
import Context from './context';
export default function() {
const ctx = useContext(Context);
useEffect(() => {
const onEnded = event => {
if(event === 'manual') {
return true;
}
setTimeout(() => ctx.$emit('ended', 'manual'), 10000);
return false;
};
ctx.$on('ended', onEnded);
return () => ctx.$off('ended', onEnded);
});
return (<div>推迟10秒后结束</div>);
}
在 ended 事件触发后使用 return false 阻止事件,然后等事件执行完毕之后使用 ctx.$emit('ended', 'manual') 重新触发事件。这里有个需要注意的地方,后者触发的事件中又会再次将当前插件注册的回调事件执行一遍,所以这里增加了第二个参数用来让回调方法判断是否是插件自己触发的,如果是自己触发的则表示无须再次执行直接跳过。
后记
Chimee 的插件系统可扩展性比较强,如果对其比较感兴趣的可以看看官方的这篇《为什么要将 Chimee 设计成一个组件化框架?》。使用 React 开发插件解决了事件和数据绑定的问题,该开发模式下基本上就是废弃了官方自己提供的 data, methods 和 events 三个属性,前两者自然是由 React 内部的 state 和方法提供,后者则是自行在组件内部使用 ctx.$on 的形式手动监听。
我的第一款Drone插件
Drone 是一款基于Docker的CI工具,不仅运行环境是容器化的,甚至于 CI 流中的每一个插件也是 docker 容器。基于容器的 Drone 插件一大特色就是允许我们使用任何语言去开发插件,Python, PHP, Nodejs, bash... 这些都是可以的。每个开发者都可以使用自己熟悉的语言去开发自己想要的插件。Drone 的官方插件市场罗列了很多插件,并且在文档提供了 Golang 和 Bash 版本的插件开发文档,并开源了一个插件脚手架。
官方插件里有一个 drone-telegram 插件,不过插件目前是不支持自定义 telegrame 请求地址的,对于我这种机器部署在国内需要使用反代的方式访问 telegram 的用户来说不是很方便。搜索到 node-telegram-api 这个模块可以配置,下面我就简单说说基于 Nodejs 写一个 telegram 消息通知的插件。
原理
Docker 容器在启动的时候可以设置一个启动命令,这样我们就可以做到实例化插件容器的时候就执行插件脚本。另外 Drone 运行插件的时候会将本次 CI 构建相关的信息以及插件配置信息以环境变量的形式注入到插件容器实例中。所有的语言都是可以去读取系统环境变量的,这也让其支持所有语言成为了可能。
- 插件配置会以
PLUGIN_xxx的形式注入,例如PLUGIN_TOKEN。 - 构建相关的信息会以
CI_xxx或者DRONE_xxx的形式注入,例如DRONE_COMMIT,CI_COMMIT_AUTHOR_NAME。
编写插件
编写插件很简单,只需要注意需要获取的配置使用 PLUGIN_ 获取即可,例如 telegram 需要使用的 PLUGIN_TOKEN 等。下面是具体的插件代码:
const render = require('drone-render');
const TelegramBot = require('node-telegram-bot-api');
const {
PLUGIN_TOKEN,
PLUGIN_TO,
TELEGRAM_TOKEN,
TELEGRAM_TO,
PLUGIN_LANG,
PLUGIN_MESSAGE,
PLUGIN_BASE_API_URL
} = process.env;
const TOKEN = PLUGIN_TOKEN || TELEGRAM_TOKEN;
const TO = PLUGIN_TO || TELEGRAM_TO;
if(PLUGIN_LANG) {
render.locale(PLUGIN_LANG);
}
const bot = new TelegramBot(TOKEN, {
baseApiUrl: PLUGIN_BASE_API_URL
});
bot.sendMessage(TO, render(PLUGIN_MESSAGE));
可以看到其实代码非常的简单。
Dockerfile
插件写好之后就需要创建 Dockerfile 文件制作镜像了。由于 Dockerfile 的每一个指令都会为容器创建一个layer,所以这里有个小技巧是将 npm install 依赖安装这种不怎么会变化的操作提前,频繁变化的插件脚本的拷贝置后。这样在多次打包发布的时候会占优势,仅仅只会传后面变化的部分,减小传输体积。
FROM mhart/alpine-node:8.9.3
WORKDIR /telegram-node
COPY package.json /telegram-node/package.json
RUN npm install
COPY index.js /telegram-node/index.js
ENTRYPOINT [ "node", "/telegram-node/index.js" ]
最后就是编译并发布我们的 docker 容器以供其它人方便使用:
docker build lizheming/drone-telegram-node .
docker push lizheming/drone-telegram-node
测试
本地使用如下命令启动镜像即可测试插件是否工作
docker run --rm \
-e PLUGIN_TOKEN=xxxxxxx \
-e PLUGIN_TO=xxxxxxx \
-e PLUGIN_MESSAGE=test \
-e PLUGIN_BASE_API_URL=xxxx \
lizheming/drone-telegram-node
问题
secrets 参数获取
经常有些密钥之类的参数不想写在 .drone.yml 配置文件中暴露给所有项目可见的用户,这时候我们就会使用 drone secrets 添加变量,具体可参考我之前写的文章《如何使用Drone》。这里需要注意的是 secrets 定义的变量名即最后 drone 注入进入的环境变量名称,所以不能随意使用。例如你的配置是:
pipline:
telegram:
image: lizheming/drone-telegram-node
secrets: [ telegram_token, telegram_to ]
message: hello
最后你在插件内部就能使用 process.env.TELEGTAM_TOKEN 来获取对应的密钥变量。
插件不生效
在编写插件的时候我碰到的一个问题是我最开始的 Dockerfile 是如下这样的:
FROM mhart/alpine-node:8.9.3
WORKDIR /telegram-node
COPY package.json /telegram-node/package.json
RUN npm install
COPY index.js /telegram-node/index.js
ENTRYPOINT [ "node", "index.js" ]
由于我设置了 WORKDIR 工作目录,正常的 docker run 是可以找到我的入口文件为 ${WORKDIR}/index.js 的。但是 drone 在执行插件的时候由于需要访问项目代码,所以会通过命令覆盖掉镜像的 WORKDIR 配置。导致了我的插件正常运行没问题,但是上到 drone 怎么也执行不了。最后查了才发现是因为复现了工作目录配置后入口文件找不到压根就没执行我的插件的问题。
后记
插件的所有代码已经发布在 Github:https://github.com/lizheming/drone-telegram-node,镜像也已发布到 docker 平台上。本插件相对于官方的插件的优势是能够设置 telegram API 接口的地址,对于国内服务使用反代的方式来说比较适合。如果想要在国内服务器上使用 drone telegram 消息通知的可以使用。不知道如何设置 telegram API 反代的也可以参考我的文章《反代访问Telegram》。
如何使用Drone
在上文《DRONE安装指南》中介绍了如何安装 Drone 服务,下面我将来介绍重头戏——如何在项目中使用 Drone 完成自动化构建任务。首先我们要在 drone 中开启自动构建,实际上它会在对象的 Git 仓库中添加 webhooks 方便当开发者执行对应 git 操作后触发自动构建任务。

.drone.yml
在《基于DOCKER的CI工具—DRONE》一文中我们有说过它的每一个构建插件都是镜像,能够模块化的配置我们的构建任务。将构建任务理解成是一条管道,项目流经管道做各种具体的任务。以下是一个简单的管道流程,代码克隆下来后执行安装并执行测试,完成后使用使用 Telegram 通知用户。
+-------+ +-------+ +------+ +----------+
| Clone | ==> | Build | ==> | Test | ==> | Telegram |
+-------+ +-------+ +------+ +----------+
通过编辑放置在项目的根目录下的 .drone.yml 文件,我们可以对构建流进行配置。下面就是上图构建流程的配置:
pipeline:
build:
image: node:6.6.0
commands:
- npm install --silent
- npm test
telegram:
image: appleboy/drone-telegram
token: 483467761:AAGVOmbOitx0V_1Vx4vlUwy-eoMBVYnSqP0
to: 337635385
message: >
{{#success build.status}}
主人,{{repo.owner}}/{{repo.name}}第{{build.number}}次构建成功!
{{else}}
主人,{{repo.owner}}/{{repo.name}}第{{build.number}}次构建失败了,快来修理下吧。
{{/success}}
在配置文件中我们总共定义了两个管道任务:
- build: 我们为 build 任务指定了
node:6.6.0镜像,并在镜像中执行npm install --silent命令安装依赖。最后执行npm test来运行单元测试脚本。 - telegram: 我们为 telegram 任务指定了
appleboy/brone-telegram镜像,并定义了一些镜像需要的环境参数。包括 telegram 的机器人 token,指定了消息的发送对象 to 以及消息的模板。
将其保存提交后就能正常触发任务并使用 telegram 接收消息了。

telegram
telegram 插件需要设置 bot token,没有的需要先关注 @BotFather 并输入 /netbot 命令创建一个 bot,这样你就能获取到 token 了。
而 to 参数需要配置的是发送用户的 ID,这个比较坑爹了。最开始以为是 username,输入之后一直没有反应,后来才反应过来可能是唯一 ID。不过 telegram ID 的获取比较困难,有人为此特地做了一个机器人。关注 @userinfobot 后和它发个消息它就会告诉你你自己的 ID 啦。这里有个小技巧是把别人的消息转发给它,它会给出对象的 ID。
关于 telegram 插件更多的配置可以参考:http://plugins.drone.io/appleboy/drone-telegram/
Secrets
由于我们将 token 直接写在了 .drone.yml 的配置文件中,项目其它人也都可见,这会带来一些潜在的安全问题。为此我们可以使用 drone 的 secrets 功能。我们小改一下配置文件:
telegram:
image: appleboy/drone-telegram
secrets: [ telegram_token, telegram_to ]
token: ${TELEGRAM_TOKEN}
to: ${TELEGRAM_TO}
同时我们在网站后台添加两个名为 telegram_token 和 telegram_to 的密钥:

保存后重新执行构建任务即可,这样我们就做到了隐藏敏感信息的目的。当然其实这么做也有缺陷,因为在网站后台添加的密钥是对管道中所有的构建插件全局共享的,如果开发者在管道中增加其它插件并 echo 或者其它操作,一样也是能获取到密钥的。为此我们就需要使用 drone-cli 命令来添加密钥,它支持指定密钥的作用域镜像。
$ drone secrets add \
--repository=lizheming/qalarm \
--name=telegrame_to \
--value=337635385 \
--image=appleboy/telegram
参考资料:
基于Docker的CI工具—Drone

我在很多 Github 项目中都会使用 travis-ci 来做自动化构建任务,其中的一项主要内容就是代码提交后自动执行单元测试计算测试覆盖率。但是 travis-ci 对于其它平台(例如 Gitlab)以及公司内网仓库来说都是不支持的,所以萌生了想要找另外一款 CI 工具代替 travis-ci 的想法。对比了几款 CI 产品之后,我最终选择了 Drone。它是一款使用 Go 开发的开源的 CI 自动构建平台,能够单独部署,支持常见的 Git 仓库,例如 Github, Gitlab, Bitbucket 以及 Gogs 等。Drone 主要有以下几个方面吸引我:
原生 Docker 支持
环境部署算是自动化构建比较复杂的一点了,目前最优解就是使用 docker 容器化技术打包我们的环境。原生 docker 的支持让我们不需要额外的在构建脚本中增加 docker 的命令,简单配置就可以为我们带来集成 docker 所引入的一系列的好处:环境隔离、标准化镜像。并且它的每一个插件都是一个镜像,让我们的自动构建环境模块化。
新版的 Drone 将其拆分成了 Server 端和 Agent 端,Server 用来处理任务分发,Agent 用来执行自动化构建任务。简单增加 Agent 容器实例,我们就可以非常方便的横向扩容 Drone。
PipeLine AS Code
在项目根目录有一个 .drone.yml 的文件,这个是 Drone 构建脚本的配置文件,它随项目一块进行版本管理,开发者不需要额外再去维护一个配置脚本。其实现代 CI 程序都是这么做了,这个主要是相对于 Jekins 来说的。虽然 Jekins 也有插件支持,但毕竟还是需要配置。
另外就是基于 yaml 的配置文件非常简单(当然也要视需求而定),例如一个简单的构建脚本如下:
pipeline:
build:
image: node:6.6.0
commands:
- npm install --silent
- npm test
比起 Jekins 纷繁复杂的配置,Drone 几行就搞定了构建的需求。
丰富的插件支持
http://plugins.drone.io/ 这个是 Drone 的插件商店,包含了常见的一些需求插件,例如:
- 构建后发送消息:slack, telegram, line, facebook, discord, gitter, email...
- 构建成功后发布:npm, docker, github release, google container...
- 构建成功后部署:AWS, Kubernetes, rsync, scp, ftp...
pipeline 中简单的增加插件镜像配置就可以支持插件,例如:
pipeline:
pipeline:
build:
image: node:6.6.0
commands:
- npm install --silent
- npm test
publish:
image: plugins/npm
secret: [ npm_username, npm_pwd, npm_email ]
username: ${NPM_USERNAME}
password: ${NPM_PWD}
email: ${NPM_EMAIL}
when:
event: [ tag ]
notify:
image: appleboy/drone-telegram
secret: [ telegram_token, telegram_to ]
token: ${TELEGRAM_TOKEN}
to: ${TELEGRAM_TO}
由于 Drone 是基于 Docker 的,甚至是所有的插件也都是一个 docker 镜像。这代表着我们可以使用任何语言来开发 Drone 插件,最后只要打包成镜像就好了。
其它 CI 工具
Drone 相对于 Jekins 来说优势比较明显,复杂的配置以及需要插件支持的构建脚本 pipeline 化都是我没考虑它的原因。不过 Jekins 其丰富的插件扩展,针对于无项目的自动化构建来说还是非常不错的。Gitlab-CI 也是一款不错的工具,不过从名字上也能看出它的缺点,就是只支持 Gitlab,而且无法扩展配置文件,对于正好在使用 Gitlab 且需求不高的同学可以试试。
总结
基本上 Drone 的流程是我想要的 CI 模式了。不过人无完人,drone 也有一些不足,主要是:
- 不同版本的文档太多,老文档的一些功能都不支持了。
- 社区还不够发展,找个问题比较困难。不过好在有个官方论坛,作者回复倒是挺勤快的。
- 前端界面比较简单,很多功能还是得依靠命令行。
不过这些问题都不影响我对它的看法,相信它会越来越好。
相关资料:




