一.需求 장면
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 검증 없음……). 예를 들어:
-
Webpack Resolve:
import Utility from 'Utilities/utility'; -
[React's Haste](/articles/react 배후의 도구화 체계/#articleHeader3):
var ReactCurrentOwner = require('ReactCurrentOwner');
import 규칙이 특수하여 이러한 중요한 기능을 사용할 수 없는 것은, 유사한 구축 도구上に 구축된 프로젝트にとって 痛点 이며, 플러그인을 통해 해결하기를 기대합니다
二.구현思路
VS Code 플러그인 메커니즘에 입각하여, 특수한 import 규칙을 지원하고 싶다면, 2 가지 방식이 있습니다:
전자는 일반적인 Definition 확장 방식으로, 구체적 사례는 Show Definitions of a Symbol 참조; 후자는 JS/TS 에만 적용되지만, 기능이 더욱 강력합니다 (Definition 에 한정되지 않음)
三.registerDefinitionProvider
registerDefinitionProvider 를 통해独自の DefinitionProvider 를 구현하는 것은 가장 일반적인 Go to Definition 확장 방식이지만, 2 가지 문제가 존재합니다:
-
의미 지원의 부재: 현재의 Document 및 점프 동작이 발생한 행렬 위치만 획득할 수 있고, 코드 의미와 관련된 정보는 일절 제공되지 않습니다. 예를 들어
import문인지 속성 액세스인지,require함수 호출인지 문자열 리터럴인지, 이러한 중요한 정보는 모두 공개되지 않았습니다 -
Definition 충돌이 발생하기 쉬움:Go to Definition 동작이 발생할 때, 모든 플러그인 (VS Code 내장) 이 등록한 관련 DefinitionProvider 가 모두 트리거되어 실행되며, DefinitionProvider 간은 서로의 존재를 알지 못하므로, 자연스럽게 복수�� Provider 가 동일하거나 유사한 Definition 을 제공하는 충돌 상황이 발생합니다
의미 지원의 부재
의미 지원의 부재는 굳은 결함입니다. 예를 들어 빈번히 입구에서 이러한 일을 수행해야 합니다:
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 충돌
동일/유사 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 간의 충돌) 을 해결하려면, 2 가지思路가 있습니다:
-
내장 Provider 의 동작을 추측하고, 내장 플러그인이 처리할 수 있는 case 를 적극적으로 피함
-
VS Code 가 특성 지원을 제공함, 예를 들어
registerDefinitionProvider에 옵션enabelWhenNoReferences를 제공 (다른 사람이 아무도 결과를 내지 않을 때에만 내 것을 사용)
전자는 비교적 취약하며, 추측에 의존하는 것은 모든 상황을 커버하기 어렵고, 더욱이 내장 플러그인이 업데이트되면, 이러한 추측은 적용되지 않을 수 있습니다. 후자는 현재 (2018/12/16) 지원을 제공하지 않으며, 장래에도 제공되지 않을 수 있습니다. 상세한 내용은 Duplicated definition references found when Go to Definition 참조
四.TypescriptServerPlugins
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 Plugin
TypeScript Language Service Plugins ("plugins") are for changing the editing experience only.
편집 체험만 강화할 수 있고, TS 코어 동작 (예를 들어 타입 체크 동작을 변경) 이나 신특성의 추가 (예를 들어 신구문을 제공) 는 할 수 없습니다
구체적으로는, 편집 체험 관련의 일은 이하를 포함합니다:
-
Lint 오류 보고를 제공
-
보완 힌트 리스트를 처리하고, 몇 가지를 필터링함, 예를 들어
window.eval -
Go to definition 을 다른 참조 위치에 가리키게 함
-
문자열 리터럴 형식의 커스텀 템플릿 언어에 오류 보고 및 보완 힌트를 제공함, 예를 들어 Microsoft/typescript-lit-html-plugin
할 수 없는 일은 이하를 포함합니다:
-
TypeScript 에 새로운 커스텀 구문을 추가함
-
컴파일러가 JavaScript 로 전译하는 동작을 변경함
-
타입 시스템을 커스터마이즈하고,
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. 더 많은 유사 예시는 이하를 참조:
-
HelloWorld: 보완 힌트 중의 몇 가지를 필터링함, 예를 들어
caller
contributes.typescriptServerPlugins
Service Plugin 이 완성되면, 다음으로 VS Code 플러그인을 통해 그것을 도입하여, VS Code 의 편집 체험을 강화할 수 있도록 합니다
2 가지 일만 수행하면 되며, 먼저 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 도 완벽하지는 않으며, 제한은 이하와 같습니다:
-
JS/TS 의 확장에만 사용되며, JSX/TSX 등을 지원하지만, 다른 언어는 지원하지 않음
-
편집 체험의 확장만 지원하며, 언어 특성을 변경할 수 없음
-
의미 지원은 여전히 TypeScript Language Service API 에 제한되며, Hook 을 남기지 않은 곳에는涉足할 수 없음
아직 댓글이 없습니다