LitElement:深入了解批量更新
由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!
介绍
LitElement是一个用于开发 Web 组件的基础类。它体积小巧,更新效率高,并且通过其惰性求值(或高效)的特性,减轻了开发人员编写 Web 组件的许多繁重工作。
我在研讨会上或刚开始使用 LitElement 的新同事那里经常会遇到以下一些问题:
- LitElement究竟是如何实现高效更新的?
- 异步渲染是什么意思?
- LitElement是如何检测属性变化的?
所以我想,是时候写博客了!
我们先来看下面的代码示例:
class MyElement extends LitElement {
foo() {
this.myPropertyA = 1;
}
}
LitElement 具有响应式特性,它会监听组件属性并触发所谓的“渲染管道”。这意味着,每当我们更改组件中的某个属性时,它都会更新并重新渲染该组件。这非常方便,因为我们无需手动操作。在上面的代码示例中,只需设置一个新值,this.myPropertyA组件就会重新渲染。
现在来看另一个例子:
class MyElement extends LitElement {
foo() {
this.myPropertyA = 1;
this.myPropertyB = 2;
}
}
嗯,看来我们遇到点麻烦了。什么时候应该重新渲染呢?是在myPropertyA设置完某个属性之后吗?那样的话,我们可能会做很多不必要的渲染工作,因为我们还希望组件在设置属性时更新myPropertyB,所以最终会渲染两次,而一次就足够了。这听起来效率很低。如果我们在一个方法中需要设置更多属性,情况会很快变得难以控制。
幸运的是,LitElement 非常智能且高效,它采用了一种巧妙的批量更新技术,只需重新渲染一次即可。为了理解其工作原理,我们将了解一些常用的批量更新模式,深入研究事件循环以及 LitElement 的内部机制。最后,我们将编写一个简单的批量更新 Web 组件基类实现。
批量处理
在开始之前,让我们先明确一下我们谈论批量处理工作时究竟是什么意思,并介绍几种技术来演示如何在 JavaScript 中实现批量处理行为。
批处理本质上是限制我们需要执行的工作量,通常作为一种性能优化手段来实现。批处理(或限制工作量)的一个常见用例是限制可能连续快速触发的 API 调用。我们不希望给 API 增加任何不必要的负载,也不希望发出大量无用的请求。或者,以 LitElement 为例,可以有效地限制组件的重新渲染次数。记住,我们的目标是避免任何不必要的工作,从而实现高性能。
一种常见的批量处理模式是使用防抖函数。如上所述,防抖函数通常用于批量处理 API 调用。假设我们有一个搜索输入框,每次输入新的搜索关键词时,都会从服务器获取一些搜索结果。如果我们每次输入更改或每个新字符都发送一个请求,将会给 API 带来相当大的负载,而且这会造成很大的资源浪费。相反,我们可以使用防抖函数来处理API 调用,使其仅在最后一次输入更改时才发送请求。
请看以下示例:
/* This is generally what a debounce function looks like, it takes a function to execute, and optionally a delay */
function debounce(func, delay = 0) {
/* We create a variable to store a reference to the last `setTimeout` */
let timeoutId;
/* And then we return a new function, that whenever called, clears the latest timeout, essentially cancelling it, and schedules a new timeout */
return function() {
/* Because this function closes over the outer function, we'll still have access to the `timeoutId` declared above. This is useful on subsequent calls to the 'inner' function. */
clearTimeout(timeoutId);
/* We then schedule a new timeout, that calls the function that we'd like to execute */
timeoutId = setTimeout(() => {
func.apply(this, arguments);
}, delay);
}
};
function search() {
/* Do some API call to get more search results */
}
/* We create a new variable that stores the _returned_ function from the debounce function */
const debouncedSearch = debounce(search, 500);
input.addEventListener("input", debouncedSearch);
如果用户在输入框中输入搜索关键字“javascript”,我们不会发出 10 个 API 请求(单词“javascript”中的每个字符都对应一个请求),而是在用户完成输入后只发出一个请求。
如果你对防抖感兴趣,想了解更多关于防抖的知识,这里有一篇来自css-tricks.com的很棒的博客。
然而,很遗憾,LitElement 并非如此运作,但这对说明“批处理”的概念至关重要。在深入探讨之前,我们需要一些关于……的知识。
事件循环!
事件循环……可能是一个相当难以理解的概念。我在这里会尽量用一个简单的例子来简要说明,但如果您有兴趣了解更多关于事件循环工作原理的信息,以下是一些我强烈推荐的优秀资源:
幸运的是,我们接下来不需要了解事件循环的所有细节,我们真正感兴趣的只是微任务。请看下面这段代码(……它经常在 Twitter 投票中流传,也曾让许多开发者感到困惑)。你认为这些console.log语句的调用顺序是什么?
console.log(1);
Promise.resolve().then(() => {
console.log(2);
});
console.log(3);
答案如下,这段代码生成的控制台日志顺序为:
// 1
// 3
// 2
我们将请杰克·阿奇博尔德向我们解释这究竟是为什么:
微任务队列会在回调函数执行完毕后,且没有其他 JavaScript 代码正在执行时进行处理,并在每个微任务结束时进行处理。在微任务执行期间加入队列的任何其他微任务都会被添加到队列末尾并进行处理。
如果我们回到之前的代码片段,可以看到发生了以下情况:
console.log(1);被调用,并记录到控制台。- 我们安排了一个新的微任务
Promise.resolve().then(() => {}),但此时 JavaScript 尚未执行完毕;现在还不是处理微任务的时候。 - JavaScript 代码仍在继续执行,因为我们还没完成。
console.log(3);被调用,并记录到控制台。- JavaScript 执行完毕!浏览器现在可以处理我们可能遇到的任何微任务了。
console.log(2);被调用,并记录到控制台。
那么,这为什么重要呢?它如何帮助我们利用微任务进行批量处理?让我们来看一个实际例子:
class Batching {
/* We declare and initialize a variable to keep track of whether or not an update has already been requested */
updateRequested = false;
scheduleUpdate() {
/**
* In here, we need a check to see if an update is already previously requested.
* If an update already is requested, we don't want to do any unnecessary work!
*/
if(!this.updateRequested) {
/* If no update has yet been requested, we set the `updateRequested` flag to `true` */
this.updateRequested = true;
/**
* Since we now know that microtasks run after JavaScript has finished
* executing, we can use this knowledge to our advantage, and only set
* the `updateRequested` flag back to `false` again once all the tasks
* have run, essentially delaying, or _batching_ the update!
*/
Promise.resolve().then(() => {
this.updateRequested = false;
this.update();
});
}
}
/* This is our `update` method that we only want to be called once */
update() {
console.log('updating!');
}
}
const batching = new Batching();
/* We call scheduleUpdate in quick succession */
batching.scheduleUpdate();
batching.scheduleUpdate();
batching.scheduleUpdate();
/* 🎉 The result: */
// "updating!"
/* `update` only ran once! */
本质上,它通过语句scheduleUpdate防止update函数被多个传入调用同时处理。这巧妙地利用了我们新近掌握的微任务知识,优先if处理其他传入调用。只有在 JavaScript 执行完毕、微任务处理开始后,才会调用此语句。update
补充一点有趣的知识:我们也可以scheduleUpdate这样编写该方法:
class Batching {
// ...
async scheduleUpdate() {
if(!this.updateRequested) {
this.updateRequested = true;
this.updateRequested = await false;
this.update();
}
}
// ...
}
根据MDN 的说法:
如果 await 运算符后面的表达式的值不是 Promise,则会将其转换为已解析的 Promise。
换句话说,一旦我们设置了它updateRequested,我们不会立即取消设置它,await关键字确保我们只安排在取消设置时才取消设置它。
事实上,LitElement 的作者之一贾斯汀·法尼亚尼曾在推特上告诉我,LitElement 之前的版本正是这样做的:
推迟承诺
好了,我们差不多可以解释 LitElement 如何实现高效更新了。但在那之前,我们还需要探讨最后一个概念,那就是延迟解析 Promise,这将在本文后面提供一个方便的工具。
请查看以下代码:
/* We declare a new variable */
let resolveFn;
/**
* We declare a new promise, but we _dont_ resolve it!
* Instead, we assign the `resolve` function to the
* `resolveFn` that we declared above
*/
const myPromise = new Promise((resolve) => {
resolveFn = resolve;
});
/**
* `myPromise` now is just a pending promise, waiting to be resolved,
* and we can do that whenever we please with our `resolveFn`
*/
myPromise; // Promise {<pending>}
/* For example, we could do a whole bunch of other work in between */
/* And finally, whenever we're ready, we can call the `resolveFn` to resolve `myPromise` */
resolveFn(); // Resolves `myPromise`
/* Aaand `myPromise` is now fulfilled! */
myPromise; // Promise {<fulfilled>: undefined} ! 🎉
这种模式常用于(例如单元测试)需要等待特定时间后再继续执行的情况。
function sleep(delay) {
return new Promise(resolve => {
setTimeout(resolve, delay)
});
}
(async () => {
await sleep(1000); // Awaits one second for the timeout to execute before continuing
})()
在这个例子中,sleep函数返回一个新的 Promise Promise。在 Promise 内部,我们设置了超时时间,但只有在setTimeout回调函数执行完毕后才解析 Promise。因此,通过await调用该sleep函数,我们实际上强制 Promise 等待setTimeout回调函数执行完毕。
但请注意,JavaScript 是单线程的,阻塞主线程通常被认为是一种不好的做法。
返回 LitElement
🧙♂️ 自动请求更新
LitElement 是响应式的。这意味着,像许多其他现代前端库一样,我们不必过多担心手动渲染或重新渲染组件的内容。作为 LitElement 的用户,我们只需声明属性,LitElement 就会自动监听这些属性,并在属性发生变化时更新组件。真棒!我喜欢这种不用自己动手的感觉。
简单的计数器元素,由webcomponents.dev的各位好心人友情提供。
假设我们有一个简单的计数器元素,它有一个count属性。要让 LitElement 重新渲染,只需要count像这样给这个属性设置一个新值:this.count = 1;。这将自动触发 LitElement 请求更新。
但是……它是如何做到的?LitElement是如何知道count属性已被设置的?它是如何监测我们的属性的?
LitElement 要求用户在静态属性 getter中声明组件的属性。这个静态属性 getter 非常实用,因为它能帮开发者省去很多样板代码,帮我们处理属性,处理属性反射,让我们能够响应属性变化,但更重要的是:它会自动创建 getter 和 setter。这很棒,因为这意味着我们的组件不会到处都是 getter 和 setter,而且它还能帮我们请求更新!
请看以下示例:
class MyCounter extends LitElement {
static get properties() {
return {
count: { type: Number }
}
}
}
在底层,LitElement 实际上将其转换为以下 getter/setter:
class MyCounter extends LitElement {
set count(value) {
this.__count = value;
this.requestUpdate(); // ❗️ (metal-gear-solid-alert.mp3)
}
get count() {
return this.__count;
}
}
注:这是用于说明的伪代码,LitElement 的实际源代码略有不同,但基本概念仍然适用。如果您有兴趣阅读源代码,可以在这里找到。
这意味着,每当我们像这样给一个属性赋新值时:this.count = 1;,就会调用该属性的 setter 方法count,该方法随后会调用更新方法requestUpdate()。因此,只需设置一个属性,系统就会自动请求更新!
对象和数组
这里我们稍微插一句,解释一下使用 LitElement 时另一个常见的情况,我经常看到有人对此感到困惑。我们现在知道,只需给属性设置一个新值就可以触发更新,但是……对象和数组呢?
假设我们有一个组件,它有一个user类似这样的属性:
{
name: "Mark",
age: 30
}
name人们通常期望设置对象的某个属性(例如 ` usersetState`)会触发重新渲染,就像这样:`setState` this.user.name = "Nick";,但却惊讶地发现并非如此。这是因为设置对象name的属性user实际上并没有改变对象本身,而是改变了对象的user一个属性user,因此,`setState`this.requestUpdate()方法永远不会被调用!
一个简单的解决方法是手动调用this.requestUpdate(),或者干脆替换整个对象,以确保 setter 方法被调用:
this.user = {
...this.user,
name: "Mark"
}
数组的情况也是如此。假设我们users的元素有一个属性,其中包含一个用户列表,类似于上面示例中使用的对象。以下代码不会安排更新:
this.users.push({name: 'Nick', age: 30});
因为我们只是修改了已存在的数组,因此不会触发users属性本身的 setter。
返回批量更新
好了,现在我们知道了批处理的含义、微任务的工作原理、如何延迟解析 Promise,以及在 LitElement 中设置属性如何调度更新。我们已经掌握了所有必要的知识,可以弄清楚 LitElement 实际是如何利用这些知识来实现更新批处理的。
为此,我们将编写一个非常简单的实现来模拟这种行为。我们将从一个非常简单的 JavaScript 类开始,该类会update在属性发生变化时高效地调用一个方法。请跟随操作:
/* We create a new class ... */
class Batching {
/* ... that has a `requestUpdate` method */
async requestUpdate() {
/**
* In here, we need a check to see if an update has already previously been requested.
* If an update is already requested, we don't want to do any unnecessary work!
*/
if (!this.updateRequested) {
/**
* If no update was previously requested, we set this
* flag to `true` to avoid doing unnecessary work in
* case `requestUpdate` might get called another (or several) times
*/
this.updateRequested = true;
/**
* ... and _this_ is where the magic happens;
* This schedules a microtask that executes once JavaScript has
* finished executing, and since we guard against any other
* incoming calls for `requestUpdate`, we only do work once
*/
this.updateRequested = await false;
/* Finally, we call our `update` method, which can then render some DOM, or do whatever */
this.update();
}
}
update() {
console.log("updating!");
}
/**
* For demonstration purposes we add some setters here,
* that when given a new value, will request an update
*/
set a(val) {
this.__a = val;
this.requestUpdate();
}
set b(val) {
this.__b = val;
this.requestUpdate();
}
}
/* We instantiate a new instance of our class */
const batching = new Batching();
/* And we set multiple properties */
batching.a = 1;
batching.b = 2;
/* 🎉 The result: */
// "updating!"
您可以在这里通过浏览器查看实时演示。
更进一步
太棒了!我们现在有了批量更新的简单实现。接下来,我们可以更进一步,提供某种 API,让用户可以await更新 LitElement 的updateComplete属性。请看下面的代码:
class Batching {
constructor() {
/**
* We initialize an `updateComplete` property with a new
* Promise that we'll only resolve once `this.update()`
* has been called
*/
this.updateComplete = this.__createDeferredPromise();
}
async requestUpdate() {
if (!this.updateRequested) {
this.updateRequested = true;
this.updateRequested = await false;
this.update();
/* When our update is, in fact, complete we resolve the Promise that was assigned to the `updateComplete` property ... */
this.__resolve();
/* ... And we assign a new promise to updateComplete for the next update */
this.updateComplete = this.__createDeferredPromise();
}
}
update() {
console.log("updating!");
}
/**
* Creates a new deferred promise that we can await,
* and assign the `resolve` function to `this.__resolve`,
* so we can resolve the promise after we call `this.update()`
*/
__createDeferredPromise() {
return new Promise((resolve) => {
this.__resolve = resolve;
});
}
set a(val) {
this.__a = val;
this.requestUpdate();
}
set b(val) {
this.__b = val;
this.requestUpdate();
}
}
/* We use an Async IIFE (Immediately Invoked Function Expression), because top-level await is not a thing yet 😑 */
(async () => {
/* We instantiate a new instance of our class */
const batching = new Batching();
/* Set multiple properties in a row */
batching.a = 1;
batching.b = 2;
/* And this is where we `await` an update */
await batching.updateComplete;
/* We then assign another property */
batching.b = 3;
/* 🎉 The result: */
// "updating!"
// "updating!"
})();
您可以在这里通过浏览器查看实时演示。
回到 Web 组件领域
太好了,既然我们已经对如何利用微任务来批量处理工作有了扎实的了解,让我们回到 Web 组件领域,看看如何将其实现为 Web 组件的简单基类。
/* We create a new class that extends from the native HTMLElement */
class BatchingElement extends HTMLElement {
constructor() {
/* We now have to call `super` to make sure our element gets set up correctly */
super();
this.updateComplete = this.__createDeferredPromise();
}
async requestUpdate() {
if (!this.updateRequested) {
this.updateRequested = true;
this.updateRequested = await false;
this.update();
this.__resolve();
this.updateComplete = this.__createDeferredPromise();
}
}
update() {}
__createDeferredPromise() {
return new Promise((resolve) => {
this.__resolve = resolve;
});
}
}
再次说明,这段代码是 LitElement 用于演示技术的简化示例,如果您有兴趣了解 LitElement 是如何实现这一点的,可以在这里找到源代码。
好了,就这些。其实变化不大,只是现在我们需要继承自某个类HTMLElement,并且需要super()在构造函数中调用它。接下来我们看看如何在组件中实际使用它:
/* We create a new class, MyElement, that extends from our BatchingElement base class */
class MyElement extends BatchingElement {
constructor() {
super();
/* We attach a shadowRoot to our component for good measure */
this.attachShadow({ mode: "open" });
}
connectedCallback() {
/* And we call an initial render when our component gets connected to the DOM */
this.update();
}
/**
* This is our `update` function that will get triggered by the
* `requestUpdate` method.
*
* Any time we set a property on this element, we'll also
* trigger an update.
*/
update() {
console.log('updating!');
this.shadowRoot.innerHTML = `
<div>value of a: ${this.a}</div>
<div>value of b: ${this.b}</div>
`;
}
/**
* And finally, we need some getters and setters to
* actually be able to trigger updates 🙃
*
* Notice how much boilerplate this is, and how nice
* LitElement makes this for us instead? 😩
*/
set a(val) {
this.__a = val;
this.requestUpdate();
}
get a(){
return this.__a;
}
set b(val) {
this.__b = val;
this.requestUpdate();
}
get b(){
return this.__b;
}
}
customElements.define("my-element", MyElement);
太棒了!我们现在已经将BatchingElement基类应用到实际的 Web 组件中了。以后如果连续更改多个属性,我们只需要调用update一次该方法。请看以下示例:
/* We get a reference to our component, so we can call methods on it */
const myElement = document.querySelector('my-element');
/**
* We assign multiple properties in quick succession,
* but our component will still only call `update` once!
*/
myElement.a = 'foo';
myElement.b = 'bar';
/* 🎉 The result: */
// "updating!" -> called in the `connectedCallback` to do an initial render
// "updating!" -> trigger by setting two properties
您可以在这里通过浏览器查看实时演示。
如果您想查看另一个实现此模式的实际示例,可以参考[此处插入链接] @generic-components,其中我使用相同的模式来批量处理slotchange事件调用。实现方式略有不同,但基本概念相同。
这就是 LitElement 如何高效地批量更新并避免不必要的工作。将其与lit-html结合使用,即可实现高效的 DOM 更新,从而获得一套真正高效且强大的库组合。
感谢阅读!
文章来源:https://dev.to/open-wc/litelement-a-deepdive-into-batched-updates-3hh

