一个实用的黑客,使CSS nth-选择器在Web组件中可变。
使用CSS变量,至少当我在2021年6月写这几行的时候,媒体查询或选择器不支持,例如,:nth-child(var(--my-variable))
不工作。
这有点不幸,但并不是无法解决的。在最近的开发中,我绕过了这个限制,在DOM中注入了style
元素到Web组件中,以使DeckDeckGo中的代码块成为动画。
简介
严格意义上讲,下面的技巧并不局限于Web组件,可能也适用于任何元素。我只是把它用在这种技术😜。
我将首先用一个普通的组件来展示这个想法,然后用同样的方法来结束这篇文章,但用StencilJS功能组件来实现。
本教程的目标
我们将开发一个Web组件,它可以渲染一个<ul/>
列表,并且可以对其条目进行动画显示。
一旦该组件被加载,将不会从DOM中添加或删除任何语义元素。动画将通过修改style
,更确切地说,是通过在选定的li:nth-child(n)
上应用不同的样式来实现的。
Vanilla JS
为了在没有任何其他东西的情况下显示这个想法,我们创建了一个index.html
页面。它消耗了我们将要开发的Vanilla组件。我们还添加了一个button
来触发动画。
<html>
<head>
<script type="module" src="./my-component.js"></script>
</head>
<body>
<my-component></my-component>
<button>Next</button>
<script>
document
.querySelector('button')
.addEventListener(
'click',
() => document.querySelector('my-component').next()
);
</script>
</body>
</html>
在一个单独的文件中,称为my-component.js
,我们创建了网络组件。在这一点上,没有任何动画。我们声明它是开放的,以便能够访问影子DOM(通过shadowRoot
),我们创建一个样式来隐藏所有li
,并定义transition
。最后,我们添加ul
列表和它的孩子li
。
class MyComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({mode: 'open'});
const style = this.initStyle();
const ul = this.initElement();
this.shadowRoot.appendChild(style);
this.shadowRoot.appendChild(ul);
}
connectedCallback() {
this.className = 'hydrated';
}
next() {
// TODO in next chapter
}
initStyle() {
const style = document.createElement('style');
style.innerHTML = `
:host {
display: block;
}
li {
opacity: 0;
transition: opacity 0.5s ease-out;
}
`;
return style;
}
initElement() {
const ul = document.createElement('ul');
const li1 = document.createElement('li');
li1.innerHTML = 'Spine';
const li2 = document.createElement('li');
li2.innerHTML = 'Cowboy';
const li3 = document.createElement('li');
li3.innerHTML = 'Shelving';
ul.append(li1, li2, li3);
return ul;
}
}
customElements.define('my-component', MyComponent);
在这一点上,如果我们在浏览器中打开我们的例子 (npx serve .
),我们应该发现一个组件,有一个隐藏的内容,还有一个还没有效果的按钮。这没有什么可看的,但这是一个开始😁。
为了开发动画,我们必须跟踪所显示的li
,这就是为什么我们要给这个组件添加一个状态(index
)。
class MyComponent extends HTMLElement {
index = 0;
constructor() {
由于它的存在,我们可以实现next()
方法,也就是我们先前在HTML页面中添加的按钮所调用的方法。
这不是我最漂亮的代码。让我们同意它只有一个演示的目的😅。
next() {
this.index = this.index === 3 ? 1 : this.index + 1;
const selector = `
li:nth-child(${this.index}) {
opacity: 1;
}
`;
let style = this.shadowRoot.querySelector('style#animation');
if (style) {
style.innerHTML = selector;
return;
}
style = document.createElement('style');
style.setAttribute('id', 'animation');
style.innerHTML = selector;
this.shadowRoot.appendChild(style);
}
那里发生了什么?
它首先设置了下一个index
,li
,来显示,并且,创建一个CSSselector
来应用opacity
的样式。简而言之,这取代了我们不能使用的CSS变量。
之后,我们检查我们的Web组件的阴影内容是否已经包含一个专门的样式来应用动画。如果有,我们就用新的值更新样式--选择器,如果没有,我们就创建一个新的样式标签。
每次调用这个方法,都会应用一个新的style
,因此会显示另一个li:nth-child(n)
。
如果我们再次打开浏览器试一试,在点击我们的按钮next
,项目应该是动画的,而且,如果我们进一步观察检查器中的组件,我们应该注意到阴影的style
元素在每次方法调用时都会改变。
StencilJS
让我们用同样的例子来增加乐趣,但是,使用一个StencilJS功能组件🤙。
你可以用命令行启动一个新的项目
npm init stencil
因为我们正在开发完全相同的组件,我们可以在项目的./src/index.html
中复制之前的HTML内容(声明组件并添加一个button
),但只有一个微小的区别,即必须声明方法next()
,并以async - await方式调用。这是一个要求--Stencil的最佳实践,组件的公共方法必须是async
。
<!DOCTYPE html>
<html dir="ltr" lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=5.0" />
<title>Stencil Component Starter</title>
<script type="module" src="/build/demo-stencil.esm.js"></script>
<script nomodule src="/build/demo-stencil.js"></script>
</head>
<body>
<!-- Same code as in previous chapter -->
<my-component></my-component>
<button>Next</button>
<script>
document.querySelector('button')
.addEventListener(
'click',
async () => await document
.querySelector('my-component').next()
);
</script>
<!-- Same code as in previous chapter -->
</body>
</html>
我们也可以重复前面的步骤,先创建一个组件,它只负责渲染一个ul
列表和隐藏的项目li
。
import { Component, h } from '@stencil/core';
@Component({
tag: 'my-component',
styles: `:host {
display: block;
}
li {
opacity: 0;
transition: opacity 0.5s ease-out;
}
`,
shadow: true,
})
export class MyComponent {
render() {
return <ul>
<li>Spine</li>
<li>Cowboy</li>
<li>Shelving</li>
</ul>
}
}
通过测试组件(npm run start
),我们也应该得到同样的结果😉。
为了跟踪要突出显示的li
,我们需要一个状态和,函数state
。我们把这两者都添加到我们的组件中。
@State()
private index: number = 0;
@Method()
async next() {
this.index = this.index === 3 ? 1 : this.index + 1;
}
与Vanilla组件相比,因为我们使用的是一个简化开发的捆绑器,所以我们不需要自己去处理重新渲染的问题。state
的每一次修改都会触发一次重新渲染,最终更新必须要更新的节点(而且只有那些必须要更新的节点)。
尽管如此,我们还是必须实现CSS选择器变量。为了这个目的,正如简单提到的,我们将使用一个功能组件。它可能与类组件一起工作,但是,我觉得功能组件很适合这项工作。
const Animate: FunctionalComponent<{index: number;}> = ({index}) => {
return (
<style>{`
li:nth-child(${index}) {
opacity: 1;
}
`}</style>
);
};
这个组件为我们作为参数的路径值渲染了一个style
元素,即我们的state
。
最后,我们必须使用功能组件,并将其与我们的状态值绑定。这样做,每次它的值发生变化时,它都会被重新渲染。
render() {
return <Host>
<Animate index={this.index}></Animate>
<ul>
<li>Spine</li>
<li>Cowboy</li>
<li>Shelving</li>
</ul>
</Host>
}
这已经是它了,我们能够复制同一个组件🥳。
上述组件只用了一个代码块。
import { Component, FunctionalComponent, h, Host, Method, State } from '@stencil/core';
const Animate: FunctionalComponent<{index: number;}> = ({index}) => {
return (
<style>{`
li:nth-child(${index}) {
opacity: 1;
}
`}</style>
);
};
@Component({
tag: 'my-component',
styles: `:host {
display: block;
}
li {
opacity: 0;
transition: opacity 0.5s ease-out;
}
`,
shadow: true,
})
export class MyComponent {
@State()
private index: number = 0;
@Method()
async next() {
this.index = this.index === 3 ? 1 : this.index + 1;
}
render() {
return <Host>
<Animate index={this.index}></Animate>
<ul>
<li>Spine</li>
<li>Cowboy</li>
<li>Shelving</li>
</ul>
</Host>
}
}
总结
说实话,我不确定这篇文章会不会找到它的读者,也不认为它可能在某一天对某人有用,但是,我喜欢用这招😜。此外,用Vanilla JS或Stencil来开发同一段代码的演示也很有趣。