Lucrul pe care îl iubesc cel mai mult la programare este Aha! moment în care începeți să înțelegeți pe deplin un concept. Chiar dacă s-ar putea să dureze mult timp și să nu depună eforturi mici pentru a ajunge acolo, sigur merită.

Cred că cel mai eficient mod de a evalua (și a contribui la îmbunătățirea) gradului nostru de înțelegere a unui subiect dat este să încercăm să aplicăm cunoștințele lumii reale. Acest lucru nu numai că ne permite să identificăm și, în cele din urmă, să abordăm slăbiciunile noastre, dar poate, de asemenea, să arunce o lumină asupra modului în care funcționează lucrurile. O simpla proces și eroare abordarea dezvăluie adesea acele detalii care rămăseseră evazive anterior.

Având în vedere acest lucru, cred că învățarea modului de implementare promisiuni a fost unul dintre cele mai importante momente din călătoria mea de programare – mi-a oferit o perspectivă neprețuită despre modul în care funcționează codul asincron și m-a făcut să fiu un programator mai bun în general.

Sper că acest articol vă va ajuta să faceți cunoștință cu implementarea promisiunilor și în JavaScript.

Ne vom concentra asupra modului de implementare a nucleului promisiunii conform specificațiile Promisiuni / A + cu câteva metode de API-ul Bluebird. Vom folosi și noi abordarea TDD cu Glumă.

TypeScript va veni și el la îndemână.

Având în vedere că vom lucra aici la abilitățile de implementare, voi presupune că aveți o oarecare înțelegere de bază a ceea ce sunt promisiunile și un sentiment vag de funcționare a acestora. Dacă nu, aici este un loc minunat pentru a începe.

Acum că avem asta din drum, mergeți mai departe și clonați repertoriu și să începem.

Nucleul unei promisiuni

După cum știți, o promisiune este un obiect cu următoarele proprietăți:

Atunci

O metodă care atașează un handler la promisiunea noastră. Întoarce o nouă promisiune cu valoarea din cea precedentă mapată de una dintre metodele de gestionare.

Manipulatori

O serie de manipulatori atașați de atunci. Un handler este un obiect care conține două metode onSuccess și onFail, ambele fiind transmise ca argumente la atunci(onSuccess, onFail).

type HandlerOnSuccess<T, U = any> = (value: T) => U | Thenable<U>;
type HandlerOnFail<U = any> = (reason: any) => U | Thenable<U>;

interface Handler<T, U> {
  onSuccess: HandlerOnSuccess<T, U>;
  onFail: HandlerOnFail<U>;
}

Stat

O promisiune poate fi în una dintre cele trei stări: rezolvat, respins, sau in asteptarea.

S-a rezolvat înseamnă că fie totul a decurs fără probleme și ne-am primit valoarea, fie am detectat și am gestionat eroarea.

Respins înseamnă că fie am respins promisiunea, fie a fost aruncată o eroare și nu am prins-o.

In asteptarea înseamnă că nici rezolva nici respinge metoda a fost încă apelată și încă așteptăm valoarea.

Termenul „promisiunea este stabilită” înseamnă că promisiunea este fie rezolvată, fie respinsă.

Valoare

O valoare pe care am rezolvat-o sau am respins-o.

Odată ce valoarea este setată, nu există nicio modalitate de a o modifica.

Testarea

Conform abordării TDD, vrem să ne scriem testele înainte ca codul propriu-zis să apară, deci să facem exact asta.

Iată testele pentru nucleul nostru:

describe('PQ <constructor>', () => {
  test('resolves like a promise', () => {
    return new PQ<number>((resolve) => {
      setTimeout(() => {
        resolve(1);
      }, 30);
    }).then((val) => {
      expect(val).toBe(1);
    });
  });

  test('is always asynchronous', () => {
    const p = new PQ((resolve) => resolve(5));

    expect((p as any).value).not.toBe(5);
  });

  test('resolves with the expected value', () => {
    return new PQ<number>((resolve) => resolve(30)).then((val) => {
      expect(val).toBe(30);
    });
  });

  test('resolves a thenable before calling then', () => {
    return new PQ<number>((resolve) =>
      resolve(new PQ((resolve) => resolve(30))),
    ).then((val) => expect(val).toBe(30));
  });

  test('catches errors (reject)', () => {
    const error = new Error('Hello there');

    return new PQ((resolve, reject) => {
      return reject(error);
    }).catch((err: Error) => {
      expect(err).toBe(error);
    });
  });

  test('catches errors (throw)', () => {
    const error = new Error('General Kenobi!');

    return new PQ(() => {
      throw error;
    }).catch((err) => {
      expect(err).toBe(error);
    });
  });

  test('is not mutable - then returns a new promise', () => {
    const start = new PQ<number>((resolve) => resolve(20));

    return PQ.all([
      start
        .then((val) => {
          expect(val).toBe(20);
          return 30;
        })
        .then((val) => expect(val).toBe(30)),
      start.then((val) => expect(val).toBe(20)),
    ]);
  });
});

Rularea testelor noastre

Recomand cu tărie utilizarea Extensie Jest pentru Visual Studio Code. Rulează testele noastre în fundal pentru noi și ne arată rezultatul chiar între liniile codului nostru ca puncte verzi și roșii pentru testele trecute și, respectiv, nereușite.

Pentru a vedea rezultatele, deschideți consola „Ieșire” și alegeți fila „Jest”.

0*dr7riPl5ZRkUF8lo

De asemenea, putem rula testele noastre executând următoarea comandă:

npm run test

Indiferent de modul în care efectuăm testele, putem vedea că toate acestea revin negative.

Să schimbăm asta.

Implementarea nucleului Promise

constructor

class PQ<T> {
  private state: States = States.PENDING;
  private handlers: Handler<T, any>[] = [];
  private value: T | any;
  public static errors = errors;

  public constructor(callback: (resolve: Resolve<T>, reject: Reject) => void) {
    try {
      callback(this.resolve, this.reject);
    } catch (e) {
      this.reject(e);
    }
  }
}

Constructorul nostru ia o suna inapoi ca parametru.

Numim acest apel invers cu aceasta.rezolva și aceasta.reject ca argumente.

Rețineți că în mod normal am fi legat aceasta.rezolva și aceasta.reject la acest, dar aici am folosit în schimb metoda săgeată de clasă.

setResult

Acum trebuie să stabilim rezultatul. Rețineți că trebuie să gestionăm rezultatul corect, ceea ce înseamnă că, în cazul în care ne întoarce o promisiune, trebuie să o rezolvăm mai întâi.

class PQ<T> {

  // ...
  
  private setResult = (value: T | any, state: States) => {
    const set = () => {
      if (this.state !== States.PENDING) {
        return null;
      }

      if (isThenable(value)) {
        return (value as Thenable<T>).then(this.resolve, this.reject);
      }

      this.value = value;
      this.state = state;

      return this.executeHandlers();
    };

    setTimeout(set, 0);
  };
}

Mai întâi, verificăm dacă statul nu este in asteptarea – dacă este, promisiunea este deja stabilită și nu îi putem atribui nicio valoare nouă.

Apoi, trebuie să verificăm dacă o valoare este a atunci poate fi atins. Mai simplu spus, a atunci poate fi atins este un obiect cu atunci ca metodă.

Prin convenție, a atunci poate fi atins ar trebui să se comporte ca o promisiune. Deci, pentru a obține rezultatul, vom suna atunci și treceți ca argumente aceasta.rezolva și aceasta.reject.

Odata ce atunci poate fi atins se va stabili, va apela una dintre metodele noastre și ne va oferi valoarea nepromisă așteptată.

Deci, acum trebuie să verificăm dacă un obiect este un atunci poate fi atins.

describe('isThenable', () => {
  test('detects objects with a then method', () => {
    expect(isThenable({ then: () => null })).toBe(true);
    expect(isThenable(null)).toBe(false);
    expect(isThenable({})).toBe(false);
  });
});
const isFunction = (func: any) => typeof func === 'function';

const isObject = (supposedObject: any) =>
  typeof supposedObject === 'object' &&
  supposedObject !== null &&
  !Array.isArray(supposedObject);

const isThenable = (obj: any) => isObject(obj) && isFunction(obj.then);

Este important să ne dăm seama că promisiunea noastră nu va fi niciodată sincronă, chiar dacă codul din interiorul suna inapoi este.

Vom întârzia execuția până la următoarea iterație a buclei eveniment folosind setTimeout.

Acum, singurul lucru rămas de făcut este să ne setăm valoarea și starea și apoi să executăm handlerele înregistrate.

executeHandlers

class PQ<T> {

  // ...
  
  private executeHandlers = () => {
    if (this.state === States.PENDING) {
      return null;
    }

    this.handlers.forEach((handler) => {
      if (this.state === States.REJECTED) {
        return handler.onFail(this.value);
      }

      return handler.onSuccess(this.value);
    });

    this.handlers = [];
  };
}

Din nou, asigurați-vă că statul nu este in asteptarea.

Starea promisiunii dictează ce funcție vom folosi.

Daca este rezolvat, ar trebui să executăm onSuccess, in caz contrar – onFail.

Să curățăm acum gama noastră de handlers doar pentru a fi în siguranță și pentru a nu executa nimic accidental în viitor. Un handler poate fi atașat și executat ulterior oricum.

Și despre asta trebuie să discutăm în continuare: o modalitate de a ne atașa handlerul.

attachHandler

class PQ<T> {

  // ...
  
  private attachHandler = (handler: Handler<T, any>) => {
    this.handlers = [...this.handlers, handler];

    this.executeHandlers();
  };
}

Este într-adevăr atât de simplu pe cât pare. Adăugăm doar un handler la matricea noastră de handlers și îl executăm. Asta e.

Acum, pentru a pune totul împreună, trebuie să implementăm atunci metodă.

atunci

class PQ<T> {

  // ...
  
  public then<U>(
    onSuccess?: HandlerOnSuccess<T, U>,
    onFail?: HandlerOnFail<U>,
  ) {
    return new PQ<U | T>((resolve, reject) => {
      return this.attachHandler({
        onSuccess: (result) => {
          if (!onSuccess) {
            return resolve(result);
          }

          try {
            return resolve(onSuccess(result));
          } catch (e) {
            return reject(e);
          }
        },
        onFail: (reason) => {
          if (!onFail) {
            return reject(reason);
          }

          try {
            return resolve(onFail(reason));
          } catch (e) {
            return reject(e);
          }
        },
      });
    });
  }
}

În atunci, ne întoarcem o promisiune și în suna inapoi atașăm un handler care este apoi folosit pentru a aștepta ca promisiunea actuală să fie soluționată.

Când se întâmplă acest lucru, oricare dintre handler onSuccess sau onFail va fi executat și vom proceda în consecință.

Un lucru de reținut aici este că niciunul dintre manipulatori nu a trecut la atunci este necesară. Cu toate acestea, este important să nu încercăm să executăm ceva care ar putea fi nedefinit.

De asemenea, în onFail când gestionarul este trecut, de fapt rezolvăm promisiunea returnată, deoarece eroarea a fost gestionată.

captură

Captură este de fapt doar o abstracție asupra atunci metodă.

class PQ<T> {

  // ...
  
  public catch<U>(onFail: HandlerOnFail<U>) {
    return this.then<U>(identity, onFail);
  }
}

Asta e.

In cele din urma

In cele din urma este, de asemenea, doar o abstracție față de a face atunci(în cele din urmăCb, în cele din urmăCb), deoarece nu prea îi pasă de rezultatul promisiunii.

De fapt, păstrează și rezultatul promisiunii anterioare și îl returnează. Deci orice este returnat de în cele din urmăCb nu prea contează.

