2023년 11월 25일

call/apply와 데코레이터, 포워딩

자바스크립트는 함수를 다룰 때 탁월한 유연성을 제공합니다. 함수는 이곳저곳 전달될 수 있고, 객체로도 사용될 수 있습니다. 이번 챕터에선 함수 간에 호출을 어떻게 포워딩(forwarding) 하는지, 함수를 어떻게 데코레이팅(decorating) 하는지에 대해 알아보겠습니다.

코드 변경 없이 캐싱 기능 추가하기

CPU를 많이 잡아먹지만 결과는 안정적인 함수 slow(x)가 있다고 가정해 봅시다. 결과가 안정적이라는 말은 x가 같으면 호출 결과도 같다는 것을 의미합니다.

slow(x)가 자주 호출된다면, 결과를 어딘가에 저장(캐싱)해 재연산에 걸리는 시간을 줄이고 싶을 겁니다.

아래 예시에선 slow() 안에 캐싱 관련 코드를 추가하는 대신, 래퍼 함수를 만들어 캐싱 기능을 추가할 예정입니다. 곧 정리하겠지만, 이렇게 래퍼 함수를 만들면 여러 가지 이점이 있습니다.

코드를 먼저 살펴봅시다. 설명은 아래쪽에 적어두었습니다.

function slow(x) {
  // CPU 집약적인 작업이 여기에 올 수 있습니다.
  alert(`slow(${x})을/를 호출함`);
  return x;
}

function cachingDecorator(func) {
  let cache = new Map();

  return function(x) {
    if (cache.has(x)) {    // cache에 해당 키가 있으면
      return cache.get(x); // 대응하는 값을 cache에서 읽어옵니다.
    }

    let result = func(x);  // 그렇지 않은 경우엔 func를 호출하고,

    cache.set(x, result);  // 그 결과를 캐싱(저장)합니다.
    return result;
  };
}

slow = cachingDecorator(slow);

alert( slow(1) ); // slow(1)이 저장되었습니다.
alert( "다시 호출: " + slow(1) ); // 동일한 결과

alert( slow(2) ); // slow(2)가 저장되었습니다.
alert( "다시 호출: " + slow(2) ); // 윗줄과 동일한 결과

cachingDecorator같이 인수로 받은 함수의 행동을 변경시켜주는 함수를 데코레이터(decorator) 라고 부릅니다.

모든 함수를 대상으로 cachingDecorator를 호출 할 수 있는데, 이때 반환되는 것은 캐싱 래퍼입니다. 함수에 cachingDecorator를 적용하기만 하면 캐싱이 가능한 함수를 원하는 만큼 구현할 수 있기 때문에 데코레이터 함수는 아주 유용하게 사용됩니다.

캐싱 관련 코드를 함수 코드와 분리할 수 있기 때문에 함수의 코드가 간결해진다는 장점도 있습니다.

아래 그림에서 볼 수 있듯이 cachingDecorator(func)를 호출하면 ‘래퍼(wrapper)’, function(x)이 반환됩니다. 래퍼 function(x)func(x)의 호출 결과를 캐싱 로직으로 감쌉니다(wrapping).

바깥 코드에서 봤을 때, 함수 slow는 래퍼로 감싼 이전이나 이후나 동일한 일을 수행합니다. 행동 양식에 캐싱 기능이 추가된 것뿐입니다.

slow 본문을 수정하는 것 보다 독립된 래퍼 함수 cachingDecorator를 사용할 때 생기는 이점을 정리하면 다음과 같습니다.

  • cachingDecorator를 재사용 할 수 있습니다. 원하는 함수 어디에든 cachingDecorator를 적용할 수 있습니다.
  • 캐싱 로직이 분리되어 slow 자체의 복잡성이 증가하지 않습니다.
  • 필요하다면 여러 개의 데코레이터를 조합해서 사용할 수도 있습니다(추가 데코레이터는 cachingDecorator 뒤를 따릅니다).

'func.call’를 사용해 컨텍스트 지정하기

