发布于 2026-01-06 6 阅读
0

使用 JSDoc 实现类型安全的 Web 组件

使用 JSDoc 实现类型安全的 Web 组件

编写代码很难,而编写出易于他人(或未来的自己)理解的代码则更难。正因如此,文档是每个软件项目中至关重要的组成部分。

我相信我们都曾遇到过这种情况:你正在愉快地编写代码,突然发现一个很棒的库可以帮助你,于是你开始使用它……



import foo from 'foo-lib';

foo.doTheThing(//...


Enter fullscreen mode Exit fullscreen mode

但是,是foo.doTheThing()先取字符串再取数字,还是反过来?

于是你访问了http://foo-lib.org,大概点击了五次之后,就找到了函数签名,并了解了它的用法。首先,你已经很幸运了,因为很多库的文档都不完善😱

然而,这已经痛苦地表明,这些信息与你的工作流程并不像它应该的那样紧密相关。你不得不停止编码去查找信息,而它本可以直接在你的编辑器里。😊

所以我们肯定可以做得更好🤗 让我们从一个非常简单的网页组件开始吧。

注意:我们假设所使用的编辑器是 VS Code。

如果你想一起玩——所有代码都在GitHub上。

<title-bar>

标题栏



<title-bar>
  #shadow-root (open)
    <h1>You are awesome</h1>
    <div class="dot" style="left: 0px; top: 0px" title="I am dot"></div>
</title-bar>


Enter fullscreen mode Exit fullscreen mode

它只是一个带……的小盒子

  • 产权
  • darkMode 属性
  • 格式化函数
  • 左侧边栏属性

我们将使用 LitElement 来创建它。

注意:这里我们使用 JavaScript,但大部分情况下(除了类型转换和定义),这个例子对于 TypeScript 也是一样的。



import { LitElement, html, css } from 'lit-element';

export class TitleBar extends LitElement {
  static get properties() {
    return {
      title: { type: String },
      darkMode: { type: Boolean, reflect: true, attribute: 'dark-mode' },
      bar: { type: Object },
    };
  }

  constructor() {
    super();
    this.title = 'You are awesome';
    this.darkMode = false;
    this.bar = { x: 0, y: 0, title: 'I am dot' };
    this.formatter = null;
  }

  render() {
    // positioning the bar like this is just for illustration purposes => do not do this
    return html`
      <h1>${this.format(this.title)}</h1>
      <div
        class="dot"
        style=${`left: ${this.bar.x}px; top: ${this.bar.y}`}
        title=${this.bar.title}
      ></div>
    `;
  }

  format(value) {
    // we'll get to this later
  }

  static get styles() {
    // we'll get to this later
  }
}

customElements.define('title-bar', TitleBar);


Enter fullscreen mode Exit fullscreen mode

使用后你会得到什么

让我们查询一下新创建的元素。😊



const el = document.querySelector('title-bar');


Enter fullscreen mode Exit fullscreen mode

我们的编辑器无法知道el实际是什么,因此无法帮助我们编写更好的代码。
这意味着即使信息可用,我们也无法获得自定义属性的代码补全功能。

自动完成缺失

所以我们需要做的就是把它投射出来:



const el = /** @type {TitleBar} */ (document.querySelector('title-bar'));


Enter fullscreen mode Exit fullscreen mode

现在我们已经有了自动补全功能。🎉

自动完成类型

但是我们仍然可以编写类似这样的代码



el.foo = 'bar';
el.title = true;


Enter fullscreen mode Exit fullscreen mode

而且没有人会抱怨。

让我们改变现状💪

添加类型检查

将文件添加tsconfig.json到您的项目中



{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "moduleResolution": "node",
    "lib": ["es2017", "dom"],
    "allowJs": true,
    "checkJs": true,
    "noEmit": true,
    "strict": false,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "esModuleInterop": true
  },
  "include": [
    "src",
    "test",
    "node_modules/@open-wc/**/*.js"
  ],
  "exclude": [
    "node_modules/!(@open-wc)"
  ]
}


Enter fullscreen mode Exit fullscreen mode

这就是让 VS Code 将代码标记为存在问题所需的全部步骤:



Property 'foo' does not exist on type 'TitleBar'.
Type 'true' is not assignable to type 'string'.


Enter fullscreen mode Exit fullscreen mode

你甚至可以更进一步,在控制台和持续集成中进行代码检查。

你只需要这样做:



npm i -D typescript


