背景#
之前在進行 SDK 編譯流程的時候,為了方便開發者開發,經常會寫一些 alias 方便將一些長的相對路徑變成一個個很短的 alias 之後的路徑。
例如
{
// ...
"baseUrl":"src",
"paths": {
"@/package": ["./index"],
"@/package/*": ["./*"],
}
}
這樣的作用是將別名永遠指向 src 目錄,當 src 目錄下面層級非常深的文件引用頂層的文件的時候,可以直接寫成,這樣可以減少相對路徑的引用,讓代碼看起來美觀 && 方便調整文件的目錄結構。
import Components from "@/package/ui/header"
這樣的話開發階段的 DX 體驗友好,但是當我們最終把代碼打包成 SDK 的時候,使用 SDK 的宿主環境不一定和我們配置成一樣的 alias,不一致的話就會存在找不到文件的問題,這就需要我們在打包 TS 的時候將 alias 的相關文件編譯回相對路徑,以實現所有引入方的兼容。這樣就需要對 TS 的編譯流程有個了解。
TS compiler 相關#
TS 的編譯流程#
SourceCode(源碼) ~~ 掃描器(Scanner) ~~> Token 流
Token 流 ~~ 解析器 (parser) ~~> AST(抽象語法樹)
AST ~~ 綁定器 (binder) ~~> Symbols(符號)
AST + 符號(Symbols) ~~ 檢查器 ~~> 類型驗證
AST + 檢查器 ~~ 發射器(emitter) ~~> JavaScript 代碼
掃描器#
TS(TypeScript)中的 scanner(掃描器)是編譯器的第一個階段,也被稱為詞法分析器。它負責將源代碼文件中的字符流轉換成一系列的詞法單元(tokens)。
工作方式如下:
- 讀取字符流: scanner 從源代碼文件中逐個讀取字符。
- 識別詞法單元: scanner 根據一組預定義的語法規則,將字符組合成識別出的詞法單元,如標識符、關鍵字、運算符、常量等。它使用有限自動機(finite automation)或正則表達式來匹配字符序列。
- 生成詞法單元:一旦識別出一個完整的詞法單元,scanner 將其生成为一個包含類型和值信息的對象,並將其傳遞給下一個階段的編譯器。
- 處理特殊情況: scanner 同時處理特殊情況,如註釋、字符串字面量,以及對轉義字符的解析等。
例如,考慮以下 TypeScript 代碼片段:
let age: number = 25;
scanner 將逐個讀取字符並生成以下詞法單元:
- let 關鍵字
- age 標識符
- : 冒號(運算符)
- number 關鍵字
- = 等號(運算符)
- 25 數字常量
- ; 分號(分隔符)
詞法單元生成的順序由語法規則定義,scanner 會不斷重複這個過程,直到源代碼文件中的所有字符都被處理完畢。這個階段只是將相關的 token 提取出來,沒有進行語法,語義相關的分析。
import * as ts from "typescript";
// TypeScript has a singleton scanner
const scanner = ts.createScanner(ts.ScriptTarget.Latest, /*skipTrivia*/ true);
// That is initialized using a function `initializeState` similar to
function initializeState(text: string) {
scanner.setText(text);
scanner.setOnError((message: ts.DiagnosticMessage, length: number) => {
console.error(message);
});
scanner.setScriptTarget(ts.ScriptTarget.ES5);
scanner.setLanguageVariant(ts.LanguageVariant.Standard);
}
// Sample usage
initializeState(`
var foo = 123;
`.trim());
// Start the scanning
var token = scanner.scan();
while (token != ts.SyntaxKind.EndOfFileToken) {
console.log(ts.SyntaxKind[token]);
token = scanner.scan();
}
output
VarKeyword
Identifier
FirstAssignment
FirstLiteralToken
SemicolonToken
解析器#
TS(TypeScript)的解析器是用於將 TypeScript 代碼轉換為抽象語法樹(Abstract Syntax Tree,簡稱 AST)的工具。解析器的主要作用是將源代碼解析為語法樹,以便後續的靜態分析、類型檢查和編譯等操作。
解析器通過分析源代碼的詞法(Lexical)和語法(Syntactic)結構來構建語法樹。詞法分析階段將源代碼分解為標記(Tokens),例如關鍵字、標識符、運算符和常量等。語法分析階段將標記組織成一個樹形結構,確保代碼的語法正確性。
import * as ts from "typescript";
function printAllChildren(node: ts.Node, depth = 0) {
console.log(new Array(depth + 1).join('----'), ts.SyntaxKind[node.kind], node.pos, node.end);
depth++;
node.getChildren().forEach(c=> printAllChildren(c, depth));
}
var sourceCode = `
var foo = 123;
`.trim();
var sourceFile = ts.createSourceFile('foo.ts', sourceCode, ts.ScriptTarget.ES5, true);
printAllChildren(sourceFile);
output
SourceFile 0 14
---- SyntaxList 0 14
-------- VariableStatement 0 14
------------ VariableDeclarationList 0 13
---------------- VarKeyword 0 3
---------------- SyntaxList 3 13
-------------------- VariableDeclaration 3 13
------------------------ Identifier 3 7
------------------------ FirstAssignment 7 9
------------------------ FirstLiteralToken 9 13
------------ SemicolonToken 13 14
---- EndOfFileToken 14 14
綁定器#
一般的 JavaScript 解析器的流程大致是
SourceCode ~~Scanner~~> Tokens ~~Parser~~> AST ~~Emitter~~> JavaScript
但是上面的流程對於 TS 來說少了一個關鍵的步驟 TypeScript 的語義** 系統,** 為了協助(檢查器執行)類型檢查,綁定器將源碼的各部分連接成一個相關的類型系統,供檢查器使用。綁定器的主要職責是創建_符號_(Symbols)
- 簡單理解
- 深入探索結構
#
可以通過裡面的 pos end 來判斷作用域相關的引用唯一性
檢查器#
這裡會聯合上面綁定器產生出來的 Symbol 一起做類型推導、類型檢查等
代碼示例
import * as ts from "typescript";
import path from 'path'
// 創建一個 TypeScript 項目
const program = ts.createProgram({
rootNames: [path.join(__dirname, './check.ts')], // 項目中所有要檢查的文件的路徑
options: {
...ts.getDefaultCompilerOptions(),
baseUrl: '.'
}, // 編譯選項
});
// 獲取項目中的所有語義錯誤
const diagnostics = ts.getPreEmitDiagnostics(program)
// 打印錯誤信息
diagnostics.forEach((diagnostic) => {
console.log(
`Error: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`
);
});
check.ts
const a:string = 1
console.log(a)
const b = ({)
output
Error: Type 'number' is not assignable to type 'string'.
Error: Property assignment expected.
Error: '}' expected.
發射器#
- emitter.ts:是 TS -> JavaScript 的發射器
- declarationEmitter.ts:這個發射器用於為 TypeScript 源文件(.ts) 創建_聲明文件_
Emit 階段會調用 Printer 將 AST 轉換為文本,Printer(打印器)這個名字非常貼切,將 AST 打印成文本
import * as ts from 'typescript';
const printer = ts.createPrinter();
const result = printer.printNode(
ts.EmitHint.Unspecified,
makeNode(),
undefined,
);
console.log(result);
function makeNode() {
return ts.factory.createVariableStatement(
undefined,
ts.factory.createVariableDeclarationList(
[
ts.factory.createVariableDeclaration(
ts.factory.createIdentifier('video'),
undefined,
ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword),
ts.factory.createStringLiteral('conference'),
),
],
ts.NodeFlags.Const,
),
);
}
Transformers#
上面的部分介紹了 TS 編譯代碼的一些流程,同時 TS 也給我們提供了一些類似於 “生命週期的鉤子”,允許我們在編譯的流程中添加自己自定義的部分。
- before 在 TypeScript 之前運行轉換器(代碼還沒有編譯)
- after 在 TypeScript 之後運行轉換器(代碼已編譯)
- afterDeclarations 在聲明步驟之後運行轉換器(你可以在這裡轉換類型定義)
API#
visiting#
- ts.visitNode (node, visitor) 用來遍歷 root node
- ts.visitEachChild (node, visitor, context) 用來遍歷子節點
- ts.isXyz (node) 用來判斷節點類型的 例如 ts.isVariableDeclaration (node)
Nodes#
- ts.createXyz 創建新節點(然後返回),ts.createIdentifier ('world')
- ts.updateXyz 用來更新節點 ts.updateVariableDeclaration ()
寫一個 transformer#
const transformer =
(_program: ts.Program) => (context: ts.TransformationContext) => {
return (sourceFile: ts.Bundle | ts.SourceFile) => {
const visitor = (node: ts.Node) => {
console.log('zxzxxxx', node);
if (ts.isIdentifier(node)) {
switch (node.escapedText) {
case 'babel':
return ts.factory.createStringLiteral('babel-transformer');
case 'typescript':
return ts.factory.createStringLiteral('typescript-transformer');
}
}
return ts.visitEachChild(node, visitor, context);
};
return ts.visitNode(sourceFile, visitor);
};
};
const program = ts.createProgram([path.join(__dirname, './02.ts')], {
baseUrl: '.',
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext,
declaration: true,
declarationMap: true,
jsx: ts.JsxEmit.React,
moduleResolution: ts.ModuleResolutionKind.NodeJs,
skipLibCheck: true,
allowSyntheticDefaultImports: true,
outDir: path.join(__dirname, '../dist/transform'),
});
const res = program.emit(undefined, undefined, undefined, undefined, {
after: [transformer(program)],
});
console.log(res);
更多代碼示例 https://github.com/itsdouges/typescript-transformer-handbook/tree/master/example-transformers
實際具體的應用#
import path from 'path';
import { chain, head, isEmpty } from 'lodash';
import ts from 'typescript';
export function replaceAlias(
fileName: string,
importPath: string,
paths?: Record<string, string[]>
) {
if (isEmpty(paths)) return importPath;
const normalizedPaths = chain(paths)
.mapKeys((_, key) => key.replace(/\*$/, ''))
.mapValues(head)
.omitBy(isEmpty)
.mapValues((resolve) => (resolve as string).replace(/\*$/, ''))
.value();
for (const [alias, resolveTo] of Object.entries(normalizedPaths)) {
if (importPath.startsWith(alias)) {
const resolvedPath = importPath.replace(alias, resolveTo);
const relativePath = path.relative(path.dirname(fileName), resolvedPath);
return relativePath.startsWith('.') ? relativePath : `./${relativePath}`;
}
}
return importPath;
}
export default function (_program?: ts.Program | null, _pluginOptions = {}) {
return ((ctx) => {
const { factory } = ctx;
const compilerOptions = ctx.getCompilerOptions();
return (sourceFile: ts.Bundle | ts.SourceFile) => {
const { fileName } = sourceFile.getSourceFile();
function traverseVisitor(node: ts.Node): ts.Node | null {
let importValue: string | null = null;
if (ts.isCallExpression(node)) {
const { expression } = node;
if (node.arguments.length === 0) return null;
const arg = node.arguments[0];
if (!ts.isStringLiteral(arg)) return null;
if (
// Can't call getText on after step
expression.getText(sourceFile as ts.SourceFile) !== 'require' &&
expression.kind !== ts.SyntaxKind.ImportKeyword
)
return null;
importValue = arg.text;
// import, export
} else if (
ts.isImportDeclaration(node) ||
ts.isExportDeclaration(node)
) {
if (
!node.moduleSpecifier ||
!ts.isStringLiteral(node.moduleSpecifier)
) return null;
importValue = node.moduleSpecifier.text;
} else if (
ts.isImportTypeNode(node) &&
ts.isLiteralTypeNode(node.argument) &&
ts.isStringLiteral(node.argument.literal)
) {
importValue = node.argument.literal.text;
} else if (ts.isModuleDeclaration(node)) {
if (!ts.isStringLiteral(node.name)) return null;
importValue = node.name.text;
} else {
return null;
}
const newImport = replaceAlias(
fileName,
importValue,
compilerOptions.paths
);
if (!newImport || newImport === importValue) return null;
const newSpec = factory.createStringLiteral(newImport);
let newNode: ts.Node | null = null;
if (ts.isImportTypeNode(node))
newNode = factory.updateImportTypeNode(
node,
factory.createLiteralTypeNode(newSpec),
node.assertions,
node.qualifier,
node.typeArguments,
node.isTypeOf
);
if (ts.isImportDeclaration(node))
newNode = factory.updateImportDeclaration(
node,
node.modifiers,
node.importClause,
newSpec,
node.assertClause
);
if (ts.isExportDeclaration(node))
newNode = factory.updateExportDeclaration(
node,
node.modifiers,
node.isTypeOnly,
node.exportClause,
newSpec,
node.assertClause
);
if (ts.isCallExpression(node))
newNode = factory.updateCallExpression(
node,
node.expression,
node.typeArguments,
[newSpec]
);
if (ts.isModuleDeclaration(node))
newNode = factory.updateModuleDeclaration(
node,
node.modifiers,
newSpec,
node.body
);
return newNode;
}
function visitor(node: ts.Node): ts.Node {
return traverseVisitor(node) || ts.visitEachChild(node, visitor, ctx);
}
return ts.visitNode(sourceFile, visitor);
};
}) as ts.TransformerFactory<ts.Bundle | ts.SourceFile>;
}
參考資料#
https://www.youtube.com/watch?v=BU0pzqyF0nw
https://github.com/basarat/typescript-book
https://github.com/itsdouges/typescript-transformer-handbook
https://github.com/LeDDGroup/typescript-transform-paths
https://github.com/nonara/ts-patch
https://github.com/LeDDGroup/typescript-transform-paths/blob/v1.0.0/src/index.ts
https://github.com/microsoft/TypeScript-Compiler-Notes