Zepto AJAX 모듈 소스 분석

기본 개념

AJAX의 작동 원리를 이해하지 못하면, 소스 코드를 분석하는 데 어려움을 겪을 수 있습니다. 관련 기초 지식을 미리 확인하는 것이 도움이 됩니다.

전역 AJAX 이벤트

기본 설정인 $.ajaxSettings.globaltrue로 설정되어 있어, 요청 주기 내에 다음과 같은 이벤트가 발생합니다:

  • ajaxStart: 활성화된 요청이 없을 때 발생
  • ajaxBeforeSend: 요청 전에 호출되며 취소 가능
  • ajaxSend: ajaxBeforeSend와 유사하지만 취소 불가
  • ajaxSuccess: 성공 응답 시 발생
  • ajaxError: 오류 발생 시
  • ajaxComplete: 요청 완료 여부에 상관없이 발생
  • ajaxStop: 마지막 활성 요청이 종료되었을 때

기본적으로 이벤트는 document에서 발생하지만, 요청의 context가 DOM 요소일 경우 해당 요소에서 발생한 후 부모로 버블링됩니다. 단, ajaxStartajaxStop은 전역 이벤트이므로 예외입니다.

이벤트 트리거 구현

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){}
});

내부 로직 개요:

  1. 옵션 복사 및 기본값 적용
  2. 전역 ajaxStart 이벤트 트리거
  3. 크로스 도메인 여부 판단
  4. 유효 URL 확보
  5. 데이터 시리얼라이제이션
  6. JSONP 여부 확인 및 처리
  7. MIME 타입 결정 및 헤더 설정
  8. onreadystatechange 핸들러 등록
  9. beforeSend 처리
  10. xhr.open 호출
  11. 헤더 설정
  12. 타임아웃 처리
  13. 요청 전송

구체적 구현

$.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의 핵심은 글로벌 콜백 함수의 저장과 복원입니다. 초기에 원래 함수를 보존하고, 요청 후 데이터를 받기 위해 임시로 재정의한 후, 완료 시 다시 원래 상태로 되돌립니다.

태그: Zepto AJAX JSONP XmlHttpRequest JavaScript

7월 3일 04:57에 게시됨