위에서 구현한 캐싱 데코레이터는 객체 메서드에 사용하기엔 적합하지 않습니다.

객체 메서드 worker.slow()는 데코레이터 적용 후 제대로 동작하지 않죠.

// worker.slow에 캐싱 기능을 추가해봅시다.
let worker = {
  someMethod() {
    return 1;
  },

  slow(x) {
    // CPU 집약적인 작업이라 가정
    alert(`slow(${x})을/를 호출함`);
    return x * this.someMethod(); // (*)
  }
};

// 이전과 동일한 코드
function cachingDecorator(func) {
  let cache = new Map();
  return function(x) {
    if (cache.has(x)) {
      return cache.get(x);
    }
    let result = func(x); // (**)
    cache.set(x, result);
    return result;
  };
}

alert( worker.slow(1) ); // 기존 메서드는 잘 동작합니다.

worker.slow = cachingDecorator(worker.slow); // 캐싱 데코레이터 적용

alert( worker.slow(2) ); // 에러 발생!, Error: Cannot read property 'someMethod' of undefined

(*)로 표시한 줄에서 this.someMethod 접근에 실패했기 때문에 에러가 발생했습니다.

원인은 (**)로 표시한 줄에서 래퍼가 기존 함수 func(x)를 호출하면 thisundefined가 되기 때문입니다.

아래 코드를 실행해도 비슷한 증상이 나타납니다.

let func = worker.slow;
func(2);

래퍼가 기존 메서드 호출 결과를 전달하려 했지만 this의 컨텍스트가 사라졌기 때문에 에러가 발생하는 것이죠.

에러가 발생하지 않게 코드를 수정해 봅시다.

먼저, this를 명시적으로 고정해 함수를 호출할 수 있게 해주는 특별한 내장 함수 메서드 func.call(context, …args)에 대해 알아봅시다.

문법은 다음과 같습니다.

func.call(context, arg1, arg2, ...)

메서드를 호출하면 메서드의 첫 번째 인수가 this, 이어지는 인수가 func의 인수가 된 후, func이 호출됩니다.

아래 함수와 메서드를 호출하면 거의 동일한 일이 발생합니다.

func(1, 2, 3);
func.call(obj, 1, 2, 3)

둘 다 인수로 1, 2, 3을 받죠. 유일한 차이점은 func.call에선 thisobj로 고정된다는 점입니다.

다른 컨텍스트(다른 객체) 하에 sayHi를 호출하는 예시를 살펴봅시다. sayHi.call(user)를 호출하면 sayHi의 컨텍스트가 this=user로, sayHi.call(admin)을 호출하면 sayHi의 컨텍스트가 this=admin로 설정됩니다.

function sayHi() {
  alert(this.name);
}

let user = { name: "John" };
let admin = { name: "Admin" };

// call을 사용해 원하는 객체가 'this'가 되도록 합니다.
sayHi.call( user ); // this = John
sayHi.call( admin ); // this = Admin

아래 예시에선 call을 사용해 컨텍스트와 phrase에 원하는 값을 지정해 보았습니다.

function say(phrase) {
  alert(this.name + ': ' + phrase);
}

let user = { name: "John" };

// this엔 user가 고정되고, "Hello"는 메서드의 첫 번째 인수가 됩니다.
say.call( user, "Hello" ); // John: Hello

래퍼 안에서 call을 사용해 컨텍스트를 원본 함수로 전달하면 에러가 발생하지 않습니다.

let worker = {
  someMethod() {
    return 1;
  },

  slow(x) {
    alert(`slow(${x})을/를 호출함`);
    return x * this.someMethod(); // (*)
  }
};

function cachingDecorator(func) {
  let cache = new Map();
  return function(x) {
    if (cache.has(x)) {
      return cache.get(x);
    }
    let result = func.call(this, x); // 이젠 'this'가 제대로 전달됩니다.
    cache.set(x, result);
    return result;
  };
}

worker.slow = cachingDecorator(worker.slow); // 캐싱 데코레이터 적용

