[es6for3] template문자열

es6for3 시리즈

ES6는 새로운 언어철학을 제시하고 편리한 기능을 제공합니다. 하지만 언어 사용 시에 미묘한 부분이 있기 마련이고 이런 디테일을 파악해야 높은 수준의 개발이 가능합니다. 보다 깊이 ES6를 이해하는 방법은 ES6를 실제 함수로 만들어보는 것입니다. 또한 ES6를 실무에서 직접 사용하면 좋겠지만 크롬 최신버전조차 별도로 플래그를 활성화해야만 쓸 수 있는게 현재(52버전)상황입니다. 바벨(Babel)등의 번역기를 이용하면 ES5코드로 변환해주긴 하지만 큰 분량의 폴리필 라이브러리를로딩해야하고 번역되는 과정도 애매한 경우가 많습니다. 따라서 es6의 기조를 따르되 es3에서도 거의 같은 컨셉으로 사용할 수 있게 해주는 도우미를 생각해보는 시리즈입니다. 이러한 과정을 통해 보다 es6의 기능을 정확히 이해할 수 있습니다.

개요

템플릿 문자열은 기존 문자열의 부족한 점을 보강하는 문법으로 .. 라는 새로운 리터럴을 사용할 뿐 아니라 js문법에는 전혀 없었던 함수를 호출하지 않고 그냥 선언하는 새로운 문법을 사용합니다. 우선 기본적인 시그니처는 다음과 같습니다.

const text = tagFunction `문자열.. ${식}`

이 글은 es6의 템플릿문자열을 직접 설명하는 글이 아닙니다. 따라서 자세한 스펙설명은 생략하고 이를 es3에서 최대한 근접하게 사용할 수 있는 방법을 궁리해보겠습니다.

여러줄의 처리

es3에서 여러줄을 처리하는 스크립트적인 방법은 단 한 가지뿐입니다(나머지 방법은 DOM을 빌리거나 해야하니 안정적이지 않습니다^^) 함수의 toString을 이용하는 것이죠. 다음과 같은 함수의 toString()은 함수 전체의 문자열을 반환하게 됩니다.

var test = function(){/*
  여기에 입력..
*/};

console.log(test.toString()); 

하지만 쓸데없이 function(){/*나 */} 같은 문자열이 섞여있으니 곤란하므로 정규식으로 골라냅니다.

/^(.+\/\*\n)([\s\S]+)(\n\*\/.+)$/g

위 정규식은 /*앞에 부분 전부와 개행까지, 개행을 포함한 */뒷부분을 그룹지어 제거하고 가운데 그룹만 남길 수 있게 합니다.

이제 간단히 몸통만 얻을 수 있게 되었습니다.

var test = function(){/*
몸통
*/};
var rex = /^(.+\/\*\n)([\s\S]+)(\n\*\/.+)$/g;
console.log(test.toString().replace(rex, '$2')); //몸통

이렇게 우선 여러줄 처리는 해결했습니다.

template함수와 태그함수

이제 template를 처리할 함수를 하나 정의할건데 시그니처는 tag함수와 문자열이 될 함수를 받는 것으로 처리할 수 있을 것입니다.
하지만 ${..}로 대표되는 삽입기능을 이용하려면 인지로 객체를 넘겨줄 수 밖에 없죠. 따라서 세번째 인자에 오브젝트를 받아 변수명을 대체하도록 해야할 것입니다. 약간 귀찮지만 지역변수같은걸 자동으로 인식할 방법은 없으므로 이게 한계입니다.

var template = function(tag, str, vo){
...
};

기본 시그니처는 정의해졌으니 tag함수의 작동방법을 이해해보죠.
tag함수는 기본적으로 두개의 배열을 인자로 넘겨받아 차례로 결과를 조립하는 함수입니다.
본디 es6의 템플릿도 기본 tag함수가 작동하는 방식이므로 기본 tag함수를 그대로 구현하여 null이 들어오는 경우 대체해주면 될것입니다.

var defaultTag = function(strings){
  var result = '', values, i, j, k;

  //첫번째 이후는 전부 values배열임
  values = [].slice.call(arguments, 1);
  k = values.length;

  //strings와 values를 번갈아가면서 합침
  for(i = 0, j = strings.length; i++){
    result += strings[i];
    if(i < k) result += values[i];
  }
  return result;
};

기본 태그 함수야 쉽게 작성할 수 있지만 문제는 넘겨받은 문자열의 템플릿값을 분리하는 작업입니다. 이를 위해 간단한 정규식을 이용합니다.
하지만 분리하는데 성공한다고 해도 values쪽은 해당 문자열을 식으로 인식하여 처리해야합니다. 이는 상당한 파서를 제작해야하는 부담이 있습니다. 가장 쉬운 방법을 궁리해봤습니다. 식별자에 해당되는 것들에게 this.을 붙여주고 new Function에게 세번째 인자인 vo를 context로 넘겨주는 꽁수를 써보죠.

//템플릿문자열 기준으로 strings를 쪼개서 strings배열을 생성함.
var strings = str.split(/\$\{[^}]+\}/g);

//replace순회를 통해 values배열을 생성
var values = [];

//식별자용 정규식
var rexVar = /([a-zA-Z_$][a-zA-Z_$0-9]+)/g;

//문자열을 순회하면서 values를 채워감.
str.replace(/\$\{([^}]+)\}/g, function(v, ex){
   values.push((new Function('', 'return ' + ex.replace(rexVar, 'this.$1'))).call(vo) + '');
});

처음 등장한 strings용 정규식은 간단합니다.

  1. ‘\${’ – ${로 시작하고
  2. ‘[^}]+’ – }이 아닌 문자열 부분
  3. ‘}’ – }로 끝나는 문자열