Enter fullscreen mode Exit fullscreen mode

并将此脚本添加到您的 package.json 文件中。



  "scripts": {
    "lint:types": "tsc"
  }


Enter fullscreen mode Exit fullscreen mode

然后我们可以这样执行:



npm run lint:types


Enter fullscreen mode Exit fullscreen mode

这将产生与上面相同的错误,但会显示文件路径和行号。

所以,只需做这些额外的几件事,你的 IDE 就能帮助你保持类型安全。

说实话,这可不是温和的提醒——那些红色的曲线很难被忽视,如果你还需要一些额外的动力,你可以按 F8,它会直接把下一个错误抛给你 :p。

显示类型错误

它是如何运作的?

如果你和我一样,你可能想知道它是如何知道哪些属性属于哪种类型的?我当然还没有定义任何类型!

TypeScript 可以根据你的 ES6 代码做出很多假设。真正的魔法在于构造函数:



constructor() {
  super();
  this.title = 'You are awesome';
  this.darkMode = false;
  this.bar = { x: 0, y: 0, title: 'I am dot' };
  this.formatter = null;
}


Enter fullscreen mode Exit fullscreen mode
  • 标题显然是一个字符串
  • darkMode 布尔值
  • 一个带有 x、y(数字)和标题(字符串)的对象

所以,只要在构造函数中定义好初始值,大多数类型就应该可以正常工作了。👍
(别担心——我没有忘记格式化程序,我们稍后会讲到)

类型功能已经很棒了,但我们还可以做得更好。

看看 VS Code 中的智能感知功能。

智能感知标题已输入

目前它的功能非常有限……所以我们来添加一些JSDoc:



/**
 * The title to display inside the title bar
 * - should be less then 100 characters
 * - should not contain HTMl
 * - should be between 2-5 words
 *
 * @example
 * // DO:
 * el.title = 'Welcome to the jungle';
 *
 * // DON'T:
 * el.title = 'Info';
 * el.title = 'Welcome to <strong>the</strong> jungle';
 * el.title = 'We like to talk about more then just what sees the eye';
 */
this.title = 'You are awesome';


Enter fullscreen mode Exit fullscreen mode

智能感知标题类型Js文档

好多了😊

注意:这里不需要添加逗号,@type因为很明显这是一个字符串,如果添加的话,它可能会在某些时候失去同步。

手动设置类型

如果我们看一下



this.formatter = null;


Enter fullscreen mode Exit fullscreen mode

单凭这一行代码无法判断属性值是什么。
你可以赋一个空的/默认函数,例如:



this.formatter = value => `${value}`;


Enter fullscreen mode Exit fullscreen mode

但这并非在所有情况下都适用。
在我们的示例中,如果没有格式化函数,我们希望跳过格式化步骤。
设置默认函数会违背其初衷。
在这种情况下,必须提供一个格式化函数@type,您可以使用 JSDoc 来实现。



/**
 * You can provide a specific formatter that will change the way the title
 * gets displayed.
 *
 * *Note*: Changing the formatter does NOT trigger a rerender.
 *
 * @example
 * el.formatter = (value) => `${value} for real!`;
 *
 * @type {Function}
 */
this.formatter = null;


Enter fullscreen mode Exit fullscreen mode

这样,如果您输入了错误的类型,就会显示错误信息。



el.formatter = false;
// Type 'false' is not assignable to type 'Function'.


Enter fullscreen mode Exit fullscreen mode

此外,即时显示功能@example也让创建自己的格式化程序变得非常容易。

intellisenseFormatterTypedJsDoc

创建您自己的类型并使用它们

还有一处房产看起来不太好,那就是那处bar房产。

智能感知条形码

我们的类型安全机制在这里已经实现了,这很好,但我们只知道 x 是一个数字;没有其他信息。
我们还可以使用 JSDocs 来改进这一点。

因此,我们定义了一个名为 的特殊类型Bar



/**
 * This is a visible bar that gets displayed at the appropriate coordinates.
 * It has a height of 100%. An optional title can be provided.
 *
 * @typedef {Object} Bar
 * @property {number} x The distance from the left
 * @property {number} y The distance from the top
 * @property {string} [title] Optional title that will be set as an attribute (defaults to '')
 */


Enter fullscreen mode Exit fullscreen mode

这样,我们还可以将某些属性定义为可选属性。
之后我们只需要为其赋值即可。



/**
 * @type {Bar}
 */