alert( worker.slow(2) ); // 제대로 동작합니다.
alert( worker.slow(2) ); // 제대로 동작합니다. 다만, 원본 함수가 호출되지 않고 캐시 된 값이 출력됩니다.

이제 에러 없이 모든 게 정상적으로 동작합니다.

명확한 이해를 위해 this가 어떤 과정을 거쳐 전달되는지 자세히 살펴보겠습니다.

  1. 데코레이터를 적용한 후에 worker.slow는 래퍼 function (x) { ... }가 됩니다.
  2. worker.slow(2)를 실행하면 래퍼는 2를 인수로 받고, this=worker가 됩니다(점 앞의 객체).
  3. 결과가 캐시되지 않은 상황이라면 func.call(this, x)에서 현재 this (=worker)와 인수(=2)를 원본 메서드에 전달합니다.

여러 인수 전달하기

cachingDecorator를 좀 더 다채롭게 해봅시다. 지금 상태론 인수가 하나뿐인 함수에만 cachingDecorator를 적용할 수 있습니다.

복수 인수를 가진 메서드, worker.slow를 캐싱하려면 어떻게 해야 할지 생각해 봅시다.

let worker = {
  slow(min, max) {
    return min + max; // CPU를 아주 많이 쓰는 작업이라고 가정
  }
};

// 동일한 인수를 전달했을 때 호출 결과를 기억할 수 있어야 합니다.
worker.slow = cachingDecorator(worker.slow);

지금까진 인수가 x 하나뿐이었기 때문에 cache.set(x, result)으로 결과를 저장하고 cache.get(x)으로 저장된 결과를 불러오기만 하면 됐습니다. 그런데 이제부턴 (min,max)같이 인수가 여러 개이고, 이 인수들을 넘겨 호출한 결과를 기억해야 합니다. 네이티브 은 단일 키만 받지만 말이죠.

해결 방법은 여러 가지입니다.

  1. 복수 키를 지원하는 맵과 유사한 자료 구조 구현하기(서드 파티 라이브러리 등을 사용해도 됨)
  2. 중첩 맵을 사용하기. (max, result) 쌍 저장은 cache.set(min)으로, resultcache.get(min).get(max)을 사용해 얻습니다.
  3. 두 값을 하나로 합치기. 의 키로 문자열 "min,max"를 사용합니다. 여러 값을 하나로 합치는 코드는 해싱 함수(hashing function) 에 구현해 유연성을 높입니다.

세 번째 방법만으로 충분하기 때문에 이 방법을 사용해 코드를 수정해 보겠습니다.

여기에 더하여 func.call(this, x)func.call(this, ...arguments)로 교체해, 래퍼 함수로 감싼 함수가 호출될 때 복수 인수 넘길 수 있도록 하겠습니다.

더 강력해진 cachingDecorator를 살펴봅시다.

let worker = {
  slow(min, max) {
    alert(`slow(${min},${max})을/를 호출함`);
    return min + max;
  }
};

function cachingDecorator(func, hash) {
  let cache = new Map();
  return function() {
    let key = hash(arguments); // (*)
    if (cache.has(key)) {
      return cache.get(key);
    }

    let result = func.call(this, ...arguments); // (**)

    cache.set(key, result);
    return result;
  };
}

function hash(args) {
  return args[0] + ',' + args[1];
}

worker.slow = cachingDecorator(worker.slow, hash);

alert( worker.slow(3, 5) ); // 제대로 동작합니다.
alert( "다시 호출: " + worker.slow(3, 5) ); // 동일한 결과 출력(캐시된 결과)

이제 인수의 개수에 관계없이 래퍼가 잘 동작합니다. 해시 함수가 복수의 인수를 자유자재로 처리할 수 있도록 수정을 해야 하긴 하지만 말이죠. 이를 가능하게 해주는 흥미로운 방법은 아래에서 소개해 드리겠습니다.

