on
Let's Build a Browser Engine!
제4 장: 스타일
이 글은 Matt Brubeck의 Let’s Build a Browser Engine!을 번역한 글입니다.
알림 : 부족한 실력 탓에 잘못된 번역, 부자연스러운 문장이 있을 수 있습니다. 해당 문제에 대한 의견을 댓글이나 GitHub 저장소 Pull Request를 통해 제안해 주시면 감사한 마음으로 적극 반영하도록 하겠습니다. 감사합니다.
Let’s Build a Browser Engine!
제4 장: 스타일
토이 브라우저 엔진 구축 시리즈에 다시 오신 걸 환영합니다. 이제 막 들어왔다면 아래에서 이전 글들을 찾을 수 있습니다.
이번 장에서는 CSS 표준이 속성 값 할당이라고 부르며 필자가 스타일 모듈이라고 부르는 것을 다루게 됩니다. 이 모듈은 DOM 노드와 CSS 규칙을 입력으로 받고 이를 일치시켜 주어진 노드의 CSS 속성값을 결정합니다.
필자가 매우 복잡한 부분을 아직 구현하지 못했기 때문에 이번 장에서는 많은 양의 코드를 다루진 않습니다. 하지만 나머지 부분들만으로도 매우 흥미로우며 빠진 부분들에 대해서는 어떻게 구현될지 설명할 것입니다.
스타일 트리
Robinson의 스타일 모듈의 출력은 스타일 트리
입니다. 이 트리의 각각의 노드들은 DOM 노드를 가리키는 포인터와 CSS 속성 값을 갖습니다.
// CSS 속성 이름에서 값으로의 맵
type PropertyMap = HashMap<String, Value>;
// 스타일 데이터와 관련된 노드
struct StyledNode<'a> {
node: &'a Node, // DOM 노드를 가리키는 포인터
specified_values: PropertyMap,
children: Vec<StyledNode<'a>>,
}
‘a 는 무엇인가요? 이것은 라이프타임에 관련된 것으로 Rust는 해당 포인터가 가비지 컬렉션 없이도 메모리 안정성(memory-safe)을 갖도록 합니다. 독자가 Rust로 작업하지 않는다면 무시해도 됩니다. 코드의 의미에 있어 중요하지 않습니다.
우리는 새로운 트리를 만드는 대신에 dom::Node
에 새로운 필드를 추가할 수도 있었습니다. 하지만 필자는 이전 “장”에서 스타일 코드를 빼고 싶었습니다. 또한 이것은 대부분의 렌더링 엔진에 있는 병렬 트리에 관한 얘기를 할 기회를 줍니다.
브라우저 엔진 모듈은 보통 하나의 트리를 입력받아 형태는 다르지만 연관된 트리를 출력으로 생성합니다. 예를 들어, Gecko의 레이아웃 코드는 DOM 트리를 입력받아 프레임 트리를 생성합니다. 프레임 트리는 뷰 트리를 생성하는데 사용됩니다. Blink와 WebKit은 DOM 트리를 렌더 트리로 변환합니다. 이 엔진들 모두 이후 단계에서 레이어 트리와 위젯 트리를 포함한 더 많은 트리를 생성합니다.
몇 가지 단계를 더 마치면 토이 브라우저 엔진의 파이프라인은 다음과 같게 됩니다.
필자의 구현에서 DOM 트리의 각 노드는 스타일 트리에서 하나의 노드를 갖습니다. 그러나 보다 복잡한 파이프라인 단계에선 여러 개의 입력 노드가 단일 출력 노드로 합쳐질 수 있습니다. 또는 하나의 입력 노드가 여러 개의 출력 노드로 확장될 수 있습니다. 예를 들어, 스타일 트리는 display 속성이 'none'
으로 설정된 요소를 제외할 수 있습니다. (레이아웃 단계에서 제거하는 것이 코드를 좀 더 간단하게 만들기 때문에 필자는 해당 단계에서 제외 작업을 수행하겠습니다.)
선택자 매칭
스타일 트리를 만드는 첫 번째 단계는 선택자 매칭입니다. 필자의 CSS 파서가 오직 단순 선택자만을 지원하기 때문에 이 단계는 매우 쉽습니다. 요소 자체를 보는 것으로 단순 선택자의 일치 여부를 확인할 수 있습니다. 체인 선택자 매칭에 경우 DOM 트리에서 요소의 형제, 부모 노드 등을 순회하는 작업이 요구됩니다.
fn matches(elem: &ElementData, selector: &Selector) -> bool {
match *selector {
Simple(ref simple_selector) => matches_simple_selector(elem, simple_selector)
}
}
편의를 위해, DOM 요소 유형에 간단 ID와 클래스 접근자(accessor)를 추가할 수 있습니다. 클래스 속성은 공백으로 구분된 다수의 클래스 이름을 포함할 수 있기 때문에 해시 테이블로 반환하도록 합니다.
impl ElementData {
pub fn id(&self) -> Option<&String> {
self.attributes.get("id")
}
pub fn classes(&self) -> HashSet<&str> {
match self.attributes.get("class") {
Some(classlist) => classlist.split(' ').collect(),
None => HashSet::new()
}
}
}
이제 단순 선택자가 요소와 일치하는지 확인하기 위해 각 선택자 구성요소를 보고 일치하는 클래스, ID, 태그 이름이 없으면 거짓으로 반환하도록 합니다.
fn matches_simple_selector(elem: &ElementData, selector: &SimpleSelector) -> bool {
// 유형 선택자 확인
if selector.tag_name.iter().any(|name| elem.tag_name != *name) {
return false;
}
// ID 선택자 확인
if selector.id.iter().any(|id| elem.id() != Some(id)) {
return false;
}
// 클래스 선택자 확인
let elem_classes = elem.classes();
if selector.class.iter().any(|class| !elem_classes.contains(&**class)) {
return false;
}
// 불일치하는 선택자 구성요소를 찾을 수 없음.
return true;
}
Rust 참고사항: 이 함수는 이터레이터가 주어진 테스트를 통과하는 요소를 포함하는 경우
true
를 반환하는 any 메소드를 사용합니다. 이는 Python (또는 Haskell)의 any 함수, Javascript의 some 메소드와 같습니다.
스타일 트리 구축
이어서 DOM 트리를 순회해야 합니다. 트리의 각 요소에 대해 스타일 시트와 일치하는 규칙을 찾게 됩니다.
동일한 요소와 일치하는 두 규칙을 비교할 때 각 일치 항목에서 가장 높은 명시도를 갖는 선택자를 사용해야 합니다. 우리의 CSS 파서의 경우 선택자를 높은 명시도에서 낮은 순으로 저장하므로 일치하는 선택자를 찾는 즉시 탐색을 중지하고 규칙을 가리키는 포인터와 함께 명시도를 반환하면 됩니다.
type MatchedRule<'a> = (Specificity, &'a Rule);
// `rule`이 `elem`과 일치하는 경우, `MatchedRule`을 반환. 그렇지 않으면 `None`을 반환.
fn match_rule<'a>(elem: &ElementData, rule: &'a Rule) -> Option<MatchedRule<'a>> {
// 처음 (가장 명시도가 높은)으로 일치하는 선택자 탐색
rule.selectors.iter()
.find(|selector| matches(elem, *selector))
.map(|selector| (selector.specificity(), rule))
}
요소와 일치하는 모든 규칙들을 찾기 위해 filter_map
을 사용합니다. 이것은 스타일 시트를 선형 탐색하며 모든 규칙에 대해 검사를 수행하고 일치하지 않는 것은 버립니다. 실제 브라우저 엔진은 규칙들을 태그 이름, ID, 클래스 등을 기반으로 한 다중 해시 테이블에 저장하여 속도를 높이기도 합니다.
// 주어진 요소와 일치하는 모든 CSS 규칙 탐색.
fn matching_rules<'a>(elem: &ElementData, stylesheet: &'a Stylesheet) -> Vec<MatchedRule<'a>> {
stylesheet.rules.iter().filter_map(|rule| match_rule(elem, rule)).collect()
}
일치하는 규칙을 찾게 되면 요소의 명시된 값(specified values)을 찾을 수 있게 됩니다. 먼저 각 규칙의 속성 값을 해시 맵에 삽입합니다. 명시도 기준으로 일치 항목을 정렬하여 낮은 명시도 규칙이 처리된 후 높은 규칙이 처리되도록 합니다. 이를 통해 해시 맵에 값이 높은 규칙의 값으로 덮어쓸 수 있습니다.
// 하나의 요소에 스타일을 적용하고 명시된 값을 반환.
fn specified_values(elem: &ElementData, stylesheet: &Stylesheet) -> PropertyMap {
let mut values = HashMap::new();
let mut rules = matching_rules(elem, stylesheet);
// 낮은 명시도부터 높은 명시도 순으로 규칙을 진행.
rules.sort_by(|&(a, _), &(b, _)| a.cmp(&b));
for (_, rule) in rules {
for declaration in &rule.declarations {
values.insert(declaration.name.clone(), declaration.value.clone());
}
}
return values;
}
이제 DOM 트리를 탐색하고 스타일 트리를 만드는 데 필요한 모든 것을 갖추었습니다. 선택자 매칭 작업은 오직 요소 노드에서만 수행되므로 텍스트 노드에 명시된 값은 빈 맵임을 참고하기 바랍니다.
// 전체 DOM 트리에 스타일 시트를 적용하고 스타일 노드 트리를 반환.
pub fn style_tree<'a>(root: &'a Node, stylesheet: &'a Stylesheet) -> StyledNode<'a> {
StyledNode {
node: root,
specified_values: match root.node_type {
Element(ref elem) => specified_values(elem, stylesheet),
Text(_) => HashMap::new()
},
children: root.children.iter().map(|child| style_tree(child, stylesheet)).collect(),
}
}
여기까지가 Robinson의 스타일 트리 구축 코드입니다. 다음으로 몇 가지 생략된 것들에 대해 이야기하겠습니다.
캐스케이드
웹 페이지 작성자가 제공하는 스타일 시트를 작성자 스타일 시트(author style sheets) 라고 합니다. 여기에 더해 브라우저도 사용자 에이전트 스타일 시트(user agent style sheets) 라고 하는 기본 스타일(default styles)을 제공합니다. 그리고 사용자 에이전트 스타일 시트를 통해 사용자 지정 스타일을 추가할 수 있도록 합니다. (Gecko의 userContent.css와 같이)
캐스케이드는 이 세 가지 “기원(Origin)” 중 어는 것을 다른 것보다 우선으로 할지 정의합니다. 캐스케이드를 위한 6 단계가 존재하며 그중 하나는 기원의 “일반” 선언이며 또 다른 하나는 기원의 !important 선언이 있습니다.
Robinson의 스타일 코드는 캐스케이드를 구현하지 않으며 하나의 스타일 시트를 입력으로 받습니다. 기본 스타일 시트의 부재는 HTML 요소가 어떠한 스타일도 없을 수 있음을 의미합니다. 예를 들어 다음과 같은 스타일 시트를 명시했음에도 <head> 요소의 내용이 숨겨지지 않을 수 있습니다.
head { display: none; }
캐스케이드의 구현은 꽤 쉽게 할 수 있습니다. 규칙의 기원을 추적하고 기원과 명시도를 추가한 중요도에 따라 선언부를 정렬하기만 하면 될 것입니다. 단순한 2단계 케스케이드로도 일반적인 경우(사용자와 작성자 스타일이 있는 경우)를 충분히 지원할 수 있습니다.
계산 값
위에서 언급한 “명시된 값(Specified values)” 에 더해 CSS는 초기(initial), 계산(computed), 사용(used) 그리고 실제(actual) 값을 정의합니다.
초깃값(Initial values)은 캐스케이드에서 명시되지 않은 속성의 기본 값입니다. 계산 값(Computed values)은 명시된 값을 기반으로 하지만 일부 속성별 정규화 규칙이 적용될수 있습니다.
이것을 올바르게 구현하기 위해선 CSS 명세의 정의를 기반으로 각 속성에 대한 별도의 코드가 필요합니다. 이 작업이 실제 브라우저 엔진에선 필요하지만 필자는 토이 프로젝트에서는 피하고 싶었습니다. 추후 단계에서 이 값들을 사용하는 코드는 명시된 값이 없는 경우 설정되는 기본값으로 초깃값을 설정하여 진행합니다.
사용 값과 실제 값은 레이아웃 이후에 계산이 수행되며, 추후에 다루도록 하겠습니다.
상속
텍스트 노드가 선택자와 일치되지 않는다면 어떻게 색상과 폰트 그리고 나머지 스타일을 얻을까요? 답은 상속입니다.
속성이 상속되면 캐스케이드 값이 없는 노드는 부모의 속성 값을 받게 됩니다. ‘color’ 와 같은 몇몇 속성은 기본적으로 상속이 됩니다. 다른 속성들은 ‘inherit’ 특수 값을 명시하는 경우에만 상속을 수행합니다.
필자의 코드는 상속을 지원하지 않습니다. 독자가 상속을 구현하기 원한다면 부모의 스타일 정보를 specified_values
함수에 전달하고 상속할 속성을 결정하는데 하드코딩된 룩업 테이블을 사용할 수 있을 것입니다.
스타일 속성
어떠한 HTML 요소도 CSS 선언부 목록을 포함한 style
속성을 가질 수 있습니다. 이 선언부는 자동적으로 요소 자체에 적용되기 때문에 어떠한 선택자도 없습니다.
<span style="color: red; background: yellow;">
독자가 style
속성을 지원하고자 한다면 specified_values
함수가 속성을 확인하도록 만들 수 있습니다. 만약 속성이 존재한다면 CSS 파서의 parse_declarations
함수로 전달합니다. style
속성이 어떠한 CSS 선택자보다 명시도가 높으므로, 일반 작성자 선언부를 적용한 후에 parse_declarations
결과 선언부를 적용합니다.
연습
독자만의 선택자 매칭과 값 대입 코드 작성 외에도 위에서 설명한 생략된 작업 중 하나 이상을 수행해볼 수 있습니다. 이는 독자의 프로젝트나 Robinson을 fork 하여 수행할 수 있습니다.
- 캐스케이딩
- 초기, 계산 값
- 상속
style
속성
또한 파트 3에서 CSS 파서를 복합 선택자를 포함하도록 확장한 경우 복합 선택자에 대한 매칭을 구현할 수 있을 것입니다.
다음 장에서 계속…
5장에선 레이아웃 모듈을 소개할 것입니다. 아직 필자가 소스 코드 작성을 완료하지 못해서 다음 글을 쓰기까지 약간 늦어질 거 같습니다. 레이아웃은 두 개의 장으로 나눌 계획입니다. (아마도 블록 레이아웃과 인라인 레이아웃으로 나뉠 것입니다.)
그동안, 독자들이 필자의 글을 기반으로 만든 것들을 볼 수 있다면 매우 기쁠 것 같습니다. 독자의 코드가 온라인 어딘가에 있다면 주저 말고 아래의 댓글로 링크를 알려주시기 바랍니다. 지금까지 필자가 본 것은 Martin Tomasi의 Java 구현과 Pohl Longsine의 Swift 버전입니다.
Discussion and feedback