本文主要介绍如何利用开源的 gulp 插件或 node.js 模块从 Sketch 文件输出资源,希望能让工程师与设计师更好地协作。现在的程序开发资源不像以往只是简单的图片,很多 Web 前端 UI 框架使用 icon font,设计师虽然可以使用 iconfont.cn,icomoon.io 这类网站管理和输出资源,但效率不高。

文中会尽量囊括目前各种应用场景的资源,包括多分辨率 PNG,iOS 和 macOS 平台的 PDF,Web 的图标字体、SVG Sprite 或 SVG symbol,Android 平台的 Vector Drawable 等等。读者需要了解基础的命令行操作,以及基础的 gulp 和 node.js 编程,由于使用 Sketch 自带的命令行工具 sketchtool,所有依赖此工具的都必须在安装有 Sketch 的 macOS 上运行。

项目源码

文章中的所有代码及 Sketch 文档都保存在 Sketch Export Master 项目中,为了文章的篇幅简洁部分非主要代码会省略,可以到项目的 GitHub 上查看完整代码。

Sketch 文件规范

在现实中需要使用快速导出资源并转换多种格式操作的,大部分都是处理一些数量较多的系列图标或者插图等,文章中会以图标作为示例。为了方便程序准确的读取 Sketch 文件的信息,需要让设计师在设计图标时遵循一些规范,根据当前 Sketch 的功能,我使用如下的规范。Sketch 文件可以在 Sketch Export Master 项目中找到。

每一个图标都是一个 Symbol Master,这样做 Sketch 文件可以被当作库来使用。可以先创建 Artboard 再将其转为 Symbol,就可以在原位置创建 Symbol 而不会产生一个 Symbol 实例,如果已经使用组来分类每个图标,可以使用 Automate 插件内 “Symbol - Selection to Symbol Masters” 功能直接转为 Symbol Master。

在 Sketch 52.x 中 Symbol 实例可以更改 style overrides,所以为每个图标绑定一个类似 “Colors/Default” 的黑色样式是个不错的做法。当然图标也可以是彩色或者包含透明度的,但你必须清楚了解某些类型资源本身的限制,例如目前图标字体只会是单色。

尽量将图标的图层都合并到一个形状图层上,特别是需要输出代码形式的资源,这样可以得到更少的代码。如果需要导出 Android 平台的 Vector Drawable,建议把形状图层填充选项设置为 Non-Zero,在 Sketch 52.x 中此设置包含在样式中,所以默认样式要包含此设置。另外一些特殊功能蒙板、投影样式等,某些代码类型的资源可能不支持,需要仔细了解各自的限制。

资源的命名上,为了避免多余整理命名操作,不建议使用 Sketch 的分组符号 “/”,而是用小写英文和分隔符(“-” 或 “_”)的组合。示例中使用 Sketch 组件命名使用 “_” 分隔符。

图标尺寸使用图标原始的尺寸,例如统一使用 24x24px。

安装 gulp 和配置项目

先升级或安装 node.js 到最近的版本,安装 gulp 命令行工具,这里使用 gulp-cli 2.0.1 和 gulp 4.0.0。

sudo npm install --g gulp-cli

你可以使用下面命令,从 Sketch Export Master 项目上克隆代码,这样会得到一个完整功能的 gulpfile.js,这时可以忽略下文的安装 gulp 插件和 node.js 模块。

git clone https://github.com/Ashung/sketch-export-master.git
cd sketch-export-master
npm install

如果你不需要所有功能,也可以初始化一个新的项目,并安装最新版的 gulp。

cd icon_project
npm init
npm install --save-dev gulp

这种情况就需要安装输出不同资源所需要 gulp 插件或 node.js 模块。这里使用 del 在生成资源前删除旧的资源,避免因 Sketch 文件图层名修改造成资源冗余。使用 gulp-imagemin 压缩 PNG 和 SVG 资源。 child-process-promise 用来运行命令从 Sketch 文件导出最初的资源。这几个是必须的。

npm install --save-dev del child-process-promise gulp-imagemin

项目的目录结构如下,sketch 文件夹用来保存设计源文件。

icon_project/
    |-- node_modules/
    |-- sketch/
        |-- icons.sketch
    |-- gulpfile.js
    |-- package.json
    |-- package-lock.json

在 gulp 中使用 sketchtool

文中没有使用 gulp-sketch,是因为 sketchtool 附在 Sketch 安装文件内,经常更新而且需要 sketchtool 和 Sketch 的版本相匹配,所以这里直接使用 child-process-promise 运行 sketchtool 命令是比较合适的选择。sketchtool 可以使用命名导出 Sketch 支持的各种格式,详细的用法请参考官方文档

以下是 “gulpfile.js” 公共部分。

const gulp = require('gulp');
const del = require('del');
const exec = require('child-process-promise').exec;

const imagemin = require('gulp-imagemin');

let sketchtool = '/Applications/Sketch.app/Contents/Resources/sketchtool/bin/sketchtool';
let sketchFile = './sketch/icons.sketch';

为了让 gulp --tasks 或其他程序只列出可用的独立任务,采用了以下的编码方式。

单个任务,运行 gulp task1

const gulp = require('gulp');

function task() { ... }
task.displayName = 'task1';
task.description = 'task description';

gulp.task(task);

多个系列任务,运行 gulp task1。很多情况下会使用这种方式,导出一种资源通常需要删除旧数据,导出资源和优化资源等 3 个子任务。

const gulp = require('gulp');

function subtask1() { ... }
subtask1.displayName = 'subtask 1';

function subtask2() { ... }
subtask2.displayName = 'subtask 2';

let task = gulp.series(subtask1, subtask2);
task.description = 'task 1';

gulp.task('task1', task);

这种编码习惯,在运行 gulp --tasks 时可以清晰列出 task 名和描述,及其依赖的子任务。

gulp --tasks

[14:32:08] Tasks for ~/Works/icon_project/gulpfile.js
[14:32:08] └─┬ PNG 1x  Export 1x Optimized PNG
[14:32:08]   └─┬ <series>
[14:32:08]     ├── Clean 1x PNG
[14:32:08]     ├── Export 1x PNG
[14:32:08]     └── Optimize 1x PNG