개선 후, 바뀐 것은 두 가지입니다.

  • (*)로 표시한 줄에서 hash가 호출되면서 arguments를 사용한 단일 키가 만들어집니다. 여기선 간단한 ‘결합’ 함수로 인수 (3, 5)를 키 "3,5"로 바꿨는데, 좀 더 복잡한 경우라면 또 다른 해싱 함수가 필요할 수 있습니다.
  • (**)로 표시한 줄에선 func.call(this, ...arguments)를 사용해 컨텍스트(this)와 래퍼가 가진 인수 전부(...arguments)를 기존 함수에 전달하였습니다.

func.apply

그런데 여기서 func.call(this, ...arguments) 대신, func.apply(this, arguments)를 사용해도 됩니다.

내장 메서드 func.apply의 문법은 다음과 같습니다.

func.apply(context, args)

applyfuncthiscontext로 고정해주고, 유사 배열 객체인 args를 인수로 사용할 수 있게 해줍니다.

callapply의 문법적 차이는 call이 복수 인수를 따로따로 받는 대신 apply는 인수를 유사 배열 객체로 받는다는 점뿐입니다.

따라서 아래 코드 두 줄은 거의 같은 역할을 합니다.

func.call(context, ...args); // 전개 구문을 사용해 인수가 담긴 배열을 전달하는 것과
func.apply(context, args);   // call을 사용하는 것은 동일합니다.

그런데 약간의 차이가 있긴 합니다.

  • 전개 구문 ...이터러블 args을 분해 해 call에 전달할 수 있도록 해줍니다.
  • apply는 오직 유사 배열 형태의 args만 받습니다.

이 차이만 빼면 두 메서드는 완전히 동일하게 동작합니다. 인수가 이터러블 형태라면 call을, 유사 배열 형태라면 apply를 사용하면 됩니다.

배열같이 이터러블이면서 유사 배열인 객체엔 둘 다를 사용할 수 있는데, 대부분의 자바스크립트 엔진은 내부에서 apply를 최적화 하기 때문에 apply를 사용하는 게 좀 더 빠르긴 합니다.

이렇게 컨텍스트와 함께 인수 전체를 다른 함수에 전달하는 것을 콜 포워딩(call forwarding) 이라고 합니다.

가장 간단한 형태의 콜 포워딩은 다음과 같습니다.

let wrapper = function() {
  return func.apply(this, arguments);
};

이런 식으로 외부에서 wrapper를 호출하면, 기존 함수인 func를 호출하는 것과 명확하게 구분할 수 없습니다.

메서드 빌리기

위에서 구현한 해싱 함수를 개선해봅시다.

function hash(args) {
  return args[0] + ',' + args[1];
}

지금 상태에선 인수 두 개만 다룰 수 있습니다. args의 요소 개수에 상관없이 요소들을 합칠 수 있으면 더 좋겠네요.

가장 자연스러운 해결책은 배열 메서드 arr.join을 사용하는 것입니다.

function hash(args) {
  return args.join();
}

그런데 아쉽게도 이 방법은 동작하지 않습니다. hash(arguments)를 호출할 때 인수로 넘겨주는 arguments는 진짜 배열이 아니고 이터러블 객체나 유사 배열 객체이기 때문입니다.

배열이 아닌 것에 join을 호출하면 에러가 발생합니다.

function hash() {
  alert( arguments.join() ); // Error: arguments.join is not a function
}

hash(1, 2);

그런데 아래와 같은 방법을 사용하면 배열 메서드 join을 사용할 수 있습니다.

function hash() {
  alert( [].join.call(arguments) ); // 1,2
}

hash(1, 2);

The trick is called method borrowing.

일반 배열에서 join 메서드를 빌려오고([].join), [].join.call를 사용해 arguments를 컨텍스트로 고정한 후 join메서드를 호출하는 것이죠.

이게 어떻게 가능할까요?

네이티브 메서드 arr.join(glue)의 내부 알고리즘은 아주 간단하기 때문입니다.

