[Node] TypeScript 中的 symbolLinks
0. 背景
TypeScript 在跨文件查找符号定义时,是借助 symbolLinks
进行定位的。
当前文件 import
的符号,会通过 symbolLinks
与其他文件 export
的符号建立关联。
下文我们来探索一下 symbolLinks
的建立和使用过程。
1. 查找定义
1.1 VSCode 示例
我们新建了两个文件 index.ts 还有它的依赖 lib.ts。
index.ts 的内容如下,
import x from './lib';
x
lib.ts
的内容如下,
const a = 1;
export default a;
在 VSCode 中查看 index.ts 文件中第二行 x
的定义,
就会跳转到 lib.ts 第一行 a
的位置。
1.2 Mock GoToDefinition
我们来模拟一下上述 VSCode 查找定义的过程。
以下示例完整的代码在这里:github: debug-symbol-links
克隆、安装依赖、并执行构建之后,选择 Mock GoToDefinition
进行 Debug,
我们成功为 index.ts 中的 x
,找到了它的定义 lib.ts 中的 a
。
与 VSCode 内部实现一致,
我们传给 goToDefinition
的是 x
的位置 23
(文件中从左到右的字符数,从 0
开始),
返回的也是一个位置,fileName
是 lib.ts 的绝对地址,textSpan
表示了 a
的起始位置和宽度。
1.3 Symbol Links
那么 TypeScript 到底是怎样跨文件查找定义的呢?
这就涉及到了 TypeScript 实现中的 symbolLinks
对象。
我们在 node_modules/_typescript@3.7.3@typescript/lib/typescript.js#L35183
,
resolveAlias 函数中,打个条件断点,
来看看 TypeScript 是怎么给 x
这个符号建立 symbolLinks 的。
symbol.name === 'x'
function resolveAlias(symbol: Symbol): Symbol {
...
const links = getSymbolLinks(symbol); // 获取符号 x 的 symbolLinks
if (!links.target) {
links.target = resolvingSymbol; // 先设置一个正在解析的标志
...
const target = getTargetOfAliasDeclaration(node);
if (links.target === resolvingSymbol) {
// 解析到了就在 symbolLinks 中建立关联,否则就关联到 unknown 符号上
links.target = target || unknownSymbol;
}
...
}
...
return links.target;
}
resolveAlias
对于 symbolLinks 来说,是一个很重要的函数。
符号 x
一开始的 symbolLinks
之 target
字段是空的,并未指向其他符号,
resolveAlias
做的事情,就是找到 lib.ts 中的符号 a
。
找 lib.ts 中符号 a
的过程,是通过 getTargetOfAliasDeclaration
来做的,
它会根据 lib.ts 文件语义分析的结果,找到所有它导出的符号,
然后在这些符号上递归调用 resolveAlias
,找到它们 symbolLinks
的 target
。
这样才能从 x
找到 export
,然后再找到 a
。
我们可以取消上述 resolveAlias
断点处的条件判断,看看递归调用过程,
可以看到
resolveAlias
在获取符号 x
的 target
时,又递归了自己,继续获取符号
default
(模块 default
导出的符号)的 target
。
这样就可以将 x
symbolLinks
的 target
直接指向 a
了。
知道了这些之后,我们来 hack 一下,看能不能让 TypeScript 去我们指定的文件中查找定义。
2. Hack ResolveAlias
2.1 示例
为此,我们新建一个 hack.ts 文件作为示例,
import x from './hack_lib';
x
它依赖了 hack_lib.ts,但是这个文件并不存在。
我们要做的事情是,在 resolveAlias
解析不到 x
symbolLinks
之 target
时,
手动给它指定一个 “target”。
完整的代码在这里:github: debug-symbol-links
克隆、安装依赖、并执行构建之后,选择 Hack GoToDefinition
进行 Debug,
居然可以找到 x
的定义了!
我们来看下这是怎么实现的。
2.2 手动建立关联
在 resolveAlias
中,我们嵌入了一些代码,
每次给符号的 symbolLinks
查找完 target
都会调用它。
ts._hackResolveAlias && ts._hackResolveAlias(symbol, links, target, resolveAlias);
在这个函数中,我们进行判断,如果没有找到 target
,就手动给它指定一个。
具体步骤如下:
(1)手动加载一个外部文件,并进行语义分析
(2)找到这个模块导出的符号,并递归调用 resolveAlias
找到符号的源头
(3)设置 symbolLinks
的 target
字段,建立关联
const hackResolveAlias = (symbol: ts.Symbol, links, target: ts.Symbol | undefined, tsResolveAlias) => {
if (
symbol.flags & ts.SymbolFlags.Alias // 是一个符号别名
&& target == null // 且没找到别名
) {
// 认为这是在对导入的符号建立 symbolLinks 时没有成功
// 手动加载一个外部文件,并进行语义分析
const libFilePath = path.join(__dirname, '../../debug/lib.ts');
// 设置它是一个外部模块,语义分析时才会计算 sourceFile.symbol
const isExternalModule = true;
const sourceFile = createSourceFile(libFilePath, isExternalModule);
// 语义分析之后,sourceFile.symbol 才有值
bindSourceFile(sourceFile, compilerOptions);
// 找到这个模块 default 导出的符号
const { symbol: moduleSymbol } = sourceFile as any;
const exportSymbol = moduleSymbol.exports.get(ts.InternalSymbolName.Default);
// 递归解析,找到 default 导出的符号之源头在哪里
const target = tsResolveAlias(exportSymbol);
// 手动建立 symbolLinks
links.target = target;
}
};
2.3 嵌入代码
嵌入代码时,首先锁定了 package.json TypeScript 的版本,
然后使用了配置方式,在 postinstall
时修改文件,
const config = [
// 在 node_modules/typescript/lib/typescript.js#L35185 之前插入 hack 代码
{
file: path.join(__dirname, '../node_modules/typescript/lib/typescript.js'),
embeds: [
{
insert: 35185,
code: `ts._hackResolveAlias && ts._hackResolveAlias(symbol, links, target, resolveAlias);`,
},
],
},
];
源码在这里:github: debug-symbol-links/script/config.js