使用 JSDoc 实现类型安全的 Web 组件
编写代码很难,而编写出易于他人(或未来的自己)理解的代码则更难。正因如此,文档是每个软件项目中至关重要的组成部分。
我相信我们都曾遇到过这种情况:你正在愉快地编写代码,突然发现一个很棒的库可以帮助你,于是你开始使用它……
import foo from 'foo-lib';
foo.doTheThing(//...
但是,是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>
它只是一个带……的小盒子
- 产权
- 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);
使用后你会得到什么
让我们查询一下新创建的元素。😊
const el = document.querySelector('title-bar');
我们的编辑器无法知道el实际是什么,因此无法帮助我们编写更好的代码。
这意味着即使信息可用,我们也无法获得自定义属性的代码补全功能。
所以我们需要做的就是把它投射出来:
const el = /** @type {TitleBar} */ (document.querySelector('title-bar'));
现在我们已经有了自动补全功能。🎉
但是我们仍然可以编写类似这样的代码
el.foo = 'bar';
el.title = true;
而且没有人会抱怨。
让我们改变现状💪
添加类型检查
将文件添加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)"
]
}
这就是让 VS Code 将代码标记为存在问题所需的全部步骤:
Property 'foo' does not exist on type 'TitleBar'.
Type 'true' is not assignable to type 'string'.
你甚至可以更进一步,在控制台和持续集成中进行代码检查。
你只需要这样做:
npm i -D typescript
并将此脚本添加到您的 package.json 文件中。
"scripts": {
"lint:types": "tsc"
}
然后我们可以这样执行:
npm run lint:types
这将产生与上面相同的错误,但会显示文件路径和行号。
所以,只需做这些额外的几件事,你的 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;
}
- 标题显然是一个字符串
- 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';
好多了😊
注意:这里不需要添加逗号,@type因为很明显这是一个字符串,如果添加的话,它可能会在某些时候失去同步。
手动设置类型
如果我们看一下
this.formatter = null;
单凭这一行代码无法判断属性值是什么。
你可以赋一个空的/默认函数,例如:
this.formatter = value => `${value}`;
但这并非在所有情况下都适用。
在我们的示例中,如果没有格式化函数,我们希望跳过格式化步骤。
设置默认函数会违背其初衷。
在这种情况下,必须提供一个格式化函数@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;
这样,如果您输入了错误的类型,就会显示错误信息。
el.formatter = false;
// Type 'false' is not assignable to type 'Function'.
此外,即时显示功能@example也让创建自己的格式化程序变得非常容易。
创建您自己的类型并使用它们
还有一处房产看起来不太好,那就是那处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 '')
*/
这样,我们还可以将某些属性定义为可选属性。
之后我们只需要为其赋值即可。
/**
* @type {Bar}
*/
this.bar = { x: 0, y: 0, title: 'I am dot' };
为函数参数添加类型
让我们创建一个简单的格式化函数,默认情况下允许添加前缀/后缀,如果需要更多功能,您可以覆盖该函数formatter。
注:这并非一个特别实用的例子,但足以说明问题。
format(value = '', { prefix, suffix = '' } = { prefix: '' }) {
let formattedValue = value;
if (this.formatter) {
formattedValue = this.formatter(value);
}
return `${prefix}${formattedValue}${suffix}`;
}
仅使用默认选项,它就已经知道所有类型了。
所以,可能只需要添加一些文档就足够了。
/**
* This function can prefix/suffix your string.
*
* @example
* el.format('foo', { prefix: '...' });
*/
format(value = '', { prefix = '', suffix = '' } = {}) {
或者,如果您想要使用联合类型(例如,允许字符串和数字),
请务必只记录您实际需要的内容,因为这种方法会覆盖默认类型,这可能会导致类型不同步。
/**
* This function can prefix/suffix your string.
*
* @example
* el.format('foo', { prefix: '...' });
*
* @param {string|number} value String to format
*/
format(value, { prefix = '', suffix = '' } = {}) {
如果你确实需要为每个对象选项添加非常具体的描述,那么你需要复制这些类型定义。
/**
* 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: '' }) {
跨文件导入类型
文件从来都不是孤立存在的,所以有时你可能会想在另一个位置使用某种类型的文件。
让我们以我们熟悉的待办事项列表为例。
你会拥有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;
}
那么我该如何重用这些类型呢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>
我们想计算一些统计数据。
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);
}
上面的代码实际上有错误😱,item.prio它不存在。类型定义本来可以帮我们解决这个问题,但是该怎么做呢?
首先,我们导入类型
/**
* @typedef {import('./todo-item.js').ToDoItem} ToDoItem
*/
然后我们进行类型转换。
const items = /** @type {ToDoItem[]} */ (Array.from(
this.querySelectorAll('todo-item'),
));
我们已经看到类型错误了💪
使用数据对象创建自定义元素
大多数情况下,我们不仅想要访问现有的 DOM 并对结果进行类型转换,而且还希望能够实际渲染数据数组中的这些元素。
以下是一个示例数组
this.dataItems = [
{ label: 'Item 1', priority: 5, done: false },
{ label: 'Item 2', priority: 2, done: true },
{ label: 'Item 3', priority: 7, done: false },
];
然后我们渲染它。
return html`
${this.dataItems.map(
item => html`
<todo-item .label=${item.label} .priority=${item.priority} .done=${item.done}></todo-item>
`,
)}
`;
如何才能确保这类产品的安全性?
可惜,直接通过类型转换@type {ToDoItem[]}似乎行不通😭
它期望该对象是 HTMLElement 的完整表示,而我们这个只有 3 个属性的小对象当然缺少很多属性。
我们可以做的就是定义Data Representation我们的 Web 组件。例如,定义在 DOM 中创建这样一个元素需要哪些步骤。
/**
* Object Data representation of ToDoItem
*
* @typedef {Object} ToDoItemData
* @property {string} label
* @property {number} priority
* @property {Boolean} done
*/
然后我们可以导入并进行类型转换。
/**
* @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 },
];
}
🎉 Web 组件及其数据的类型安全。
让你的用户使用你的类型
如果类型不是定义文件,那么如何使它们可用就比较棘手了。
一般来说,你需要请求用户点赞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>)"
]
}
重要的是你的包名称,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).