스펙을 ‘그대로’ 차용해 설명해 보겠습니다.

  1. glue가 첫 번째 인수가 되도록 합니다. 인수가 없으면 ","가 첫 번째 인수가 됩니다.
  2. result는 빈 문자열이 되도록 초기화합니다.
  3. this[0]result에 덧붙입니다.
  4. gluethis[1]result에 덧붙입니다.
  5. gluethis[2]result에 덧붙입니다.
  6. this.length개의 항목이 모두 추가될 때까지 이 일을 반복합니다.
  7. result를 반환합니다.

기존에 call을 사용했던 방식처럼 this를 받고 this[0], this[1] 등이 합쳐집니다. 이렇게 내부 알고리즘이 구현되어있기 때문에 어떤 유사 배열이든 this가 될 수 있습니다. 상당수의 메서드가 이런 관습을 따르고 있죠. thisarguments가 할당되더라도 잘 동작하는 이유가 여기에 있습니다.

데코레이터와 함수 프로퍼티

함수 또는 메서드를 데코레이터로 감싸 대체하는 것은 대체적으로 안전합니다. 그런데 원본 함수에 func.calledCount 등의 프로퍼티가 있으면 데코레이터를 적용한 함수에선 프로퍼티를 사용할 수 없으므로 안전하지 않습니다. 함수에 프로퍼티가 있는 경우엔 데코레이터 사용에 주의해야 합니다.

위 예시에서 함수 slow에 프로퍼티가 있었다면 cachingDecorator(slow) 호출 결과인 래퍼엔 프로퍼티가 없겠죠.

몇몇 데코레이터는 자신만의 프로퍼티를 갖기도 합니다. 데코레이터는 함수가 얼마나 많이 호출되었는지 세거나 호출 시 얼마나 많은 시간이 소모되었는지 등의 정보를 래퍼의 프로퍼티에 저장할 수 있습니다.

함수 프로퍼티에 접근할 수 있게 해주는 데코레이터를 만드는 방법도 있습니다. 그런데 이걸 구현하려면 Proxy라는 특별한 객체를 사용해 함수를 감싸야 합니다. Proxy에 대해선 Proxy와 Reflect에서 다루도록 하겠습니다.

요약

데코레이터는 함수를 감싸는 래퍼로 함수의 행동을 변화시킵니다. 주요 작업은 여전히 함수에서 처리합니다.

데코레이터는 함수에 추가된 ‘기능’ 혹은 ‘상(相, aspect)’ 정도로 보시면 됩니다. 하나 혹은 여러 개의 데코레이터를 추가해도 함수의 코드는 변경되지 않습니다.

cachingDecorator는 아래와 같은 메서드를 사용해 구현하였습니다.

콜 포워딩은 대개 apply를 사용해 구현합니다.

let wrapper = function() {
  return original.apply(this, arguments);
};

특정 객체에서 메서드를 가져오고, 다른 객체를 컨텍스트로 고정한 후 함수를 호출(call)하는 형태인 메서드 빌리기에 대한 예제도 살펴보았습니다. 메서드 빌리기는 배열 메서드를 빌려서 이를 arguments에 적용할 때 흔히 사용됩니다. 나머지 매개변수와 배열을 함께 사용하면 유사한 기능을 구현할 수 있습니다.

데코레이터를 사용해서 기능을 구현한 사례들이 많습니다. 아래 문제들을 풀어보면서 데코레이터에 대해 얼마나 이해하고 있는지 확인해봅시다.

과제

중요도: 5

Create a decorator spy(func) that should return a wrapper that saves all calls to function in its calls property.

Every call is saved as an array of arguments.

For instance:

function work(a, b) {
  alert( a + b ); // work is an arbitrary function or method
}

work = spy(work);

work(1, 2); // 3
work(4, 5); // 9

for (let args of work.calls) {
  alert( 'call:' + args.join() ); // "call:1,2", "call:4,5"
}

P.S. That decorator is sometimes useful for unit-testing. Its advanced form is sinon.spy in Sinon.JS library.

테스트 코드가 담긴 샌드박스를 열어 정답을 작성해보세요.

The wrapper returned by spy(f) should store all arguments and then use f.apply to forward the call.