在运行任务过程中也能清晰看到当前执行的任务。

gulp "PNG 1x"

[14:33:24] Using gulpfile ~/Works/icon_project/gulpfile.js
[14:33:24] Starting 'PNG 1x'...
[14:33:24] Starting 'Clean 1x PNG'...
[14:33:24] Finished 'Clean 1x PNG' after 9.57 ms
[14:33:24] Starting 'Export 1x PNG'...
[14:33:25] Finished 'Export 1x PNG' after 614 ms
[14:33:25] Starting 'Optimize 1x PNG'...
[14:33:26] gulp-imagemin: Minified 83 images (saved 3.05 kB - 12.7%)
[14:33:26] Finished 'Optimize 1x PNG' after 971 ms
[14:33:26] Finished 'PNG 1x' after 1.6 s

资源文件名修改

sketchtool 会在导出放大的资源时自动增加 “@nx” 后缀,其他平台并不需要这种多余后缀,还有 Android 平台需要把不同分辨率保存在不同文件夹内,还有在整个示例中输出的文件名统一使用 “_” 分隔符,文件夹名使用 “-” 分隔符, SVG 或 CSS 代码中的 id 或 class 也使用 “-” 分隔符。在项目中使用 gulp-rename 来重命名文件和更改目录,可以不需要增加一个子任务函数来处理名称,把代码加在某个任务的 pipe 内。gulp-rename 的重命名文件是对文件的复制,所以还需要删除旧的文件。

npm install --save-dev gulp-rename

统一文件名分隔符。