describe('PQ.prototype.finally', () => {
  test('it is called regardless of the promise state', () => {
    let counter = 0;
    return PQ.resolve(15)
      .finally(() => {
        counter += 1;
      })
      .then(() => {
        return PQ.reject(15);
      })
      .then(() => {
        // wont be called
        counter = 1000;
      })
      .finally(() => {
        counter += 1;
      })
      .catch((reason) => {
        expect(reason).toBe(15);
        expect(counter).toBe(2);
      });
  });
});
class PQ<T> {

  // ...
  

  public finally<U>(cb: Finally<U>) {
    return new PQ<U>((resolve, reject) => {
      let val: U | any;
      let isRejected: boolean;

      return this.then(
        (value) => {
          isRejected = false;
          val = value;
          return cb();
        },
        (reason) => {
          isRejected = true;
          val = reason;
          return cb();
        },
      ).then(() => {
        if (isRejected) {
          return reject(val);
        }

        return resolve(val);
      });
    });
  }
}

toString

describe('PQ.prototype.toString', () => {
  test('returns [object PQ]', () => {
    expect(new PQ<undefined>((resolve) => resolve()).toString()).toBe(
      '[object PQ]',
    );
  });
});
class PQ<T> {

  // ...
  
  public toString() {
    return `[object PQ]`;
  }
}

Acesta va returna doar un șir [object PQ].

După ce am implementat nucleul promisiunilor noastre, putem acum să implementăm unele dintre metodele Bluebird menționate anterior, care vor face mai ușor să funcționăm la promisiuni.

Metode suplimentare

Promisiune.rezolvați

Cum ar trebui să funcționeze.

describe('PQ.resolve', () => {
  test('resolves a value', () => {
    return PQ.resolve(5).then((value) => {
      expect(value).toBe(5);
    });
  });
});
class PQ<T> {

  // ...
  
  public static resolve<U = any>(value?: U | Thenable<U>) {
    return new PQ<U>((resolve) => {
      return resolve(value);
    });
  }
}

Promisiune.reject

Cum ar trebui să funcționeze.

describe('PQ.reject', () => {
  test('rejects a value', () => {
    return PQ.reject(5).catch((value) => {
      expect(value).toBe(5);
    });
  });
});
class PQ<T> {

  // ...
  
  public static reject<U>(reason?: any) {
    return new PQ<U>((resolve, reject) => {
      return reject(reason);
    });
  }
}

Promiteți.toate

Cum ar trebui să funcționeze.

describe('PQ.all', () => {
  test('resolves a collection of promises', () => {
    return PQ.all([PQ.resolve(1), PQ.resolve(2), 3]).then((collection) => {
      expect(collection).toEqual([1, 2, 3]);
    });
  });

  test('rejects if one item rejects', () => {
    return PQ.all([PQ.resolve(1), PQ.reject(2)]).catch((reason) => {
      expect(reason).toBe(2);
    });
  });
});
class PQ<T> {

  // ...
  
  public static all<U = any>(collection: (U | Thenable<U>)[]) {
    return new PQ<U[]>((resolve, reject) => {
      if (!Array.isArray(collection)) {
        return reject(new TypeError('An array must be provided.'));
      }

      let counter = collection.length;
      const resolvedCollection: U[] = [];

      const tryResolve = (value: U, index: number) => {
        counter -= 1;
        resolvedCollection[index] = value;

        if (counter !== 0) {
          return null;
        }

        return resolve(resolvedCollection);
      };

      return collection.forEach((item, index) => {
        return PQ.resolve(item)
          .then((value) => {
            return tryResolve(value, index);
          })
          .catch(reject);
      });
    });
  }
}

Cred că implementarea este destul de simplă.

Începând de la lungimea colecției, numărăm înapoi cu fiecare tryResolve până ajungem la 0, ceea ce înseamnă că fiecare articol din colecție a fost rezolvat. Apoi rezolvăm colecția nou creată.

Promite.orice

Cum ar trebui să funcționeze.