function spy(func) {

  function wrapper(...args) {
    // using ...args instead of arguments to store "real" array in wrapper.calls
    wrapper.calls.push(args);
    return func.apply(this, args);
  }

  wrapper.calls = [];

  return wrapper;
}

테스트 코드가 담긴 샌드박스를 열어 정답을 확인해보세요.

중요도: 5

Create a decorator delay(f, ms) that delays each call of f by ms milliseconds.

For instance:

function f(x) {
  alert(x);
}

// create wrappers
let f1000 = delay(f, 1000);
let f1500 = delay(f, 1500);

f1000("test"); // shows "test" after 1000ms
f1500("test"); // shows "test" after 1500ms

In other words, delay(f, ms) returns a "delayed by ms" variant of f.

In the code above, f is a function of a single argument, but your solution should pass all arguments and the context this.

테스트 코드가 담긴 샌드박스를 열어 정답을 작성해보세요.

The solution:

function delay(f, ms) {

  return function() {
    setTimeout(() => f.apply(this, arguments), ms);
  };

}

let f1000 = delay(alert, 1000);

f1000("test"); // shows "test" after 1000ms

Please note how an arrow function is used here. As we know, arrow functions do not have own this and arguments, so f.apply(this, arguments) takes this and arguments from the wrapper.

If we pass a regular function, setTimeout would call it without arguments and this=window (assuming we’re in the browser).

We still can pass the right this by using an intermediate variable, but that’s a little bit more cumbersome:

function delay(f, ms) {

  return function(...args) {
    let savedThis = this; // store this into an intermediate variable
    setTimeout(function() {
      f.apply(savedThis, args); // use it here
    }, ms);
  };

}

테스트 코드가 담긴 샌드박스를 열어 정답을 확인해보세요.

중요도: 5

The result of debounce(f, ms) decorator is a wrapper that suspends calls to f until there’s ms milliseconds of inactivity (no calls, “cooldown period”), then invokes f once with the latest arguments.

In other words, debounce is like a secretary that accepts “phone calls”, and waits until there’s ms milliseconds of being quiet. And only then it transfers the latest call information to “the boss” (calls the actual f).

For instance, we had a function f and replaced it with f = debounce(f, 1000).

Then if the wrapped function is called at 0ms, 200ms and 500ms, and then there are no calls, then the actual f will be only called once, at 1500ms. That is: after the cooldown period of 1000ms from the last call.

…And it will get the arguments of the very last call, other calls are ignored.

Here’s the code for it (uses the debounce decorator from the Lodash library):

let f = _.debounce(alert, 1000);

f("a");
setTimeout( () => f("b"), 200);
setTimeout( () => f("c"), 500);
// debounced function waits 1000ms after the last call and then runs: alert("c")

Now a practical example. Let’s say, the user types something, and we’d like to send a request to the server when the input is finished.

There’s no point in sending the request for every character typed. Instead we’d like to wait, and then process the whole result.

In a web-browser, we can setup an event handler – a function that’s called on every change of an input field. Normally, an event handler is called very often, for every typed key. But if we debounce it by 1000ms, then it will be only called once, after 1000ms after the last input.

In this live example, the handler puts the result into a box below, try it:

See? The second input calls the debounced function, so its content is processed after 1000ms from the last input.

So, debounce is a great way to process a sequence of events: be it a sequence of key presses, mouse movements or something else.

It waits the given time after the last call, and then runs its function, that can process the result.

The task is to implement debounce decorator.

Hint: that’s just a few lines if you think about it :)

테스트 코드가 담긴 샌드박스를 열어 정답을 작성해보세요.

function debounce(func, ms) {
  let timeout;
  return function() {
    clearTimeout(timeout);
    timeout = setTimeout(() => func.apply(this, arguments), ms);
  };
}

A call to debounce returns a wrapper. When called, it schedules the original function call after given ms and cancels the previous such timeout.

테스트 코드가 담긴 샌드박스를 열어 정답을 확인해보세요.

중요도: 5

Create a “throttling” decorator throttle(f, ms) – that returns a wrapper.