function subtask1() {
    return gulp.src('...')
        .pipe(rename((path, file) => {
            path.basename = path.basename.replace(/(-|\s+)/g, '_'); // 将 - 或空格都替换为 _
            del(file.path);
        })
        .pipe(gulp.dest('...'));
}

删除或替换后缀。

path.basename = path.basename.replace(/@\dx$/, ''); // 删除后缀
path.basename = path.basename.replace(/@(\d)x$/, '_$1x'); // 替换后缀

处理多个 Sketch 文件

文中的示例都只是处理一个 Sketch 文件,如果需要从多个文件导出资源可以参考下面的方式。

function subtask() {
    let dest = './dest/png-1x';
    return gulp.src('./sketch/*.sketch')
        .pipe(vinylPaths(file => {
            return exec(`${sketchtool} export artboards ${file} --formats="png" --scale="1" --output="${dest}" --include-symbols="yes"`);
        }));
}

清理多余文件

示例的 Sketch 文件需要导出的资源都是以 Symbol 形式,但 sketchtool 只有并没有只导出 symbol 功能,而是使用导出 artboard 的 --include-symbol="yes" 参数,也就是如果 Sketch 文件有 art board 是会被导出的,所以有些情况下在导出资源之后,需要删除多余的内容。

命名为 “Library Preview” 的 Artboard 可作为 Library 的预览图,可以将不需要导出的 Artboard 增加某种前缀或者后缀,例如以 “_” 开始。

function subtaskCleanFiles() {
    return del([
        './dest/*/Library Preview*',
        './dest/*/_*'
    ]);
}
subtaskCleanIconFont.displayName = 'Clean files';

清理资源的任务要在导出资源之后,优化或转换资源之前运行。

let taskPNG1x = gulp.series(subtaskCleanPNG1x, subtaskExportPNG1x, subtaskCleanFiles, subtaskOptimizePNG1x);
taskPNG1x.description = 'Export 1x Optimized PNG';

gulp.task('PNG 1x', taskPNG1x);

输出资源

Web: 1x PNG

直接使用 sketchtool 命令导出 PNG,然后使用 gulp-imagemin 的默认选项压缩,如果需要加入其他压缩工具或修改压缩选项请参考官方文档。

// 清理资源
function subtaskCleanPNG1x() {
    return del(['./dest/png-1x']);
}
subtaskCleanPNG1x.displayName = 'Clean 1x PNG';

// 导出资源
function subtaskExportPNG1x() {
    let dest = './dest/png-1x';
    return exec(`${sketchtool} export artboards ${sketchFile} --formats="png" --scale="1" --output="${dest}" --include-symbols="yes"`);
}
subtaskExportPNG1x.displayName = 'Export 1x PNG';

// 压缩资源
function subtaskOptimizePNG1x() {
    return gulp.src('./dest/png-1x/*')
        .pipe(imagemin())
        .pipe(gulp.dest('./dest/png-1x'));
}
subtaskOptimizePNG1x.displayName = 'Optimize 1x PNG';

let taskPNG1x = gulp.series(subtaskCleanPNG1x, subtaskExportPNG1x, subtaskOptimizePNG1x);
taskPNG1x.description = 'Export 1x Optimized PNG';

gulp.task('PNG 1x', taskPNG1x);

导出资源运行 gulp "PNG 1x"

Web: 2x PNG

sketchtool 会在放大 2 倍资源自动增加 “@2x” 后缀,这里使用 gulp-rename 重命名文件。

在 gulpfile.js 引入 gulp-rename。

const rename = require('gulp-rename');

任务代码。

function subtaskCleanPNG2x() {
    return del(['./dest/png-2x']);
}
subtaskCleanPNG2x.displayName = 'Clean 2x PNG';

function subtaskExportPNG2x() {
    let dest = './dest/png-2x';
    return exec(`${sketchtool} export artboards ${sketchFile} --formats="png" --scale="2" --output="${dest}" --include-symbols="yes"`);
}
subtaskExportPNG2x.displayName = 'Export 2x PNG';

function subtaskOptimizePNG2x() {
    return gulp.src('./dest/png-2x/*')
        .pipe(rename((path, file) => {
            path.basename = path.basename.replace(/@2x$/, '');
            del(file.path);
        }))
        .pipe(imagemin())
        .pipe(gulp.dest('./dest/png-2x'));
}
subtaskOptimizePNG2x.displayName = 'Optimize 2x PNG';

let taskPNG2x = gulp.series(subtaskCleanPNG2x, subtaskExportPNG2x, subtaskOptimizePNG2x);
taskPNG2x.description = 'Export 2x Optimized PNG';

gulp.task('PNG 2x', taskPNG2x);

导出资源运行 gulp "PNG 2x"

Web: 1x 和 2x PNG

同时导出 1x 和 2x 的 PNG,使用 gulp-rename 重命名文件。

在 gulpfile.js 引入 gulp-rename。

const rename = require('gulp-rename');

任务代码。

function subtaskCleanPNG() {
    return del(['./dest/png']);
}
subtaskCleanPNG.displayName = 'Clean PNG';

function subtaskExportPNG() {
    let dest = './dest/png';
    return exec(`${sketchtool} export artboards ${sketchFile} --formats="png" --scale="1,2" --output="${dest}" --include-symbols="yes"`);
}
subtaskExportPNG.displayName = 'Export PNG';

function subtaskOptimizePNG() {
    return gulp.src('./dest/png/*')
        .pipe(rename((path, file) => {
            if (/@2x$/.test(path.basename)) {
                path.basename = path.basename.replace(/@2x$/, '_2x');
                del(file.path);
            }
        }))
        .pipe(imagemin())
        .pipe(gulp.dest('./dest/png'));
}
subtaskOptimizePNG.displayName = 'Optimize PNG';

let taskPNG = gulp.series(subtaskCleanPNG, subtaskExportPNG, subtaskOptimizePNG);
taskPNG.description = 'Export Optimized PNG';

gulp.task('PNG', taskPNG);

导出资源运行 gulp "PNG"

iOS: 多分辨率 PNG

sketchtool 可以非常方便导出 Sketch 资源为 iOS 多分辨率 PNG,自带 @2x 和 @3x 后缀。

function subtaskCleanIOSPNG() {
    return del(['./dest/ios-png']);
}
subtaskCleanIOSPNG.displayName = 'Clean iOS PNG';

function subtaskExportIOSPNG() {
    let dest = './dest/ios-png/';
    return exec(`${sketchtool} export artboards ${sketchFile} --formats="png" --scale="1,2,3" --output="${dest}" --include-symbols="yes"`);
}
subtaskExportIOSPNG.displayName = 'Export iOS PNG';

function subtaskOptimizeIOSPNG() {
    return gulp.src('./dest/ios-png/*')
        .pipe(imagemin())
        .pipe(gulp.dest('./dest/ios-png/'));
}
subtaskOptimizeIOSPNG.displayName = 'Optimize iOS PNG';

let taskIOSPNG = gulp.series(subtaskCleanIOSPNG, subtaskExportIOSPNG, subtaskOptimizeIOSPNG);
taskIOSPNG.description = 'Export Optimized iOS PNG';

gulp.task('iOS PNG', taskIOSPNG);

导出资源运行 gulp "iOS PNG"

Android: 多分辨率 PNG

同时导出 1x、1.5x、2x、3x、4x 的 PNG,使用 gulp-rename 重命名文件保存到不同的文件夹中,另外按照 Android 资源的命名习惯,在文件名前增加 “ic_” 前缀,并将所有 “-” 替换为 “_”。

在 gulpfile.js 引入 gulp-rename。

const rename = require('gulp-rename');

任务代码。

function subtaskCleanAndroidPNG() {
    return del(['./dest/android-png']);
}
subtaskCleanAndroidPNG.displayName = 'Clean Android PNG';

function subtaskExportAndroidPNG() {
    let dest = './dest/android-png/';
    return exec(`${sketchtool} export artboards ${sketchFile} --formats="png" --scale="1,1.5,2,3,4" --output="${dest}" --include-symbols="yes"`);
}
subtaskExportAndroidPNG.displayName = 'Export Android PNG';

function subtaskOptimizeAndroidPNG() {
    return gulp.src('./dest/android-png/*')
        .pipe(rename((path, file) => {
            if (/@1x$/.test(path.basename)) {
                path.dirname = 'drawable-hdpi';
            }
            else if (/@2x$/.test(path.basename)) {
                path.dirname = 'drawable-xhdpi';
            }
            else if (/@3x$/.test(path.basename)) {
                path.dirname = 'drawable-xxhdpi';
            }
            else if (/@4x$/.test(path.basename)) {
                path.dirname = 'drawable-xxxxhdpi';
            }
            else {
                path.dirname = 'drawable-mdpi';
            }
            path.basename = path.basename.replace(/@\dx$/, '');
            path.basename = path.basename.replace(/-/g, '_');
            path.basename = 'ic_' + path.basename;
            del(file.path);
        }))
        .pipe(imagemin())
        .pipe(gulp.dest('./dest/android-png/'));
}
subtaskOptimizeAndroidPNG.displayName = 'Optimize Android PNG';

let taskAndroidPNG = gulp.series(subtaskCleanAndroidPNG, subtaskExportAndroidPNG, subtaskOptimizeAndroidPNG);
taskAndroidPNG.description = 'Export Optimized Android PNG';

gulp.task('Android PNG', taskAndroidPNG);

导出资源运行 gulp "Android PNG"

iOS: PDF

function subtaskCleanPDF() {
    return del(['./dest/pdf']);
}
subtaskCleanPDF.displayName = 'Clean PDF';

function subtaskExportPDF() {
    let dest = './dest/pdf/';
    return exec(`${sketchtool} export artboards ${sketchFile} --formats="pdf" --output="${dest}" --include-symbols="yes"`);
}
subtaskExportPDF.displayName = 'Export PDF';

let taskPDF = gulp.series(subtaskCleanPDF, subtaskExportPDF);
taskPDF.description = 'Export PDF for iOS and macOS';

gulp.task('PDF', taskPDF);

导出资源运行 gulp "PDF"

SVG

这里压缩 SVG 时保留 2 位小数,保留 viewBox 属性,而删除 width 和 height 属性,这样将 SVG 代码插入到 HTML 上时可以使用 CSS 控制尺寸,更多压缩配置请参考 svgo 文档。

function subtaskCleanSVG() {
    return del(['./dest/svg']);
}
subtaskCleanSVG.displayName = 'Clean SVG';

function subtaskExportSVG() {
    let dest = './dest/svg/';
    return exec(`${sketchtool} export artboards ${sketchFile} --formats="svg" --output="${dest}" --include-symbols="yes"`);
}
subtaskExportSVG.displayName = 'Export SVG';

function subtaskOptimizeSVG() {
    return gulp.src('./dest/svg/*.svg')
        .pipe(imagemin([
            imagemin.svgo({
                plugins: [
                    { cleanupListOfValues: { floatPrecision: 2, leadingZero: false } },
                    { cleanupNumericValues: { floatPrecision: 2, leadingZero: false } },
                    { convertPathData: { floatPrecision: 2, leadingZero: false } },
                    { removeViewBox: false },
                    { removeDimensions: true }
                ]
            })
        ]))
        .pipe(gulp.dest('./dest/svg'));
}
subtaskOptimizeSVG.displayName = 'Optimize SVG';

let taskSVG = gulp.series(subtaskCleanSVG, subtaskExportSVG, subtaskOptimizeSVG);
taskSVG.description = 'Export SVG';

gulp.task('SVG', taskSVG);

导出资源运行 gulp "SVG"

图标检索文档

当图标数量较多时最好有一份 HTML 格式展示所有图标,并且可以搜索的文档。这里使用 gulp-mustache 来从 mustache 模版文件生成 HTML。

安装 gulp-mustache。

npm install --save-dev gulp-mustache

templates/icons.html 的内容(完整代码),使用 Vue.js 实现一个简单的搜索,用 clipboard js 实现复制代码功能。

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ title }} - {{ description }}</title>
<script src="https://cdn.jsdelivr.net/npm/vue"></script>
<script src="https://cdn.jsdelivr.net/npm/clipboard@2/dist/clipboard.min.js"></script>
<style>
....
</style>
</head>
<body>
<div id="app">
    <input class="search" type="text" v-model="search" placeholder="search..."/>
    <div class="container">
        <div v-for="icon in filteredList" class="icon" v-bind:data-clipboard-text="'svg/' + icon.name + '.svg'" v-on:click="copy()">
            <img v-bind:src="'svg/' + icon.name + '.svg'" width="48" height="48" alt="">
            <span class="icon-name">{{=<% %>=}}{{ icon.name }}<%={{ }}=%></span>
        </div>
    </div>
