Jak jsme použili JSDoc &Webpack k napsání některých vlastních JavaScriptových dekorátorů a anotací

Tento článek byl původně zveřejněn na blogu Wavebox

Ve Waveboxu používáme pro některé naše kódy JavaScript a tento týden jsme při pokusu o export některých dat narazili na zajímavý problém (a řešení).

Mnoho našich dat zapouzdřujeme do tříd/modelů JavaScriptu, což znamená, že můžeme ukládat řídká data a přistupovat k nim prostřednictvím modelů, přičemž modely automaticky nahrazují výchozí hodnoty a vytvářejí pro nás složitější getry. V rámci nové funkce chceme být schopni sdílet některá tato data, ale ne všechna... a tady jsme přišli se zajímavým řešením, které zahrnuje dekorátory a anotace JSDoc...

Modely

Většinu našich datových struktur ukládáme do tříd, které obalují nezpracovaná data, jednoduchý model vypadá asi takto...

class App {
  constructor (data) {
    this.__data__ = data
  }

  get id () { return this.__data__.id }

  get name () { return this.__data__.name || 'Untitled' }

  get nameIsCustom () { return Boolean(this.__data__.name) }

  get lastAccessed () { return this.__data__.lastAccessed || 0 }
}

const app = new App({ id: 123, name: 'test', lastAccessed: 1000 })

__data__ proměnná obsahuje nezpracovaný objekt JavaScriptu a při přístupu k něčemu v modelu obvykle používáme getter, který poskytuje hodnotu.

Ve výše uvedeném příkladu máme několik základních getterů, které pouze vracejí některá data jako id . Máme také některé getry, které vrátí výchozí hodnotu, pokud hodnota neexistuje, jako je name a lastAccessed .

Tyto modely tvoří základní část toho, jak spravujeme data, a zajišťují, že nemusíme kontrolovat nedefinované v celém kódu, nahrazovat výchozí hodnoty a tak dále.

Export některých dat

Pracovali jsme na nové funkci, která vám umožní sdílet některé vaše modely, ale vyskytl se problém. Chceme sdílet jen některá data. V našem jednoduchém příkladu aplikace výše jsou některá pole, která chceme sdílet, a některá ne...

  • id &name je dobré se o ně podělit 👍
  • nameIsCustom funguje to tak, že si přečtete pole se jménem, ​​nesdílejte 🚫
  • lastAccessed toto nechceme sdílet 🙅‍♂️

Pojďme se tedy podívat na nejzákladnější příklad, můžeme vypustit nameIsCustom pouhým přečtením surového __data__ objekt...

console.log(app.__data__)
// { id: 123, name: 'test', lastAccessed: 1000 }

...ale stále nám to dává lastAccessed pole, které nechceme. A tak jsme napsali exportní funkci, která vypadá spíš takto...

class App {
  ...
  getExportData () {
    const { lastAccessed, ...exportData } = this.__data__
    return exportData
  }
}

...vypadá skvěle. Funguje to! Ale předpovídám problém...

Zachování udržitelnosti kódu

getExportData() funkce funguje skvěle, ale je tu problém. Některé z našich modelů jsou poměrně velké a tyto modely budou mít v budoucnu nová pole. Budoucí já nebo budoucí kdokoli jiný, kdo pracuje na kódu, zaručeně zapomene do této funkce přidat další vyloučení a dostaneme chybu. Ne tak skvělé. Začal jsem tedy přemýšlet o tom, jak bychom to mohli udělat trochu udržitelnější.

Velké změny v modelech nepřipadaly v úvahu, začali jsme s tímto vzorem již před nějakým časem a existují desítky tisíc použití modelů prostřednictvím kódu, takže cokoliv vymyslíme, musí mít všude minimální dopad.

To mě přimělo přemýšlet o dekoratérech. Přemýšlel jsem o způsobu, jak bych mohl vygenerovat seznam vlastností k exportu na stejné místo, kde jsou definovány. To by zlepšilo udržovatelnost do budoucna.

V hlavě jsem si vymyslel nějaký pseudo kód, který vypadal nějak takto...

const exportProps = new Set()
function exportProp () {
  return (fn, descriptor) => {
    exportProps.add(descriptor.name)
  }
}

class App {
  @exportProp()
  get id () { return this.__data__.id }

  @exportProp()
  get name () { return this.__data__.name || 'Untitled' }

  get nameIsCustom () { return Boolean(this.__data__.name) }

  get lastAccessed () { return this.__data__.lastAccessed || 0 }
}

