简介:json-sketchapp 是一份面向 Sketch 插件开发者、交互/UI 设计师与前端工程师的实验性项目压缩包,用于把结构化 JSON 数据快速导入并生成 Sketch 文件,适合研究 Sketch 插件命令、JSON 到图层映射及 skpm 工程组织。压缩包共 9 个文件、约 81KB,以 4 个 JSON 配置与示例数据、1 个 JavaScript 命令脚本、1 个 Markdown 说明文档和 1 张图标资源为主,另含依赖锁文件与忽略配置,体量轻便、结构完整。已有 673 人学习或下载。借助其中的示例 JSON、manifest 与命令源码,读者可了解插件从构建、监听到启动的 npm 脚本流程,参考 Babel 自定义配置,并据此改造 JSON 结构、调试导入逻辑,作为 Sketch 自动化与插件入门的实验样例。
1. 从一份 JSON 到可交付的 Sketch 文件:json-sketchapp 解决的是哪一段脏活
设计稿从设计工具导出成 JSON 不稀奇,难的是把这份 JSON 重新变回可编辑的 Sketch 文件:图层层级、Flex 布局、字体和图片资源,任何一处对不上,交付就会变成手工返工。json-sketchapp 这个资源做的正是这一段脏活,它用 Sketch 插件承载转换逻辑,用 skpm 组织工程和命令入口,用 Yoga 先把布局算成绝对坐标,再用 SketchJavaScript API 创建 Artboard、Shape、Text、Image 并保存为 .sketch。适合正在做设计交付工具链、低代码平台导出、批量生成设计稿的从业者,也适合想拆一个完整 Sketch 插件样例的新手。它不是万能转换器,JSON 字段需要按约定整理,但把“能跑通”和“能交付”之间的那层胶水补上了。
2. JSON 图层树与 Yoga 布局:先把坐标算准,再谈 SketchJavaScript 落图
2.1 为什么中间要加一层“可布局图层树”
JSON 只是描述,Sketch 文档是另一套对象模型。把 JSON 里的节点直接映射成 Shape、Text、Image,看起来省事,实际会把布局问题全部推给创建阶段:父节点还没算宽高,子节点已经按固定坐标落下,后面再想改 Flex 方向、内边距、间距,只能推倒重来。json-sketchapp 这类插件通常会在中间加一层“可布局图层树”:先把原始 JSON 规范化成统一节点,再让 Yoga 按 Flexbox 规则算出每个节点的绝对坐标和尺寸,最后才进入 SketchJavaScript 的创建流程。
这一层的价值在于把“布局”和“落图”解耦。Yoga 是跨平台 Flexbox 布局引擎,和 CSS 的 flex-direction、justify-content、align-items、padding、margin 这些概念接近,前端或低代码平台产出的 JSON 很容易映射过去。Sketch 自己的智能布局更适合人工编辑,插件 API 对复杂嵌套布局的控制并不总是顺手;用 Yoga 先算一遍,再写死 frame,反而更可控。我的习惯是:只要 JSON 里出现 flex 属性,就不在 Sketch 创建阶段做二次布局,所有坐标以 Yoga 的输出为准,Sketch 只负责画。
提示:JSON 里同时出现
frame和flex时,要约定优先级。我一般让frame只用于根节点或绝对定位节点,其余节点全部走 Yoga,避免两套坐标系统打架。
2.2 JSON 字段约定与 Sketch 类映射
资源里的 JSON 不需要长得像 Sketch 文件,但字段要稳定。下面是一份够用的最小结构,根节点是 artboard,子节点用type区分矩形、文本、图片和分组。字段名可以按项目改,但映射关系要提前定死,否则插件入口会堆满 if-else。
{ "type": "artboard", "name": "首页", "width": 375, "height": 812, "style": { "backgroundColor": "#FFFFFF" }, "children": [ { "type": "group", "name": "卡片", "flex": { "direction": "column", "padding": [16, 16, 16, 16], "gap": 12 }, "children": [ { "type": "rect", "name": "背景", "width": "100%", "height": 120, "style": { "fill": "#F5F6F8", "radius": 12, "border": { "color": "#E5E7EB", "width": 1 } } }, { "type": "text", "name": "标题", "text": "JSON 转 Sketch", "style": { "fontSize": 18, "fontFamily": "PingFang SC", "color": "#111827", "lineHeight": 26 } }, { "type": "image", "name": "封面", "src": "./assets/cover.png", "width": 120, "height": 80 } ] } ] }字段映射建议保持一张表,插件里按表分发,后面加类型不至于改乱。type决定创建哪个 Sketch 类,name进图层名,frame或 Yoga 输出决定位置,style只放视觉属性,children只放子节点。图片的src不要直接塞进 Sketch API,先解析成绝对路径;文本的text和样式分开,后面做文本测量时只取需要的那几个字段。
| JSON 字段 | Sketch 对应 | 说明 |
|---|---|---|
type: artboard | Artboard | 画板,根节点通常只有一个 |
type: group | Group | 分组,可继续嵌套 |
type: rect | Shape | 矩形,填充、边框、圆角走样式映射 |
type: text | Text | 文本,字体和行高要单独处理 |
type: image | Image | 图片,src先转绝对路径 |
flex | Yoga 节点属性 | 不直接进 Sketch,只参与布局计算 |
style.fill | style.fills | 颜色字符串转 Sketch 填充 |
style.border | style.borders | 边框宽度和颜色 |
style.radius | 圆角属性 | 按当前 API 的 ShapePath 或圆角字段处理 |
代码后说明:这份 JSON 只表达“结构 + 布局 + 样式”,不表达 Sketch 的内部对象引用。width: "100%"这类百分比不要直接传给 Sketch,先在 Yoga 里换算成数值。图片src用相对路径只是书写方便,插件读取时必须相对 JSON 文件所在目录解析,否则运行目录一变就找不到图。
2.3 用 Yoga 把 flex 属性算成绝对坐标
Yoga 的使用方式不复杂:为每个节点建一个 Yoga Node,把 flex 属性、宽高、内边距、间距设置进去,子节点按顺序插入,最后对根节点调用calculateLayout,再递归读取getComputedLayout()。下面这段代码是插件里常见的布局计算骨架,导入方式按实际安装包的导出调整,核心是节点创建和递归回填。
const Yoga = require('yoga-layout'); function buildYogaNode(node) { const n = Yoga.Node.create(); // 固定宽高优先;百分比和 auto 交给 Yoga 的父级约束 if (typeof node.width === 'number') n.setWidth(node.width); if (typeof node.height === 'number') n.setHeight(node.height); const flex = node.flex || {}; if (flex.direction === 'row') n.setFlexDirection(Yoga.FLEX_DIRECTION_ROW); if (flex.direction === 'column') n.setFlexDirection(Yoga.FLEX_DIRECTION_COLUMN); if (flex.gap) n.setGap(Yoga.GUTTER_ALL, flex.gap); // padding 支持 [上, 右, 下, 左] if (Array.isArray(flex.padding)) { const [top, right, bottom, left] = flex.padding; n.setPadding(Yoga.EDGE_TOP, top); n.setPadding(Yoga.EDGE_RIGHT, right); n.setPadding(Yoga.EDGE_BOTTOM, bottom); n.setPadding(Yoga.EDGE_LEFT, left); } if (flex.justify) n.setJustifyContent(flex.justify); if (flex.align) n.setAlignItems(flex.align); (node.children || []).forEach((child, index) => { const childNode = buildYogaNode(child); n.insertChild(childNode, index); }); node.__yoga = n; return n; } function applyLayout(node, parentX = 0, parentY = 0) { const layout = node.__yoga.getComputedLayout(); node.__layout = { x: parentX + layout.left, y: parentY + layout.top, width: layout.width, height: layout.height }; (node.children || []).forEach((child) => { applyLayout(child, node.__layout.x, node.__layout.y); }); } function layoutTree(root) { const rootNode = buildYogaNode(root); rootNode.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR); applyLayout(root); return root; }逻辑说明:buildYogaNode只负责把 JSON 的 flex 字段翻译成 Yoga 属性,不碰 Sketch API;applyLayout递归把 Yoga 的局部坐标累加成绝对坐标,结果挂在node.__layout上,后续创建图层直接读这个对象。参数上,setGap对应gap,setPadding的四个方向按上右下左传,calculateLayout的宽高传undefined表示由根节点自身尺寸或父级约束决定。根节点最好显式给宽高,否则 Yoga 可能按内容撑开,和设计稿画板尺寸对不上。
注意:Yoga 节点用完要释放,批量转换时尤其明显。可以在递归结束后统一调用
free(),或者每处理完一个根节点就释放整棵树,避免长任务把内存拖高。
2.4 文本测量和图片尺寸的回退
Yoga 能算盒子,但算不准文本。文本宽度依赖字体、字号、字重、行高和系统字体回退,纯用字符数乘系数,短文案看着没问题,长文案就会溢出。我的做法是分两档:精度优先时,先用 Sketch 的 Text 图层试排一次,拿真实 frame 回填 Yoga 的测量函数;性能优先时,用字体度量估算,再在创建 Text 后做一次溢出修正。图片同理,src解析成绝对路径后,用NSImage读尺寸,读不到就回退到 JSON 里给的宽高。
const { Text } = require('sketch/dom'); function measureTextNode(node) { const style = node.style || {}; const probe = new Text({ text: node.text || '', style: { fontSize: style.fontSize || 14, fontFamily: style.fontFamily || 'PingFang SC', lineHeight: style.lineHeight || null } }); // 试排后拿真实尺寸,再删除临时图层 const size = { width: probe.frame.width, height: probe.frame.height }; probe.remove(); return size; } function measureImageNode(node, absolutePath) { const image = NSImage.alloc().initWithContentsOfFile(absolutePath); if (image && image.size()) { return { width: image.size().width, height: image.size().height }; } return { width: node.width || 100, height: node.height || 100 }; }逻辑说明:measureTextNode会创建临时 Text 图层,读取frame后立即删除,适合节点量不大的交付场景;如果一次转换几千个文本节点,试排成本会很高,可以改成按“字体 + 字号 + 文本长度区间”做缓存。measureImageNode只负责读原始像素尺寸,不负责缩放,缩放策略由 JSON 的width、height和resizeMode决定。图片路径解析失败时不要静默跳过,至少把节点名和路径打到日志里,否则交付时才会发现空白图。
3. skpm 工程与插件命令入口:manifest、菜单命令和文件读取怎么串起来
3.1 skpm 初始化和最小目录
skpm 是 Sketch 插件工程的脚手架和构建工具,负责把 JavaScript 打包成 Sketch 能加载的插件包,同时提供 manifest 生成、命令注册和开发时的 watch 构建。开始阶段不用把目录铺得很大,先保证“能装进 Sketch、能在菜单里点、能读到 JSON”这三件事跑通,再拆解析、布局、落图模块。
# 用 skpm 创建工程骨架,名称按自己的项目改 npx skpm create json-sketchapp cd json-sketchapp npm install npm install yoga-layout # 开发时保持构建监听 npm run watch逻辑说明:npx skpm create生成基础目录和package.json,npm install yoga-layout安装布局依赖,npm run watch让源码改动自动构建到插件目录。命令里的包名不要照抄成真实项目名,换成自己的标识即可。初始化后先跑一次空命令,确认 Sketch 菜单里能看到插件,再往里加逻辑,避免一开始就把构建问题和 API 问题混在一起。
package.json里通常会有build、watch、start这类脚本,具体名字以脚手架生成为准。我的习惯是把“构建”和“安装”分开:构建只产出插件包,安装由 skpm 的链接命令处理。这样调试时不会每次改一行代码就触发一次完整安装。
{ "name": "json-sketchapp", "version": "0.0.1", "description": "JSON to Sketch converter plugin", "scripts": { "build": "skpm-build", "watch": "skpm-build --watch", "start": "skpm-build --watch --run" }, "devDependencies": { "skpm": "*" }, "dependencies": { "yoga-layout": "*" } }逻辑说明:build做一次性构建,watch监听源码变化,start在监听的同时尝试运行插件。依赖里yoga-layout放在dependencies,因为它会进最终包;skpm放在devDependencies,只参与构建。版本号不要写死成网上抄来的数字,按当前环境实际安装结果为准。
3.2 manifest 里注册命令和菜单
Sketch 插件靠 manifest 找入口。一个命令至少要有name、identifier、script和handler,菜单项再通过menu指到命令 identifier。identifier 要稳定,后面加命令时不要复用;handler 名要和脚本里导出的函数一致,否则菜单能点但执行会报找不到处理器。
{ "name": "json-sketchapp", "identifier": "json-sketchapp.convert", "version": "0.0.1", "description": "将 JSON 描述转换为 Sketch 图层", "author": "某开发者", "commands": [ { "name": "从 JSON 生成", "identifier": "json-sketchapp.convert.command", "script": "./command.js", "handler": "onRun" } ], "menu": { "title": "json-sketchapp", "items": ["json-sketchapp.convert.command"] } }逻辑说明:commands[].script指向构建后的入口文件,handler是导出函数名;menu.items里放的是命令 identifier,不是显示名。identifier建议带项目前缀,避免和别的插件撞车。manifest 里的author用虚构代称或团队占位,不要写真实个人信息。改完 manifest 后重新构建,Sketch 菜单才会刷新。
3.3 从路径读取 JSON 的适配层
插件读文件最容易翻车的地方,是把“当前工作目录”当成 JSON 所在目录。Sketch 插件运行时的目录并不等于你双击 JSON 的目录,相对路径会随环境变化。稳妥做法是让用户选文件或输入路径,拿到路径后用 Foundation 桥接读取字符串,再JSON.parse;如果 JSON 里还有图片相对路径,统一以 JSON 文件所在目录为基准解析。
import sketch from 'sketch'; function readJsonFile(filePath) { // 按当前 Sketch 运行时暴露的 Foundation 桥接读取 UTF-8 文本 const content = NSString.stringWithContentsOfFile_encoding_error( filePath, NSUTF8StringEncoding, null ); if (!content) { throw new Error(`读取失败:${filePath}`); } return JSON.parse(content.toString()); } export default function onRun() { const result = sketch.UI.getInputFromUser('输入 JSON 文件绝对路径', { initialValue: '', description: '例如 /Users/xxx/design.json' }); if (!result || !result.response) return; const filePath = result.response.trim(); const json = readJsonFile(filePath); sketch.UI.message(`已读取:${json.name || '未命名'}`); return json; }逻辑说明:getInputFromUser只负责拿路径,不负责校验文件是否存在;stringWithContentsOfFile_encoding_error返回 Objective-C 字符串,转成 JS 字符串后再解析。NSUTF8StringEncoding和NSString是运行时桥接对象,不同 Sketch 版本暴露方式可能有差异,实际工程里建议把这层包成readTextFile适配函数,入口只调用适配层。路径里的空格和中文不要手工拼接,原样传给读取函数。
提示:正式交付时更稳的做法是用文件选择面板拿路径,而不是让用户手输。文件选择面板的 API 在不同版本里差异较大,封装一层
pickJsonFile(),入口只依赖这个函数,后面换实现不影响主流程。
3.4 构建、调试和命令入口的拆分
命令入口最忌讳写成大杂烩:读文件、解析 JSON、算布局、建图层、保存全塞在onRun里,报错时只能一行行加日志。我的拆法是入口只做四件事:拿路径、读 JSON、调用convert(json, options)、提示结果。转换函数再按“规范化、布局、落图、保存”四段拆开,每段都能单独跑测试。
import sketch from 'sketch'; import { convertJsonToSketch } from './converter'; export default function onRun() { try { const result = sketch.UI.getInputFromUser('输入 JSON 文件绝对路径', { initialValue: '', description: '路径会作为图片相对路径的基准目录' }); if (!result || !result.response) return; const jsonPath = result.response.trim(); const outputPath = jsonPath.replace(/\.json$/i, '.sketch'); convertJsonToSketch({ jsonPath, outputPath, templatePath: null, // 没有模板时走当前文档或新建文档 onProgress: (message) => console.log('[json-sketchapp]', message) }); sketch.UI.message(`转换完成:${outputPath}`); } catch (error) { console.error(error); sketch.UI.message(`转换失败:${error.message}`); } }逻辑说明:convertJsonToSketch接收路径和输出路径,内部自己读 JSON、解析图片目录、算布局、建图层、保存。templatePath用于指定带字体样式和页面设置的模板文档,没有模板时走默认文档流程。onProgress只是日志钩子,批量转换时可以替换成进度面板。入口层不碰 Sketch 图层 API,出错时能快速判断是读文件问题还是落图问题。
调试阶段先用console.log看 Sketch 插件日志,再配合sketch.UI.message给用户看简短结果。不要在循环里每条节点都弹一次 message,Sketch 会卡;批量日志写到 console,最后汇总一条。构建失败时先看 skpm 的终端输出,再看 Sketch 的插件日志,两边信息对不上时,优先怀疑构建产物没有更新。
4. 写进 Sketch 文档:Artboard、Shape、Text、Image 的创建顺序与保存
4.1 文档、页面和 Artboard 的初始化
转换开始前要先决定“往哪里写”。直接改当前打开的文档风险很高,用户可能正在编辑别的稿子;更稳的做法是打开一份模板文档,清空页面后写入,再另存为新的.sketch。模板里可以预置页面尺寸、颜色变量、字体样式,插件只负责填图层。没有模板时,退而求其次用当前文档新建页面,但交付前要提醒用户别覆盖原稿。
const { Document, Artboard } = require('sketch/dom'); function prepareDocument(templatePath) { const doc = templatePath ? Document.open(templatePath) : require('sketch').getSelectedDocument(); const page = doc.pages[0]; page.layers = []; // 清空模板页,保留页面设置 return { doc, page }; } function createRootArtboard(page, root) { return new Artboard({ name: root.name || 'JSON 导出', parent: page, frame: { x: 0, y: 0, width: root.__layout.width, height: root.__layout.height }, style: { fills: root.style && root.style.backgroundColor ? [{ color: root.style.backgroundColor, fillType: 'Color' }] : [] } }); }逻辑说明:Document.open打开模板,page.layers = []清空图层但保留页面本身;new Artboard的frame用 Yoga 算出的根节点尺寸,不要再用 JSON 里的原始宽高,否则根节点有 padding 时会偏。style.fills只处理纯色背景,渐变和图案要么在模板里预置,要么单独扩展样式映射。
4.2 Shape 图层与样式映射
矩形是最高频的图层。创建时先给parent,再给frame,最后挂style。顺序反了容易出现图层跑到页面根下、坐标按页面算的情况。填充、边框、阴影分别对应fills、borders、shadows,颜色字符串直接传给 Sketch 的样式字段通常能识别,但透明度和渐变要按对象结构写。
const { Shape } = require('sketch/dom'); function createRect(node, parent) { const layout = node.__layout; const style = node.style || {}; const shape = new Shape({ name: node.name || 'rect', parent, frame: { x: layout.x, y: layout.y, width: layout.width, height: layout.height }, style: { fills: style.fill ? [{ color: style.fill, fillType: 'Color' }] : [], borders: style.border ? [{ color: style.border.color, thickness: style.border.width || 1, position: 'Inside' }] : [] } }); // 圆角按当前 API 暴露的字段处理;没有统一字段时走 ShapePath 分支 if (style.radius && shape.style) { shape.style.cornerRadius = style.radius; } return shape; }逻辑说明:frame用的是绝对坐标,但 Sketch 的parent如果是 Group,子图层坐标会相对父级,所以layout.x/y要减去父级偏移,不能直接塞绝对坐标。fillType: 'Color'表示纯色填充,边框的position常用Inside、Center、Outside。圆角字段在不同 API 层表现不一致,代码里留了分支注释,实际工程按当前 Sketch 版本调通一个分支即可,不要同时写两套赋值。
| JSON 样式 | Sketch 字段 | 备注 |
|---|---|---|
fill | style.fills[].color | 纯色优先 |
border.color | style.borders[].color | 和thickness配对 |
border.width | style.borders[].thickness | 默认 1 |
radius | 圆角字段 | 矩形和 ShapePath 处理方式不同 |
shadow | style.shadows[] | 偏移、模糊、颜色分开写 |
4.3 Text 和 Image 的创建
文本创建的关键不是new Text,而是字体和行高。Sketch 里字体缺失会触发回退,回退后的字宽和设计稿不一致,前面 Yoga 算得再准也会溢出。我的做法是维护一份字体白名单,JSON 里出现白名单外的字体就回退到系统默认字体,并在日志里记一笔。图片则要先解析绝对路径,再读NSImage,路径不存在时创建一个占位矩形,别让整个转换中断。
const { Text, Image } = require('sketch/dom'); function createText(node, parent) { const layout = node.__layout; const style = node.style || {}; return new Text({ name: node.name || 'text', parent, frame: { x: layout.x, y: layout.y, width: layout.width, height: layout.height }, text: node.text || '', style: { fontSize: style.fontSize || 14, fontFamily: style.fontFamily || 'PingFang SC', textColor: style.color || '#111827', lineHeight: style.lineHeight || null, alignment: style.alignment || 'left' } }); } function createImage(node, parent, absolutePath) { const layout = node.__layout; const image = NSImage.alloc().initWithContentsOfFile(absolutePath); if (!image) { return createRect({ name: `${node.name || 'image'}(缺失)`, __layout: layout, style: { fill: '#F3F4F6', border: { color: '#D1D5DB', width: 1 } } }, parent); } return new Image({ name: node.name || 'image', parent, frame: { x: layout.x, y: layout.y, width: layout.width, height: layout.height }, image }); }逻辑说明:Text的frame高度最好用文本测量后的真实高度,不要直接拿 Yoga 的估算值,否则多行文本会被截断。lineHeight传null表示用字体默认行高。Image的image参数接收NSImage,路径解析失败时回退到占位矩形,保证图层树完整,交付时一眼能看出缺图位置。
4.4 保存与导出:别把“预览”当交付
转换完成后必须落盘成.sketch文件,而不是只改当前文档。保存接口在不同 Sketch 版本里有差异,工程里最好包一层saveAs,优先用另存接口,没有就退回保存当前文档,并提示用户手动另存。输出路径从 JSON 路径推导,后缀换成.sketch,不要覆盖原 JSON。
function saveDocument(doc, outputPath) { // 不同版本保存接口命名不同,按封装层逐级回退 if (typeof doc.saveAs === 'function') { return doc.saveAs(outputPath); } if (typeof doc.save === 'function') { doc.save(); return outputPath; } throw new Error('当前运行时不支持保存接口,请手动另存'); } function convertJsonToSketch({ jsonPath, outputPath, templatePath }) { const json = readJsonFile(jsonPath); const root = layoutTree(normalizeTree(json, jsonPath)); const { doc, page } = prepareDocument(templatePath); const artboard = createRootArtboard(page, root); renderChildren(root.children || [], artboard, jsonPath); saveDocument(doc, outputPath); }逻辑说明:saveAs返回输出路径,save只保存当前文档,调用后仍要告诉用户实际文件位置。normalizeTree负责把图片相对路径转绝对路径、补默认样式、把百分比宽高转成 Yoga 可计算的数值。renderChildren递归创建图层,父级传 Artboard 或 Group。交付前重新打开生成的.sketch,检查字体、图片、图层顺序三件事,别只看插件里的成功提示。
5. 避坑与排查:JSON 转 Sketch 最常见的五类翻车现场
5.1 现象:Yoga 在插件里报 wasm 或 native 依赖加载失败
现象很直接:Sketch 菜单能点,命令一执行就报Cannot find module、wasm file not found或者直接静默退出,日志里只有一句加载失败。原因通常不是 Yoga 本身,而是 skpm 的打包链路没有把 wasm 资源或原生依赖带进插件包,开发目录能跑,装进 Sketch 就缺文件。另一个常见原因是yoga-layout的导入方式按 Node 环境写,Sketch 运行时找不到对应的导出。
解决思路分两层:先确认构建产物里有没有 wasm 文件,没有就在 skpm 的 webpack 配置里把 wasm 作为 asset 复制到输出目录;再确认代码里的导入路径和当前包导出一致,不行就换成纯 JS 或 asm 版本。批量转换场景还有更稳的办法:把 Yoga 布局计算前置到 Node 脚本,插件只读算好的layout字段,彻底绕开运行时依赖。
// webpack.skpm.config.js 里补充 wasm 资源复制,路径按实际包调整 module.exports = function (config) { config.module.rules.push({ test: /\.wasm$/, type: 'asset/resource', generator: { filename: 'assets/[name][ext]' } }); return config; };逻辑说明:这段配置只解决“wasm 没进包”的问题,不解决导入路径问题。配置改完要删掉旧构建产物再重新构建,避免缓存里的旧包继续被 Sketch 加载。如果项目里 Yoga 只用于一次性布局,优先考虑前置计算,插件运行时越轻,交付越稳。
5.2 现象:文本溢出或高度被截断,设计稿和生成稿对不上
现象是短文案正常,长文案在 Sketch 里溢出文本框,或者多行文本只显示第一行。原因一般是 Yoga 用字符数估算文本宽高,Sketch 用真实字体排版,两边口径不一致;字体回退、字重缺失、行高为null都会放大误差。还有一种情况是文本节点在 Yoga 里被设了固定高度,创建 Text 后没有按真实内容回填。
解决时先把文本测量做成独立函数:能试排就试排,不能试排就按字体度量估算,并在创建 Text 后用真实frame.height反写。多行文本尽量给固定宽度、让高度自适应,不要同时锁死宽高。字体白名单要提前定,JSON 里出现白名单外字体时统一回退,别让每个节点自己决定。
function fitTextHeight(textLayer, minHeight) { const measured = textLayer.frame.height; if (measured > minHeight) { textLayer.frame.height = measured; } return textLayer.frame.height; }逻辑说明:fitTextHeight只放大不缩小,避免把设计稿里刻意留白的文本压扁。minHeight传 Yoga 算出的高度,作为下限。批量转换时这条逻辑要放在文本创建之后、父级布局回填之前,否则父级 Group 的高度还是旧值。
5.3 现象:图片空白或路径找不到,日志里却只有“转换完成”
现象是生成的 Sketch 文件里图片位置是空的,或者只剩一个占位框。原因多半是相对路径按了插件运行目录解析,而不是 JSON 文件所在目录;也有可能是图片文件名大小写不一致,在大小写敏感的环境里读不到。最隐蔽的一种是 JSON 里写的是 URL,插件却按本地文件路径读取。
解决时统一在normalizeTree阶段把图片路径转成绝对路径:本地相对路径用path.resolve(path.dirname(jsonPath), src),URL 要么下载到临时目录,要么明确标记为不支持并创建占位图。读取失败不要静默跳过,把节点名、原始src、解析后的路径一起打到日志。交付前用脚本扫一遍 JSON 里所有图片路径是否存在,比在 Sketch 里逐张找快得多。
const path = require('path'); function resolveImagePath(jsonPath, src) { if (/^https?:\/\//i.test(src)) { throw new Error(`暂不支持的远程图片:${src}`); } return path.resolve(path.dirname(jsonPath), src); }逻辑说明:path.dirname(jsonPath)是基准目录,path.resolve会处理../和./。远程图片不要在主流程里同步下载,容易卡住插件;要么前置到构建脚本,要么明确报错让调用方处理。
5.4 现象:Group 内子图层坐标翻倍,整体偏移
现象是单独看每个图层尺寸都对,一放进 Group 就整体偏移,偏移量刚好等于 Group 的 x/y,嵌套越深偏得越多。原因是 Yoga 输出的是绝对坐标,而 Sketch 的 Group 子图层坐标是相对父级的,创建时没有减去父级偏移。另一种情况是先创建子图层再创建 Group,Sketch 自动重排了层级,坐标基准跟着变。
解决时在递归创建图层时传父级偏移,子图层frame.x = layout.x - parentAbsoluteX,frame.y = layout.y - parentAbsoluteY。创建顺序保持“先父后子”,Group 先建好再往里塞子节点。如果已经生成了偏移的稿子,不要手工挪,改代码重新跑一遍,手工挪会掩盖坐标基准问题。
function renderNode(node, parent, parentAbsX = 0, parentAbsY = 0) { const layout = node.__layout; const frame = { x: layout.x - parentAbsX, y: layout.y - parentAbsY, width: layout.width, height: layout.height }; const layer = createLayerByType(node, parent, frame); (node.children || []).forEach((child) => { renderNode(child, layer, layout.x, layout.y); }); }逻辑说明:parentAbsX/Y是父级在画板里的绝对坐标,frame是相对父级的坐标。Artboard 作为根父级时偏移为 0。createLayerByType按node.type分发,Group 和 Artboard 都要把自身绝对坐标继续往下传。
5.5 现象:交付后字体缺失、图片丢失,重新打开才暴露
现象是转换完成当时看着正常,文件发给别人重新打开,字体变成默认字体,图片变成低分辨率缩略图或直接丢失。原因是字体只存在本机,图片只引用了外部路径没有嵌入;Sketch 文件本身是包结构,外部资源不会自动跟着走。还有一种情况是颜色配置不同,同一色值在不同显示器上观感差异被误判成样式错误。
解决时把“交付验收”做成固定动作:字体走白名单并在模板里预置文本样式,图片创建后确认是嵌入还是链接,交付前用另一台没装设计字体的机器打开抽检。批量转换先跑一个包含中文、英文、数字、表情符号的最小样例,确认字体回退策略,再跑全量。
| 检查项 | 做法 | 失败表现 |
|---|---|---|
| 字体 | 白名单 + 模板文本样式 | 字宽变化、行数变化 |
| 图片 | 绝对路径 + 嵌入检查 | 空白框、低清缩略图 |
| 图层顺序 | 先父后子递归 | 遮挡关系错误 |
| 保存 | 另存为独立.sketch | 覆盖模板或原稿 |
代码后说明:这张表不是流程装饰,是每次交付前要逐项打勾的清单。字体和图片两项最容易被“本机正常”骗过去,换一台机器打开就能暴露。批量任务里至少抽三份文件做交叉打开,别只抽第一份。
6. 批量转换与验收:用一个最小样例把回归成本压下来
批量转换最怕一上来就跑全量。我的做法是先准备一个最小 JSON:一个 Artboard、一个 Group、一个矩形、一段中英文混排文本、一张本地图片。这个样例覆盖 Yoga 布局、字体回退、图片路径解析、保存四段主流程,任何一段改坏了,跑它就能暴露。最小样例通过后,再用脚本把业务 JSON 排成队列,插件逐个读、逐个输出,日志里记录输入路径、输出路径、图层数量、缺失图片数量。
验收不要只看“转换完成”。我一般用 Python 脚本先做静态检查:递归统计 JSON 里的图层类型、图片路径是否存在、文本节点有没有空字体;再用 Sketch 自带的命令行导出工具把生成的.sketch导成预览图,抽三张和原设计稿对比。静态检查能挡住大部分路径和字段问题,预览图能挡住布局和字体问题。两步都过了,才把批量结果交给下游。
import json import os import sys def walk(node, base_dir, stats): stats[node.get("type", "unknown")] = stats.get(node.get("type", "unknown"), 0) + 1 if node.get("type") == "image": src = node.get("src", "") absolute = os.path.abspath(os.path.join(base_dir, src)) if not os.path.exists(absolute): stats["missing_images"].append(absolute) for child in node.get("children", []): walk(child, base_dir, stats) def check(json_path): with open(json_path, "r", encoding="utf-8") as f: root = json.load(f) stats = {"missing_images": []} walk(root, os.path.dirname(os.path.abspath(json_path)), stats) return stats if __name__ == "__main__": result = check(sys.argv[1]) print(json.dumps(result, ensure_ascii=False, indent=2))逻辑说明:walk递归统计节点类型,图片节点按 JSON 所在目录解析绝对路径并检查存在性;missing_images收集缺失文件,方便批量跑完后一次性补图。这个脚本不依赖 Sketch,放在 CI 或本地预检都行。参数只接收一个 JSON 路径,批量时用 shell 循环套一层即可。检查通过不代表布局一定对,但能挡住“路径写错、字段拼错”这类低级翻车。
从那以后我每次改转换逻辑,都先跑最小样例,再跑十份带图片和长文本的业务 JSON,最后才开全量。这个习惯救过好几次:有一次只是改了文本测量函数,最小样例里中英文混排立刻
本文还有配套的精品资源,点击获取