Nhiệm vụ: Thêm tính năng phân tích mã nguồn
Thêm tính năng phân tích mã nguồn
Tôi đã suy nghĩ rất nhiều về các cơ chế khác nhau theo hướng này, và cho đến nay, việc sử dụng máy chủ TypeScript có vẻ là phương pháp triển vọng nhất. Tại sao? Bởi vì khi đó chúng ta có thể không chỉ thao tác với chính các tệp và đọc chúng riêng lẻ, mà còn phân tích các phụ thuộc của chúng. Ví dụ, chúng ta bảo tác nhân kiểm tra một thành phần nào đó. Nó có thể chỉ cần thông qua một công cụ đặc biệt để lấy các thực thể có ở đó (biến, hàm, import, export, v.v.), cũng như dễ dàng yêu cầu các phụ thuộc bổ sung mà nó cần mà không cần bận tâm về vị trí của chúng. Nhưng nếu nó cần mã nguồn cuối cùng của phụ thuộc cần thiết, nó có thể dễ dàng biết được phụ thuộc đó nằm ở tệp nào và đọc nó.
Nhân tiện, chúng ta cũng nên có các công cụ để đọc tệp với phạm vi dòng được chỉ định. Rốt cuộc, một tệp mã nguồn có thể rất lớn, và nếu TS cung cấp ngay thông tin không chỉ về vị trí của nó mà còn về phạm vi dòng, thì chúng ta không bắt thiết phải đọc toàn bộ tệp đó.
Ворклоги
Đã lấy được các phần việc cũ, đây là tập hợp tối thiểu các phương thức hữu ích.
query analyzeDir {
analyzeDir(rootDir: "src/components/pages/_App/")
}
{
"data": {
"analyzeDir": {
"rootDir": "src/components/pages/_App",
"count": 3,
"files": [
{
"path": "src/components/pages/_App/getInitialProps.ts"
},
{
"path": "src/components/pages/_App/index.tsx"
},
{
"path": "src/components/pages/_App/interfaces.ts"
}
]
}
}
}
query analyzeFile {
analyzeFile(path: "src/components/pages/_App/getInitialProps.ts") {
path
imports {
default
isExternalLibraryImport
module
namespace
resolvedPath
named {
name
alias
}
}
exports
}
}
{
"data": {
"analyzeFile": {
"path": "src/components/pages/_App/getInitialProps.ts",
"imports": [
{
"default": null,
"isExternalLibraryImport": false,
"module": "src/gql/apolloClient",
"namespace": null,
"resolvedPath": "src/gql/apolloClient/index.ts",
"named": [
{
"name": "initializeApollo",
"alias": null
}
]
},
{
"default": "NextApp",
"isExternalLibraryImport": true,
"module": "next/app",
"namespace": null,
"resolvedPath": null,
"named": []
},
{
"default": null,
"isExternalLibraryImport": false,
"module": "./interfaces",
"namespace": null,
"resolvedPath": "src/components/pages/_App/interfaces.ts",
"named": [
{
"name": "AppInitialProps",
"alias": null
},
{
"name": "MainApp",
"alias": null
},
{
"name": "NextPageContextCustom",
"alias": null
},
{
"name": "PageProps",
"alias": null
},
{
"name": "withWs",
"alias": null
}
]
},
{
"default": null,
"isExternalLibraryImport": false,
"module": "src/helpers/getSiteOrigin",
"namespace": null,
"resolvedPath": "src/helpers/getSiteOrigin.ts",
"named": [
{
"name": "getSiteOrigin",
"alias": null
}
]
}
],
"exports": [
{
"name": "getInitialProps",
"kind": "Function",
"line": 13
}
]
}
}
}
Nhưng vấn đề hiện tại là TypeScript server của chúng ta được khởi chạy trên mỗi request.
import path from 'path'
import ts from 'typescript'
export interface ProgramContext {
program: ts.Program
checker: ts.TypeChecker
/** Dir base directory (thư mục chứa tsconfig.json). */
baseDir: string
}
/**
* Xây dựng một Chương trình TypeScript từ tsconfig.json của dự án.
* Được xây dựng lại cho mỗi lần gọi — bộ đệm tăng dần của TS nằm bên trong một Chương trình duy nhất,
* và tiến trình máy chủ có thể nhận biết các thay đổi tệp giữa các request.
*/
export function getProgram(): ProgramContext {
const configPath = ts.findConfigFile('./', ts.sys.fileExists, 'tsconfig.json')
if (!configPath) {
throw new Error('tsconfig.json not found')
}
const baseDir = path.resolve(path.dirname(configPath))
const configFile = ts.readConfigFile(configPath, ts.sys.readFile)
const parsed = ts.parseJsonConfigFileContent(
configFile.config,
ts.sys,
baseDir,
)
/*
Ngay tại đây chúng ta tạo ts-server và đây là một thao tác khá nặng nề,
nó không chỉ ngốn nhiều CPU và bộ nhớ mà còn gây ra độ trễ đáng kể
*/
const program = ts.createProgram({
rootNames: parsed.fileNames,
options: parsed.options,
})
return {
program,
checker: program.getTypeChecker(),
baseDir,
}
}
Chúng ta cần có khả năng duy trì các instance ts-server lâu hơn để không phải khởi động lại từ đầu mỗi lần. Và đây là một vài lựa chọn (theo opus 4.5):
1. ts.createIncrementalProgram / ts.createWatchProgram
Cơ chế tích hợp sẵn của TypeScript để biên dịch tăng dần:
const host = ts.createWatchCompilerHost(configPath, {}, ts.sys);
const watchProgram = ts.createWatchProgram(host);
const program = watchProgram.getProgram().getProgram();
Cache AST và chỉ biên dịch lại các tệp đã thay đổi.
2. ts.server.ProjectService (Language Service)
Đây là thứ mà VS Code sử dụng. Một language server đầy đủ tính năng có hỗ trợ cache:
import * as ts from 'typescript/lib/tsserverlibrary'
const projectService = new ts.server.ProjectService({ ... })
Nặng hơn, nhưng cung cấp quyền truy cập vào tsserver đang chạy.
3. Kết nối với tsserver hiện có qua LSP
Nếu tsserver đang chạy (ví dụ: trong IDE), bạn có thể giao tiếp với nó qua stdio hoặc node-ipc. Nhưng điều này đòi hỏi phải biết nó đang lắng nghe ở đâu.
4. Cache đơn giản ở cấp độ ứng dụng
Lưu trữ program trong bộ nhớ và chỉ tạo lại khi các tệp thay đổi:
let cachedProgram: ts.Program | null = null;
let lastModified: number = 0;
export function getProgram(baseDir: string) {
const currentModified = getMaxMtime(baseDir);
if (cachedProgram && currentModified <= lastModified) {
return {
program: cachedProgram,
checker: cachedProgram.getTypeChecker(),
baseDir,
};
}
}
Khuyến nghị: Nếu bạn cần kết quả nhanh — phương án 4 (cache đơn giản). Nếu bạn cần tích hợp đầy đủ với IDE — phương án 2 hoặc 3, nhưng điều này phức tạp hơn đáng kể.
Đã viết lại hàm lấy TypeScript server. Bây giờ chương trình có vòng đời riêng (bộ đếm thời gian kể từ lần gọi cuối cùng) để nó không bị treo vô thời hạn nếu không có yêu cầu nào được gửi đến. Giờ đây, lần khởi động lạnh đầu tiên sẽ mất một khoảng thời gian tùy thuộc vào dự án, nhưng sau đó mọi yêu cầu được xử lý gần như tức thì, kể cả trên các yêu cầu khác nhau. Nghĩa là, bạn có thể yêu cầu danh sách các tệp trước (đây là một resolver), sau đó phân tích một tệp nào đó (đây là một resolver khác), và kết quả phân tích sẽ trả về gần như ngay lập tức (giống như các yêu cầu tiếp theo khác).
import path from 'path'
import ts from 'typescript'
export interface ProgramContext {
program: ts.Program
checker: ts.TypeChecker
/** Dir base directory (the folder that contains tsconfig.json). */
baseDir: string
}
export interface GetProgramOptions {
/** Absolute path to project root directory */
projectRoot: string
/** Path to tsconfig.json (relative to projectRoot or absolute). Default: 'tsconfig.json' */
tsconfigPath?: string
}
interface CachedProject {
program: ts.Program
/** File versions for incremental rebuild detection */
fileVersions: Map<string, number>
/** Timestamp of last access */
lastAccess: number
/** Timer for TTL expiration */
expirationTimer: ReturnType<typeof setTimeout>
}
/** TTL in milliseconds (30 minutes) */
const CACHE_TTL_MS = 30 * 60 * 1000
/** Cache of programs per project root */
const projectCache = new Map<string, CachedProject>()
/**
* Get file modification time as version number.
*/
function getFileVersion(filePath: string): number {
try {
const stat = ts.sys.getModifiedTime?.(filePath)
return stat ? stat.getTime() : 0
} catch {
return 0
}
}
/**
* Check if any source files have changed since last build.
*/
function hasChanges(cached: CachedProject): boolean {
for (const [filePath, version] of cached.fileVersions) {
if (getFileVersion(filePath) !== version) {
return true
}
}
return false
}
/**
* Build or retrieve a cached TypeScript Program for the given project.
* Uses incremental compilation — only rebuilds when source files change.
*/
export function getProgram(options: GetProgramOptions): ProgramContext {
const { projectRoot, tsconfigPath = 'tsconfig.json' } = options
const baseDir = path.resolve(projectRoot)
const configPath = path.isAbsolute(tsconfigPath)
? tsconfigPath
: path.join(baseDir, tsconfigPath)
if (!ts.sys.fileExists(configPath)) {
throw new Error(`tsconfig.json not found at ${configPath}`)
}
const cacheKey = configPath
const cached = projectCache.get(cacheKey)
if (cached && !hasChanges(cached)) {
resetExpirationTimer(cacheKey, cached)
return {
program: cached.program,
checker: cached.program.getTypeChecker(),
baseDir,
}
}
const configFile = ts.readConfigFile(configPath, ts.sys.readFile)
const parsed = ts.parseJsonConfigFileContent(
configFile.config,
ts.sys,
baseDir,
)
const program = ts.createProgram({
rootNames: parsed.fileNames,
options: parsed.options,
oldProgram: cached?.program,
})
const fileVersions = new Map<string, number>()
for (const sourceFile of program.getSourceFiles()) {
fileVersions.set(sourceFile.fileName, getFileVersion(sourceFile.fileName))
}
if (cached) {
clearTimeout(cached.expirationTimer)
}
const newCached: CachedProject = {
program,
fileVersions,
lastAccess: Date.now(),
expirationTimer: createExpirationTimer(cacheKey),
}
projectCache.set(cacheKey, newCached)
return {
program,
checker: program.getTypeChecker(),
baseDir,
}
}
/**
* Reset expiration timer on cache access.
*/
function resetExpirationTimer(cacheKey: string, cached: CachedProject): void {
clearTimeout(cached.expirationTimer)
cached.lastAccess = Date.now()
cached.expirationTimer = createExpirationTimer(cacheKey)
}
/**
* Create a timer that removes the cache entry after TTL.
*/
function createExpirationTimer(cacheKey: string): ReturnType<typeof setTimeout> {
return setTimeout(() => {
projectCache.delete(cacheKey)
}, CACHE_TTL_MS)
}
/**
* Clear cached program for a specific project.
*/
export function clearProgramCache(configPath: string): void {
const key = path.resolve(configPath)
const cached = projectCache.get(key)
if (cached) {
clearTimeout(cached.expirationTimer)
projectCache.delete(key)
}
}
/**
* Clear all cached programs.
*/
export function clearAllProgramCaches(): void {
for (const cached of projectCache.values()) {
clearTimeout(cached.expirationTimer)
}
projectCache.clear()
}