WsztRush

用ACE来写代码(二)

仅仅把代码高亮了还不够,在正常的编辑器中当输入少量的几个字符串就可以根据它来提示可能的输入:

这样用起来能极大地提高输入的效率,而实现起来非常简单:

ace.require("ace/ext/language_tools");
var editor = ace.edit("editor");
editor.session.setMode("ace/mode/groovy");
editor.setTheme("ace/theme/tomorrow");
editor.setOptions({
    enableBasicAutocompletion: true,
    enableSnippets: true,
    enableLiveAutocompletion: true
});

另外注意需要引入ext-language_tools.js文件!感觉看英文的文档有些地方不是很清楚(可能是英语水平的问题☺),于是我们继续开始读源码。

源码分析

我们设置了enableLiveAutocompletion后输入内容时会执行doLiveAutocomplete方法:

var doLiveAutocomplete = function(e) {
    var editor = e.editor;
    var hasCompleter = editor.completer && editor.completer.activated;
    if (e.command.name === "backspace") {// 删除动作
        if (hasCompleter && !getCompletionPrefix(editor))
            editor.completer.detach();
    } else if (e.command.name === "insertstring") {// 输入动作
        var prefix = getCompletionPrefix(editor);
        if (prefix && !hasCompleter) {
            if (!editor.completer) {
                editor.completer = new Autocomplete();
            }
            editor.completer.autoInsert = false;
            editor.completer.showPopup(editor);// 入口方法
        }
    }
};

对操作的类型及内容做一些简单的过滤之后就交由Autocomplete来完成实质性的工作:

this.showPopup = function(editor) {
    // 初始化
    if (this.editor)
        this.detach();
    this.activated = true;
    this.editor = editor;
    if (editor.completer != this) {
        if (editor.completer)
            editor.completer.detach();
        editor.completer = this;
    }
    // 绑定方法
    editor.on("changeSelection", this.changeListener);
    editor.on("blur", this.blurListener);
    editor.on("mousedown", this.mousedownListener);
    editor.on("mousewheel", this.mousewheelListener);
    // 更新补全信息列表
    this.updateCompletions();
};

方法showPopup中先进行初始化:

  1. 使用detach进行清理;
  2. 绑定事件;

接下来就使用updateCompletions来获取补全列表信息并进行展示:

this.updateCompletions = function(keepPopupPosition) {
    if (keepPopupPosition && this.base && this.completions) {
        var pos = this.editor.getCursorPosition();
        var prefix = this.editor.session.getTextRange({start: this.base, end: pos});
        // 内容没有发生变化
        if (prefix == this.completions.filterText)
            return;
        this.completions.setFilter(prefix);
        if (!this.completions.filtered.length)
            return this.detach();
        if (this.completions.filtered.length == 1
            && this.completions.filtered[0].value == prefix
            && !this.completions.filtered[0].snippet)
            return this.detach();
        this.openPopup(this.editor, prefix, keepPopupPosition);
        return;
    }
    var _id = this.gatherCompletionsId;
    // 收集所有的补全信息并执行(全部用回调函数来搞看着好累- -!)
    this.gatherCompletions(this.editor, function(err, results) {
        var detachIfFinished = function() {
            if (!results.finished) return;
            return this.detach();
        }.bind(this);
        // 获取前缀
        var prefix = results.prefix;
        var matches = results && results.matches;
        // 没有匹配到的时候就可以清理一下然后返回了
        if (!matches || !matches.length)
            return detachIfFinished();
        if (prefix.indexOf(results.prefix) !== 0 || _id != this.gatherCompletionsId)
            return;
        this.completions = new FilteredList(matches);
        // 是否精确匹配
        if (this.exactMatch)
            this.completions.exactMatch = true;
        // 过滤,过滤完的结果保存在filtered中
        this.completions.setFilter(prefix);
        var filtered = this.completions.filtered;
        // 检查过滤完的结果,没有匹配到的就清理并返回
        if (!filtered.length)
            return detachIfFinished();
        if (filtered.length == 1 && filtered[0].value == prefix && !filtered[0].snippet)
            return detachIfFinished();
        if (this.autoInsert && filtered.length == 1 && results.finished)
            return this.insertMatch(filtered[0]);
        // 展示内容
        this.openPopup(this.editor, prefix, keepPopupPosition);
    }.bind(this));
};

其中参数keepPopupPosition表示是否保持弹出框的位置保持不变:

补全框中的内容会随着你的输入变化而变化,但是位置却保持不变就是这个参数在起作用!

其中比较关键的用gatherCompletions来收集所有补全器提供的数据(感觉是用这个方法把language_tools.js和autocomplete.js打通):

this.gatherCompletions = function(editor, callback) {
    var session = editor.getSession();
    var pos = editor.getCursorPosition();
    var line = session.getLine(pos.row);
    var prefix = util.retrievePrecedingIdentifier(line, pos.column);
    this.base = session.doc.createAnchor(pos.row, pos.column - prefix.length);
    this.base.$insertRight = true;
    var matches = [];
    var total = editor.completers.length;
    // 遍历执行每个补全器
    editor.completers.forEach(function(completer, i) {
        // 获取补全列表
        completer.getCompletions(editor, session, pos, prefix, function(err, results) {
            // 在没有发生错误的时候,将结果合并到matchs中
            if (!err)
                matches = matches.concat(results);
            var pos = editor.getCursorPosition();
            var line = session.getLine(pos.row);
            // 调用回调函数
            callback(null, {
                prefix: util.retrievePrecedingIdentifier(line, pos.column, results[0] && results[0].identifierRegex),
                matches: matches,
                finished: (--total === 0)
            });
        });
    });
    return true;
};

在每个补全器的getCompletions方法中都会调用callback方法:将自己的结果合并到全局的数据中。获取补全器的数据之后就会调用openPopup方法来更新展示:

this.openPopup = function(editor, prefix, keepPopupPosition) {
    if (!this.popup)
        this.$init();
    this.popup.setData(this.completions.filtered);
    editor.keyBinding.addKeyboardHandler(this.keyboardHandler);
    var renderer = editor.renderer;
    this.popup.setRow(this.autoSelect ? 0 : -1);
    if (!keepPopupPosition) {
        // 设置展示
        this.popup.setTheme(editor.getTheme());
        this.popup.setFontSize(editor.getFontSize());
        var lineHeight = renderer.layerConfig.lineHeight;
        // 设置位置
        var pos = renderer.$cursorLayer.getPixelPosition(this.base, true);
        pos.left -= this.popup.getTextLeftOffset();
        var rect = editor.container.getBoundingClientRect();
        pos.top += rect.top - renderer.layerConfig.offset;
        pos.left += rect.left - editor.renderer.scrollLeft;
        pos.left += renderer.gutterWidth;
        // 展示内容
        this.popup.show(pos, lineHeight);
    } else if (keepPopupPosition && !prefix) {
        this.detach();
    }
};

回过头再来看language_tools.js中的补全器:

  1. getCompletions:获取补全列表;
  2. getDocTooltip:返回HTML格式的提示内容;

每个补全列表中的元素包含如下信息:

而getDocTooltip感觉又进一步地提升了写代码时候的体验(在写代码的时候就知道输入的是什么):

具体是怎么实现的呢?接着来看代码,Mode中的getCompletions如下:

this.getCompletions = function(state, session, pos, prefix) {
    // 获取当前Mode的关键字
    var keywords = this.$keywordList || this.$createKeywordList();
    // 根据关键字组装补全列表
    return keywords.map(function(word) {
        return {
            name: word,
            value: word,
            score: 0,
            meta: "keyword"
        };
    });
};

在当前文件中写过的单词被自动提示补全的逻辑在text_completer.js中实现(逻辑很简单),比较麻烦的是enableSnippets,这个后面有时间再看。

自定义补全

知道了ACE的补全运行的原理,那么现在扩展起来就比较简单了:

var languageTools = ace.require("ace/ext/language_tools");
    languageTools.addCompleter({
        getCompletions: function(editor, session, pos, prefix, callback) {
        callback(null,  [
            {
                name : "test",
                value : "test",
                caption: "test",
                meta: "test",
                type: "local",
                score : 1000 // 让test排在最上面
            }
        ]);
    }
});

虽然看到的例子都是同步执行callback方法,但用异步来做也是完全没有问题的:

在上面看源码的时候还不明白为啥每次回调的时候都要更新显示而不是等全部执行完后更新一次~

在事件驱动的系统中接口的设计还是需要多思考、多推敲的啊!

总结

有了自动补全之后与IDE的距离又近了一步,不仅仅能加快脚步编写的速度,更重要的是代码的准确性也会有所提高,当然这还是不够的!