어떻게? 왜 작동하는가?
자 이전글에서 모노레포에서 tsconfigPath의 마법 같은 경로문제 해결 기능을 보았다.
하지만, 개발자라면 마법과 같은 일은 없으리라는 것을 알고 있을 것이다. 결국 왜? 를 알아야 내 것이 되는 것이다.
어떻게 tsconfigPath가 경로문제를 해결했는지 확인해보자.
바쁜 분은 결론만 보아도 이해에 도움이 될 수 있을 것 같다.
viteFinal
.storybook/main.ts의 viteFinal은 vite-builder를 통해 storybook을 구성하는 경우에 사용되는 옵션이다.
추가적인 플러그인을 적용시키는게 가능하다.
즉 아래 코드는 기존 storybook vite의 config를 가져와서 tsconfigPath plugin을 적용시킨 후에 merge하고, 이를 return하는 것이다.
viteFinal: async (config) => {
return mergeConfig(config, {
plugins: [tsconfigPath()],
});
},
tsconfigPath는 어떻게 경로를 가져오는가?
오랜만에 소스코드를 뜯어봤다.
https://github.com/aleclarson/vite-tsconfig-paths/blob/master/src/index.ts
27번째 줄부터 우리가 사용했던 tsconfigPath 함수의 선언부이다.
이걸 읽기위해서는 vite Plugin의 양식에 대해 알아야한다.
결국 이 함수를 통해 반환된 객체가 vitePlugin으로써 적용되는 것이기 때문이다.
enforce
enforce는 Vite코어 플러그인보다 먼저 실행할지, 이후에 실행할지를 결정지어준다.
즉, 이 플러그인은 'pre'로써 Vite의 코어 플러그인보다 먼저 실행된다.
configResolved
이 메서드의 첫번째 인자를 통해 지금까지 설정된 config값을 읽어올 수 있다.
그럼 이제 코드를 읽어보자.
async configResolved(config) {
let projectRoot = config.root //config에서 vite.config.ts가 있는 경로를 가져옴
let workspaceRoot!: string
let { root } = opts //opts는 tsconfigPath함수를 호출할때 넣어주는 인자이므로, 우리는 undefined이다
if (root) { //따라서 root도 undefined이므로
root = resolve(projectRoot, root)
} else {
//여기가 수행된다.
workspaceRoot = searchForWorkspaceRoot(projectRoot)//이건 vite의 api다.
}
자 여기서 searchForWorkspaceRoot를 찾아보면 모노레포의 최상단 root를 가져온다는 것을 알 수 있다.
그럼 이제 workspaceRoot는 모노레포의 최상단이된다.
const tsconfck = await import('tsconfck') // tsconfck라이브러리를 사용해서
const projects = opts.projects //opts가 undefined이므로 삼항연산자의 뒤쪽을 본다.
? opts.projects.map((file) => {
...
})
//여기를 봐야한다.
: await tsconfck.findAll(workspaceRoot, {
configNames: opts.configNames || ['tsconfig.json', 'jsconfig.json'],
skip(dir) {
return dir == 'node_modules' || dir == '.git'
},
}) //findAll메서드를 찾아봐야한다.
tsconfck의 findAll을 찾아보자.
findAll은 해당 디렉토리에서 tsconfig.json파일을 죄다 찾아서 절대 경로를 반환한다.
그런데 workspaceRoot를 넣었으니 전체 모노레포에서 tsconfig.json의 절대경로를 모두 찾아오는 것이다.
그럼 이제 projects 변수는 모노레포의 모든 tsconfig.json의 절대경로를 갖게된다.
계속 진행해보자.
let hasTypeScriptDep = false
... //중간에 hasTypeScriptDep가 바뀌는 조건문이 있으나 우리 상황하고는 다른 상황이라 뺐다.
const parsedProjects = new Set( //새로운 집합 객체를 만듦
await Promise.all(
projects.map((tsconfigFile) => {//모든 tsconfigFile경로에 대해
if (tsconfigFile === null) {
debug('tsconfig file not found:', tsconfigFile)
return null
}
return (
hasTypeScriptDep //false이다.
? tsconfck.parseNative(tsconfigFile, parseOptions)
: tsconfck.parse(tsconfigFile, parseOptions) //이 친구가 수행된다. parse함수를 찾아보자
).catch((error) => {
...
})
})
)
)
parse는 어떤함수인가?
위 사진을 보면 parse의 반환값은 TSConfckParseResult이다.
반환값이 tsconfigFile, tsconfig, solution, referenced, extended을 가진 객체라는걸 알 수 있다.
이제 parsedProjects는 위의 요소를 가진 유일한 객체의 집합이라는 것을 알 수 있다.
resolversByDir = {}
parsedProjects.forEach((project) => { //아까 가져온 객체들의 집합을 순회한다.
if (!project) {
return
}
if (project.referenced) { // project의 tsconfig에 있는 referenced를 읽어서 있다면
project.referenced.forEach((projectRef) => {
parsedProjects.add(projectRef) //tsconfig의 referenced인 tsconfig을 다시 project에추가한다.
}) //이래서 Set자료형을 사용한 것이다.
parsedProjects.delete(project) //오버라이딩을 막기위해 지웠다가 다시 추가한다고한다.
parsedProjects.add(project) //(코드 주인 피셜, 영어주석은 가독성을 위해 지웠다.)
project.referenced = undefined
} else {
...
}
})
},
자 이렇게 referenced가 있는 tsconfig파일은 재귀적으로 Set객체 내부에 쫙 펴지게 된다.
그러면 referenced가 없는 tsconfig만 Set에 남게되기때문에 반드시 else문과 만나게된다.
그럼 else문을 살펴보자.
const resolver = createResolver(project)
//실제로 파일을 가져오는 resolver를 만들어주는 것 같다. 함수를 좀더 자세히 살펴보자.
if (resolver) { //resolver를 성공적으로 만들었다면
const projectDir = normalizePath(dirname(project.tsconfigFile)) //tsconfigFile의 정규화된 경로를 얻어옴
const resolvers = (resolversByDir[projectDir] ||= [])
// 얻어온 tsconfigFile경로로 resolversByDir객체에서 얻어와서 이걸 resolvers에 할당함
// 근데 해당 tsconfigFile경로에 해당하는 resolversByDir의 값이 없다면 빈배열을 resolverByDir[projectDir]에 할당하고
// 그걸 resolvers에 할당함
resolvers.push(resolver) //resolvers에 방금 만들어진 resolver를 push함
}
그림으로 그려보면 아래와 같이 정리할 수 있다.
createResolver
자 이제 위 객체의 value 배열에 들어갈 resolver를 어떻게 만들어 내는지 확인해보자.
function createResolver(project: TSConfckParseResult): Resolver | null {
const configPath = normalizePath(project.tsconfigFile)
//tsconfigFile의 절대경로위치를 정규화해서 가져온다.
const config = project.tsconfig as { //tsconfig파일의 내용을 가져온다.
files?: string[]
include?: string[]
exclude?: string[]
compilerOptions?: CompilerOptions
}
const options = config.compilerOptions || {}
const { baseUrl, paths } = options //compilerOptions에서 제공한 baseUrl과 path다!
드디어 작성한 절대경로에 접근할 수 있는 baseUrl과 paths 가 등장했다! 계속 가보자.
type InternalResolver = (
viteResolve: ViteResolve,
id: string,
importer: string
) => Promise<string | undefined>
const resolveWithBaseUrl: InternalResolver | undefined = baseUrl
? (viteResolve, id, importer) => viteResolve(join(baseUrl, id), importer)
: undefined
//여기서 viteResolve는 vite의 함수이고,
// id는 해석할 모듈의 경로, importer는 해당 모듈을 가져오는 파일의 경로이다.
//baseUrl값이 없다면 resolveWithBaseUrl은 undefined가 되고,
//아니라면 함수, id, importer를 받아서 해당 함수를 baseUrl+id한 경로, importer와 함께
//인자로 해서 호출하는 함수를 할당한다.
let resolveId: InternalResolver
if (paths) { //지정한 path가 있다면
const pathMappings = resolvePathMappings(
paths,
baseUrl ?? dirname(configPath) //baseUrl이 있으면 그값을
// 없다면 tsconfig가 있는 디렉토리를 넘긴다.
)
resolvePathMappings이 중요해 보인다. 코드를 읽어보자.
export function resolvePathMappings(
paths: Record<string, string[]>,
base: string
) {
const sortedPatterns = Object.keys(paths).sort(
(a: string, b: string) => getPrefixLength(b) - getPrefixLength(a)
) //패턴을 Prefix 길이 별로 정렬한다.
const resolved: PathMapping[] = []
for (let pattern of sortedPatterns) {
const relativePaths = paths[pattern] //해당 패턴의 상대경로를 가져온다.
//ex) @/*: ["src/*"]인경우 relativePaths는 ["src/*"]이 된다.
pattern = escapeStringRegexp(pattern).replace(/\*/g, '(.+)')
resolved.push({
pattern: new RegExp('^' + pattern + '$'),
//key에 해당하는 값을 정규식 패턴으로 저장한다.
paths: relativePaths.map((relativePath) => resolve(base, relativePath)),
//base에 붙여서 상대 경로를 반환한다.
})
}
return resolved //전체 tsconfig에서 지정한 절대 경로 가 pattern, paths를 가진 객체 배열로 변환된다.
}
여기부분이 tsconfig에서 지정한 절대경로를 객체형태로 변환해서 읽는 부분이다.
이렇게 얻어온 pathMappings를 어떻게 사용하는지 보자.
// -------------------읽은 부분이다.---------------
if (paths) {
const pathMappings = resolvePathMappings(
paths,
baseUrl ?? dirname(configPath)
)
//여기까지 pathMappings을 가져왔다.
// -------------------읽은 부분이다.---------------
const resolveWithPaths: InternalResolver = async (
viteResolve,
id,
importer
) => {
for (const mapping of pathMappings) {
const match = id.match(mapping.pattern)
//인자로 받은 id가 아까 pathMapping안에 있던 패턴과 일치하는 지 확인한다.
if (!match) {
continue
}
for (let pathTemplate of mapping.paths) {
let starCount = 0
const mappedId = pathTemplate.replace(/\*/g, () => {
const matchIndex = Math.min(++starCount, match.length - 1)
return match[matchIndex]
}) //mappedId를 통해 와일드 카드를 실제 경로로 치환해준다
//ex) src/* -> src/components
const resolved = await viteResolve(mappedId, importer)
if (resolved) {
return resolved
}
}
}
}
여기까지 resolveWithPaths라는 함수가 와일드카드가 포함된 패턴을 실제 경로로 매칭시켜주는 일을 한다는 것을 알 수 있다. 계속 가보자.
if (resolveWithBaseUrl) { //baseUrl이 있어서 undefined가 아닌상태라면
resolveId = (viteResolve, id, importer) =>
//선언한 resolveWithPaths를 통해 경로를 가져온다.
resolveWithPaths(viteResolve, id, importer).then((resolved) => {
//resovled되지 않았다면, baseUrl에서 찾아온다.
return resolved ?? resolveWithBaseUrl(viteResolve, id, importer)
})
} else {
resolveId = resolveWithPaths //위에 선언한 resolveWithPaths를 resolveId에 할당한다
}
} else {
//여기의 else는 path가 없는 경우이다.
resolveId = resolveWithBaseUrl! //기본 resolveWithBaseUrl을 할당한다.
}
여기까지보면 resolveId에 resolveWithPaths나 resolveWithBaseUrl을 통해서 viteResolve를 호출할 함수를 할당해주고있다는 것을 알 수 있었다.
이후에도 많은 부분이 있었지만 주요 골자가 아니므로 return부로 넘어가자
return async (viteResolve, id, importer) => { //잘보면 함수를 반환한다는걸 알 수 있다.
...
importer = normalizePath(importer)
...
path = await resolveId(viteResolve, id, importer)
//path를 절대경로를 통해 viteResolve함수를 실행시키는 resolveId함수로 얻어온다.
return [path && suffix ? path + suffix : path, true]
//suffix는 Vite의 접미사인데, 요부분은 신경쓰지 않고 코드를 따로 보지 않았다.
// return [path,true] 라고 봐도 현재 읽고 있는 관점에서는 무방하다고 생각한다.
}
}
}
이렇게 createResolver를 호출하면 path가 담긴 array를 반환하는 resolver함수가 반환된다는것을 확인할 수 있었다.
자 여기까지가 configResolved함수가 호출됐을때 일어나는 일이었다.
configResolved 정리
위 과정을 통해 모노레포에서 configResolved가 호출되었을때 아래와 같이 작동한다는 것을 알 수 있다.
그런데 이건 Vite값이 확정된 후 호출되는 훅이다.
이렇게 만든 resolversByDir는 어디서 사용하는가?
바로 resolveId이다. 마지막으로 resolvedId메서드를 보자.
resolveId
이 메서드가 모듈을 불러올때마다 호출되어, 실제로 그 역할을 해내게 된다.
async resolveId(id, importer, options) {
if (importer && !relativeImportRE.test(id) && !isAbsolute(id)) {
...
//여기가 바로 위에서 그토록 썼던 viteResolve의 본체다
const viteResolve: ViteResolve = async (id, importer) =>
(await this.resolve(id, importer, resolveOptions))?.id
//vite의 this객체에서 resolve메서드를 사용하는 것으로 보인다.
let prevProjectDir: string | undefined
let projectDir = dirname(importer) //import하려는 모듈의 디렉터리
loop: while (projectDir && projectDir != prevProjectDir) {
//tsconfig과 만날때까지 폴더를 거슬러 올라가며 경로를 탐색한다.
const resolvers = resolversByDir[projectDir] //미리만들어둔 객체에서 resolvers를 가져온다.
if (resolvers)
for (const resolve of resolvers) {
const [resolved, matched] = await resolve(
viteResolve,
id,
importer
)
if (resolved) {
return resolved //모듈을 resolve시킨다.
}
if (matched) {
// Once a matching resolver is found, stop looking.
break loop
}
}
prevProjectDir = projectDir //이전경로를 현재경로로
projectDir = dirname(prevProjectDir) //현재 경로 폴더의 경로를 얻으므로 한단계 상위로 올라가게 된다.
}
}
},
}
resolveId정리
위 코드를 통해 모노레포에서 어느 곳에서든 vite를 통해 특정 모듈을 요청할때마다 아래와 같이 작동한다는 것을 알 수 있다.
정리
1. Vite 서버가 실행되면 configResolved가 수행되며, 이때 아래의 그림 과정을 통해 모노레포에서 tsconfig에 따라 모듈 상대/절대경로를 정해주는 resolversByDir 객체를 만들어둔다.
2. 모듈이 요청되면 resolveId가 수행되며 해당 모듈이 있는 디렉토리부터 상위 디렉토리로 이동하며 tsconfig이 있는 디렉토리까지 이동한다. 그러면 resolversByDir 에 resolver함수가 존재하므로 resolve가 되고 모듈이 가져와진다.