const app = new App({})
Object.keys(app).forEach((key) => { app[key })

console.log(Array.from(exportProps))
// [id, name]

...každý getter můžete ozdobit @exportProp což je hezké, ale realizace má k ideálu daleko. Ve skutečnosti je to ten druh kódu, ze kterého se mi dělá nevolno 🤢. Za prvé, exportované vlastnosti nyní musí projít dekorátorem, než se k nim přistoupí, bude to mít výkonnostní hit. Chcete-li vygenerovat seznam, musíte také vytvořit prázdný objekt a iterovat jej, ačkoli na tom není nic špatného, ​​nepřipadalo mi to nijak zvlášť příjemné.

Začal jsem tedy přemýšlet, jak jinak bychom mohli dosáhnout podobného vzoru...

Pomocí JSDoc

Tehdy jsem začal přemýšlet, zda bychom mohli použít JSDoc k napsání některých anotací v době sestavování? Tím by se odstranila potřeba generovat cokoli za běhu, zůstaly by výkonné getry a umožnilo by nám to podle potřeby přidat anotaci ke každé vlastnosti in-situ.

Začal jsem si hrát a přišel jsem na tohle...

class App {
  /**
  * @export_prop
  */
  get id () { return this.__data__.id }

  /**
  * @export_prop
  */
  get name () { return this.__data__.name || 'Untitled' }

  get nameIsCustom () { return Boolean(this.__data__.name) }

  get lastAccessed () { return this.__data__.lastAccessed || 0 }
}

Dobře, komentáře nyní zahrnují několik řádků, ale pokud to splňuje všechny ostatní požadavky, mohu s tím žít. Pokud přes soubor spustíme JSDoc, dostaneme něco takového...

[{
  "comment": "/**\n   * @export_prop\n   */",
  "meta": {
    "filename": "App.js",
    "lineno": 61,
    "columnno": 2,
    "path": "/src/demo",
    "code": {
      "id": "astnode100000128",
      "name": "App#id",
      "type": "MethodDefinition",
      "paramnames": []
    },
    "vars": { "": null }
  },
  "tags": [{
    "originalTitle": "export_prop",
    "title": "export_prop",
    "text": ""
  }],
  "name": "id",
  "longname": "App#id",
  "kind": "member",
  "memberof": "App",
  "scope": "instance",
  "params": []
}, ...]

...a hej presto! Získáme název getteru a v seznamu značek je anotace export_prop, kterou jsme přidali. Trochu se v tom zacyklíme a můžeme vygenerovat pěkný seznam názvů vlastností k exportu.

Míchání JSDoc a Webpack

Mohli byste napsat předpřipravený skript pro zapsání dokumentů do souboru a poté jej načíst při kompilaci, ale kde je v tom zábava? Pro naše potřeby sdružování používáme Webpack, což znamená, že můžeme napsat vlastní zavaděč. To za nás spustí JSDoc přes soubor, trochu si pohraje s daty a dá nám pěkný výstup. Tento výstup můžeme použít ke konfiguraci, která data vycházejí z modelu.

Náš zavaděč Webpacku tedy může vypadat trochu takto, pouze spustí JSDoc nad vstupním souborem, odstraní vše, co nepotřebujeme, a zapíše výstup jako objekt JSON...

const path = require('path')
const jsdoc = require('jsdoc-api')

module.exports = async function () {
  const callback = this.async()

  try {
    const exportProps = new Set()
    const docs = await jsdoc.explain({ files: this.resourcePath })

    for (const entry of docs) {
      if (entry.kind === 'member' && entry.scope === 'instance' && entry.params && entry.tags) {
        for (const tag of tags) {
          if (tag.title === 'export_prop') {
            exportProps.add(entry.name)
            break
          }
        }
      }
    }
    callback(null, 'export default ' + JSON.stringify(Array.from(exportProps)))
  } catch (ex) {
    callback(ex)
  }
}
...and we just need to update our webpack config to use the loader...

config.resolveLoader.alias['export-props'] = 'export-props-loader.js' 
config.module.rules.push({
  test: /\*/,
  use: {
    loader: 'export-props'
  }
})

...skvělý! To je všechna ta tvrdá práce. Nyní to můžeme přidat do našeho modelu aplikace a uvidíme, co získáme!

import exportProps from 'export-props!./App.js'

class App {
  /**
  * @export_prop
  */
  get id () { return this.__data__.id }

  /**
  * @export_prop
  */
  get name () { return this.__data__.name || 'Untitled' }

  get nameIsCustom () { return Boolean(this.__data__.name) }

  get lastAccessed () { return this.__data__.lastAccessed || 0 }

  getExportData () {
    return exportProps.reduce((acc, key) => {
      if (this.__data__[key] !== undefined) {
        acc[key] = this.__data__[key]
      }
      return acc
    }, {})
  }
}

const app = new App({ id: 123, name: 'test', lastAccessed: 1000 }) 
console.log(app.getExportData())
// { id: 123, name: 'test' }

Ahoj presto! Je to tady! Pomocí JSDoc můžeme generovat seznam vlastností k exportu v době kompilace, serializovat je do pole a číst je za běhu. Tento seznam pak můžeme použít k tomu, abychom do exportovaných dat zahrnuli pouze to, co chceme 👍.

Skutečně skvělá věc je, že můžeme definovat, které vlastnosti se exportují vedle místa, kde jsou deklarovány, v naději, že budoucí vývojář bude moci pokračovat spolu se vzorem.

Jdeme o krok dále

Možná máte nějaké vlastnosti, které vyžadují další konfiguraci nebo nějaké speciální chování... Některé anotace můžete změnit, aby vypadaly nějak takto...

class App {
  /**
  * @export_prop isSpecial=true
  */
  get id () { return this.__data__.id }
}

...a pak při použití nakladače...

if (tag.title === 'export_prop') {
  if (tag.value === 'isSpecial=true') {
    // Do something special
  } else {
    exportProps.add(entry.name)
  }
  break
}

Pokud to potřebujete, umožňuje to nakonfigurovat, co každý z nich dělá.

Shrnutí

Myslel jsem, že se s vámi podělím o tento úhledný malý trik, protože jakmile máte nastavení vzoru, je použití triviálně snadné. Myslím tím jistě, je to úplné nesprávné použití JSDoc, komentářů a zavaděčů Webpack, ale funguje to bezchybně, běží v době kompilace a pomáhá udržovat náš kód udržovatelný. Je to výhra!