기본 개념
AJAX의 작동 원리를 이해하지 못하면, 소스 코드를 분석하는 데 어려움을 겪을 수 있습니다. 관련 기초 지식을 미리 확인하는 것이 도움이 됩니다.
전역 AJAX 이벤트
기본 설정인 $.ajaxSettings.global는 true로 설정되어 있어, 요청 주기 내에 다음과 같은 이벤트가 발생합니다:
ajaxStart: 활성화된 요청이 없을 때 발생ajaxBeforeSend: 요청 전에 호출되며 취소 가능ajaxSend:ajaxBeforeSend와 유사하지만 취소 불가ajaxSuccess: 성공 응답 시 발생ajaxError: 오류 발생 시ajaxComplete: 요청 완료 여부에 상관없이 발생ajaxStop: 마지막 활성 요청이 종료되었을 때
기본적으로 이벤트는 document에서 발생하지만, 요청의 context가 DOM 요소일 경우 해당 요소에서 발생한 후 부모로 버블링됩니다. 단, ajaxStart과 ajaxStop은 전역 이벤트이므로 예외입니다.
이벤트 트리거 구현
function triggerAndReturn(context, eventName, data) {
const event = $.Event(eventName);
$(context).trigger(event, data);
return !event.isDefaultPrevented();
}
function triggerGlobal(settings, context, eventName, data) {
if (settings.global) return triggerAndReturn(context || document, eventName, data);
}
$.active = 0;
function ajaxStart(settings) {
if (settings.global && $.active++ === 0) triggerGlobal(settings, null, 'ajaxStart');
}
function ajaxStop(settings) {
if (settings.global && !(--$.active)) triggerGlobal(settings, null, 'ajaxStop');
}
function ajaxBeforeSend(xhr, settings) {
const context = settings.context;
if (settings.beforeSend.call(context, xhr, settings) === false ||
triggerGlobal(settings, context, 'ajaxBeforeSend', [xhr, settings]) === false)
return false;
triggerGlobal(settings, context, 'ajaxSend', [xhr, settings]);
}
function ajaxSuccess(data, xhr, settings, deferred) {
const context = settings.context;
settings.success.call(context, data, 'success', xhr);
if (deferred) deferred.resolveWith(context, [data, 'success', xhr]);
triggerGlobal(settings, context, 'ajaxSuccess', [xhr, settings, data]);
ajaxComplete('success', xhr, settings);
}
function ajaxError(error, type, xhr, settings, deferred) {
const context = settings.context;
settings.error.call(context, xhr, type, error);
if (deferred) deferred.rejectWith(context, [xhr, type, error]);
triggerGlobal(settings, context, 'ajaxError', [xhr, settings, error || type]);
ajaxComplete(type, xhr, settings);
}
function ajaxComplete(status, xhr, settings) {
const context = settings.context;
settings.complete.call(context, xhr, status);
triggerGlobal(settings, context, 'ajaxComplete', [xhr, settings]);
ajaxStop(settings);
}
이 구현은 간단하며, 특정 요청 처리 중에 일련의 트리거 함수를 호출하고, 내장된 $.Event 시스템을 통해 이벤트를 발생시킵니다.
핵심 메서드: $.ajax
사용법:
$.ajax({
type: 'GET',
url: '/projects',
data: { name: 'Zepto.js' },
dataType: 'json',
timeout: 300,
context: $('body'),
success: function(data){},
error: function(xhr, type){}
});
내부 로직 개요:
- 옵션 복사 및 기본값 적용
- 전역
ajaxStart이벤트 트리거 - 크로스 도메인 여부 판단
- 유효 URL 확보
- 데이터 시리얼라이제이션
- JSONP 여부 확인 및 처리
- MIME 타입 결정 및 헤더 설정
onreadystatechange핸들러 등록beforeSend처리xhr.open호출- 헤더 설정
- 타임아웃 처리
- 요청 전송
구체적 구현
$.ajax = function(options) {
const settings = $.extend({}, options || {});
const deferred = $.Deferred && $.Deferred();
let urlAnchor, hashIndex;
for (const key in $.ajaxSettings) {
if (settings[key] === undefined) settings[key] = $.ajaxSettings[key];
}
ajaxStart(settings);
if (!settings.crossDomain) {
urlAnchor = document.createElement('a');
urlAnchor.href = settings.url;
urlAnchor.href = urlAnchor.href;
settings.crossDomain = (window.location.protocol + '//' + window.location.host) !== (urlAnchor.protocol + '//' + urlAnchor.host);
}
if (!settings.url) settings.url = window.location.toString();
if ((hashIndex = settings.url.indexOf('#')) > -1) settings.url = settings.url.slice(0, hashIndex);
serializeData(settings);
const dataType = settings.dataType;
const hasPlaceholder = /\?.+=\?/.test(settings.url);
if (hasPlaceholder) dataType = 'jsonp';
if (settings.cache === false || (
(!options || options.cache !== true) &&
('script' == dataType || 'jsonp' == dataType)
)) {
settings.url = appendQuery(settings.url, '_=' + Date.now());
}
if ('jsonp' == dataType) {
if (!hasPlaceholder) {
settings.url = appendQuery(settings.url,
settings.jsonp ? (settings.jsonp + '=?') : settings.jsonp === false ? '' : 'callback=?');
}
return $.ajaxJSONP(settings, deferred);
}
const mime = settings.accepts[dataType];
const headers = {};
const setHeader = (name, value) => headers[name.toLowerCase()] = [name, value];
const protocol = /^([\w-]+:)\/\//.test(settings.url) ? RegExp.$1 : window.location.protocol;
const xhr = settings.xhr();
const nativeSetHeader = xhr.setRequestHeader;
let abortTimeout;
if (deferred) deferred.promise(xhr);
if (!settings.crossDomain) setHeader('X-Requested-With', 'XMLHttpRequest');
setHeader('Accept', mime || '*/*');
if (mime = settings.mimeType || mime) {
if (mime.indexOf(',') > -1) mime = mime.split(',', 2)[0];
xhr.overrideMimeType && xhr.overrideMimeType(mime);
}
if (settings.contentType || (settings.contentType !== false && settings.data && settings.type.toUpperCase() !== 'GET'))
setHeader('Content-Type', settings.contentType || 'application/x-www-form-urlencoded');
if (settings.headers) for (const name in settings.headers) setHeader(name, settings.headers[name]);
xhr.setRequestHeader = setHeader;
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
xhr.onreadystatechange = empty;
clearTimeout(abortTimeout);
let result, error = false;
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304 || (xhr.status === 0 && protocol === 'file:')) {
dataType = dataType || mimeToDataType(settings.mimeType || xhr.getResponseHeader('content-type'));
if (xhr.responseType === 'arraybuffer' || xhr.responseType === 'blob')
result = xhr.response;
else {
result = xhr.responseText;
try {
result = ajaxDataFilter(result, dataType, settings);
if (dataType === 'script') (1, eval)(result);
else if (dataType === 'xml') result = xhr.responseXML;
else if (dataType === 'json') result = blankRE.test(result) ? null : $.parseJSON(result);
} catch (e) { error = e; }
if (error) return ajaxError(error, 'parsererror', xhr, settings, deferred);
}
ajaxSuccess(result, xhr, settings, deferred);
} else {
ajaxError(xhr.statusText || null, xhr.status ? 'error' : 'abort', xhr, settings, deferred);
}
}
};
if (ajaxBeforeSend(xhr, settings) === false) {
xhr.abort();
ajaxError(null, 'abort', xhr, settings, deferred);
return xhr;
}
const async = 'async' in settings ? settings.async : true;
xhr.open(settings.type, settings.url, async, settings.username, settings.password);
if (settings.xhrFields) for (const name in settings.xhrFields) xhr[name] = settings.xhrFields[name];
for (const name in headers) nativeSetHeader.apply(xhr, headers[name]);
if (settings.timeout > 0) abortTimeout = setTimeout(() => {
xhr.onreadystatechange = empty;
xhr.abort();
ajaxError(null, 'timeout', xhr, settings, deferred);
}, settings.timeout);
xhr.send(settings.data ? settings.data : null);
return xhr;
};
보조 함수
function mimeToDataType(mime) {
if (mime) mime = mime.split(';', 2)[0];
return mime && (mime === htmlType ? 'html' :
mime === jsonType ? 'json' :
scriptTypeRE.test(mime) ? 'script' :
xmlTypeRE.test(mime) && 'xml') || 'text';
}
function appendQuery(url, query) {
if (query === '') return url;
return (url + '&' + query).replace(/[&?]{1,2}/, '?');
}
function serializeData(options) {
if (options.processData && options.data && $.type(options.data) !== "string")
options.data = $.param(options.data, options.traditional);
if (options.data && (!options.type || options.type.toUpperCase() === 'GET' || options.dataType === 'jsonp'))
options.url = appendQuery(options.url, options.data), options.data = undefined;
}
function ajaxDataFilter(data, type, settings) {
return settings.dataFilter === empty ? data : settings.dataFilter.call(settings.context, data, type);
}
간편 메서드
$.get = function(...args) {
return $.ajax(parseArguments.apply(null, args));
};
$.post = function(...args) {
const options = parseArguments.apply(null, args);
options.type = 'POST';
return $.ajax(options);
};
$.getJSON = function(...args) {
const options = parseArguments.apply(null, args);
options.dataType = 'json';
return $.ajax(options);
};
인자 파싱 유틸리티
function parseArguments(url, data, success, dataType) {
if ($.isFunction(data)) {
dataType = success;
success = data;
data = undefined;
}
if (!$.isFunction(success)) {
dataType = success;
success = undefined;
}
return {
url: url,
data: data,
success: success,
dataType: dataType
};
}
load 메서드
$.fn.load = function(url, data, success) {
if (!this.length) return this;
const self = this;
const parts = url.split(/\s/);
const selector = parts.length > 1 ? parts[1] : null;
const options = parseArguments(url, data, success);
const callback = options.success;
if (selector) options.url = parts[0];
options.success = function(response) {
self.html(selector ?
$('<div>').html(response.replace(rscript, "")).find(selector) :
response);
callback && callback.apply(self, arguments);
};
$.ajax(options);
return this;
};
JSONP 처리: $.ajaxJSONP
사용법:
$.ajaxJSONP(options); // options에는 `jsonpCallback` 필드 포함
구현:
$.ajaxJSONP = function(options, deferred) {
if (!('type' in options)) return $.ajax(options);
const callbackName = ($.isFunction(options.jsonpCallback) ?
options.jsonpCallback() : options.jsonpCallback) || ('Zepto' + (jsonpID++));
const script = document.createElement('script');
const originalCallback = window[callbackName];
let responseData;
const abort = function(errorType) {
$(script).triggerHandler('error', errorType || 'abort');
};
const xhr = { abort: abort };
let abortTimeout;
if (deferred) deferred.promise(xhr);
$(script).on('load error', function(e, errorType) {
clearTimeout(abortTimeout);
$(script).off().remove();
if (e.type === 'error' || !responseData) {
ajaxError(null, errorType || 'error', xhr, options, deferred);
} else {
ajaxSuccess(responseData[0], xhr, options, deferred);
}
window[callbackName] = originalCallback;
if (responseData && $.isFunction(originalCallback))
originalCallback(responseData[0]);
originalCallback = responseData = undefined;
});
if (ajaxBeforeSend(xhr, options) === false) {
abort('abort');
return xhr;
}
window[callbackName] = function() {
responseData = arguments;
};
script.src = options.url.replace(/\?(.+)=\?/, '?$1=' + callbackName);
document.head.appendChild(script);
if (options.timeout > 0) abortTimeout = setTimeout(() => abort('timeout'), options.timeout);
return xhr;
};
JSONP의 핵심은 글로벌 콜백 함수의 저장과 복원입니다. 초기에 원래 함수를 보존하고, 요청 후 데이터를 받기 위해 임시로 재정의한 후, 완료 시 다시 원래 상태로 되돌립니다.