describe('PQ.any', () => {
  test('resolves the first value', () => {
    return PQ.any<number>([
      PQ.resolve(1),
      new PQ((resolve) => setTimeout(resolve, 15)),
    ]).then((val) => expect(val).toBe(1));
  });

  test('rejects if the first value rejects', () => {
    return PQ.any([
      new PQ((resolve) => setTimeout(resolve, 15)),
      PQ.reject(1),
    ]).catch((reason) => {
      expect(reason).toBe(1);
    });
  });
});
class PQ<T> {

  // ...

  public static any<U = any>(collection: (U | Thenable<U>)[]) {
    return new PQ<U>((resolve, reject) => {
      return collection.forEach((item) => {
        return PQ.resolve(item)
          .then(resolve)
          .catch(reject);
      });
    });
  }
}

Așteptăm pur și simplu prima valoare pentru a o rezolva și o returnăm într-o promisiune.

Promisiune.props

Cum ar trebui să funcționeze.

describe('PQ.props', () => {
  test('resolves object correctly', () => {
    return PQ.props<{ test: number; test2: number }>({
      test: PQ.resolve(1),
      test2: PQ.resolve(2),
    }).then((obj) => {
      return expect(obj).toEqual({ test: 1, test2: 2 });
    });
  });

  test('rejects non objects', () => {
    return PQ.props([]).catch((reason) => {
      expect(reason).toBeInstanceOf(TypeError);
    });
  });
});
class PQ<T> {

  // ...
  
  public static props<U = any>(obj: object) {
    return new PQ<U>((resolve, reject) => {
      if (!isObject(obj)) {
        return reject(new TypeError('An object must be provided.'));
      }

      const resolvedObject = {};

      const keys = Object.keys(obj);
      const resolvedValues = PQ.all<string>(keys.map((key) => obj[key]));

      return resolvedValues
        .then((collection) => {
          return collection.map((value, index) => {
            resolvedObject[keys[index]] = value;
          });
        })
        .then(() => resolve(resolvedObject as U))
        .catch(reject);
    });
  }
}

Iterăm peste cheile obiectului trecut, rezolvând fiecare valoare. Apoi atribuim valorile noului obiect și rezolvăm o promisiune cu acesta.

Promisiune.prototip.spread

Cum ar trebui să funcționeze.

describe('PQ.protoype.spread', () => {
  test('spreads arguments', () => {
    return PQ.all<number>([1, 2, 3]).spread((...args) => {
      expect(args).toEqual([1, 2, 3]);
      return 5;
    });
  });

  test('accepts normal value (non collection)', () => {
    return PQ.resolve(1).spread((one) => {
      expect(one).toBe(1);
    });
  });
});
class PQ<T> {

  // ...
  
  public spread<U>(handler: (...args: any[]) => U) {
    return this.then<U>((collection) => {
      if (Array.isArray(collection)) {
        return handler(...collection);
      }

      return handler(collection);
    });
  }
}

Promitere.amânare

Cum ar trebui să funcționeze.

describe('PQ.delay', () => {
  test('waits for the given amount of miliseconds before resolving', () => {
    return new PQ<string>((resolve) => {
      setTimeout(() => {
        resolve('timeout');
      }, 50);

      return PQ.delay(40).then(() => resolve('delay'));
    }).then((val) => {
      expect(val).toBe('delay');
    });
  });

  test('waits for the given amount of miliseconds before resolving 2', () => {
    return new PQ<string>((resolve) => {
      setTimeout(() => {
        resolve('timeout');
      }, 50);

      return PQ.delay(60).then(() => resolve('delay'));
    }).then((val) => {
      expect(val).toBe('timeout');
    });
  });
});
class PQ<T> {

  // ...
  
  public static delay(timeInMs: number) {
    return new PQ((resolve) => {
      return setTimeout(resolve, timeInMs);
    });
  }
}

Prin utilizarea setTimeout, pur și simplu amânăm executarea rezolva funcționează după numărul dat de milisecunde.

Promiteți.prototip.timp