식별자용 정규식은 영문자나 $로 시작하되 두번째부터는 영문숫자$가 가능한 형태로 찾아냅니다. 이렇게 찾아진 문자열을 순회하면서 values에는 식별자앞에 this.을 붙여주고 this에 vo를 넣어주는 것으로 연산을 new Function에게 미뤄 답을 얻어냅니다.
이를 통해 복잡한 식처리기를 구현하지 않고 적당히 new Function에게 미뤄서 답을 얻는 것이 가능해집니다.

  1. 예를 들어 ‘${a + b + c}’에서 ex는 ‘a + b + c’가 되는데,
  2. 이를 위와 같은 정규식을 돌리고 나면 ‘this.a + this.b + this.c’ 로 치환됩니다.
  3. 결과적으로 (new Function(”, ‘return this.a + this.b + this.c’)).call({a:3, b:5, c:7}); 로 정리되어

최종적으로 값을 얻게 됩니다. 헌데 context를 지정하기 위해 본문의 식별자를 replace하는 과정은 문제를 일으킬 수도 있고 쓸데없는 부하를 걸기도 합니다. 그냥 ‘a + b + c’를 그냥 쓰려면 어떻게 해야할까요?

지역변수로 세탁하여 값을 구하기

식별자가 그대로인채로 new Function이 값을 구하려면 천상 지역변수로 넣어주던가 아니면 인자로 처리해야합니다. 지역변수는 다시 문자열을 만들어야하므로 문제가 생길소지가 다분합니다. 안전하게 인자로 생성하고 apply로 값을 넘겨주는 편이 안전할 것입니다.
우선 vo객체로부터 인자문자열을 만들고 apply에 사용할 배열을 채워주면 됩니다.

var voKey, voVal, k;
voKey = [], voVal = [];

//vo를 순회한다.
for(k in vo){
  //각각 키와 값에 같은 순서로 넣어준다.
  voKey.push(k);
  voVal.push(vo[k]);
}

//인자리스트를 문자열로 정리
voKey = voKey.join(',');

str.replace(/\$\{([^}]+)\}/g, function(v, ex){
   //ex에 대한 replace를 제거하고 인자와 apply로 해결
   values.push((new Function(voKey, 'return ' + ex)).apply(null, voVal) + '');
});

이 경우는

(new Function('a, b, c', 'return a + b + c')).apply(null, [3, 5, 7]);

형태로 해소된 것이라 훨씬 ex본문은 건들지 않기 때문에 더욱 안전하게 됩니다.

정리 및 사용하기

얼추 재료가 다 갖춰졌으니 모두 모아보면 됩니다.

var template = (function(){
  var defaultTag = function(strings){
    var result = '', values, i, j, k;
    values = [].slice.call(arguments, 1);
    k = values.length;
    for(i = 0, j = strings.length; i < j; i++){
      result += strings[i];
      if(i < k) result += values[i];
    }
    return result;
  };
  var rexF2T = /^(.+\/\*\n)([\s\S]+)(\n\*\/.+)$/g;
  var rexEX = /\$\{([^}]+)\}/g;
  var rexSplit = /\$\{[^}]+\}/g;

  return function(tag, str, vo){
    var values, k, key, val;

    //태그함수가 없으면 기본함수
    if(!tag) tag = defaultTag;

    //vo가 없으면 빈객체
    if(!vo) vo = {};

    //문자열이 함수인 경우 본문만 캡쳐
    if(typeof str == 'function'){
      str = str.toString().replace(rexF2T, '$2');
    }

    //첫번째인자는 무조건 strings배열
    values = [str.split(rexSplit)];

    //문자열에서 values부분을 추출함
    if(str.indexOf('${') > -1){
      key = [], val = [];
      for(k in vo) key.push(k), val.push(vo[k]);
      key = key.join(',');
      str.replace(rexEX, function(v, ex){
        values.push((new Function(key, 'return ' + ex)).apply(null, val) + '');
      });
    }
    //최종적으로 tag함수에게 넘겨서 결과를 받음
    return tag.apply(null, values);
  };
})();

앞서 설명한 내용을 정리한 정도입니다. 이제 사용해보죠.

//문자열로 넘긴 경우
console.log(template(null, '${a} = ${b} + ${c}', {a:5, b:3, c:2})); //'5 = 3 + 2'

//함수를 통해 여러문자열을 넘긴 경우
console.log(template(null, function(){/*
${a} = ${b} + ${c}
${a + b}, ${c}
*/}), {a:5, b:3, c:2}));

결론

첫 번째 es6for3(이하 es643) 포스팅을 했습니다. 차근차근 생각해보면 보다 깊이 es6의 기능을 분해해서 이해할 수 있고 이를 통해 레거시 브라우저에서도 같은 기능을 제공할 수 있습니다. 여기서 오해하면 안될 점이 있습니다.

  1. 구 브라우저를 폴리필하려는 것이 아니라 구버전의 산재된 유틸리티의 api를 es6에 근접하게 정의하여
  2. es3로 개발하는 경우에도 es6의 설계원리를 적용하여
  3. 일관된 스타일로 개발하려는 것이

바로 es643의 목적입니다. 문법적인 폴리필은 원래 불가능하고 바벨같은 녀석들이 변환해줄 뿐입니다만, 컴파일러에게 맡겨서 해결될 일과 아닌 일이 있는 법이죠 ^^; 몇 줄 안되지만 es6 template에 대해 보다 깊은 이해를 할 수 있었습니다. 다음에도 다른 es6의 기능을 해체해보면서 더 깊이 이해하는 계기를 마련해보겠습니다.

테스트코드는 다음의 링크에서 확인해볼 수 있습니다.

template.html

%d 블로거가 이것을 좋아합니다: