VS Code能够正确支持JS/TS跳转到定义、补全提示等功能,但仅限于符合Node Module Resolution以及TypeScript Module Resolution规则的模块引用,如:
// 标准import
import {fn1, fn2} from './myModule.js';// node modules引用
var x = require("./moduleB");// TypeScript
// 允许省略后缀名
import {fn1, fn2} from './myModule';
// 支持baseUrl
import { localize } from 'vs/nls';
如果是其它自定义import
规则,这些功能都将不可用(无法跳转、没有提示、没有Lint校验……),例如:
import Utility from 'Utilities/utility';
var ReactCurrentOwner = require('ReactCurrentOwner');
因import
规则特殊导致这些关键功能不可用,对于建立在类似构建工具上的项目而言是个痛点,期望通过插件来解决
立足VS Code插件机制,想要支持特殊import
规则的话,有2种方式:
前者是通用的Definition扩展方式,具体案例见Show Definitions of a Symbol;后者仅适用于JS/TS,但功能更强大(不限于Definition)
通过registerDefinitionProvider
实现自己的DefinitionProvider是最常见的Go to Definition扩展方式,但存在2个问题:
import
语句还是属性访问,是个require
函数调用还是字符串字面量,这些关键信息都没有暴露出来缺少语义支持是个硬缺陷,例如经常需要在入口处做类似这样的事情:
getToken() {
const position = Editor.getEditorCursorStart();
const token = this.scanReverse(position) + this.scanForward(position);
if (this.isEmpty(token)) {
return null;
}
return token;
}
(摘自WesleyLuk90/node-import-resolver-code)
更大的问题是受限于触摸不到语法树,很多事情“做不了”,比如:
// 非ES Module标准的Webpack Resolve
import myModule from 'non-es-module-compatible-path-to/my-awesome-module';// 试图跳转到doSomething定义
myModule.doSomething();
想要跳转到依赖文件中的定义,必须要做到这2点:
myModule
是个依赖模块,并找到myModule
指向的文件doSomething
定义所在的行列位置也就是说,必须对当前文件以及依赖文件内容进行语义分析,而VS Code插件机制并没有开放这种能力
诚然,插件自己(通过Babel等工具)实现语义分析可以应对这种场景,但会发现更多的问题:
myModule.
缺少补全提示myModule.doAnotherThing(
缺少参数提示myModule.undefinedFunction()
缺少Lint报错这一整套原本存在的功能现在都要重新实现一遍,投入就像无底洞,我们似乎陷入了一个误区:试图从上层修复下层问题,最后发现要铺满整块地面才能解决(几乎要重新实现整个下层)
相同/相似Definition的问题主要表现在用户插件与内置插件功能冲突上,由于通过插件API无法获知内置Provider的Definition结果,冲突在所难免
从实现上来看,所有DefinitionProvider提供的Definition结果会被merge到一起:
// ref: https://github.com/Microsoft/vscode/blob/master/src/vs/editor/contrib/goToDefinition/goToDefinition.ts
function getDefinitions<T>(): Promise<DefinitionLink[]> {
const provider = registry.ordered(model); // get results
const promises = provider.map((provider): Promise<DefinitionLink | DefinitionLink[] | null | undefined> => {
return Promise.resolve(provide(provider, model, position)).then(undefined, err => {
onUnexpectedExternalError(err);
return null;
});
});
return Promise.all(promises)
.then(flatten)
.then(coalesce);
}
VS Code考虑到了重复定义的情况,内部做了去重,但只针对完全相同的定义(即uri
与range(startLine, startColumn, endLine, endColumn)
都完全相同):
the design is to be cooperative and we only de-dupe items that are exactly the same.
对应源码:
// ref: https://github.com/Microsoft/vscode/blob/master/src/vs/editor/contrib/goToDefinition/goToDefinitionCommands.ts
export class DefinitionAction extends EditorAction {
public run(): TPromise<void> {
const definitionPromise = this._getDeclarationsAtPosition().then(references => {
const result: DefinitionLink[] = [];
for (let i = 0; i < references.length; i++) {
let reference = references[i];
let { uri, range } = reference;
result.push({ uri, range });
} if (result.length === 0) {
// 无definition结果,提示没找到定义
if (this._configuration.showMessage) {
const info = model.getWordAtPosition(pos);
MessageController.get(editor).showMessage(this._getNoResultFoundMessage(info), pos);
}
} else if (result.length === 1 && idxOfCurrent !== -1) {
// 只有1条结果,直接跳转
let [current] = result;
this._openReference(editor, editorService, current, false); } else {
// 多条结果,去重并显示
this._onResult(editorService, editor, new ReferencesModel(result));
}
});
}
}
去重逻辑:
// ref: https://github.com/Microsoft/vscode/blob/master/src/vs/editor/contrib/referenceSearch/referencesModel.ts
export class ReferencesModel implements IDisposable {
constructor(references: Location[]) {
this._disposables = [];
// 按字典序对文件路径排序,同一文件内按range起始位置排序
references.sort(ReferencesModel._compareReferences); let current: FileReferences;
for (let ref of references) {
// 按文件分组
if (!current || current.uri.toString() !== ref.uri.toString()) {
// new group
current = new FileReferences(this, ref.uri);
this.groups.push(current);
} // 去重,滤掉完全相同的range
if (current.children.length === 0
|| !Range.equalsRange(ref.range, current.children[current.children.length - 1].range)) { let oneRef = new OneReference(current, ref.range);
this._disposables.push(oneRef.onRefChanged((e) => this._onDidChangeReferenceRange.fire(e)));
this.references.push(oneRef);
current.children.push(oneRef);
}
}
}
}
最后,还有一个重要的展示逻辑:
private _openReference(): TPromise<ICodeEditor> {
return editorService.openCodeEditor({
resource: reference.uri,
options: {
// 选中range起始位置(光标移动到该位置)
selection: Range.collapseToStart(reference.range),
revealIfOpened: true,
revealInCenterIfOutsideViewport: true
}
}, editor, sideBySide);
}
这就引发了很容易出现的“重复”定义问题,在显示时,这3个range
看起来完全一样:
Range(0, 0, 28, 12)
Position(0, 0)
// Position(0, 0)会被转换成
Range(0, 0, 0, 0)
起点位于同一行相近位置的range
也难以分辨(如Range(0, 0, _, _)
与Range(0, 1, _, _)
),展示上看起来都是重复定义
要解决类似的“重复”(本质上是DefinitionProvider间的冲突),有两种思路:
registerDefinitionProvider
提供选项enabelWhenNoReferences
(仅在别人都没结果时才走我的)前者比较脆弱,依靠猜测很难覆盖所有情况,并且一旦内置插件更新了,这些猜测可能就不适用了。而后者目前(2018/12/16)没有提供支持,将来可能也不会提供,具体见Duplicated definition references found when Go to Definition
TypeScript server plugins are loaded for all JavaScript and TypeScript files when the user is using VS Code’s version of TypeScript.
简言之,就是通过插件内置指定的TypeScript Language Service Plugin,从而扩展VS Code处理JS/TS的能力
TypeScript Language Service Plugins (“plugins”) are for changing the editing experience only.
仅能增强编辑体验,无法改变TS核心行为(比如改变类型检查行为)或增加新特性(比如提供一种新语法或者)
具体的,编辑体验相关的事情包括:
window.eval
做不到的事情包括:
tsc
命令的校验行为因此,如果只是想增强编辑体验,TypeScript Language Service Plugin是很不错的选择
VS Code默认行为是无后缀名的优先跳.ts
(无论源文件是JS还是TS),如果想要.js
文件里的模块引用都指向.js
文件的话,可以通过简单的Service Plugin来实现:
function init(modules: { typescript: typeof ts_module }) {
const ts = modules.typescript; function create(info: ts.server.PluginCreateInfo) {
const resolveModuleNames = info.languageServiceHost.resolveModuleNames; // 篡改resolveModuleNames,以扩展自定义行为
info.languageServiceHost.resolveModuleNames = function(moduleNames: string[], containingFile: string) {
const isJsFile = containingFile.endsWith('.js');
let resolvedNames = moduleNames;
if (isJsFile) {
const dir = path.dirname(containingFile);
resolvedNames = moduleNames.map(moduleName => {
// 仅针对无后缀名的相对路径引用
const needsToResolve = /^\./.test(moduleName) && !/\.\w+$/.test(moduleName);
if (needsToResolve) {
const targetFile = path.resolve(dir, moduleName + '.js');
if (ts.sys.fileExists(targetFile)) {
// 添上.js后缀名,要求跳转到.js文件
return moduleName + '.js';
}
} return moduleName;
}); return resolveModuleNames.call(info.languageServiceHost, resolvedNames, containingFile);
} return info.languageService;
}
} return { create };
}
其中,moduleNames
就是在语法分析完成之后收集到的import
模块名,也就是说,TypeScript Language Service Plugin有语义支持
P.S.更多类似示例,见:
caller
Service Plugin写好了,接下来通过VS Code插件把它引进来,使之能够增强VS Code的编辑体验
只需要做两件事情,先把Service Plugin作为npm依赖装上:
{
"dependencies": {
"my-typescript-server-plugin": "*"
}
}
再通过contributes.typescriptServerPlugins
扩展点引入:
"contributes": {
"typescriptServerPlugins": [
{
"name": "my-typescript-server-plugin"
}
]
}
最后,VS Code内置的typescript-language-features插件会把所有插件的typescriptServerPlugins
都收集起来并注册到TypeScriptServiceClient
:
// 收集
export function getContributedTypeScriptServerPlugins(): TypeScriptServerPlugin[] {
const plugins: TypeScriptServerPlugin[] = [];
for (const extension of vscode.extensions.all) {
const pack = extension.packageJSON;
if (pack.contributes && pack.contributes.typescriptServerPlugins && Array.isArray(pack.contributes.typescriptServerPlugins)) {
for (const plugin of pack.contributes.typescriptServerPlugins) {
plugins.push({
name: plugin.name,
path: extension.extensionPath,
languages: Array.isArray(plugin.languages) ? plugin.languages : [],
});
}
}
}
return plugins;
}// 注册
this.client = this._register(new TypeScriptServiceClient(
workspaceState,
version => this.versionStatus.onDidChangeTypeScriptVersion(version),
plugins,
logDirectoryProvider,
allModeIds)
);
因此,contributes.typescriptServerPlugins
扩展点是用来连接TypeScript Language Service Plugin和VS Code里的TypeScriptServiceClient
的桥梁
对于JS/TS,VS Code还提供了一种更强大的扩展方式,叫TypescriptServerPlugin
与通用的registerDefinitionProvider
相比,TypescriptServerPlugin能够触摸到语法树,这是极大的优势,在跳转到定义、Lint检查、补全提示等语义相关的场景尤为适用
当然,TypescriptServerPlugin也并非完美,限制如下: