[js] style객체 사용하기

JS 로 인라인 스타일을 지정하면 매우 나쁘다고들 합니다. 이는 많은 전문가분들이 무간섭 스크립팅을 강조하여 때문에 널리 퍼지게 된 철학입니다.
하지만 CSS 를 선언하는 것만으로 상황에 맞는 View 의 모양을 결정 짓는 건 매우 어렵습니다. 게다가 브라우저별 특성에 따른 prefix, hack 이슈를 생각하면 결국 JS 와 협력할 수 밖에 없습니다.
오히려 무리하게 선언(Declare)을 통해 해결하려는 기조가 퍼져 CSS 는 이미 프로그래밍 언어 수준의 복잡성을 갖게 되었으며 조건문이나 반복문 마저도 포함되어 버렸습니다. 제어문에 대한 지식없이 과연 css3 를 사용할 수 있을까요?

이 모든 것을 브라우저가 어떻게 처리하는 걸까요?

 
 
 

스타일의 뿌리top

현재 브라우저의 CSS 시스템을 잠깐 다른 관점으로 바라봅시다. 스타일시트하면 으레 셀렉터와 다양한 속성을 지정하는 부분의 문법을 떠올리기 마련입니다. 하지만 그것은 그저 사용자가 작성한 문자열일 뿐입니다. 실제로 이것을 파싱하여 메모리에 적재한 뒤 그 정보를 통해 문서를 그려내는 것은 브라우저입니다.

사정이 이렇다보니 첫 번째 관심사가 어떻게 파싱하는걸까가 되기 쉽상이지만 정말 중요한 건,

파싱한 뒤 어떠한 구조로 메모리에 저장하는 것일까?

입니다. W3C 의 관련 문서에서 이를 세부적으로 정의하고 있습니다. 스타일에 대한 전반적인 가이드라인은 DOM 모델 2에서 발표되었는데 보통 스타일스펙 이라 불리는 것의 정체입니다. 링크는 아래와 같습니다.

Document Object Model (DOM) Level 2 Style Specification

이 문서에는 DOM 이 외형을 표현하기 위해 어떠한 방식으로 스타일을 사용하는지에 대한 체계적이고 구체적인 방법과 인터페이스를 명시하고 있습니다….만,

더럽게 길고 영어로 되어있는데다가 태반이 인터페이스라 지루합니다. 과감히 생략하고 중요한 문서로 들어가겠습니다. 한참 링크를 타고 돌다보면 CSSStyleDeclatation 라는 인터페이스에 도달하게 됩니다.

Interface CSSStyleDeclaration

최종적으로 모든 스타일의 정보는 이 인터페이스를 구상한 클래스에 저장됩니다. 즉 스타일시트에 정의한 클래스나 아이디, 태그의 스타일이 되었든, DOM 에 직접 지정한 인라인스타일이든, JS 로 변경한 DOM 의 style 속성이든 최종적으로 boder:0 같은 스타일 정보를 저장하는 저장소객체는 바로 CSSStyleDelaration 의 구현체입니다.

이 녀석은 일종의 해시맵(Hashmap) 으로 이해할 수 있습니다. 따라서 다음과 같은 코드가 JS 상에서 성립합니다.

var temp = document.getElementById( "test" );
temp.style["border"] = 0;

이거 완전히 오브젝트 같은 느낌입니다.

var style = { };
style["border"] = 0;

즉 이 인터페이스가 해시맵 기반을 내장하는 것은 우연이 아닙니다. 처음부터 JS 로 통제하기 쉽도록 언어와 합체되어있는 거죠. DOM 객체에 있는 style 속성, 그리고 그 속성에 할당된 객체. 바로 그 객체의 정체가 CSSStyleDelaration 구현체 인스턴스(이하 스타일객체) 입니다. 간단히 그림으로 표현하면 아래 정도일까요.

1

 
 
 

스타일시트 안의 Rule객체top

DOM 객체에 style속성이 있고 그 속성에 스타일객체가 있다는 것은 이제 알게 되었습니다. 그럼 link 나 style 태그를 이용하여 정의한 스타일시트의 경우는 어떻게 되는 것일까요?
 
 
태그와 실제 기능 객체의 관계
이것을 이해하려면 태그엘리먼트와 실제 기능 객체 간의 관계를 이해할 필요가 있습니다.

HTML 상의 태그엘레멘트들은 HTML 문서에 포함되기 위한 공통 인터페이스로 이해할 수 있습니다.

예를 들어 img 태그를 생각해봅시다.

img 태그는 DOM 객체로서는 스타일도 지정할 수 있고 parentNode, nodeType 등의 속성을 갖고 있습니다. 또한 다른 부모 태그가 자식으로 소유할 수도 있고 태그 트리에서 제거할 수도 있습니다.

이러한 DOM 트리 내에서의 일반적인 작동을 보장하는 것은 img 태그엘리먼트의 DOM 객체로서의 공통적인 기능을 구현한 부분입니다.

하지만 실제 img 태그는 이미지를 그리기 위해 사용합니다. 이미지란 바이너리 데이터를 이용하여 이미지 코덱에 맞춰 디코딩한 뒤 픽셀로 화면에 일일히 점을 찍는 과정입니다.

이러한 기능은 사실 상 DOM 의 태그로서의 기능이 아닙니다. img 태그 안에 있는 이미지뷰어의 기능이죠. 마찬가지로 canvas 태그는 DOM 에 편입되기 위해 태그가 필요할 뿐 진짜 해야할 일은 context 객체가 하고 있습니다.

생각을 더욱 확장해보면 HTML에 존재하는 모든 태그는 내부적인 기능 객체가 별로 존재하고 태그로 포장하여 거대한 태그트리에 편입되는 식입니다.

이러한 관점을 style 태그나 link 태그에 적용해봅시다. 다음과 같은 문서를 생각해보죠.

<html>
  <head>
    <style id=”s1″>
    p{margin:10px 0}
    </style>
  </head>

그렇다면 저 style 태그는 실제 스타일시트객체를 DOM에 편입시키기 위한 껍데기로서 이해할 수 있습니다. 캔버스에서 getContext 를 통해 실 객체를 얻어내는 것처럼 진짜 스타일시트객체를 얻는 과정이 필요합니다.

var s1 = document.getElementById( "s1" );
var sheet = s1.styleSheet || s1.sheet;

위 코드에서 우선 태그엘리먼트로서 s1 을 얻어낸 뒤 s1 의 속성인 styleSheet 또는 sheet 를 얻어 냅니다.[1. 더 이상 IE냐 아니냐의 방어코드가 아닙니다. 최신 IE는 둘다 갖고 있는 경우도 있습니다. 따라서 기본적인 객체탐지입니다!]

이 sheet 객체야말로 진짜 스타일시트의 정보를 담고 있는 실체입니다. sheet객체는 CSSStyleSheet 의 구현체로 link 태그나 style 태그에 따라 더 많은 속성을 갖고 있습니다만 중요한 것은 룰셋(RuleSet) 이란 속성을 갖고 있다는 것입니다. 간단히 그림으로 짚고 넘어갑시다.

2

 
 
 

룰셋(RuleSet)과 룰객체(Rule)top

사실 CSSStyleSheet(이하 스타일시트객체)를 소유한 녀석이 link, style 태그 외에도 하나 더 있습니다. document 객체에는 defaultStyle 이 내장 되어있습니다. 이것도 일종의 스타일시트 객체입니다.

DOM이 모든 DOM 엘리먼트를 담고 있는 거대한 트리구조의 컨테이너로 이해할 수 있다면 스타일시트객체는 수 많은 룰(Rule)을 담고 있는 룰 컨테이너로 이해할 수 있습니다. 이러한 룰을 저장하고 있는 저장소가 바로 룰셋입니다. 룰셋은 다음과 같이 얻을 수 있습니다.

var s1 = document.getElementById( "s1" );
var sheet = s1.styleSheet || s1.sheet;
ruleset = sheet.cssRules || sheet.rules;

이렇게 얻은 룰셋 안에는 사용자가 css 구문으로 작성한 스타일 하나하나가 룰개체로 환원되어 들어가 있습니다. 룰셋에는 새로운 룰을 추가하거나 기존의 룰을 제거하는 기능이 들어있고 룰을 찾는 기능도 들어 있습니다. 왜 룰셋에 집착하냐구요?

바로 룰셋 안에 들어 있는 룰객체(CSSRule의 구현체)에는 style속성이 있고 이 style 속성에 DOM.style 과 마찬가지로 스타일객체가 들어있기 때문입니다. 따라서 JS 를 통해서 제어해도 완벽히 스타일시트를 제어할 수 있게 되는 것이죠. 간단히 위에 등장했던 p 태그의 스타일( p{margin:10px 0} )을 순수히 스크립트만으로 만들어봅시다.

//1. 스타일태그엘리먼트를 만들어 head에 넣자
sheet = document.createElement( 'style' );
document.getElementsByTagName( 'head' )[0].appendChild( sheet );

//2. 스타일시트와 룰셋을 얻어낸다.
sheet = sheet.styleSheet || sheet.sheet;
ruleSet = sheet.cssRules || sheet.rules;

//3. 룰셋에 새로운 빈 룰을 0번째 룰로 추가한다.
sheet.insertRule( 'p{}', 0 );

//4. 0번 룰을 가져온다.
rule = ruleSet[0];

//5. 룰의 스타일객체에 직접 쓴다!
rule.style["margin"] = "10px 0";

위의 과정으로 작성된 스타일은 DOM 에 적용된 인라인 스타일이 아니라 스타일시트에 지정한 스타일이 됩니다(모든 P 에 적용되죠) 따라서 스타일시트의 성능과 하드웨어의 도움 등을 그대로 승계하게 됩니다. 또한 런타임 중에 인라인 스타일이 아닌 스타일시트의 값 그 자체를 변경하는 일도 가능하게 됩니다. 간단히 그림으로 정리해볼까요?

3

스타일객체를 DOM 엘리먼트도 소유하고 스타일시트객체의 룰셋안에 있는 룰객체도 소유하고 있다는 것을 알 수 있습니다.

 
 
 

본격적인 룰객체 제어top

이미 스타일시트객체와 룰셋객체를 얻을 때 크로스브라우징 문제를 다뤘습니다만 룰셋에 룰객체를 추가하거나 삭제하는 것과 심지어 룰객체를 찾는 것 마저도 브라우저 호환성을 확보하려면 꽤나 복잡한 과정을 거쳐야합니다.

이를 처리할 cssStyle 클래스를 같이 구축하면서 문제를 하나하나 해결해보죠. 우선 클로저 영역을 설정하여 이 모든 것을 처리할 매니져를 생성합시다.

var CssStyle = ( function( doc ){

	var sheet, ruleSet, css;

	sheet = doc.createElement( 'style' );
	doc.getElementsByTagName( 'head' )[0].appendChild( sheet );
	sheet = sheet.styleSheet || sheet.sheet;
	ruleSet = sheet.cssRules || sheet.rules;

	var index, add, remove;
	//..이후의 소스코드

	return css;
} )( document );

위에서 다룬 코드 그대로 이므로 설명은 생략합니다.
최종적으로 css 클래스를 반환받겠지만, 급선무는 유틸리티 함수의 작성입니다.
 
 

룰셋에서 셀렉터로 룰객체 찾기top

룰셋에서는 오직 인덱스를 통해서만 룰객체를 찾을 수 있습니다. 하지만 인덱스 자체는 add, remove 하면서 변화될 수 있는 것이므로 비교적 고유한 셀렉터로 룰객체를 찾을 수 있어야합니다. 이를 처리할 index 함수를 구현해보죠.

index = function( $selector ){
	var i, j, k;
	for( i = 0, j = ruleSet.length ; i < j ; i++ )
		if( ruleSet[k = i].selectorText.toLowerCase() == $selector ||
		 ruleSet[k = j - i - 1].selectorText.toLowerCase() == $selector
		) return k;
};

//css 셀렉터를 넘기면 인덱스를 받는다!
var i = index( "p" );

 
 

추가, 삭제하기top

이제 add 와 remove 를 구현해야 하는데, 기본적인 아이디어는 insertRule 을 지원하는 브라우저가 있는가 하면 addRule 을 지원하는 브라우저가 있다는 점입니다. 객체탐지로 분기하여 처리합시다.

if( sheet.insertRule ){
	add = function( $selector ){
		sheet.insertRule( $selector+'{}', ruleSet.length );
	};
	remove = function( $selector ){
		sheet.deleteRule( index( $selector ) );
	};
}else{
	add = function( $selector ){
		sheet.addRule( $selector, ' ' );
	};
	remove = function( $selector ){
		sheet.removeRule( index( $selector ) );
	};
}

미리 구현해둔 index 함수 덕분에 어렵지 않게 구현할 수 있습니다.

 
 
 

CssStyle 클래스의 역할top

이미 기능적으로는 유틸리티 함수로 구현이 끝났지만 cssStyle클래스를 작성하는 이유는 인스턴스화 하기 위해서 입니다. 내부적으로 개별 룰객체 하나를 래핑함으로서 prefix문제나 크로스브라우징의 문제를 해결해갈 수 있습니다. 우선 생성자와 소멸자를 구현합시다.

//생성자, 스타일시트에 등록한다.
css = function( $selector ){
	add( $selector );
	this.selector = $selector;
	this.rule = ruleSet[ruleSet.length - 1];
	this.style = this.rule.style;
};

//소멸자, 스타일시트에서 제거한다.
css.prototype.destroy = function(){
	remove( this.selector );
	this.style = this.rule = this.selector = null;
};

일단 이를 통하면 다음과 같이 자유롭게 스타일시트에 무언가 추가하고 통제하는 것이 가능합니다.

var CssStyle = ( function( doc ){
	//..
} )( document );

var p = new CssStyle( "p" );
p.style["margin"] = "10px 0";

p.destroy();

 
 

prefix 처리top

CssStyle 에서 prefix 처리를 위임하려면 style 속성에 직접 지정하는 것을 막고 메서드를 통해 지정하도록 해야합니다. 또한 prefix 를 할 태그의 대상을 알아야 하고 prefix 문자열로 무엇을 처리할 지 알아야합니다.

우선 prefix 문자열과 prefix 가 적용될 속성을 정리해봅시다. 이 때 prefix 에서 조심할 부분은 css 용이 아니라 스타일객체에서 사용될 속성이므로 -webkit- 이 아니라 webkit 이 된다는 점입니다.

즉 css에서는 다음과 같은 구문이

p{ -webket-transform:scale(2,2) }

JS 로 하면

p.style.webkitTransform = "scale(2,2)";

요렇게 되므로 prefix 가 webkit 이 되어야한다는 거죠.