Cum ar trebui să funcționeze.

describe('PQ.prototype.timeout', () => {
  test('rejects after given timeout', () => {
    return new PQ<number>((resolve) => {
      setTimeout(resolve, 50);
    })
      .timeout(40)
      .catch((reason) => {
        expect(reason).toBeInstanceOf(PQ.errors.TimeoutError);
      });
  });

  test('resolves before given timeout', () => {
    return new PQ<number>((resolve) => {
      setTimeout(() => resolve(500), 500);
    })
      .timeout(600)
      .then((value) => {
        expect(value).toBe(500);
      });
  });
});
class PQ<T> {

  // ...
  
  public timeout(timeInMs: number) {
    return new PQ<T>((resolve, reject) => {
      const timeoutCb = () => {
        return reject(new PQ.errors.TimeoutError());
      };

      setTimeout(timeoutCb, timeInMs);

      return this.then(resolve);
    });
  }
}

Acesta este un pic dificil.

Dacă setTimeout execută mai repede decât atunci în promisiunea noastră, va respinge promisiunea cu eroarea noastră specială.

Promite.promite

Cum ar trebui să funcționeze.

describe('PQ.promisify', () => {
  test('works', () => {
    const getName = (firstName, lastName, callback) => {
      return callback(null, `${firstName} ${lastName}`);
    };

    const fn = PQ.promisify<string>(getName);
    const firstName="Maciej";
    const lastName="Cieslar";

    return fn(firstName, lastName).then((value) => {
      return expect(value).toBe(`${firstName} ${lastName}`);
    });
  });
});
class PQ<T> {

  // ...
  
  public static promisify<U = any>(
    fn: (...args: any[]) => void,
    context = null,
  ) {
    return (...args: any[]) => {
      return new PQ<U>((resolve, reject) => {
        return fn.apply(context, [
          ...args,
          (err: any, result: U) => {
            if (err) {
              return reject(err);
            }

            return resolve(result);
          },
        ]);
      });
    };
  }
}

Aplicăm funcției toate argumentele trecute, plus – ca ultimul – dăm prima eroare suna inapoi.

Promite.promisifyAll

Cum ar trebui să funcționeze.

describe('PQ.promisifyAll', () => {
  test('promisifies a object', () => {
    const person = {
      name: 'Maciej Cieslar',
      getName(callback) {
        return callback(null, this.name);
      },
    };

    const promisifiedPerson = PQ.promisifyAll<{
      getNameAsync: () => PQ<string>;
    }>(person);

    return promisifiedPerson.getNameAsync().then((name) => {
      expect(name).toBe('Maciej Cieslar');
    });
  });
});
class PQ<T> {

  // ...
  
  public static promisifyAll<U>(obj: any): U {
    return Object.keys(obj).reduce((result, key) => {
      let prop = obj[key];

      if (isFunction(prop)) {
        prop = PQ.promisify(prop, obj);
      }

      result[`${key}Async`] = prop;

      return result;
    }, {}) as U;
  }
}

Iterăm peste tastele obiectului și promite metodele sale și adăugați la fiecare nume al cuvântului metodă Asincron.

Înfășurându-se

Prezentate aici au fost doar câteva dintre toate metodele API Bluebird, așa că vă încurajez să explorați, să vă jucați și să încercați să implementați restul.

S-ar putea să pară greu la început, dar nu vă descurajați – ar fi lipsit de valoare dacă ar fi ușor.

Vă mulțumesc foarte mult pentru citire! Sper că ați găsit acest articol informativ și că v-a ajutat să înțelegeți conceptul promisiunilor și că de acum înainte vă veți simți mai confortabil folosindu-le sau pur și simplu scriind cod asincron.

Dacă aveți întrebări sau comentarii, nu ezitați să le puneți în secțiunea de comentarii de mai jos sau să-mi trimiteți un mesaj.

Verifică-mi social media!

Alătură-te newsletter-ului meu!

Publicat inițial la www.mcieslar.com pe 4 august 2018.