Escribir un marco de JavaScript – Enlace de datos con proxy ES6

En este capítulo, explicaré cómo crear una biblioteca de enlace de datos simple pero potente con los nuevos proxy de ES6.

Pre-requisitos

ES6 hizo que JavaScript fuera mucho más elegante, pero la mayoría de las nuevas características son sólo sintácticas. Los proxies son una de las pocas adiciones no rellenables. Si no está familiarizado con ellos, por favor, eche un vistazo a los documentos del MDN Proxy antes de continuar.

También será útil tener un conocimiento básico de los objetos ES6 Reflection API y Set, Map y WeakMap.

La biblioteca nx-observe

nx-observe es una solución de enlace de datos en menos de 140 líneas de código. Expone las funciones observable(obj) y observe(fn), que se utilizan para crear objetos observables y funciones de observador. Una función de observador se ejecuta automáticamente cuando cambia una propiedad observable utilizada por ella. El siguiente ejemplo demuestra esto.


// este es un ejemplo observable
const person = observable({name: 'John', age: 20})

function print () {
console.log(`${person.name}, ${person.age}`)
}

// esto crea una función de observador
// salidas 'John, 20' a la consola
observer(print)

// salida en consola 'Dave, 20'
setTimeout(() => person.name = 'Dave', 100)

//salida en consola 'Dave, 22'
setTimeout(() => person.age = 22, 200)


La función de print pasada a observe() se vuelve a ejecutar cada vez que cambia el person.name o la person.age, print llama nuevamente una función de observador.

Implementando un observable simple.

En esta sección, voy a explicar lo que sucede bajo el capó de nx-observe. Primero, le mostraré cómo los cambios en las propiedades de un observable se detectan y se combinan con los observadores. Luego explicaré una forma de ejecutar las funciones de observador activadas por estos cambios.

Registro de cambios

Los cambios se registran envolviendo objetos observables en proxies ES6. Estos proxies interceptan a la perfección las operaciones get y set con la ayuda de la API de Reflection .

Las variables currentObserver y queueObserver()se utilizan en el código a continuación, pero solo se explicarán en la siguiente sección. Por ahora, es suficiente saber que currentObserver siempre apunta a la función de observador que se está ejecutando actualmente, y queueObserver() es una función que pone en cola a un observador para que se ejecute pronto.


/* mapea las propiedades observables a un conjunto de funciones de observador, que utilizan la propiedad */
const observers = new WeakMap()

/* apunta a la función de observador que se está llevando a cabo en la actualidad, puede ser indefinida */
let currentObserver

/* transforma un objeto en observable envolviéndolo en un proxy, también agrega un Mapa en blanco para que los pares propiedad-observador sean guardados más tarde. */
function observable (obj) {
observers.set(obj, new Map())
return new Proxy(obj, {get, set})
}

/* esta trampa intercepta las operaciones, no hace nada si ningún observador está ejecutando en ese momento. */
function get (target, key, receiver) {
const result = Reflect.get(target, key, receiver)
if (currentObserver) {
registerObserver(target, key, currentObserver)
}
return result
}

/* si se está ejecutando una función de observador, esta función empareja la función de observador con la propiedad observable actualmente recuperada y la guarda en el Mapa de observadores. */
function registerObserver (target, key, observer) {
let observersForKey = observers.get(target).get(key)
if (!observersForKey) {
observersForKey = new Set()
observers.get(target).set(key, observersForKey)
}
observersForKey.add(observer)
}

/* esta trampa intercepta las operaciones del conjunto, hace cola a cada observador asociado con la propiedad actualmente establecida para ser ejecutada posteriormente */
function set (target, key, value, receiver) {
const observersForKey = observers.get(target).get(key)
if (observersForKey) {
observersForKey.forEach(queueObserver)
}
return Reflect.set(target, key, value, receiver)
}

La rampa gett no hace nada si currentObserver no se establece. De lo contrario, empareja la propiedad observable obtenida observers y el observador actualmente en ejecución y los guarda en el mapa débil. Los observadores se guardan en una propiedad Set observable. Esto asegura que no haya duplicados.