this.bar = { x: 0, y: 0, title: 'I am dot' };


Enter fullscreen mode Exit fullscreen mode

智能感知条形图Js文档

为函数参数添加类型

让我们创建一个简单的格式化函数,默认情况下允许添加前缀/后缀,如果需要更多功能,您可以覆盖该函数formatter

注:这并非一个特别实用的例子,但足以说明问题。



format(value = '', { prefix, suffix = '' } = { prefix: '' }) {
  let formattedValue = value;
  if (this.formatter) {
    formattedValue = this.formatter(value);
  }
  return `${prefix}${formattedValue}${suffix}`;
}


Enter fullscreen mode Exit fullscreen mode

仅使用默认选项,它就已经知道所有类型了。

智能感知格式类型

所以,可能只需要添加一些文档就足够了。



/**
 * This function can prefix/suffix your string.
 *
 * @example
 * el.format('foo', { prefix: '...' });
 */
format(value = '', { prefix = '', suffix = '' } = {}) {


Enter fullscreen mode Exit fullscreen mode

智能感知格式类型JsDocsOnly描述

或者,如果您想要使用联合类型(例如,允许字符串和数字),
请务必只记录您实际需要的内容,因为这种方法会覆盖默认类型,这可能会导致类型不同步。



/**
 * This function can prefix/suffix your string.
 *
 * @example
 * el.format('foo', { prefix: '...' });
 *
 * @param {string|number} value String to format
 */
format(value, { prefix = '', suffix = '' } = {}) {


Enter fullscreen mode Exit fullscreen mode

智能感知格式类型JsDoc

如果你确实需要为每个对象选项添加非常具体的描述,那么你需要复制这些类型定义。



/**
 * This function can prefix/suffix your string.
 *
 * @example
 * el.format('foo', { prefix: '...' });
 *
 * @param {string} value String to format
 * @param {Object} opts Options
 * @param {string} opts.prefix Mandatory and will be added before the string
 * @param {string} [opts.suffix] Optional and will be added after the string
 */
format(value, { prefix, suffix = '' } = { prefix: '' }) {


Enter fullscreen mode Exit fullscreen mode

智能感知格式类型 JsDocExtraAllOptions

跨文件导入类型

文件从来都不是孤立存在的,所以有时你可能会想在另一个位置使用某种类型的文件。
让我们以我们熟悉的待办事项列表为例。
你会拥有todo-item.js& todo-list.js

该项将具有如下所示的构造函数。



constructor() {
  super();
  /**
   * What you need to do
   */
  this.label = '';

  /**
   * How important is it? 1-10
   *
   * 1 = less important; 10 = very important
   */
  this.priority = 1;

  /**
   * Is this task done already?
   */
  this.done = false;
}


Enter fullscreen mode Exit fullscreen mode

那么我该如何重用这些类型呢todo-list.js

假设结构如下:



<todo-list>
  <todo-item .label=${One} .priority=${5} .done=${true}></todo-item>
  <todo-item .label=${Two} .priority=${8} .done=${false}></todo-item>
</todo-list>


Enter fullscreen mode Exit fullscreen mode

我们想计算一些统计数据。



calculateStats() {
  const items = Array.from(
    this.querySelectorAll('todo-item'),
  );

  let doneCounter = 0;
  let prioritySum = 0;
  items.forEach(item => {
    doneCounter += item.done ? 1 : 0;
    prioritySum += item.prio;
  });
  console.log('Done tasks', doneCounter);
  console.log('Average priority', prioritySum / items.length);
}


Enter fullscreen mode Exit fullscreen mode

上面的代码实际上有错误😱,
item.prio它不存在。类型定义本来可以帮我们解决这个问题,但是该怎么做呢?

首先,我们导入类型



/**
 * @typedef {import('./todo-item.js').ToDoItem} ToDoItem
 */


Enter fullscreen mode Exit fullscreen mode

然后我们进行类型转换。



const items = /** @type {ToDoItem[]} */ (Array.from(
  this.querySelectorAll('todo-item'),
));


Enter fullscreen mode Exit fullscreen mode

我们已经看到类型错误了💪

importCast

使用数据对象创建自定义元素

大多数情况下,我们不仅想要访问现有的 DOM 并对结果进行类型转换,而且还希望能够实际渲染数据数组中的这些元素。

以下是一个示例数组



this.dataItems = [
  { label: 'Item 1', priority: 5, done: false },
  { label: 'Item 2', priority: 2, done: true },
  { label: 'Item 3', priority: 7, done: false },
];


Enter fullscreen mode Exit fullscreen mode

然后我们渲染它。



return html`
  ${this.dataItems.map(
    item => html`
      <todo-item .label=${item.label} .priority=${item.priority} .done=${item.done}></todo-item>
    `,
  )}
`;


Enter fullscreen mode Exit fullscreen mode

如何才能确保这类产品的安全性?

可惜,直接通过类型转换@type {ToDoItem[]}似乎行不通😭

ElementAsObjectFail

它期望该对象是 HTMLElement 的完整表示,而我们这个只有 3 个属性的小对象当然缺少很多属性。

我们可以做的就是定义Data Representation我们的 Web 组件。例如,定义在 DOM 中创建这样一个元素需要哪些步骤。



/**
 * Object Data representation of ToDoItem
 *
 * @typedef {Object} ToDoItemData
 * @property {string} label
 * @property {number} priority
 * @property {Boolean} done
 */


Enter fullscreen mode Exit fullscreen mode

然后我们可以导入并进行类型转换。



/**
 * @typedef {import('./todo-item.js').ToDoItemData} ToDoItemData
 * @typedef {import('./todo-item.js').ToDoItem} ToDoItem
 */

// [...]

constructor() {
  super();
  /**
   * @type {ToDoItemData[]}
   */
  this.dataItems = [
    { label: 'Item 1', priority: 5, done: false },
    { label: 'Item 2', priority: 2, done: true },
    { label: 'Item 3', priority: 7, done: false },
  ];
}


Enter fullscreen mode Exit fullscreen mode

🎉 Web 组件及其数据的类型安全。

ItemDataTypeErrors

让你的用户使用你的类型

如果类型不是定义文件,那么如何使它们可用就比较棘手了。

一般来说,你需要请求用户点赞tsconfig.json



{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "moduleResolution": "node",
    "lib": ["es2017", "dom"],
    "allowJs": true,
    "checkJs": true,
    "noEmit": true,
    "strict": false,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "esModuleInterop": true
  },
  "include": [
    "**/*.js",
    "node_modules/<your-package-name>/**/*.js"
  ],
  "exclude": [
    "node_modules/!(<your-package-name>)"
  ]
}


Enter fullscreen mode Exit fullscreen mode

重要的是你的包名称,include而不是exclude它的值。

如果你觉得这有点复杂,那就对了。虽然有一些改进流程的想法,但最近似乎没有引起太多关注——请点赞并加入讨论。

对于完整的 TypeScript 项目,您可能需要做更多,例如创建两个tsconfigs.json文件,一个用于代码检查,一个用于构建(因为 allowJs 会阻止自动创建定义文件)。

您可以在 Open Web Components 的 Typescript 设置中找到有关此方法的更多详细信息

快速回顾:

有了这些属性/函数选项,您应该可以满足大多数 Web 组件的需求。

  • 在构造函数中设置属性的默认值,类型将自动存在。
  • 如果没有默认值,请务必添加@types
  • 添加更多信息/文档/示例(以 JSDoc 形式),以提升开发者体验。
  • 请确保对 DOM 结果进行类型转换。
  • 通过控制台/持续集成添加类型检查,以确保它们正确无误。
  • 告知用户如何使用您的类型
  • 将TypeScript JSDoc 参考文档添加到书签

如果您需要更多关于 JSDoc 类型功能的介绍,请参阅《使用 JSDoc 实现 JavaScript 类型安全》。我强烈推荐您阅读!

完整的代码可以在GitHub上找到
要了解用户如何获取代码,请查看测试用例

接下来会发生什么?

  • 这些步骤有助于简化 Web 组件的使用,并提高其安全性。
  • 这里并非所有内容都适用于所有情况,而且肯定会有一些情况我们还没有相应的解决方案。
  • 如果您遇到任何问题(最好能提供解决方案),请告知我们,我们会将其添加到这本“Web 组件类型指南”中。
  • VS Code 正在努力实现通过定义 Web 组件属性来实现声明式 HTML 的自动完成功能——请参阅相关提案,以便在使用未定义属性时收到错误提示:


Follow me on [Twitter](https://twitter.com/daKmoR).
If you have any interest in web component make sure to check out [open-wc.org](https://open-wc.org).
Enter fullscreen mode Exit fullscreen mode
文章来源:https://dev.to/dakmor/type-safe-web-components-with-jsdoc-4icf