When it’s called multiple times, it passes the call to f at maximum once per ms milliseconds.

The difference with debounce is that it’s completely different decorator:

  • debounce runs the function once after the “cooldown” period. Good for processing the final result.
  • throttle runs it not more often than given ms time. Good for regular updates that shouldn’t be very often.

In other words, throttle is like a secretary that accepts phone calls, but bothers the boss (calls the actual f) not more often than once per ms milliseconds.

Let’s check the real-life application to better understand that requirement and to see where it comes from.

For instance, we want to track mouse movements.

In a browser we can setup a function to run at every mouse movement and get the pointer location as it moves. During an active mouse usage, this function usually runs very frequently, can be something like 100 times per second (every 10 ms). We’d like to update some information on the web-page when the pointer moves.

…But updating function update() is too heavy to do it on every micro-movement. There is also no sense in updating more often than once per 100ms.

So we’ll wrap it into the decorator: use throttle(update, 100) as the function to run on each mouse move instead of the original update(). The decorator will be called often, but forward the call to update() at maximum once per 100ms.

Visually, it will look like this:

  1. For the first mouse movement the decorated variant immediately passes the call to update. That’s important, the user sees our reaction to their move immediately.
  2. Then as the mouse moves on, until 100ms nothing happens. The decorated variant ignores calls.
  3. At the end of 100ms – one more update happens with the last coordinates.
  4. Then, finally, the mouse stops somewhere. The decorated variant waits until 100ms expire and then runs update with last coordinates. So, quite important, the final mouse coordinates are processed.

A code example:

function f(a) {
  console.log(a);
}

// f1000 passes calls to f at maximum once per 1000 ms
let f1000 = throttle(f, 1000);

f1000(1); // shows 1
f1000(2); // (throttling, 1000ms not out yet)
f1000(3); // (throttling, 1000ms not out yet)

// when 1000 ms time out...
// ...outputs 3, intermediate value 2 was ignored

P.S. Arguments and the context this passed to f1000 should be passed to the original f.

테스트 코드가 담긴 샌드박스를 열어 정답을 작성해보세요.

function throttle(func, ms) {

  let isThrottled = false,
    savedArgs,
    savedThis;

  function wrapper() {

    if (isThrottled) { // (2)
      savedArgs = arguments;
      savedThis = this;
      return;
    }

    func.apply(this, arguments); // (1)

    isThrottled = true;

    setTimeout(function() {
      isThrottled = false; // (3)
      if (savedArgs) {
        wrapper.apply(savedThis, savedArgs);
        savedArgs = savedThis = null;
      }
    }, ms);
  }

  return wrapper;
}

A call to throttle(func, ms) returns wrapper.

  1. During the first call, the wrapper just runs func and sets the cooldown state (isThrottled = true).
  2. In this state all calls are memorized in savedArgs/savedThis. Please note that both the context and the arguments are equally important and should be memorized. We need them simultaneously to reproduce the call.
  3. After ms milliseconds pass, setTimeout triggers. The cooldown state is removed (isThrottled = false) and, if we had ignored calls, wrapper is executed with the last memorized arguments and context.

The 3rd step runs not func, but wrapper, because we not only need to execute func, but once again enter the cooldown state and setup the timeout to reset it.

테스트 코드가 담긴 샌드박스를 열어 정답을 확인해보세요.

튜토리얼 지도

댓글

댓글을 달기 전에 마우스를 올렸을 때 나타나는 글을 먼저 읽어주세요.
  • 추가 코멘트, 질문 및 답변을 자유롭게 남겨주세요. 개선해야 할 것이 있다면 댓글 대신 이슈를 만들어주세요.
  • 잘 이해되지 않는 부분은 구체적으로 언급해주세요.
  • 댓글에 한 줄짜리 코드를 삽입하고 싶다면 <code> 태그를, 여러 줄로 구성된 코드를 삽입하고 싶다면 <pre> 태그를 이용하세요. 10줄 이상의 코드는 plnkr, JSBin, codepen 등의 샌드박스를 사용하세요.