Babel (1): Babel과 plugin
바벨이란?
바벨은 JS의 컴파일러, 트랜스파일러라고 한다.
바벨의 역할을 이야기하자면 브라우저 호환을 위한 es 버전을 맞게 코드를 변환시키거나 typescript or coffescript를 js로 다시 변환시키는 작업을 하는데 이를 현대 한국어를 중세 한국말로 변환하는 역할이라고 비유하기도 한다.
조금 더 자세히 설명하자면
바벨은 일반적으로 다목적 JS 컴파일러이다.
더 나아가 많은 형태의 정적 분석 (Static analysis)에 사용되는 모듈들의 모음이기도 하다
정적 분석(Static analysis) 이란 코드를 실행하지 않고 분석하는 작업을 말한다. 코드를 실행하는 동안 분석하는 것은 동적 분석(Dynamic analysis)으로 알려져 있다. 정적 분석의 목적은 다양하다. 구문 검사, 컴파일링, 코드 하이라이팅, 코드 변환, 최적화, 압축 등의 용도로 사용될 수 있다.
생산성을 향상시켜주고 더 좋은 프로그램을 작성하게 해주는 많은 다양한 형태의 도구들을 바벨을 사용하여 만들 수 있다.
바벨은 JS 컴파일러이다. 정확히는 source to source 컴파일러이며 이는 트랜스파일러(Transpiler)라고 불린다. 즉, 바벨에 자바스트립트 코드를 넘겨주면 바벨에서 코드를 수정하고 새로운 코드를 생성하여 반환해주게 된다.
트랜스파일러
언어를 변환해주는 기능을 제공하는 소프트웨어인것은 일반적인 컴파일러와 동일하지만트랜스파일러는 완전히 두 언어 사이를 변환해 주는 것이 아니라 유사한 두 언어 사이에서 변환해주는 한정된 역할을 제공해주는 sw이다. 고급 프로그래밍 언어를 저급 프로그래밍 언어로 변환해주는 컴파일러가 영어를 완전히 다른 언어인 한국어로 변해주는 것을 번역가라고 한다면 트랜스파일러는 옛 우리말을 현대 한국어로 변환해주는 번역가라고 부를 수 있다. 바벨이라는 툴이 트랜스파일러인 이유는 최신 자바스크립트 문법으로 작성된 코드를 구버전 브라우저도 이해할 수 있는 수준의 오래된 자바스크립트 코드로 변환해 주는 소프트웨어이기 때문이다. 즉, 바벨은 트랜스 파일러로써 유사한 두 언어 사이에서 변환 기능을 제공해준다.
바벨 플러그인이란?
그렇다면 플러그인이란 무엇일까?
우리는 한번쯤 써드파티 라이브러리를 사용할 때 플러그인을 사용해본 적 있을 것이다.
예를 들어 우리가 가장 많이 사용하는 스타일 엔진들에서도 사용 문서에 보면 자신들의 바벨 플러그인을 추가하라고 나온다.
대표적으로 styled-components
에서도 babel-plugin-styled-components
를 .babelrc
에 추가하라고 할 것이다.
왜 이러한 플러그인을 추가하라고 하는 것일까?
위와 같은 css-in-js는 기본적으로 css객체를 만드는데, css에 정의한 각 클래스에 대해 생성된 클래스 이름을 가진 객체로 컴파일한다.
에를 들어,
const styles = css`
.flex-box {
display: flex;
}
`
위와 같은 코드가 있다면 템플릿 문자열 함수 내부에 정의한 클래스 이름과 생성된 css객체를 매핑한다.
console.log(styles) // { flex-box: "1f-d34j8rn43y587t" }
이러한 동작은 런타임 시 생성할 수도 있지만 cpu 사용량/시간 오버해드, 구문 분석 js 코드 양의 증가 등의 문제들이 있기 때문에 컴파일 시 생성하게 된다.
이러한 라이브러리들은 컴파일타임에 클래스 이름을 생성하는 자체 플러그인을 만들어 준다.
바벨 동작 방식
그렇다면 바벨은 어떻게 이러한 작업들을 할 수 있는 것일까?
요약해보자면 바벨은 컴파일시 추상구문트리(AST)를 만들고 그 속을 탐색해 아나가며 노드들을 추가 업데이트 제거한다. 이를 다시 코드로 변환하여 새로운 코드를 생성하게 되는 것이다.
AST
바벨의 각 과정들은 추상구문 트리를 생성하거나 이것들을 다루게 되는데 이러한 바벨 동작 방식을 이해하기 위해서는 우선 추상구문트리(AST)를 알아야 한다.
바벨 뿐아니라 V8엔진에서도 JS 코드를 가지고와서 실행하는 단계에서 스트리밍을 하며 가져오는 청크 파일들에서 for
와 같은 키워드 등 + 공백이나 탭 + 변수나 함수 식별자 등을 토큰이라하며 해당 토큰으로 파싱을 하여 추상구문트리(AST)를 생성한다. 해당 AST로 바이트로코드로 변환하여 코드를 실행하게 된다.
여기서 추상구문트리가 무엇일까?
컴퓨터 과학에서 추상 구문 트리 또는 그냥 구문 트리는 형식언어로 작성된 텍스트(종종 소스코드)의 추상 구문(Abstract syntax) 구조를 트리로 표현한 것이다.
추상구문
데이터의 Abstract syntax는 특정 표현이나 인코딩과 관계없이 데이터 유형으로 설명되는 구조이다. 이것은 특히 추상 구문 트리로 트리 구조에서 일반적으로 저장되는 컴퓨터 언어의 텍스트 표현에 사용된다. 데이터의 구조만으로 이뤄진 추상 구문은 concrete syntax와 대조된다. 여기에는 표현에 대한 정보도 포함된다. 예를 들어 콘크리트 구문에는 구조가 내재되어 있기 때문에 추상 구문에 포함되지 않는 괄호 또는 쉼표 등의 기능이 포함된다. 추상구문은 구조가 추상적이지만 이름(식별자)이 여전히 구체적일 경우 1차 추상 구문 과 이름 자체가 추상 인경우 고차 추상 구문으로 분류한다.
이렇게 추상 구문 트리의 각 노드는 텍스트에서 발생하는 구성을 나타낸다.
추상 구문 트리는 프로그램 코드의 구조를 나타내기 위해 컴파일러에서 널리 사용되는 데이터 구조이다. AST는 일반적으로 컴파일러의 구문 분석 단계의 결과이다. 컴파일러가 필요로 하는 여러 단계를 통해 프로그램의 중간 표현 역할을 하는 경우가 많으며 컴파일러의 최종 출력에 강한 영향을 미친다.
AST는 변수 유형과 소스 코드의 각 선언 위치도 보존해야한다
실행 가능한 문의 순서는 명시적으로 표현되고 잘 정의되어야 한다.
이진 연산의 왼쪽 및 오른쪽 구성요소를 저장하고 올바르게 식별해야 한다.
식별자와 할당된 값은 할당문에 대해 저장해야한다.
일부 연산에는 덧셈에 대한 두 항과 같이 항상 두 가지 요소가 필요하다.
그러나 일부 언어 구성에는 명령 셀에서 프로그램으로 전달되는 인수 목록과 같이 임의로 많은 수의 자식이 필요하다. 결과적으로 이러한 언어로 작성된 코드를 나타낸는데 사용되는 AST는 알 수 없는 양의 지식을 빠르게 추가할 수 있을 만큼 충분히 유연해야한다.
GitHub - estree/estree: The ESTree Spec
estree는 원래 firefox에서 spidermonkey engine의 JS 파서를 JS API로 사용할 수 있게 되었을 때, 이를 JS 소스 토드의 공용어처럼 사용했었고, estree는 이를 기반으로 발전된 사용자들의 standard를 제공한다.
> # AST Descriptor Syntax
The spec uses a custom syntax to describe its structures. For example, at the time of writing, ‘es2015.md’ contained a description of Program as seen below
extend interface Program {
sourceType: “script” | “module”;
body: [ Statement | ModuleDeclaration ];
}
바벨은 estree에서 수정된 AST를 사용하며 핵심스팩은 여기서 확인할 수 있다.
function square(n) {
return n * n;
}
AST 노드(AST nodes) 들에 더 알고 싶으면 AST 탐색기 를 확인해보자. 이 링크 는 위 코드를 붙여넣기한 예제이다.
위의 코드는 이런 트리로 나타낼 수 있다:
- FunctionDeclaration:
- id:
- Identifier:
- name: square
- params [1]
- Identifier
- name: n
- body:
- BlockStatement
- body [1]
- ReturnStatement
- argument
- BinaryExpression
- operator: *
- left
- Identifier
- name: n
- right
- Identifier
- name: n
또는 이런 자바스크립트 객체로 나타낼 수도 있다.
{
type: “FunctionDeclaration”,
id: {
type: “Identifier”,
name: “square”
},
params: [{
type: “Identifier”,
name: “n”
}],
body: {
type: “BlockStatement”,
body: [{
type: “ReturnStatement”,
argument: {
type: “BinaryExpression”,
operator: “*”,
left: {
type: “Identifier”,
name: “n”
},
right: {
type: “Identifier”,
name: “n”
}
}
}]
}
}
AST 의 각 레벨들이 유사한 구조를 가지고 있다는것을 알 수 있다.
{
type: “FunctionDeclaration”,
id: {…},
params: […],
body: {…}
}
{
type: “Identifier”,
name: …
}
{
type: “BinaryExpression”,
operator: …,
left: {…},
right: {…}
}
이것들을 각 노드라고 한다. AST는 단일 노드 또는 수백, 수천개의 노드로 이뤄질 수 있다. 이 노드들이 모여서 프로그램의 구문을 설명할 수 있는데 이것은 정적 분석에 사용될 수 있다.
모든 노드는 이런 인터페이스를 가지고 있는데,
interface Node {
type: string;
}
type 필드는 객체가 어떤 노드 타입인지 나타내는 문자열이다. (예. “FunctionDeclaration”, “Identifier”, or “BinaryExpression”). 각 노드 type 은 이 특정 노드 type 을 기술하는 추가 속성들의 집합을 정의한다.
Babel 이 만드는 모든 노드에는 오리지널 소스코드안의 노드의 위치를 알려주는 추가 속성이 있다.
{
type: …,
start: 0,
end: 38,
loc: {
start: {
line: 1,
column: 0
},
end: {
line: 3,
column: 1
}
},
…
}
이 start, end, loc 속성들은 모든 단일 노드안에 있다.
바벨의 실행 단계
바벨 과정의 주요 3단계는 분석(parse), 변환(transfrom), 생성(generate)가 있다.
분석(parse)
분석 단계에서는 코드에서 토큰을 찾아 토큰을 기준으로 AST를 만든다.
바벨의 분석 단계는 어휘분석(Lexical Analysis)과 구문 분석 (Syntactic Analysis) 두 가지 단계가 있다
어휘 분석 (Lexical Analysis)
어휘 분석 과정은 코드 문자열을 취해 토큰들을 뽑는다.
토큰은 언어 구문 조각의 플랫한 배열이라고 생각해도 된다.
예를 들어 n * n
이라는 코드가 있다면 이는 아래와 같은 토큰 배열로 변환될 수 있다.
[
{ type: { ... }, value: 'n', start 0, end: 1, loc: { ... },
{ type: { ... }, value: '*', start 2, end: 3, loc: { ... },
{ type: { ... }, value: 'n', start 4, end: 5, loc: { ... },
]
여기서 각 type
들은 이 토큰을 기술하는 속성들의 집합이다.
{
type: {
label: 'name',
keyword: undefined,
beforeExpr: false,
startsExpr: true,
rightAssociative: false,
isLoop: false,
isAssign: false,
prefix: false,
postfix: false,
binop: null,
updateContext: null
},
...
}
토큰 배열은 또한 AST 노드처럼 start
, end
, loc
을 가지고 있다.
구문 분석 (Syntactic Anaysis)
이러한 어휘 분석 과정을 거치고 나면 생성되는 토큰들의 스트림을 취해 AST 표현으로 변환하게 된다.
이 과정에서는 토큰들 안에 있는 정보를 사용하여, 토큰들을 코드의 구조를 나타내는 AST로 재구성하여 다루기 쉽게 만든다.
변환(Transform)
변환 단계에서는 추상 구문 트리를 받아 그 속을 탐색해 나아가며 노드들을 추가, 업데이트, 제거 한다. 바벨이나 어떤 컴파일러에서도 이 과정은 단연코 가장 복잡한 부분이다. 이 과정에서 플러그인이 수행된다.
생성(Generate)
코드 생성 단계에서는 최종 AST를 취하여 다시 소스 코드 문자열로 만드는데, 소스 맵 또한 생성한다.
바벨은 이러한 주요 3단계를 거치게 되고 원하는 결과를 생성하게 된다.
변환(Transfrom)
바벨에 변환 과정에서 AST를 받아서 그 노드들을 변환하는 작업을 하게 되는데,
해당 노드들을 변환하기 위해서는 각 노드를 방문하여 노드가 무엇인지 확인할 필요가 있다.
각 노드를 방문하고 해당 노드를 변환할 수 있게 바벨에서는 여러가지 메서드를 제공한다.
이러한 메서드를 가지고 플러그인 등을 만들어서 원하는 방식으로 사용할 수 있다.
{
type: "FunctionDeclaration",
id: {
type: "Identifier",
name: "square"
},
params: [{
type: "Identifier",
name: "n"
}],
body: {
type: "BlockStatement",
body: [{
type: "ReturnStatement",
argument: {
type: "BinaryExpression",
operator: "*",
left: {
type: "Identifier",
name: "n"
},
right: {
type: "Identifier",
name: "n"
}
}
}]
}
}
예제 함수를 AST로 변환하면 위와 같은 노드객체를 갖게된다. 각 type으로 접근할 수도 있고, 각 타입에 맞추어 visitor를 설정할 수도 있다.
traverse
각 노드를 방문하기 위해서는 트리를 재귀적으로 순회해야한다. Traverse()
이라는 메서드를 사용할 수 있다.
babel object
바벨 플러그인을 만든다고 예를 들면,
해당 함수는 바벨 객체를 받아올 수 있다.
require("@babel/core");
로 얻어지는 값과 같다
export default function(babel) {
// plugin contents
}
이 babel객체에서 types를 사용하기 위해 간단히
export default function({ types: t }) {
// plugin contents
}
이와 같이 사용할 수 있다.
t
를 사용하여 해당 바벨 메서드에 접근할 수 있다.
t.identifier();
visitors
그리고 바벨에서는 visitor
라는 프로퍼티를 사용하여 각 노드에 방문자와 해당 방문시 컨텐츠를 구성할 있다.
const MyVisitor = {
Identifier() {
console.log("Called!");
}
};
path.traverse(MyVisitor);
Called! // Identifier square
Called! // Identifier n
Called! // Identifier n
Called! // Identifier n
이런식으로 enter와 exit을 구별하여 작성할 수도 있다.
Identifier: {
enter() {
console.log("Entered!");
},
exit() {
console.log("Exited!");
}
}
더하여 |
를 사용하여 같은 함수를 여러 visitor에 적용할 수 있다
const MyVisitor = {
"ExportNamedDeclaration|Flow"(path) {}
};
path & state
path
는 node와 해당 node의 부모 등 여러 노드와 상관 관계를 그린 객체이다.state
는 traverse되는 파일의 상태를 말한다. state.file.path
로 path
에 접근할 수 있으며, plugin을 설정할 때 같이 설정해준 option들도 state.opts
로 접근할 수 있다.
path는 이런식으로 표현되며,
{
"parent": {
"type": "FunctionDeclaration",
"id": {...},
....
},
"node": {
"type": "Identifier",
"name": "square"
}
}
여러 path에 대한 메타데이터를 가지고 있다.
{
"parent": {...},
"node": {...},
"hub": {...},
"contexts": [],
"data": {},
"shouldSkip": false,
"shouldStop": false,
"removed": false,
"state": null,
"opts": null,
"skipKeys": null,
"parentPath": null,
"context": null,
"container": null,
"listKey": null,
"inList": false,
"parentKey": null,
"key": null,
"scope": null,
"type": null,
"typeAnnotation": null
}
visitor
함수에서 path.node로 접근한다.
그리고 이러한 함수에는 path와 state 두개의 인자를 받을 수 있다.
const MyVisitor = {
Identifier(path) {
console.log("Visiting: " + path.node.name);
}
};
a + b + c;
path.traverse(MyVisitor);
Visiting: a
Visiting: b
Visiting: c
export default function({ types: t }) {
return {
visitor: {
Identifier(path, state) {},
ASTNodeTypeHere(path, state) {}
}
};
};
scope
path.scope 객체의 여러 메서드를 사용하여 해당 스콥과 스콥 내 바인딩에 관련된 작업을 할 수 있다.
이러한 스코프는 코드 변환될 때 깨지지 않도록 유의해야한다. 그렇기 때문에 해당 스코프 객체에서 참조된 부분들이 추적될 수 있도록 한다.
해당 함수 스코프 내에서 이름을 바꿀 수 있는 paht.scope.rename
이라는 메서드를 사용하여
function square(n) {
return n * n;
}
예제 함수 내 특정 변수를 바꿀 수 있다.
FunctionDeclaration(path) {
path.scope.rename("n", "x");
}
function square(x) {
return x * x;
}
참고)
https://github.com/babel/babel
https://github.com/jamiebuilds/babel-handbook
https://github.com/babel/babylon/blob/master/ast/spec.md
GitHub - estree/estree: The ESTree Spec