La rampa sett es recuperar todos los observadores emparejados con la propiedad observable modificada y ponerlos en cola para su posterior ejecución.

Puede encontrar una figura y una descripción paso a paso que explica el código de ejemplo de nx-observe a continuación.

Enlace de datos JavaScript con proxy es6 – ejemplo de código observable

  • Se crea el objeto person observable.
  • currentObserver está ajustado a print.
  • print comienza a ejecutarse.
  • person.name se recupera en el interior print.
  • Se invoca la captura get del proxy en person
  • El conjunto de observadores que pertenece al (person, name)par se
  • recupera por observers.get(person).get(‘name’).
  • currentObserver (print) se agrega al conjunto de observadores.
  • Paso 4-7 se ejecutan de nuevo con person.age.
  • ${person.name}, ${person.age} Se imprime a la consola.
  • print termina de ejecutarse.
  • currentObserver se establece en indefinido.
  • Algún otro código comienza a ejecutarse.
  • person.age se establece en un nuevo valor (22).
  • Se invoca la captura set del proxy en person
  • El conjunto de observadores que pertenece al (person, age)par se
  • recupera por observers.get(person).get(‘age’).
  • Los observadores en el conjunto de observadores (incluidos print)
  • están en cola para su ejecución.
  • print ejecuta de nuevo.

Corriendo los observadores

Los observadores en cola se ejecutan de forma asíncrona en un lote, lo que se traduce en un rendimiento superior. Durante el registro, los observadores se agregan sincrónicamente a la queuedObservers Set.  Set no puede contener duplicados, por lo que poner en cola el mismo observador varias veces no dará como resultado múltiples ejecuciones. Si Set estaba vacío antes, se programa una nueva tarea para iterar y ejecutar todos los observadores en cola después de algún tiempo.


/* contiene las funciones de observador activadas, que deberían ejecutarse pronto */
const queuedObservers = new Set()

/* apunta al observador actual, puede ser indefinido */
let currentObserver

/* la función de observación expuesta */
function observe (fn) {
queueObserver(fn)
}

/* añade el observador a la cola y se asegura de que la cola se ejecute pronto */
function queueObserver (observer) {
if (queuedObservers.size === 0) {
Promise.resolve().then(runObservers)
}
queuedObservers.add(observer)
}

/* ejecuta los observadores en cola, currentObserver está configurado como indefinido al final */
function runObservers () {
try {
queuedObservers.forEach(runObserver)
} finally {
currentObserver = undefined
queuedObservers.clear()
}
}

/* establece el global currentObserver en observador, y luego lo ejecuta */
function runObserver (observer) {
currentObserver = observer
observer()
}


El código anterior garantiza que cada vez que se ejecuta un observador, la variable currentObserver global apunta a él. Activar currentObserver ‘enciende’ las rampas gett para escuchar y emparejar a currentObserver con todas las propiedades observables que usa mientras se ejecuta.

Construyendo un árbol observable dinámico

Hasta ahora, nuestro modelo funciona bien con estructuras de datos de un solo nivel, pero nos obliga a envolver cada nueva propiedad con valor de objeto en un observable a mano. Por ejemplo, el siguiente código no funcionaría como se esperaba.


const person = observable({data: {name: 'John'}})

function print () {
console.log(person.data.name)
}

// salida en consola 'John'
observe(print)

// no hace nada
setTimeout(() => person.data.name = 'Dave', 100)


Para hacer que este código funcione, tendríamos que reemplazar observable({data: {name: ‘John’}}) con observable({data: observable({name: ‘John’})}). Afortunadamente, podemos eliminar este inconveniente modificando un get.


function get (target, key, receiver) {
const result = Reflect.get(target, key, receiver)
if (currentObserver) {
registerObserver(target, key, currentObserver)
if (typeof result === 'object') {
const observableResult = observable(result)
Reflect.set(target, key, observableResult, receiver)
return observableResult
}
}
return result
}


La captura get anterior envuelve el valor devuelto en un proxy observable antes de devolverlo, en caso de que sea un objeto. Esto también es perfecto desde el punto de vista del rendimiento, ya que los observables solo se crean cuando realmente los necesita un observador.