var CssStyle = ( function( doc ){
	//index,add,remove..

	var prefix, prefixTarget;

	// 브라우저 감지를 통한 prefix 확정, 감지는 알아서들 구현=.=;
	prefix = detect == "ie" ? 'ms' : detect == "firefox" ? 'Moz' : detect == "wekit" ? 'webkit' : '';

	// prefix가 적용되는 속성을 지정
	prefixTarget = {
		transform:1,transform-origin:1,transform-style:1,
		transition:1,transition-property:1,transition-duration:1,
		transition-timing-function:1,transition-delay:1,
		animation-name:1,animation-duration:1,animation-timing-function:1,
		animation-iteration-count:1,
		animation-direction:1,animation-play-state:1,animation-delay:1,
		text-shadow:1,box-shadow:1,box-sizing:1,
		border-radius:1,border-top-left-radius:1,border-top-right-radius:1,
		border-bottom-left-radius:1,border-bottom-right-radius:1,
		border-image:1,border-image-source:1,border-image-slice:1,
		border-image-width:1,border-image-outset:1,border-image-repeat:1
	};

그럼 이제 클래스의 set 메서드를 정의하면 됩니다.

css.prototype.set = function( $prop, $val ){
	//prefix가 있으면 붙인다.
	if( prefixTarget[$prop] ){
		$prop = prefix + $prop.charAt(0).toUpperCase() + $prop.substr( 1 );
	}
	this.style[$prop] = $val;
};

이제 맘놓고 transform 을 쓸 수 있습니다.

var p = new CssStyle( "p" );
p.set( "transform", "scale(2,2)" );

 
 

크로스브라우징 처리top

수 많은 이슈가 있지만 여기서는 가장 흔한 opacity 의 IE 적용문제를 처리해보겠습니다. IE 에서는 opacity 대신 필터를 이용합니다. 다음과 같이 set을 더욱 확장해 봅시다.

css.prototype.set = function( $prop, $val ){
	if( $prop == "opacity" &&  IE ){
		return this.style["filter"] = "alpha(opacity=" + parseInt( $val * 100 ) + ")";
	}
	if( prefixTarget[$prop] ){
		$prop = prefix + $prop.charAt(0).toUpperCase() + $prop.substr( 1 );
	}
	this.style[$prop] = $val;
};

뭐 껌이죠. 이제 브라우저 신경안쓰고 지정할 수 있게 되었습니다.

var p = new CssStyle( "p" );
p.set( "opacity", 0.5 ); //IE도 OK!

 
 
 

결론top

인라인스타일 뿐 아니라 스타일시트의 룰객체를 이용하여 스타일시트 전반에 대한 내용도 JS 를 통해 통제할 수 있음을 살펴봤습니다. cssText 속성을 래핑한다면 더욱 css 와 동일한 문법이 됩니다. 이를 이용한 라이브러리도 이미 존재하고 있습니다.

http://davidwalsh.name/write-css-javascript

무간섭스크립트 라는 마법의 단어에서 잠깐 벗어나 공학도로서 생각해봅시다. 브라우저 입장에서는 스크립트로 룰객체를 정의하든 css텍스트 파일을 파싱하여 적재했든 동일한 메모리객체일 뿐입니다. 무엇이 무간섭인가라고 묻는다면 css 만 정리한 JS 가 별도로 분리되어있는 것과 CSS 파일이 존재하는 것과 개념적인 차이는 없습니다.
하지만 JS 를 통해 스타일시트를 통제하면 풍부한 프로그래밍 언어로서 변수, 문, 식을 다양하게 응용할 수 있습니다.

여태 설명한 코드를 모아보면 다음과 같습니다.

var CssStyle = ( function( doc ){

	var sheet, ruleSet, css;

	sheet = doc.createElement( 'style' );
	doc.getElementsByTagName( 'head' )[0].appendChild( sheet );
	sheet = sheet.styleSheet || sheet.sheet;
	ruleSet = sheet.cssRules || sheet.rules;

	var index, add, remove;

	index = function( $selector ){
		var i, j, k;
		for( i = 0, j = ruleSet.length ; i < j ; i++ )
		if( ruleSet[k = i].selectorText.toLowerCase() == $selector ||
		 ruleSet[k = j - i - 1].selectorText.toLowerCase() == $selector
		) return k;
	};
	if( sheet.insertRule ){
		add = function( $selector ){
		 sheet.insertRule( $selector+'{}', ruleSet.length );
		};
		remove = function( $selector ){
		 sheet.deleteRule( index( $selector ) );
		};
	}else{
		add = function( $selector ){
		 sheet.addRule( $selector, ' ' );
		};
		remove = function( $selector ){
		 sheet.removeRule( index( $selector ) );
		};
	}

	var prefix, prefixTarget;
	prefix = detect == "ie" ? 'ms' : 
		detect == "firefox" ? 'Moz' : 
		detect == "wekit" ? 'webkit' : '';
	prefixTarget = {
		transform:1,transform-origin:1,transform-style:1,
		transition:1,transition-property:1,transition-duration:1,
		transition-timing-function:1,transition-delay:1,
		animation-name:1,animation-duration:1,animation-timing-function:1,
		animation-iteration-count:1,
		animation-direction:1,animation-play-state:1,animation-delay:1,
		text-shadow:1,box-shadow:1,box-sizing:1,
		border-radius:1,border-top-left-radius:1,border-top-right-radius:1,
		border-bottom-left-radius:1,border-bottom-right-radius:1,
		border-image:1,border-image-source:1,border-image-slice:1,
		border-image-width:1,border-image-outset:1,border-image-repeat:1
	};

	css = function( $selector ){
		add( $selector );
		this.selector = $selector;
		this.rule = ruleSet[ruleSet.length - 1];
		this.style = this.rule.style;
	};
	css.prototype.destroy = function(){
		remove( this.selector );
		this.style = this.rule = this.selector = null;
	};
	css.prototype.set = function( $prop, $val ){
		if( $prop == "opacity" &&  IE ){
			this.style["filter"] = "alpha(opacity="+
			 parseInt( $val * 100 ) + ")";
			return;
		}
		if( prefixTarget[$prop] ){
			$prop = prefix + $prop.charAt(0).toUpperCase() + $prop.substr( 1 );
		}
		this.style[$prop] = $val;
	};
	return css;
} )( document );