</div>
<script>
    const app = new Vue({
        el: '#app',
        data: {
            search: '',
            icons: [
                {{#icons}}
                { 'name': '{{name}}' }{{^last}},{{/last}}
                {{/icons}}
            ]
        },
        computed: {
            filteredList() {
                return this.icons.filter(icon => {
                    if ((new RegExp(this.search, 'i')).test(icon.name)) {
                        return icon;
                    }
                })
            }
        },
        methods: {
            copy: () => {
                let clipboard = new ClipboardJS('.icon');
                clipboard.on('success', e => {
                    if (document.getElementById('toast')) {
                        let toast = document.getElementById('toast');
                        toast.remove(toast.selectedIndex);
                    }
                    clipboard.destroy();
                    let toast = document.createElement('div');
                    toast.setAttribute('id', 'toast');
                    toast.innerHTML = `"${e.text}" copy!`;
                    document.body.appendChild(toast);
                    setTimeout(() => {
                        toast.remove(toast.selectedIndex);
                    }, 1500);
                });
            }
        }
    });
</script>
</body>
</html>

在 gulpfile.js 引入 gulp-mustache,并增加一些 Mustache 模版上需要的信息,例如 HTML 文档 Title、项目版本号、生成日期等。

const mustache = require("gulp-mustache");

let packageInfo = require('./package.json');
let projectTitle = packageInfo.name.split('-').map(item => {
    return item[0].toUpperCase() + item.substr(1)
}).join(' ');
let projectDescription = packageInfo.description;
let projectVersion = packageInfo.version;
let projectBuildDate = String(new Date().getFullYear()) +
    (new Date().getMonth() > 8 ? new Date().getMonth() + 1 : '0' + (new Date().getMonth() + 1)) +
    (new Date().getDate() > 9 ? new Date().getDate() : '0' + new Date().getDate());

把创建文档的操作作为一个子任务,在优化 SVG 完成之后执行。

function subtaskCleanSVG() { ... }

function subtaskExportSVG() { ... }

function subtaskOptimizeSVG() { ... }

function subtaskCreateIconsHTML() {
    return gulp.src('./templates/icons.html')
        .pipe(mustache({
            title: projectTitle,
            description: projectDescription,
            version: projectVersion,
            date: projectBuildDate,
            icons: fs.readdirSync('./dest/svg/').map(file => {
                return {
                    'name': file.replace(/\.svg$/, '')
                }
            })
        }))
        .pipe(gulp.dest('./dest/'));
}    
subtaskCreateIconsHTML.displayName = 'Create a search HTML for all icons';

let taskSVG = gulp.series(subtaskCleanSVG, subtaskExportSVG, subtaskOptimizeSVG, subtaskCreateIconsHTML);
taskSVG.description = 'Export SVG';

gulp.task('SVG', taskSVG);

Web: Icon Font

安装字体生成模块 gulp-iconfont,此任务依赖 SVG 任务。

npm install --save-dev gulp-iconfont

增加 Icon Font 的检索文档模版 templates/icons.html(完整代码),内容与 SVG 的检索文档模版大致相同,只是在图标下方增加 CSS 类名和字符的 Unicode 编码。

增加 templates/iconfont.css 模版,文中使用原生的 CSS,可以直接改为 SCSS 或 LESS,也可以根据项目上目前使用的 iconfont css 修改模版。

@font-face {
    font-family: "{{ fontName }}";
    font-style: normal;
    font-weight: normal;
    font-display: auto;
    src: url("{{ fontName }}.eot");
    src: url("{{ fontName }}.eot") format("embedded-opentype"),
         url("{{ fontName }}.woff2") format("woff2"),
         url("{{ fontName }}.woff") format("woff"),
         url("{{ fontName }}.ttf") format("truetype"),
         url("{{ fontName }}.svg") format("svg");
}
.ic {
    font-family: "{{ fontName }}";
    display: inline-block;
    font-style: normal;
    font-weight: normal;
    font-variant: normal;
    text-rendering: auto;
    line-height: 1;
    -moz-osx-font-smoothing: grayscale;
    -webkit-font-smoothing: antialiased;
}
.ic-s { font-size: 16px; }
.ic-m { font-size: 20px; }
.ic-1x { font-size: 24px; }
.ic-l { font-size: 32px; }
.ic-2x { font-size: 48px; }
{{#icons}}
.{{className}}:before { content: "\{{code}}"; }
{{/icons}}

在 HTML 上显示 icon font 的方式。

<i class="ic ic-account"></i>
<i class="ic ic-account ic-1x"></i> <!-- 24px -->

在 gulpfile.js 引入 Mustache 和 gulp-iconfont。

const mustacheRender = require("mustache").render;
const iconfont = require('gulp-iconfont');

使用 gulp-iconfont 生成 svg、ttf、eot、woff、woff2 等 5 种字体,并渲染 templates/icons.html 和 templates/iconfont.css 两个模版。

let fontName = 'icon-font';

function subtaskCleanIconFont() {
    return del(['./dest/iconfont']);
}
subtaskCleanIconFont.displayName = 'Clean Icon Font';

function renderMustacheToFile(inputFile, outputFile, data) {
    let templateString = fs.readFileSync(inputFile, 'utf-8');
    let code = mustacheRender(templateString, data);
    fs.writeFileSync(outputFile, code);
}

function subtaskCreateIconFont() {
    let dest = './dest/iconfont';
    return gulp.src('./dest/svg/*.svg')
        .pipe(iconfont({
            fontName: fontName,
            formats: ['svg', 'ttf', 'eot', 'woff', 'woff2'],
            timestamp: Math.round(Date.now() / 1000),
            fontHeight: 1024,
            normalize: true
        }))
        .on('glyphs', glyphs => {
            let icons = glyphs.map(glyph => {
                let character = glyph.unicode[0];
                let codepoint = character.codePointAt(0).toString(16);
                if (codepoint.length < 4) {
                    codepoint = '0'.repeat(4 - codepoint.length) + codepoint;
                }
                return {
                    name: glyph.name,
                    className: 'ic-' + glyph.name.replace(/_/g, '-'),
                    character: character,
                    code: codepoint
                };
            });
            renderMustacheToFile('./templates/iconfont.html', path.join(dest, 'iconfont.html'), {
                title: projectTitle,
                description: projectDescription,
                version: projectVersion,
                date: projectBuildDate,
                icons: icons,
                fontName: fontName
            });
            renderMustacheToFile('./templates/iconfont.css', path.join(dest, 'iconfont.css'), {
                icons: icons,
                fontName: fontName
            });
        })
        .pipe(gulp.dest(dest));
}
subtaskCreateIconFont.displayName = 'Create Icon Font';

let taskIconFont = gulp.series('SVG', subtaskCleanIconFont, subtaskCreateIconFont);
taskIconFont.description = 'Export Icon Font';

gulp.task('Icon Font', taskIconFont);

导出资源运行 gulp "Icon Font"

Android: Vector Drawable

Vector Drawable 需要从 SVG 文件转换,所以此任务依赖 SVG 任务。

安装 vinyl-pathssvg2vectordrawable 模块。

npm install --save-dev svg2vectordrawable vinyl-paths

在 gulpfile.js 引入这两个模块,vinyl-paths 用于在 pipe 中获取 stream 中每个文件的路径,然后使用 svg2vectordrawable 将 SVG 转为 Vector Drawable。

const vinylPaths = require('vinyl-paths');
const svg2vectordrawable = require('svg2vectordrawable/lib/svg-file-to-vectordrawable-file');

任务代码。

function subtaskCleanVectorDrawable() {
    return del(['./dest/android-vector-drawable']);
}
subtaskCleanVectorDrawable.displayName = 'Clean Vector Drawable';

function subtaskCreateVectorDrawable() {
    let dest = './dest/android-vector-drawable';
    return gulp.src('./dest/svg/*.svg')
        .pipe(vinylPaths(file => {
            let outputPath = path.join(dest, 'ic_' + path.basename(file).replace(/\.svg$/, '.xml'));
            return svg2vectordrawable(file, outputPath);
        }));
}
subtaskCreateVectorDrawable.displayName = 'Create Vector Drawable';

let taskVectorDrawable = gulp.series('SVG', subtaskCleanVectorDrawable, subtaskCreateVectorDrawable);
taskSVG.description = 'Export Vector Drawable';

gulp.task('Vector Drawable', taskVectorDrawable);

导出资源运行 gulp "Vector Drawable"

Web: SVG Sprite

文章中使用 gulp-svg-sprite 来生成各种 SVG Sprite,svg-sprite 支持如下 5 种不同形式的 SVG Sprite,这里将每个不同形式的 Sprite 都分开成不同的任务,可以根据具体的项目选择合适的 SVG Sprite。

安装 gulp-svg-sprite。

npm install --save-dev gulp-svg-sprite

引入模块。

const svgSprite = require('gulp-svg-sprite');

加入 svg-sprite 的功能配置和 HTML 模版需要的变量。

let packageInfo = require('./package.json');
let projectTitle = packageInfo.name.split('-').map(item => {
    return item[0].toUpperCase() + item.substr(1)
}).join(' ');
let projectDescription = packageInfo.description;
let projectVersion = packageInfo.version;
let projectBuildDate = String(new Date().getFullYear()) +
    (new Date().getMonth() > 8 ? new Date().getMonth() + 1 : '0' + (new Date().getMonth() + 1)) +
    (new Date().getDate() > 9 ? new Date().getDate() : '0' + new Date().getDate());
let svgSpriteCommonConfig = {
    shape: {
        id: {
            separator: '-',
            whitespace: '-',
            generator: (name, file) => {
                return name.replace(/\.svg$/, '').replace(/(_|-|\s+)/g, '-');
            }
        }
    },
    variables: {
        'title': projectTitle,
        'description': projectDescription,
        'version': projectVersion,
        'buildDate': projectBuildDate,
    },
    mode: {}
};

SVG CSS Sprite

传统的 CSS Sprite 通过 CSS 的 background-position 定位,只是将图片换成 SVG。

在 HTML 上显示图标的方式。

<i class="icon icon-account"></i>

增加检索文档模版 templates/svg_css_sprite.html(完整代码)。

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ title }} - {{ description }}</title>
<script src="https://cdn.jsdelivr.net/npm/vue"></script>
<script src="https://cdn.jsdelivr.net/npm/clipboard@2/dist/clipboard.min.js"></script>
<style>
....
</style>
<link rel="stylesheet" href="sprite.css">
</head>
<body>
<div id="app">
    <div class="search">
        <i class="icon icon-search"></i>
        <input type="text" v-model="search" placeholder="search..."/>
    </div>
    <div class="container">
        <div v-for="icon in filteredList" class="thumb" v-bind:data-clipboard-text="icon.className" v-on:click="copy()">
            <i v-bind:class="'icon icon-2x ' + icon.className"></i>
            <span class="thumb-name">{{=<% %>=}}{{ icon.className }}<%={{ }}=%></span>
        </div>
    </div>
</div>
<script>
    const app = new Vue({
        el: '#app',
        data: {
            search: '',
            icons: [
                {{#shapes}}
                { 'name': '{{name}}', 'className': '{{#selector.shape}}{{#last}}{{#classname}}{{raw}}{{/classname}}{{/last}}{{/selector.shape}}' }{{^last}},{{/last}}
                {{/shapes}}
            ]
        },
        computed: {
            filteredList() {
                return this.icons.filter(icon => {
                    if ((new RegExp(this.search, 'i')).test(icon.name)) {
                        return icon;
                    }
                })
            }
        },
        methods: {
            copy: () => {
                let clipboard = new ClipboardJS('.thumb');
                clipboard.on('success', e => {
                    if (document.getElementById('toast')) {
                        let toast = document.getElementById('toast');
                        toast.remove(toast.selectedIndex);
                    }
                    clipboard.destroy();
                    let toast = document.createElement('div');
                    toast.setAttribute('id', 'toast');
                    toast.innerHTML = `"${e.text}" copy!`;
                    document.body.appendChild(toast);
                    setTimeout(() => {
                        toast.remove(toast.selectedIndex);
                    }, 1500);
                });
            }
        }
    });
</script>
</body>
</html>

增加 templates/svg_css_sprite.css 模版。

{{#hasCommon}}.{{commonName}} {
    display: inline-block;
    width: 24px;
    height: 24px;
    background: url("{{sprite}}") no-repeat;
}{{/hasCommon}}
{{#shapes}}
{{#selector.shape}}{{expression}}{{^last}},{{/last}}{{/selector.shape}} {
    {{#hasCommon}}background-position: {{position.absolute.xy}};{{/hasCommon}}{{^hasCommon}}background: url("{{sprite}}") {{position.absolute.xy}} no-repeat;{{/hasCommon}}
}
{{/shapes}}

任务代码。

function subtaskCleanSVGCSSSprite() {
    return del(['./dest/svg-css-sprite']);
}
subtaskCleanSVGCSSSprite.displayName = 'Clean SVG CSS Sprite';

function subtaskCreateSVGCSSSprite() {
    let config = svgSpriteCommonConfig;
    config.mode.css = {
        dest: 'svg-css-sprite',
        bust: false,
        prefix: '.icon-%s',
        dimensions: '',
        sprite: 'sprite.svg',
        common: 'icon',
        example: {
            template: './templates/svg_css_sprite.html',
            dest: 'index.html'
        },
        render: {
            css: {
                template: './templates/svg_css_sprite.css',
                dest: 'sprite.css'
            }
        }
    };
    return gulp.src('./dest/svg/*.svg')
        .pipe(svgSprite(config))
        .pipe(gulp.dest('./dest/'));
}
subtaskCreateSVGCSSSprite.displayName = 'Create SVG CSS Sprite';

let taskSVGCSSSprite = gulp.series('SVG', subtaskCleanSVGCSSSprite, subtaskCreateSVGCSSSprite);
taskSVGCSSSprite.description = 'Export SVG CSS Sprite';

gulp.task('SVG CSS Sprite', taskSVGCSSSprite);

导出资源运行 gulp "SVG CSS Sprite"

SVG View Sprite

SVG view sprite 的 SVG 文件是在 SVG CSS sprite 的基础上增加 <view id="..." viewBox="..."/> 标签。这样除了和 SVG CSS sprite 一样通过使用 CSS 背景定位还可以直接使用 img 标签插入图标,使用 img 标签插入时可以更改图标尺寸。

<img src="sprite.svg#account" width="48" height="48"/>

增加 templates/svg_view_sprite.html (完整代码)模版,CSS 模版沿用 templates/svg_css_sprite.css。

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ title }} - {{ description }}</title>
<script src="https://cdn.jsdelivr.net/npm/vue"></script>
<script src="https://cdn.jsdelivr.net/npm/clipboard@2/dist/clipboard.min.js"></script>
<style>
...
</style>
<link rel="stylesheet" href="sprite.css">
</head>
<body>
<div id="app">
    <div class="search">
        <i class="icon icon-search"></i>
        <input type="text" v-model="search" placeholder="search..."/>
    </div>
    <div class="container">
        <div v-for="icon in filteredList" class="thumb" >
            <img v-bind:src="'{{example}}#' + icon.name" width="48" height="48"/>
            <span class="copy thumb-name" v-bind:data-clipboard-text="icon.className" v-on:click="copy()">{{=<% %>=}}{{ icon.className }}<%={{ }}=%></span>
            <span class="copy thumb-name" v-bind:data-clipboard-text="'{{example}}#' + icon.className" v-on:click="copy()">{{=<% %>=}}{{ 'sprite.svg#' + icon.className }}<%={{ }}=%></span>
        </div>
    </div>
</div>
<script>
    const app = new Vue({
        el: '#app',
        data: {
            search: '',
            icons: [
                {{#shapes}}
                { 'name': '{{name}}', 'className': '{{#selector.shape}}{{#last}}{{#classname}}{{raw}}{{/classname}}{{/last}}{{/selector.shape}}' }{{^last}},{{/last}}
                {{/shapes}}
            ]
        },
        computed: {
            filteredList() {
                return this.icons.filter(icon => {
                    if ((new RegExp(this.search, 'i')).test(icon.name)) {
                        return icon;
                    }
                })
            }
        },
        methods: {
            copy: () => {
                let clipboard = new ClipboardJS('.copy');
                clipboard.on('success', e => {
                    if (document.getElementById('toast')) {
                        let toast = document.getElementById('toast');
                        toast.remove(toast.selectedIndex);
                    }
                    clipboard.destroy();
                    let toast = document.createElement('div');
                    toast.setAttribute('id', 'toast');
                    toast.innerHTML = `"${e.text}" copy!`;
                    document.body.appendChild(toast);
                    setTimeout(() => {
                        toast.remove(toast.selectedIndex);
                    }, 1500);
                });
            }
        }
    });
</script>
</body>
</html>

任务代码。

function subtaskCleanSVGViewSprite() {
    return del(['./dest/svg-view-sprite']);
}
subtaskCleanSVGViewSprite.displayName = 'Clean SVG View Sprite';

function subtaskCreateSVGViewSprite() {
    let config = svgSpriteCommonConfig;
    config.mode.view = {
        dest: 'svg-view-sprite',
        bust: false,
        prefix: '.icon-%s',
        dimensions: '',
        sprite: 'sprite.svg',
        common: 'icon',
        example: {
            template: './templates/svg_view_sprite.html',
            dest: 'index.html'
        },
        render: {
            css: {
                template: './templates/svg_css_sprite.css',
                dest: 'sprite.css'
            }
        }
    };
    return gulp.src('./dest/svg/*.svg')
        .pipe(svgSprite(config))
        .pipe(gulp.dest('./dest/'));
}
subtaskCreateSVGViewSprite.displayName = 'Create SVG View Sprite';

let taskSVGViewSprite = gulp.series('SVG', subtaskCleanSVGViewSprite, subtaskCreateSVGViewSprite);
taskSVGViewSprite.description = 'Export SVG View Sprite';

gulp.task('SVG View Sprite', taskSVGViewSprite);

导出资源运行 gulp "SVG View Sprite"

SVG Defs Sprite

SVG Defs Sprite 是在 HTML 上预先插入定义了所有图标代码的 SVG,然后通过 SVG 的 use 标签引用图标。

<body>
    <svg width="0" height="0" style="position:absolute">
        <defs>
            <svg viewBox="0 0 24 24" id="account" xmlns="http://www.w3.org/2000/svg">...</svg>
        </defs>
    </svg>

    <div>
        <svg viewBox="0 0 24 24" width="48" height="48">
            <use xlink:href="#account"></use>
        </svg>
    </div>
</body>

增加 templates/svg_defs_sprite.html (完整代码)模版,无需 CSS。

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ title }} - {{ description }}</title>
<script src="https://cdn.jsdelivr.net/npm/vue"></script>
<script src="https://cdn.jsdelivr.net/npm/clipboard@2/dist/clipboard.min.js"></script>
<style>
...
</style>
</head>
<body>
<svg width="0" height="0" style="position:absolute">
    <defs>
        {{#shapes}}
        {{{svg}}}
        {{/shapes}}
    </defs>
</svg>
<div id="app">
    <div class="search">
        <svg class="icon-search" viewBox="0 0 24 24" width="24" height="24">
            <use xlink:href="#search"></use>
        </svg>
        <i class="icon icon-search"></i>
        <input type="text" v-model="search" placeholder="search..."/>
    </div>
    <div class="container">
        <div v-for="icon in filteredList" class="thumb" v-bind:data-clipboard-text="'#'+icon.name" v-on:click="copy()">
            <svg v-bind:viewBox="'0 0 ' + icon.viewportWidth + ' ' + icon.viewportHeight" width="48" height="48">
                <use v-bind="{'xlink:href' : '#' + icon.name}"></use>
            </svg>
            <span class="thumb-name">{{=<% %>=}}{{ icon.name }}<%={{ }}=%></span>
        </div>
    </div>
</div>
<script>
    const app = new Vue({
        el: '#app',
        data: {
            search: '',
            icons: [
                {{#shapes}}
                { 'name': '{{name}}', 'viewportWidth': '{{width.outer}}', 'viewportHeight': '{{height.outer}}'}{{^last}},{{/last}}
                {{/shapes}}
            ]
        },
        computed: {
            filteredList() {
                return this.icons.filter(icon => {
                    if ((new RegExp(this.search, 'i')).test(icon.name)) {
                        return icon;
                    }
                })
            }
        },
        methods: {
            copy: () => {
                let clipboard = new ClipboardJS('.thumb');
                clipboard.on('success', e => {
                    if (document.getElementById('toast')) {
                        let toast = document.getElementById('toast');
                        toast.remove(toast.selectedIndex);
                    }
                    clipboard.destroy();
                    let toast = document.createElement('div');
                    toast.setAttribute('id', 'toast');
                    toast.innerHTML = `"${e.text}" copy!`;
                    document.body.appendChild(toast);
                    setTimeout(() => {
                        toast.remove(toast.selectedIndex);
                    }, 1500);
                });
            }
        }
    });
</script>
</body>
</html>

任务代码。

function subtaskCleanSVGDefsSprite() {
    return del(['./dest/svg-defs-sprite']);
}
subtaskCleanSVGDefsSprite.displayName = 'Clean SVG Defs Sprite';

function subtaskCreateSVGDefsSprite() {
    let config = svgSpriteCommonConfig;
    config.mode.defs = {
        dest: 'svg-defs-sprite',
        bust: false,
        sprite: 'sprite.svg',
        example: {
            template: './templates/svg_defs_sprite.html',
            dest: 'index.html'
        }
    };
    return gulp.src('./dest/svg/*.svg')
        .pipe(svgSprite(config))
        .pipe(gulp.dest('./dest/'));
}
subtaskCreateSVGDefsSprite.displayName = 'Create SVG Defs Sprite';

let taskSVGDefsSprite = gulp.series('SVG', subtaskCleanSVGDefsSprite, subtaskCreateSVGDefsSprite);
taskSVGDefsSprite.description = 'Export SVG Defs Sprite';

gulp.task('SVG Defs Sprite', taskSVGDefsSprite);

导出资源运行 gulp "SVG Defs Sprite"

SVG Symbol

SVG Symbol 与 SVG Defs Sprite 使用方法类似,只是保存所有图标的 SVG 的代码略有不同。

<body>
    <svg width="0" height="0" style="position:absolute">
        <symbol viewBox="0 0 24 24" id="account" xmlns="http://www.w3.org/2000/svg">...</symbol>
    </svg>

    <div>
        <svg viewBox="0 0 24 24" width="48" height="48">
            <use xlink:href="#account"></use>
        </svg>
    </div>
</body>

增加 templates/svg_symbol_sprite.html (完整代码)模版,在 templates/svg_defs_sprite.html 上稍作修改,无需 CSS。

...
<body>
<svg width="0" height="0" style="position:absolute">
    {{#shapes}}
    {{{svg}}}
    {{/shapes}}
</svg>
<div id="app">
...

任务代码,在 SVG Defs Sprite 上稍作修改。

function subtaskCleanSVGSymbolSprite() {
    return del(['./dest/svg-symbol-sprite']);
}
subtaskCleanSVGSymbolSprite.displayName = 'Clean SVG Symbol Sprite';

function subtaskCreateSVGSymbolSprite() {
    let config = svgSpriteCommonConfig;
    config.mode.symbol = {
        dest: 'svg-symbol-sprite',
        bust: false,
        sprite: 'sprite.svg',
        example: {
            template: './templates/svg_symbol_sprite.html',
            dest: 'index.html'
        }
    };
    return gulp.src('./dest/svg/*.svg')
        .pipe(svgSprite(config))
        .pipe(gulp.dest('./dest/'));
}
subtaskCreateSVGSymbolSprite.displayName = 'Create SVG Symbol Sprite';

let taskSVGSymbolSprite = gulp.series('SVG', subtaskCleanSVGSymbolSprite, subtaskCreateSVGSymbolSprite);
taskSVGSymbolSprite.description = 'Export SVG Symbol Sprite';

gulp.task('SVG Symbol Sprite', taskSVGSymbolSprite);

导出资源运行 gulp "SVG Symbol Sprite"

SVG Stack

SVG Stack 可以使用 img 标签和 CSS background 插入图标,可以更改图标尺寸。

<img src="sprite.svg#account" width="48" height="48"/>
<div style="width:48px;height:48px;background:url(sprite.svg#account) no-repeat"></span>
<i class="icon icon-account"></i>

增加 templates/svg_stack_sprite.html (完整代码)模版。在 templates/svg_defs_sprite.html 上稍作修改。

...
<div id="app">
    <div class="search">
        <img class="icon-search" src="{{example}}#search" width="24" height="24" alt="">
        <input type="text" v-model="search" placeholder="search..."/>
    </div>
    <div class="container">
        <div v-for="icon in filteredList" class="thumb">
            <img v-bind:src="'{{example}}#' + icon.name" width="48" height="48"/>
            <span class="copy thumb-name" v-bind:data-clipboard-text="'{{example}}#' + icon.name" v-on:click="copy()">{{=<% %>=}}{{ icon.name }}<%={{ }}=%></span>
            <span class="copy thumb-name" v-bind:data-clipboard-text="'icon-' + icon.name" v-on:click="copy()">{{=<% %>=}}{{ 'icon-' + icon.name }}<%={{ }}=%></span>
        </div>
    </div>
    <p class="info">Version: {{ version }}, build date: {{ buildDate }}, contains {{ shapes.length }} icons.</p>
</div>
<script>
    const app = new Vue({
        el: '#app',
        data: {
            search: '',
            icons: [
                {{#shapes}}
                { 'name': '{{name}}' }{{^last}},{{/last}}
                {{/shapes}}
            ]
        },
        computed: {
            filteredList() {
                return this.icons.filter(icon => {
                    if ((new RegExp(this.search, 'i')).test(icon.name)) {
                        return icon;
                    }
                })
            }
        },
        methods: {
            copy: () => {
                let clipboard = new ClipboardJS('.copy');
                clipboard.on('success', e => {
                    if (document.getElementById('toast')) {
                        let toast = document.getElementById('toast');
                        toast.remove(toast.selectedIndex);
                    }
                    clipboard.destroy();
                    let toast = document.createElement('div');
                    toast.setAttribute('id', 'toast');
                    toast.innerHTML = `"${e.text}" copy!`;
                    document.body.appendChild(toast);
                    setTimeout(() => {
                        toast.remove(toast.selectedIndex);
                    }, 1500);
                });
            }
        }
    });
</script>
...

增加 templates/svg_stack.css 模版。

.icon { display: inline-block; width: 24px; height: 24px; background-repeat: no-repeat; }
.icon-s { width: 16px; height: 16px; }
.icon-m { width: 20px; height: 20px; }
.icon-1x { width: 24px; height: 24px; }
.icon-l { width: 32px; height: 32px; }
.icon-2x { width: 48px; height: 48px; }
{{#shapes}}
.icon-{{name}} { background-image: url("{{sprite}}#{{name}}"); }
{{/shapes}}

任务代码,在 SVG Defs Sprite 上稍作修改。

function subtaskCleanSVGStackSprite() {
    return del(['./dest/svg-stack-sprite']);
}
subtaskCleanSVGStackSprite.displayName = 'Clean SVG Stack Sprite';

function subtaskCreateSVGStackSprite() {
    let config = svgSpriteCommonConfig;
    config.mode.stack = {
        dest: 'svg-stack-sprite',
        bust: false,
        sprite: 'sprite.svg',
        example: {
            template: './templates/svg_stack_sprite.html',
            dest: 'index.html'
        },
        render: {
            css: {
                template: './templates/svg_stack.css',
                dest: 'sprite.css'
            }
        }
    };
    return gulp.src('./dest/svg/*.svg')
        .pipe(svgSprite(config))
        .pipe(gulp.dest('./dest/'));
}
subtaskCreateSVGStackSprite.displayName = 'Create SVG Stack Sprite';

let taskSVGStackSprite = gulp.series('SVG', subtaskCleanSVGStackSprite, subtaskCreateSVGStackSprite);
taskSVGStackSprite.description = 'Export SVG Stack Sprite';

gulp.task('SVG Stack Sprite', taskSVGStackSprite);

导出资源运行 gulp "SVG Stack Sprite"