用NodeJs和Python开发一个Sublime插件
Sublime Text著有最性感的编辑器之称,它轻量,易于拓展,通过插件的方式可使它变得很强大,而我更是把它作为自己的主要编辑器使用。
有一次逛package control的时候,发现一个用NodeJs
来写插件的脚手架: SublimeJS_Samples,才发现用NodeJs
结合Python
也可以来开发插件,这对于对Python
不是特别了解的人可能很吃力,然后就研究了几个常用插件的源码,拿sublime-vue-formatter这个插件详细看了下,总结出原理大概是这样的:
- 在
Python
中拿到当前正在编辑的文件或者选中的内容 - 用
Python
子进程subprocess
去执行一段cmd命令并接受输出,Node
路径为我们电脑上装的Node
绝对路径(用户事先配置好), 再去执行一个NodeJs
脚本, 以Mac为例, 最后要执行的命令大概是这样的/usr/local/bin/node script/run.js --arg1=argVal1 --arg2=argVal2
(其实和我们平时在Terminal里运行的一样) Python
接收到NodeJs
的执行返回的时候, 替换文件中的相关内容
抱着试试看的心理,我也决定自己用这个模式写个图片压缩相关的插件,先简单介绍下我想写的插件的功能:
- 用户在项目根目录下新建配置文件, 指定压缩目录和释放目录, 调用tinypng的开发者API进行压缩
- 当压缩完的图片小于多少字节时把css中的引用转换成
Base64
编码,也是通过配置项 - 用户可以通过一些快捷键或者命令(子命令)在项目根目录新建配置文件、修改全局
NodeJs
路径配置等等
现在知道了我们想做什么, 下一步就是根据这个需求开始写代码, 因为是图片压缩插件, 所以我把它命名成sublime-image-compressor
命令、子命令
在插件配置中, 命令都是通过配置文件写入的, 下面贴下我们这个插件支持命令的一个配置并做介绍
// ImageCompressor.sublime-commands
[{
"caption": "ImageCompress",
"command": "image_compress"
}, {
"caption": "ImageCompress: Init Project Config File",
"command": "imagecompress_config_project"
}, {
"caption": "ImageCompress: Set Global Config",
"command": "imagecompress_set_global_plugin_options"
}]
.sublime-commands
是用来指定插件支持的命令的, 里面指定一个对象型数组, 数组有caption
和command
两项, 需要注意的是, command
的值和Python
的类是对应的, 且Python
中用驼峰命名法来定义类名, 以第一项为例, 比如command
的值为image_compress
, 我们的Python
中就需要像类似下面的样子定义一个类:
class ImageCompressCommand(sublime_plugin.TextCommand):
def run(self, edit):
其他类似
快捷键
在插件配置中, 快捷键都是通过配置文件写入的, 下面贴下我们这个插件快捷键的一个配置并做介绍, 需要注意的是, 如果需要兼容多平台, 因为每个平台的键可能都不太一样, 则需要多份配置文件, Windows
/Mac
/Linux
的命名分别是Default (Windows).sublime-keymap
/Default (OSX).sublime-keymap
/Default (Linux).sublime-keymap
, 下面我们把Default (OSX).sublime-keymap
这个文件的内容贴出来看下:
[{
"keys": ["ctrl+shift+i", "c"],
"command": "image_compress"
}, {
"keys": ["ctrl+alt+i", "f"],
"command": "image_compress_config_project"
}, {
"keys": ["ctrl+alt+i", "g"],
"command": "image_compress_set_global_plugin_options"
}]
.sublime-keymap
也是指定一个对象型数组, 每个对象有keys
和command
两个属性, keys
是指定按哪几个键生效的, command
的配置和之前的命令配置一样
当然我们也可以通过添加其他配置文件比如Context.sublime-menu
或者Main.sublime-menu
来指定插件的右键菜单或者在Toolbar
上的菜单配置
说完了几个配置, 我们一起来写Python
和NodeJs
, 先写Python
吧
在Python
中我们需要完成大概下面几个任务:
- 首先获取用户当前项目的根目录
- 获取根目录下的配置文件, 如果用户没有新建配置文件, 就读取一份默认的配置文件, 拼出命令行参数(–arg1=argVal1 –arg2=argVal2)
- 获取用户装的
NodeJs
全路径, 结合上一步拼好的命令行参数再拼出一个可执行命令(类似刚才分析流程那边那条) - 用
subprocess
模块来运行该命令
# 写插件必须引入这两个模块
import sublime, sublime_plugin
# Python内置模块的引入
import os, sys, subprocess, codecs, webbrowser, platform, json
# 当前插件, 配置文件, 快捷键配置, 执行任务的js路径配置
PLUGIN_FOLDER = os.path.dirname(os.path.realpath(__file__))
CONFIG_FILE = "image-compressor.config.json"
SETTINGS_FILE = "ImageCompressor.sublime-settings"
KEYMAP_FILE = "Default ($PLATFORM).sublime-keymap"
# 这里JS_PATH必须为绝对路径, 否则会去${nodepath}找/scripts/index.js,找不到肯定会报错
JS_PATH = PLUGIN_FOLDER.replace(" ", "\\ ") + "/scripts/index.js"
// 引入commands模块, 并catch异常
try:
import commands
except ImportError:
pass
class ImageCompressCommand(sublime_plugin.TextCommand):
"""
插件需要一个run方法, 该方法接收self和edit两个参数
def run(self, run[, args])
self: 代表当前类
view: 代表当前编辑的tab
args: 其他参数, 可以在配置中指定
比如 { "caption": "xxx", "command": "xxxx", "args": {"by": "abc"} }
class Xxxx(sublime_plugin.TextCommand):
def run(self, edit, by):
print("by=" + by)
# by=file
"""
def run(self, edit):
# 拿到项目根路径的绝对路径
currentDir = PluginUtils.get_active_project_path()
# 从.sublime-settings配置文件中获取当前平台的node_path
nodepath = PluginUtils.get_node_path()
# 获取根路径
configs = PluginUtils.load_config()
configs += " --currentDir='" + currentDir + "'"
# 空格替换
nodepath = nodepath.replace(" ", "\\ ");
# 拼一个cmd数组
cmd = [nodepath, JS_PATH]
cmd.append(configs)
# 执行命令
PluginUtils.exec_cmd(cmd)
上面我们完成了一个ImageCompressCommand
这个类的封装,并且在run
里面好几个地方都调用了PluginUtils
下的静态方法, 下面我们看看PluginUtils
的定义
#在python用staticmethod Decorator表示静态方法
class PluginUtils:
'''
读取配置文件
这里的逻辑有点不对, 应该先去项目根目录下找配置文件
找不到再找默认的配置
'''
@staticmethod
def load_config(base):
try:
args = ""
# 以读取的模式打开配置文件
fs = open(CONFIG_FILE, "r")
stream = fs.read()
# steam读出来是个json字符串, 所以需要用json.loads转换成dict
# http://python3-cookbook.readthedocs.io/zh_CN/latest/c06/p02_read-write_json_data.html
data = json.loads(stream)
# 获取dict下的key变成一个list
keys = data.keys()
# 关闭文件
fs.close()
# 枚举所有key
for key in keys:
val = data[key]
# 数组类型参数转换成字符串
if isinstance(val, list):
val = "-compress-config-split-".join(val)
args += " --" + key + "=" + str(val)
return args
except:
return ""
#从.sublime-settings里面获取相关配置项
@staticmethod
def get_pref(key):
return sublime.load_settings(SETTINGS_FILE).get(key)
'''
从.sublime-settings里面获取当前平台下nodejs的路径
'''
@staticmethod
def get_node_path():
platform = sublime.platform()
node = PluginUtils.get_pref("node_path").get(platform)
return node
'''
获取当前激活项目的根路径
参考https://github.com/fyneworks/sublime-TortoiseGIT/blob/master/TortoiseGIT.py#L8实现
'''
@staticmethod
def get_active_project_path():
window = sublime.active_window()
folders = window.folders()
if len(folders) == 1:
return folders[0]
else:
active_view = window.active_view()
active_file_name = active_view.file_name() if active_view else None
if not active_file_name:
return folders[0] if len(folders) else os.path.expanduser("~")
for folder in folders:
if active_file_name.startswith(folder):
return folder
return os.path.dirname(active_file_name)
'''
执行命令
参考https://github.com/luozhihua/sublime-vue-formatter/blob/master/vue-next-formatter.py#L196实现
'''
@staticmethod
def exec_cmd(cmd):
if int(sublime.version()) < 3000:
if sublime.platform() != "windows":
run = '"' + '" "'.join(cmd) + '"'
return commands.getoutput(run)
else:
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
return subprocess.Popen(cmd, \
stdout=subprocess.PIPE, \
startupinfo=startupinfo).communicate()[0]
else:
run = " ".join(cmd)
res = subprocess.check_output(run, stderr=subprocess.STDOUT, shell=True, env=os.environ)
print(res)
上面我们就完成了对PluginUtils
这个类的封装, 在最后一个方法中, 我们用了subprocess
下面的几个方法, subprocess
下面还有很多其他方法, 这些在文档里面都能找到, 下面再看看NodeJs
部分实现:
const dir = require("node-dir"),
co = require("co"),
tinify = require("tinify"),
mkdirp = require("mkdirp"),
Promise = require("bluebird"),
fs = require("fs"),
path = require("path");
// 缓存Object.prototype
const obj2 = {};
// 参数解析
const parseArgs = arr => {
let res = {},
tmp = null;
arr.map(item => item.replace(/^-{2}/, ""))
.forEach(val => {
tmp = val.split("=");
// Python中布尔值转换成True/False, 数字转换成'123'
if (/^[\d]+$/.test(tmp[1])) {
tmp[1] = Number(tmp[1]);
}
if (/^True$/.test(tmp[1])) {
tmp[1] = true;
}
if (/^False/.test(tmp[1])) {
tmp[1] = false;
}
res[tmp[0]] = tmp[1];
});
return res;
};
// 判断是否为一个图片文件
const isImageFile = file => /\.(jpe?g|png|gif)$/i.test(file);
// 判断是否为一个css文件
const isCssFile = file => /\.css$/i.test(file);
// 目标目录
const distDir = (prefix, folder) => {
if (!folder) {
folder = prefix;
prefix = __dirname;
}
// 拼路径
return path.join(prefix, folder);
};
// 同步读取一个文件的状态
const fstat = filePath => fs.fstatSync(filePath);
// 把文件转换成base64编码
const toBase64 = filePath => {
const buffer = fs.readFileSync(filePath);
return buffer.toString("base64");
};
// 把一个数组按照20个每项做子项目拆分成二级数组
const splitArray = array => {
const length = array.length;
let res = [];
for (let i = 0; i < length; i += 20) {
res.push(array.slice(i, i + 20));
}
return res;
};
// 判断目录是否存在
const folderExist = folder => {
try {
fs.readdirSync(folder);
return true;
} catch (e) {
return false;
}
};
// 读取目录下的css并且转换成AST对象
const listCssAST = cssdir => {
let list = [],
fullPath = null,
basename = null,
stream = null;
files = fs.readFileSync(cssdir);
list = files.filter(file => isCssFile).map(file => {
fullPath = path.join(cssdir, file);
stream = fs.readFileSync(fullPath);
basename = path.basename(fullPath);
return {
ast: stream.parse(stream.toString()),
sourcePath: fullPath,
distPath: path.join(
cssdir,
`${basename.replace(/\.css$/i, "")}.css`
)
};
});
return list;
};
// 替换css中的background: url(xxx) 为 background: url(base64: xxxx)
const replaceCss = (cssdir, files) => {
if (!files || files.length === 0) {
return;
}
const csses = listCssAST(cssdir);
if (csses.length === 0) {
return;
}
files = files.map(file => {
return {
source: file,
encoded: toBase64(file)
};
});
// TODO:遍历CSS AST并替换引用
// csses.forEach(({ ast, distPath }) => {});
};
/**
* promiseifyToFile -> source = yield promiseifyToFile("a/b/c/d.jpg");
* @param {String} file 源文件路径
* @param {String} dist 目标文件路径
* 在调用tinypng API异常后, 直接拷贝源文件到相关目录
*/
const promiseifyToFile = (file, dist) => {
return new Promise((resolve, reject) => {
try {
const fileUp = tinify.fromFile(file);
fileUp._url
.then(res => {
fileUp
.toFile(dist)
.then(e => {
if (e) {
reject(e);
return;
}
resolve();
})
.catch(reject);
})
.catch(reject);
} catch (e) {
fs.copyFileSync(file, dist);
resolve();
}
});
};
// 获取类型名
const typeOf = obj => obj2.toString.call(obj).slice(8, -1).toLowerCase();
let args;
if (process.argv.length > 2) {
// 参数读取和处理
args = parseArgs(process.argv.slice(2));
}
if (args) {
// prefix处理
args.prefix = args.prefix || "";
// 源文件目录和css文件目录的绝度路径处理
args.source = args.source.split("-compress-config-split-").map(item => path.join(args.currentDir, item));
args.cssDir = args.cssDir.split("-compress-config-split-").map(item => path.join(args.currentDir, item));
// tinypng的API Key
tinify.key = args.key;
}
/**
* 入口函数
* @param options.source
* @param options.outputDir
* @param options.prefix
* @param options.injectCssUrl
* @param options.injectMaxSize
* @param options.cssDir
* @param options.currentDir
*/
function init({
source,
outputDir,
prefix,
injectCssUrl,
injectMaxSize,
cssDir,
currentDir
}) {
let distPath = distDir(currentDir, outputDir),
filesList = [],
tmp = null,
basename;
mkdirp.sync(distPath);
// 异步上传, 并且放到具体的目录
co(function*() {
try {
for (let i of dirs) {
tmp = yield dir.promiseFiles(i);
filesList = [].concat.call(filesList, tmp.filter(file => isImageFile(file)));
}
for (tmp of filesList) {
basename = path.basename(tmp);
const res = yield promiseifyToFile(
tmp,
path.join(distPath, basename)
);
}
} catch (e) {
}
});
}
init(args);
到这里, 我们的第一个命令就实现好了, 如果想测试, 可以直接在Sublime Text
按下ctrl + '
(数字1前面那个键), 然后在console
中运行view.run_command("image_compress")
, view.run_command
接收的参数和我们在keyMap
或者command
里面定义的一致。
至此, 我们插件的第一个功能就完成了, 后面还有两个需要做, 相对简单, 不再做介绍, 用法和代码请移步GitHub。
总结
我们可以用这种模式开发很多插件, 比如代码压缩类、 代码格式化类、 代码语法检测类等等, 关于这些, npm上面有很多现成的包, NodeJs
端需要做的只有处理输入, 再返回结果就好。
这种模式方便了很多人去自己开发插件, 但是也有一些缺点, 首先开发的时候很痛苦, 不易调试, 而且在用Node
和Python
通信的时候, 数据多的时候可能会有些延迟等等。