rwson

rwson

一个前端开发

用NodeJs和Python开发一个Sublime插件

Sublime Text著有最性感的编辑器之称,它轻量,易于拓展,通过插件的方式可使它变得很强大,而我更是把它作为自己的主要编辑器使用。

有一次逛package control的时候,发现一个用NodeJs来写插件的脚手架: SublimeJS_Samples,才发现用NodeJs结合Python也可以来开发插件,这对于对Python不是特别了解的人可能很吃力,然后就研究了几个常用插件的源码,拿sublime-vue-formatter这个插件详细看了下,总结出原理大概是这样的:

  1. Python中拿到当前正在编辑的文件或者选中的内容
  2. Python子进程subprocess去执行一段cmd命令并接受输出,Node路径为我们电脑上装的Node绝对路径(用户事先配置好), 再去执行一个NodeJs脚本, 以Mac为例, 最后要执行的命令大概是这样的/usr/local/bin/node script/run.js --arg1=argVal1 --arg2=argVal2(其实和我们平时在Terminal里运行的一样)
  3. Python接收到NodeJs的执行返回的时候, 替换文件中的相关内容

抱着试试看的心理,我也决定自己用这个模式写个图片压缩相关的插件,先简单介绍下我想写的插件的功能:

  1. 用户在项目根目录下新建配置文件, 指定压缩目录和释放目录, 调用tinypng的开发者API进行压缩
  2. 当压缩完的图片小于多少字节时把css中的引用转换成Base64编码,也是通过配置项
  3. 用户可以通过一些快捷键或者命令(子命令)在项目根目录新建配置文件、修改全局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是用来指定插件支持的命令的, 里面指定一个对象型数组, 数组有captioncommand两项, 需要注意的是, 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也是指定一个对象型数组, 每个对象有keyscommand两个属性, keys是指定按哪几个键生效的, command的配置和之前的命令配置一样

当然我们也可以通过添加其他配置文件比如Context.sublime-menu或者Main.sublime-menu来指定插件的右键菜单或者在Toolbar上的菜单配置

说完了几个配置, 我们一起来写PythonNodeJs, 先写Python

Python中我们需要完成大概下面几个任务:

  1. 首先获取用户当前项目的根目录
  2. 获取根目录下的配置文件, 如果用户没有新建配置文件, 就读取一份默认的配置文件, 拼出命令行参数(–arg1=argVal1 –arg2=argVal2)
  3. 获取用户装的NodeJs全路径, 结合上一步拼好的命令行参数再拼出一个可执行命令(类似刚才分析流程那边那条)
  4. 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端需要做的只有处理输入, 再返回结果就好。

这种模式方便了很多人去自己开发插件, 但是也有一些缺点, 首先开发的时候很痛苦, 不易调试, 而且在用NodePython通信的时候, 数据多的时候可能会有些延迟等等。