CYWORLD

이준용님의 싸이홈

알림

jQuery.1.7.1에서 스크립트 실행 순서 문제 개선

헉 일기에다가 썼네...
1. 왜 ?
요즈음 모바일 페이지는 보통 싱글 페이지 아키텍쳐로 많이 만들어진다
(줏어 들은 용어인데 정확한것인지 모르겠네.. 'ㅁ';;;링크를 눌렀을때 페이지가 이동하는것이 아닌 다음페이지를 ajax로 받아와서 dom에 추가하고 그것을 보여주는 형태로 네비게이션을 구현하는 방법 ex.jQuery mobile , google+)
네비게이션이 일어났을때 기존 페이지의 정보를 잃지 않고 있기 때문에 에니메이션을 넣을수 있고(ex.기존페이지가 옆으로 밀리면서 새페이지가 보이는) 한번 불러온 이미지등의 스테틱 컨텐츠에 대하여 메모리에 있으면 다시 불러오지 않기 때문에 자원도 절약 할수 있다.
단점은 다음 페이지를 ajax로 가져오면서 url이 바뀌지 않아서 페이지를 구분 할수 없는데 이를 보완 하기 위하여 해시(#)를 이용하거나 (트위터) html5의 history pushState,replaceState를 사용한다(구글+)
그래서 이번 모바일 페이지를 만들면서 싱글 페이지 아키텍쳐로 , 네비게이션 하는 스크립트 모듈을 만들고 있었다.
기본이 되는 js 라이브러리를 jquery 로 하였는데, (jquery mobile을 사용하지는 않았다. jquery 모바일은 스티일이(css) 가 고정이 되있서 (커스터마이징 할수 있긴하지만) 다 똑같은 사이트가 만들어져서 마음에 들지 않는다.)
헤드에 있는 스크립트와 스타일시트는는 공통으로 한번만 실행 되도록 하고 , 다음부터는 네비게이션에도 실행 되지 않도록 하여 효율성을 높이고 바디 않에 있는 스크립트들은 페이지 마다의 고유의 스크립트로 간주하고 네비게이션 했을때 마다 실행 되도록 할 계획이었다.
그런데 이것을 만드는 과정중 jquery 에서 몇가지 문제가 발생하였다.
2.문제
ajax로 다음 페이지를 가져와 dom 에 추가하면서 바디 안에 있는 스크립트를 실행 하는데 이때 스크립트의 실행순서가 맞지 않았다.
문제는 다른 도메인의 스크립트를 가져올때 발생 하였다.
스크립트등의 스테틱 컨텐츠를 불러올때는 보통 메인 도메인과는 다른 도메인을 사용한다. 만약 스테틱 컨텐츠들이 같은 도메인에 있다면 브라우저는 이를 불러올때 쿠키 정보를 함께 보낸다. 하지만 이들은 이 쿠키 정보를 사용하지 않기 ���문에 네트워크의 낭비 이고 이는 특히 네트워크 비용이 더 심한 모바일 환경에서는 더욱 치명적이다.
예제를 한번 봐보자.
(여기서는 예제의 단순화를 위하여 $.ajax 대신 $.html 을 사용하였다)
(메인 도메인을 abc.com , 다른 도메인을 efg.com 으로 표시한다,:링크는 눌러보지 마세요, 제가만든게아님)
- http://abc.com/test.js , http://efg.com/test.js
( 이 파일은 abc.com , efg.com 모두 있다. )
x = 1;
alert("test.js:" + x );
- http://abc.com/test1.html" target="_blank">http://abc.com/test1.html
<html><head><script src="http://code.jquery.com/jquery-latest.js"></script></head><body><script>var" target="_blank">http://code.jquery.com/jquery-latest.js"></script></head><body><script>var x;</script><div /><script>
$('div').html('<scr' + 'ipt src="/test.js"></scr' + 'ipt><scr' + 'ipt>alert("div:"+ x);</scr' + 'ipt>');
</script></body><html>
- http://abc.com/test2.html" target="_blank">http://abc.com/test2.html
<html><head><script src="http://code.jquery.com/jquery-latest.js"></script></head><body><script>var" target="_blank">http://code.jquery.com/jquery-latest.js"></script></head><body><script>var x;</script><div /><script>
$('div').html('<scr' + 'ipt src="http://efg.com/test.js"></scr' + 'ipt><scr' + 'ipt>alert("div:"+ x);</scr' + 'ipt>');
</script></body><html>원하는 동작은 다음과 같다.
body 에 있는<div />에
<script src="/test.js"></script><script>alert("div:"+x)</script>를 넣어진다.
그리고 이 스크립트��� 실행이 되면서 먼저 test.js 가 불려진다.
test.js 에서는 x = 1 하고 , ---"test.js:" + x 를 얼럿한다. ---
그리고 돌아와서 --"div:" + x 를 얼럿한다 ---
x=1 하였으므로 두개의 얼럿은
"test.js:1" ,"div:1" 이 어야 한다.
http://abc.com/test1.html" target="_blank">http://abc.com/test1.html , http://abc.com/test2.html" target="_blank">http://abc.com/test2.html 의 동작이 달랐다.
둘은 단지 같은 파일인 test.js 를 호출하는 도메인 만 달랐는데...
같은 도메인을 쓰는 test1.html 은 정상적으로
"test.js:1" ,"div:1" 의 결과를 돌려주는 반면
다른 도메인을 쓰는 test2.html은
"div:undefined" ,"test.js:1" 의 결과가 나왔다.
3. 어떻게 해야지 해결이 될까?
- jquery 에 물어보자.
먼저 jquery에 이상하다고 버그티켓을 만들었다. ( http://bugs.jquery.com/newticket )
그런데 이놈의 짧은 영어로 설명을 하려니, 참으로 힘들더라.
그래도 어째 저째 설명을해서 올려놓고, 잊어버리고 있엇다.
얼마후 메일이 도착했다.
답변이 늦어서 티켓이 클로즈 되었다는 만약 여전히 문제가 된다면 댓글을 달아달라는..
나는 내가 답변을 미쳐 못봤구나 하고서 들어가 봤더니
"요청을 해줘서 고맙다. jsFiddle 로 테스트 케이스를 만들어주면 우리가 테스트 하기 쉽다. 먼저 최신버전 을 사용해보고 여전히 안되면 jsFiddle로 등록해 달라." 는 내용이(못알아 듣겠으니 테스트 케이스를 만들어달라는 뜻인듯) 2주전에 와있었다.
http://jsFiddle.net 은 html, css , javascript 를 쓸고 실행할수 있는 온라인 에디터 인듯 보인다.
하지만 크로스 도메인 이슈이기 때문에 이것으로 표현할수 없었다.
그래서 테스트 페이지를 만들고 이것을 설명하여 댓글로 달았더니
링크가 많다고 스팸이라고 등록이 안된다.
수정에 수정을 거쳐 링크를 많이 줄였는데, 시도가 많아서 인지 그냥 스팸이라고 안된다.
결국 리포팅하는데 실패 하였다.
- 읽어보자.
집에와서 jquery 의 minify 하지 않은 스크립트를 다운 받아 읽어 살펴 보았다.
당연한것 이지만, jquery의 구현은 이렇게 되어있었다.
스트링으로 dom을 만들어 추가(append) 하고 그 중 스크립트 tag들에 대하여
src가 없으면 내용을 eval 로 실행하고 src가 있다면 src를 $.ajax의 dataType="script"로 하여 불러온다.
$.ajax로 불러올때
같은 도메인의 스크립트는 xhr로 불러오는게 가능하기 때문에 이를 이용하여 불러오고
다른 도메인의 스크립트는 xhr이 불가능하기 때문에 ( same-origin-policy ) 일반적인 스크립트 다이나믹 로딩방법 ( document.createElement('script') 하여 head 에 붙여주는 ) 방식으로 구현이 되어있었다.
이때 async 가"async"로 고정 되어 있었다.
하지만 이 async를 없앤다고 하여도 위의 문제는 해결되지 않았다.
async 를 하면 다음에 스크립트를 불려지는것은 블록이 되지만, 스크립트 실행 자체가 블록되는것이 아니어서 같은 html 내부의 인라인 스크립트( alert('div:'+x);)는 여전히 실행이 되었다.
- 주변 사람들의 말을 들어보자.
same-origin-policy 위반이라면 위반이 아니게 cross-domain 요청하는 방법을 사용해 보는것이 어떻겠냐는 말씀을 해주시며 예제를 주셨다.
예제는 ajax를 야후의 yql 을 proxy 로 이용하여 ajax 를 jsonp 로 바꿔주는 방식 이었다.
예제는 get 방식의 html 로딩에만 적용되어 바로 적용하기 어렵고 그보다 쿠키를 좀 덜 보내려고 proxy를 이용하는것이 옳지 않아보였다.
예제를 바로 쓰기는 어려웠지만 혹시 CORS(cross origin resource sharing) 설정을 하면 ajax로 불러올수 있지 않을까 하는 생각이 들었다.
- 서버 설정을 바꿔보자.
테스트 서버에 Access-Control-Allow-Origin 헤더로 메인도메인의 orign을 넣었다.
이렇게 하면 xdr 로 ajax 요청이 가능해지고 jquery 에서는 ie7 이상에서 이 기능을 지원하는것 으로 알고 있었다.
자 이제 테스트.. 하지만 생각 대로 잘 되지 않았다.
당연한것이었다.
크로스 도메인일때 dataType 이"script" 이면 xdr 요청을 시도���차 하지 않았다.
대부분의 요청이 xdr이 허용되지 않을텐데 , xdr 요청을 시도하고 실패하면 다이나믹 로딩하도록 구현한다는것은 말이 안되는것이니까..
4. 수정
- 요구 사항
이래 저래 생각을 하다보니 이는 jquery 의 문제는 아니다.
다만 내가 우선으로 하는 가치와 맞지 않았을뿐 , 원인과 구현의 타당성은 .충분하다.
사실 위의 문제를 제시한 예제와 같이 프로그래밍 하지 않고 스크립트 동작을 async라고 생각하고 짠다면 같은 기능을 하도록 구현 할수도 있다. 인라인 스크립트를 배제 하고 , 모두 async 한다고 생각한다면 구현도 가능하고 성능도 좋아 질것이다.
하지만 내가 우선으로 했던 것은 내가 만들 네비게이션 모듈을 사용할때"실제 페이지 이동에 의한 실행"과"모듈에 의한 페이지 이동"의 동작 일치하여 쓰는 사람이 이에 대한 고려없이 html 문서를 작성하도록 하는것 이었.
그러기 위해서는 인라인으로 들어있는 html 을 브라우저가 실행하는것과 $.html() 로 실행하는것이 다른 동작을 하지 않아야만 했던것이다.
즉 위의 예제에서
<script>var x;</script><div /><script>
$('div').html('<scr' + 'ipt src="http://efg.com/test.js"></scr' + 'ipt><scr' + 'ipt>alert("div:"+x);</scr' + 'ipt>');
</script>하는것과
<script>var x;</script><div>
</div>
하는것이 같아야 한다.
그외에도 실행 순서 관련 이슈로 몇가지 문제가 더잇다.
1<script>alert(1)</script>2<script>alert(2)</script>
같은 경우
화면에1 표시후 alert(1) , 화면이 12가 된후 표���후 alert(2) 하여야 하는데
12 표시후 alert(1) , alert(2) 가 되는것과
document.write 를 사용할수 없다는것
이왕 하는김에 이것들도 고쳐 보기로 마음먹었다.
- 구현
기존 jquery에 수정하는것이 아닌 덭붙여 사용할수 있도록 plugin 형태로 구현하였다.
( 기존 jquery 구현이 잘못된것이 아니라는것을 인정하는 의미? )
스트링의 $.html() 도 $.ajax() 로 페이지를 load 할때 내부적으로는 $.append 를 사용하므로 이를 overriding 하였다.
1. 싱크가 맞지 않는경우는 script 뿐 이므로 append 할때 문자열을 script 로 잘라서, 이들을 순서대로 append 한다.
2. append 에서 script의 외부 로딩은 async로 실행 되기 때문에 순서대로 실행하면 정상적이지 않다.
그래서 appendWithCallback 이라는 callback이 가능한 append 함수를 추가하고
3. append 는 순서대로 콜백함수를 생성하여 연결하여 appendWithCallback 에 전달한다.
4. 스크립트 실행도 마찬가지로 evalScriptWithCallback 이라는 callback 가능한 스크립트 실행을 만들어서 append 와 같은 방법으로 callback을 연결하여 실행 하도록 한다.
http://static0.dyndns.biz/statics/js/jquery.1.7.1.modified.js
(function ($, win, undefined) {
'$:nomunge'; // Used by YUI compressor.
/* jquery alias */
var jQuery = $;
/* 기존 append 함수를 asyncAppend로 */
$.fn.asyncAppend = $.fn.append;
/* 스크립트를 구분하는 regexp */
var scriptRegexp = /(<script[^>]*>)([\s\S]*?)<\/script>/gi;
/* 스크립트의 CDATA 내부분를 구분하는 regexp */
var rcleanScript = /^\s*<!(?:\[CDATA\[|\-\-)/;
/* document write 를 백업 해두는 stack */
var domWriteList = [];
/* 스크립트 실행 스택 */
var scriptCallStack = [];
/* 스크립트 태그들을 순서대로 실행하는 함수 */
function evalScriptsWithSync(self, scripts, runningScriptCallback) {
var nextRun = runningScriptCallback;
for (var i = scripts.length - 1; i>= 0; i--) {
nextRun = (function () {
var index = i;
var scriptEl = scripts[i];
var next = nextRun;
return function () {
/* 다음에 실행 할 함수를 스택에 삽입하고 현재 함수를 실행
( 완료후 스택에서 pop 해서 다음것을 실행 ) */
scriptCallStack.push(next);
return evalScriptWithCallback.apply(self, [index, scriptEl, function () {
var next = scriptCallStack.pop();
if ($.isFunction(next)) next();
} ]);
}
})();
}
if ($.isFunction(nextRun)) {
nextRun();
}
}
/* 한개의 스크립트문을 실행하는 함수 완료되면 콜백 실행 */
function evalScriptWithCallback(i, elem, callback) {
var async = elem.async;
/* script tag에 async 어트리뷰트가 없으면 동기화 실행 */
if (typeof (async) == "undefined") {
async = false;
}
/* 동기화 실행 이면 document.write 를 append 로 대체 */
if (!async) {
var self = this;
domWriteList.push(win.document.write);
win.document.write = function () {
return function (str) {
self.append(str);
}
} ();
}
/* src 가 있는경우 외부스크립트를 $.ajax 로 불러옴 */
if (elem.src) {
jQuery.ajax({
url: elem.src,
async: async,
dataType: "script",
cache: true,
context: this,
error: function () {
if (jQuery.isFunction(callback)&&!async) {
callback.apply(elem);
win.document.write = domWriteList.pop();
}
}
}).done(function () {
if (jQuery.isFunction(callback)&&!async) {
callback.apply(elem);
win.document.write = domWriteList.pop();
}
});
if (jQuery.isFunction(callback)&&async) {
callback.apply(elem);
win.document.write = domWriteList.pop();
}
} else { /* src 가 없는 경우 innerText를 eval */
jQuery.globalEval((elem.text || elem.textContent || elem.innerHTML || "").replace(rcleanScript, "/*$0*/"));
if (jQuery.isFunction(callback)) {
callback.apply(elem);
win.document.write = domWriteList.pop();
}
}
/* 부모 노드가 있으면 부모 노드에서 제거 */
if (elem.parentNode) {
elem.parentNode.removeChild(elem);
}
}
/* DomManip 텍스트로 부터 dom element 들을 생성하고 script들을 실행 하는 함수의 복사본
콜백 기능을 추가 , 스크립트를 순서대로 실행 하도록 수정 */
$.fn._syncDomManip = function (args, table, callback, runningScriptCallback) {
var results, first, fragment, parent,
value = args[0],
scripts = [];
// We can't cloneNode fragments that contain checked, in WebKit
if (!jQuery.support.checkClone&&arguments.length === 3&&typeof value === "string"&&rchecked.test(value)) {
return this.each(function () {
jQuery(this)._syncDomManip(args, table, callback, runningScriptCallback);
});
}
if (jQuery.isFunction(value)) {
return this.each(function (i) {
var self = jQuery(this);
args[0] = value.call(this, i, table ? self.html() : undefined);
self._syncDomManip(args, table, callback, runningScriptCallback);
});
}
if (this[0]) {
parent = value&&value.parentNode;
// If we're in a fragment, just use that instead of building a new one
if (jQuery.support.parentNode&&parent&&parent.nodeType === 11&&parent.childNodes.length === this.length) {
results = { fragment: parent };
} else {
results = jQuery.buildFragment(args, this, scripts);
}
fragment = results.fragment;
if (fragment.childNodes.length === 1) {
first = fragment = fragment.firstChild;
} else {
first = fragment.firstChild;
}
if (first) {
table = table&&jQuery.nodeName(first, "tr");
for (var i = 0, l = this.length, lastIndex = l - 1; i<l; i++) {
callback.call(
table ?
root(this[i], first) :
this[i],
results.cacheable || (l>1&&i<lastIndex) ?
jQuery.clone(fragment, true, true) :
fragment
);
}
}
if (scripts.length>0) {
evalScriptsWithSync(this, scripts, runningScriptCallback);
}
else if ($.isFunction(runningScriptCallback)) {
runningScriptCallback();
}
}
return this;
};
/* 콜백이 가능 한 append , 파라미터가 (string , callback) 형일때 */
$.fn.appendWithCallback = function () {
return this._syncDomManip(arguments, true, function (elem) {
if (this.nodeType === 1) {
this.appendChild(elem);
}
}, arguments[1]);
};
/* append 오버라이드 */
$.fn.append = function (value, callback) {
/* string 일때만 동작 */
if (typeof (value) === "string") {
var check = value;
var appendValues = [];
var before = 0;
var self = this;
/* 추가할 text를 스크립트 들을 기준으로 자름 */
while (nowScript = scriptRegexp.exec(check)) {
if (nowScript.index>before) {
appendValues.push(check.substr(before, nowScript.index - before));
}
appendValues.push(nowScript[0]);
before = nowScript[0].length + nowScript.index;
}
if (check.length>before) {
appendValues.push(check.substr(before));
}
/* 완료 되면 다음 append 를 실행하도록 callback 으로 연결 */
var nextRun = callback;
for (var i = appendValues.length - 1; i>= 0; i--) {
nextRun = new (function () {
var thisObj = self;
var stringValue = appendValues[i];
var next = nextRun;
return function () {
return thisObj.appendWithCallback(stringValue, next);
}
})();
}
if (jQuery.isFunction(nextRun)) {
nextRun();
}
}
else {
return this.appendWithCallback(value, callback);
}
}
})(jQuery, window);
- 검증 ( 링크 확인 가능합니다. )
http://static0.dyndns.biz/statics/js/test.js
var x = 1;
alert("test:" + x);
alert(document.write);
document.write("xxx");
alert("test end:" + x);
document.write('<scr' + 'ipt src="http://static0.dyndns.biz/statics/js/test2.js"></scr'" target="_blank">http://static0.dyndns.biz/statics/js/test2.js"></scr' + 'ipt>');
http://static0.dyndns.biz/statics/js/test2.js
document.write('yyy');
http://it.dyndns.tv/jqueryTest.html ( 그냥 inline html )
<html><head></head><body><script>var x;</script><div>1<script src="http://static0.dyndns.biz/statics/js/test.js"></script>2<script>alert("div:" + x)</script>3</div></body><html>
http://it.dyndns.tv/jqueryTest2.html ( jquery 1.7.1 사용 )
<html><head><script src="http://code.jquery.com/jquery-latest.js"></script></head><body><script>var x;</script><div></div><script>
$('div').html('1<scr' + 'ipt src="http://static0.dyndns.biz/statics/js/test.js"></scr' + 'ipt>2<scr' + 'ipt>alert("div:" + x);</scr' + 'ipt>3');
</script></body><html>
http://it.dyndns.tv/jqueryTest3.html ( 실행 순서 개선 플러그인 사용 )
<html><head><script src="http://code.jquery.com/jquery-latest.js"></script><script src="http://static0.dyndns.biz/statics/js/jquery.1.7.1.modified.js"></script></head><body><script>var x;</script><div></div><script>
$('div').html('1<scr' + 'ipt src="http://static0.dyndns.biz/statics/js/test.js"></scr' + 'ipt>2<scr' + 'ipt>alert("div:" + x);</scr' + 'ipt>3');
</script></body><html>요구되는 정확한 동작은
--- 화면에 1출력
- test.js 로딩 --- test:1 얼럿
--- document.write 함수 정의 얼럿
--- 화면이 1xxx 로 바뀜 (document.write)
--- test end:1 얼럿
- test2.js 로딩 (document.write) --- 화면이 1xxxyyy 로 바뀜 ( document.write)
--- 화면이 1xxxyyy2 로 바뀜
--- div:1 얼럿
--- 화면이 1xxxyyy23 으로 바뀜
5. 문제
몇가지 문제를 알고 있지만 ,동작하는것을 보니 해이해져서 ...
마땅히 어떻게 고쳐야 할지 생각도 잘 안되고...
1. 스택 오버플로우 가능성
함수 호출이 절대로 싼 오퍼레이션이 아니다.
구현에서는 콜백이 콜백을 부르고 부르고 부르고 하는 방식으로 되어 있다.
긴 스트링을 append 콜스택이 쌓이고 쌓이다가 메모리가 부족할수도 있을거 같다.
옵티마이즈된 c언어 컴파일러와 같은경우
리커시브 콜을 return 과 함께하는 경우 콜스택을 새로 쌓지 않는데,
이 경우는 일단 return 과 함께 호출하기는 하지만 리커시브 콜도 아닌 클로져 호출에다가 javascript에서 어떻게 구현 되어 있는지 몰라서...
2. 스크립트 실행의 콜백 연결 스택
다음 실행할것들을 스택에 넣어두고 꺼내서 실행한다.
이는 스크립트 안에서 스크립트를 부르는 상황을 대비하여 만드는것인데,
실제로 이것들이 스크립트가 로딩 완료 되었을때 호출되는것인지 , 아니면 이전 스크립트 로딩완료 되어 실행되는것인지 알수가 없다.
생각을 해야 하는데 생각을 해야 하는데,,,,,
신경을 쎃야 하는것인가 아닌가도 아직 잘모르겠다. 동작은 잘하는데...
왜 계속 틀린거 같지....
3. html validate
가장 큰 문제이다.
만약
<div><div><script>alert(1);</script></div></div>를 어펜드 한다면
1 :<div><div>
2:<script>alert(1);</script>
3:</div></div>순으로 어펜드를 하것이다.
이러면 jquery 에서<div><div><script/></div></div>로 만들어 줄것인가? 의문이다.

댓글 9

TOP
TOP