确定管理状态的最有效方法在 CSS 中可能是一个具有挑战性的问题,但幸运的是,有很多基于 OOCSS 的方法可以提供一些好的解决方案。
文章系列
- 使用可复用 JavaScript 函数管理 CSS 中的状态 (您当前位置!)
- 继续关注本文中的想法
我偏好的方法来自 SMACSS(CSS 的可扩展和模块化架构),它涉及状态类。引用 SMACSS 的 自身文档,状态类是
状态是增强并覆盖所有其他样式的内容。例如,手风琴部分可能处于折叠或展开状态。消息可能处于成功或错误状态。
状态通常应用于与布局规则相同的元素,或应用于与基本模块类相同的元素。
我最常用的状态类之一是 is-active
。以前面引用的手风琴示例为例,在这种情况下,is-active
将应用所有必要的 CSS 样式来表示展开状态。如下例所示
查看 CodePen 上 Luke Harrison(@lukedidit)的笔 #1) 带状态类的Accordion组件。
您会注意到有一些 JavaScript 代码,当检测到点击事件时,它会在组件上切换 is-active
类
var accordion = document.querySelectorAll(".c-accordion");
for(var i = 0; i < accordion.length; i++) {
var accordionHeader = accordion[i].querySelector(".c-accordion__header"),
accordionCurrent = accordion[i];
accordionHeader.addEventListener("click", function(){
accordionCurrent.classList.toggle("is-active");
});
}
虽然 JavaScript 代码有效,但对于任何其他通过点击事件利用 is-active
状态类的组件,都必须一遍又一遍地重复此代码,导致大量本质上相同的代码片段重复。
效率不高,当然也不够 DRY。
更好的方法是编写一个执行相同任务的单个函数,并且可以重复使用在不同的组件上。让我们来做吧。
创建简单的可复用函数
让我们从构建一个简单的函数开始,该函数接受一个元素作为参数并切换 is-active
var makeActive = function(elem){
elem.classList.toggle("is-active");
}
这可以正常工作,但是如果我们将其插入到我们的手风琴 JavaScript 中,则会出现问题
var accordion = document.querySelectorAll(".c-accordion"),
makeActive = function(elem){
elem.classList.toggle("is-active");
}
for(var i = 0; i < accordion.length; i++) {
var accordionHeader = accordion[i].querySelector(".c-accordion__header"),
accordionCurrent = accordion[i];
accordionHeader.addEventListener("click", function(){
makeActive(accordionCurrent);
});
}
虽然 makeActive
函数是可复用的,但我们仍然需要首先编写代码来获取我们的组件及其任何内部元素,因此肯定还有很大的改进空间。
为了进行这些改进,我们可以利用 HTML5 自定义数据属性
<div class="c-accordion js-accordion">
<div class="c-accordion__header" data-active="js-accordion">My Accordion Component</div>
<div class="c-accordion__content-wrapper">
<div class="c-accordion__content">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce laoreet ultricies risus, sit amet congue nulla mollis et. Suspendisse bibendum eros sed sem facilisis ornare. Donec sit amet erat vel dui semper pretium facilisis eget nisi. Fusce consectetur vehicula libero vitae faucibus. Nullam sed orci leo. Fusce dapibus est velit, at maximus turpis iaculis in. Pellentesque ultricies ultrices nisl, eu consequat est molestie sit amet. Phasellus laoreet magna felis, ut vulputate justo tempor eu. Nam commodo aliquam vulputate.
</div>
</div>
</div>
data-active
属性已添加到之前在点击时触发 is-active
切换的元素中。此属性的值表示应发生 is-active
切换的元素,与之前一样,是顶级 c-accordion
元素。请注意,添加了新的 js-accordion
类而不是挂接到现有的 c-accordion
类。这是为了将组件的功能方面与其样式分离。
让我们看看 JavaScript 代码
// Grab all elements with data-active attribute
var elems = document.querySelectorAll("[data-active]");
// Loop through if any are found
for(var i = 0; i < elems.length; i++){
// Add event listeners to each one
elems[i].addEventListener("click", function(e){
// Prevent default action of element
e.preventDefault();
// Grab linked elements
var linkedElement = document.querySelectorAll("." + this.getAttribute("data-active"));
// Toggle linked element if present
for(var i = 0; i < linkedElement.length; i++) {
linkedElement[i].classList.toggle("is-active");
}
});
}
这确实改进了很多,因为我们不再需要编写代码来获取任何元素,只需将 data-active
属性附加到我们的触发元素并指定目标元素即可。按照目前的形式,此函数可用于任何其他需要基于点击的 is-active
类的组件,无需任何额外编码。完整示例如下
查看 CodePen 上 Luke Harrison(@lukedidit)的笔 #2) 带可复用 is-active 函数的Accordion组件。
改进我们的可复用函数
此可复用函数有效,但在扩展时,我们必须注意确保触发器和目标元素类不会相互冲突。在下面的示例中,点击一个手风琴会触发所有手风琴上的 is-active
。
<div class="c-accordion js-accordion">
<div class="c-accordion__header" data-active="js-accordion">First Accordion</div>
[...]
</div>
<div class="c-accordion js-accordion">
<div class="c-accordion__header" data-active="js-accordion">Second Accordion</div>
[...]
</div>
<div class="c-accordion js-accordion">
<div class="c-accordion__header" data-active="js-accordion">Third Accordion</div>
[...]
</div>
向每个 js-accordion
引用添加数字后缀确实解决了问题,但这是一个我们可以避免的麻烦。一个好的解决方案是改为对我们的可复用函数实现作用域,这将使我们能够封装我们的切换,以便它们仅影响我们想要的元素。
要实现作用域,我们需要创建一个名为 data-active-scope
的单独自定义属性。其值应表示切换应在其内封装的父元素,在本例中为父 js-accordion
元素。
<div class="c-accordion js-accordion">
<div class="c-accordion__header" data-active="js-accordion" data-active-scope="js-accordion">First Accordion</div>
[...]
</div>
<div class="c-accordion js-accordion">
<div class="c-accordion__header" data-active="js-accordion">Second Accordion</div>
[...]
</div>
使用上述 HTML,应发生以下行为
- 当您点击第一个手风琴时,因为它具有设置为
js-accordion
的作用域,因此只有匹配或为该js-accordion
实例子元素的data-active
元素才会切换is-active
。 - 当您点击第二个手风琴时,它没有作用域,
is-active
将在所有js-accordion
实例上切换。
只要 data-active-scope
设置正确,每个 js-accordion
元素内的任何类切换都应封装,而不管任何冲突的类名如何。
以下是修改后的 Javascript 和一个工作示例,显示带有和不带 data-active-scope
属性的手风琴
// Grab all elements with data-active attribute
var elems = document.querySelectorAll("[data-active]"),
// closestParent helper function
closestParent = function(child, match) {
if (!child || child == document) {
return null;
}
if (child.classList.contains(match) || child.nodeName.toLowerCase() == match) {
return child;
}
else {
return closestParent(child.parentNode, match);
}
}
// Loop through if any are found
for(var i = 0; i < elems.length; i++){
// Add event listeners to each one
elems[i].addEventListener("click", function(e){
// Prevent default action of element
e.preventDefault();
// Grab scope if defined
if(this.getAttribute("data-active-scope")) {
var scopeElement = closestParent(this, this.getAttribute("data-active-scope"));
}
if(scopeElement) {
// Grab scoped linked element
var linkedElement = scopeElement.querySelectorAll("." + this.getAttribute("data-active"));
// Convert to array
linkedElement = Array.prototype.slice.call(linkedElement);
// Check if our scope matches our target element and add to array if true.
// This is to make sure everything works when data-active matches data-active-scope.
if(scopeElement.classList.contains(this.getAttribute("data-active"))) {
linkedElement.unshift(scopeElement);
}
}
else {
// Grab linked element
var linkedElement = document.querySelectorAll("." + this.getAttribute("data-active"));
}
// Toggle linked element if present
for(var i = 0; i < linkedElement.length; i++) {
linkedElement[i].classList.toggle("is-active");
}
});
}
查看 CodePen 上 Luke Harrison(@lukedidit)的笔 #3) 带改进的可复用 is-active 函数的Accordion组件。
超越 is-active
我们的可复用函数现在工作得很好,并且是为各种组件设置 is-active
切换的有效方法。但是,如果我们需要为另一个状态类设置类似的切换呢?按照目前的形式,我们将不得不复制该函数并将所有 is-active
的引用更改为新的状态类。效率不高。
我们应该通过重构我们的数据属性来改进我们的可复用函数,以便接受任何类。与其将 data-active
属性附加到我们的触发器元素,不如用以下内容替换它
data-class
– 我们希望添加的类。data-class-element
– 我们希望向其添加类的元素。data-class-scope
– 作用域属性执行相同的功能,但已重命名以保持一致性。
这需要对我们的 JavaScript 进行一些小的调整
// Grab all elements with data-active attribute
var elems = document.querySelectorAll("[data-class][data-class-element]");
// closestParent helper function
closestParent = function(child, match) {
if (!child || child == document) {
return null;
}
if (child.classList.contains(match) || child.nodeName.toLowerCase() == match) {
return child;
}
else {
return closestParent(child.parentNode, match);
}
}
// Loop through if any are found
for(var i = 0; i < elems.length; i++){
// Add event listeners to each one
elems[i].addEventListener("click", function(e){
// Prevent default action of element
e.preventDefault();
// Grab scope if defined
if(this.getAttribute("data-class-scope")) {
var scopeElement = closestParent(this, this.getAttribute("data-class-scope"));
}
if(scopeElement) {
// Grab scoped linked element
var linkedElement = scopeElement.querySelectorAll("." + this.getAttribute("data-class-element"));
// Convert to array
linkedElement = Array.prototype.slice.call(linkedElement);
// Check if our scope matches our target element and add to array if true.
// This is to make sure everything works when data-active matches data-active-scope.
if(scopeElement.classList.contains(this.getAttribute("data-class-element"))) {
linkedElement.unshift(scopeElement);
}
}
else {
// Grab linked element
var linkedElement = document.querySelectorAll("." + this.getAttribute("data-class-element"));
}
// Toggle linked element if present
for(var i = 0; i < linkedElement.length; i++) {
linkedElement[i].classList.toggle(this.getAttribute("data-class"));
}
});
}
它将在 HTML 中这样设置
<button class="c-button" data-class="is-loading" data-class-element="js-form-area">Submit</button>
在下面的示例中,点击 c-button
组件会在 js-form-area
组件上切换 is-loading
类
查看 CodePen 上 Luke Harrison(@lukedidit)的笔 #4) 带改进的可复用任何类函数的表单组件。
处理多个切换
因此,我们有一个可复用函数,可以在任何元素上切换任何类。这些点击事件可以通过使用自定义数据属性来设置,而无需编写任何额外的 JavaScript。但是,仍然可以通过方法使此可复用函数更有用。
回到我们之前登录表单组件的示例,如果当 c-button
元素被点击时,除了在 js-form-area
上切换 is-loading
之外,我们还想在所有 c-input
实例上切换 is-disabled
呢?目前这是不可能的,因为我们的自定义属性每个只接受一个值。
让我们修改我们的函数,以便每个自定义数据属性不仅接受单个值,还接受逗号分隔的值列表——其中 data-class
中的每个项目值都与 data-class-element
和 data-class-scope
中匹配索引的值相关联。
如下所示
<button class="c-button" data-class="is-loading, is-disabled" data-class-element="js-form-area, js-input" data-class-scope="false, js-form-area">Submit</button>
假设使用了以上内容,则一旦点击 c-button
,就会发生以下情况
is-loading
将在js-form-area
上切换。is-disabled
将在js-input
上切换,并在父js-form-area
元素内设置作用域。
这需要对我们的 JavaScript 进行更多更改
// Grab all elements with data-active attribute
var elems = document.querySelectorAll("[data-class][data-class-element]");
// closestParent helper function
closestParent = function(child, match) {
if (!child || child == document) {
return null;
}
if (child.classList.contains(match) || child.nodeName.toLowerCase() == match) {
return child;
}
else {
return closestParent(child.parentNode, match);
}
}
// Loop through if any are found
for(var i = 0; i < elems.length; i++){
// Add event listeners to each one
elems[i].addEventListener("click", function(e){
// Prevent default action of element
e.preventDefault();
// Grab classes list and convert to array
var dataClass = this.getAttribute('data-class');
dataClass = dataClass.split(", ");
// Grab linked elements list and convert to array
var dataClassElement = this.getAttribute('data-class-element');
dataClassElement = dataClassElement.split(", ");
// Grab data-scope list if present and convert to array
if(this.getAttribute("data-class-scope")) {
var dataClassScope = this.getAttribute("data-class-scope");
dataClassScope = dataClassScope.split(", ");
}
// Loop through all our dataClassElement items
for(var b = 0; b < dataClassElement.length; b++) {
// Grab elem references, apply scope if found
if(dataClassScope && dataClassScope[b] !== "false") {
// Grab parent
var elemParent = closestParent(this, dataClassScope[b]),
// Grab all matching child elements of parent
elemRef = elemParent.querySelectorAll("." + dataClassElement[b]);
// Convert to array
elemRef = Array.prototype.slice.call(elemRef);
// Add parent if it matches the data-class-element and fits within scope
if(dataClassScope[b] === dataClassElement[b] && elemParent.classList.contains(dataClassElement[b])) {
elemRef.unshift(elemParent);
}
}
else {
var elemRef = document.querySelectorAll("." + dataClassElement[b]);
}
// Grab class we will add
var elemClass = dataClass[b];
// Do
for(var c = 0; c < elemRef.length; c++) {
elemRef[c].classList.toggle(elemClass);
}
}
});
}
这是一个其他工作示例
查看 CodePen 上 Luke Harrison(@lukedidit)的笔 #5) 带改进的可复用和多个任何类函数的表单组件。
超越切换
我们的可复用函数现在非常有用,但它假设切换类是所需的行为。如果在点击时我们希望触发器删除一个类(如果存在)并且否则不执行任何操作呢?目前,这是不可能的。
为了完善这个函数,让我们集成一些额外的逻辑来实现这种行为。我们将引入一个可选的数据属性,名为data-class-behaviour
,它接受以下选项
toggle
– 在data-class-element
上切换data-class
。这应该也是默认行为,如果未定义data-class-behaviour
,则会发生此行为。add
– 如果data-class
不存在,则在data-class-element
上添加data-class
。如果存在,则不执行任何操作。remove
– 如果data-class
存在,则从data-class-element
上移除data-class
。如果不存在,则不执行任何操作。
与之前的数据属性一样,这个新的可选属性将是一个逗号分隔的列表,以便为每个操作允许不同的行为。如下所示
<button class="c-button" data-class="is-loading, is-disabled" data-class-element="js-form-area, js-input" data-class-behaviour="toggle, remove">Submit</button>
假设使用了以上HTML,则一旦点击c-button
,将发生以下情况
is-loading
将在js-form-area
上切换。- 如果存在,
is-disabled
将从js-input
中移除。
让我们进行必要的JavaScript更改
// Grab all elements with data-active attribute
var elems = document.querySelectorAll("[data-class][data-class-element]");
// closestParent helper function
closestParent = function(child, match) {
if (!child || child == document) {
return null;
}
if (child.classList.contains(match) || child.nodeName.toLowerCase() == match) {
return child;
}
else {
return closestParent(child.parentNode, match);
}
}
// Loop through if any are found
for(var i = 0; i < elems.length; i++){
// Add event listeners to each one
elems[i].addEventListener("click", function(e){
// Prevent default action of element
e.preventDefault();
// Grab classes list and convert to array
var dataClass = this.getAttribute('data-class');
dataClass = dataClass.split(", ");
// Grab linked elements list and convert to array
var dataClassElement = this.getAttribute('data-class-element');
dataClassElement = dataClassElement.split(", ");
// Grab data-class-behaviour list if present and convert to array
if(this.getAttribute("data-class-behaviour")) {
var dataClassBehaviour = this.getAttribute("data-class-behaviour");
dataClassBehaviour = dataClassBehaviour.split(", ");
}
// Grab data-scope list if present and convert to array
if(this.getAttribute("data-class-scope")) {
var dataClassScope = this.getAttribute("data-class-scope");
dataClassScope = dataClassScope.split(", ");
}
// Loop through all our dataClassElement items
for(var b = 0; b < dataClassElement.length; b++) {
// Grab elem references, apply scope if found
if(dataClassScope && dataClassScope[b] !== "false") {
// Grab parent
var elemParent = closestParent(this, dataClassScope[b]),
// Grab all matching child elements of parent
elemRef = elemParent.querySelectorAll("." + dataClassElement[b]);
// Convert to array
elemRef = Array.prototype.slice.call(elemRef);
// Add parent if it matches the data-class-element and fits within scope
if(dataClassScope[b] === dataClassElement[b] && elemParent.classList.contains(dataClassElement[b])) {
elemRef.unshift(elemParent);
}
}
else {
var elemRef = document.querySelectorAll("." + dataClassElement[b]);
}
// Grab class we will add
var elemClass = dataClass[b];
// Grab behaviour if any exists
if(dataClassBehaviour) {
var elemBehaviour = dataClassBehaviour[b];
}
// Do
for(var c = 0; c < elemRef.length; c++) {
if(elemBehaviour === "add") {
if(!elemRef[c].classList.contains(elemClass)) {
elemRef[c].classList.add(elemClass);
}
}
else if(elemBehaviour === "remove") {
if(elemRef[c].classList.contains(elemClass)) {
elemRef[c].classList.remove(elemClass);
}
}
else {
elemRef[c].classList.toggle(elemClass);
}
}
}
});
}
最后,一个可工作的示例
查看 CodePen 上 Luke Harrison 的作品 #6) Form Component w Improved reusable + multiple any class + behaviours function (@lukedidit)。
结束
我们创建了一个功能强大的函数,可以重复使用,而无需编写任何额外的代码。它允许我们快速为点击时的多个状态类分配添加、移除或切换逻辑,并让我们将这些更改限定在所需区域。
这个可重用函数还有很多方法可以进一步改进
- 支持使用除点击之外的其他事件。
- 支持触摸设备的滑动操作。
- 某种简单的验证,允许你声明必须为真值的 JavaScript 变量,然后才能进行类更改。
同时,如果你有任何关于自己改进的想法,或者完全不同的管理状态类的方法,请务必在下面的评论中告诉我。
文章系列
- 使用可复用 JavaScript 函数管理 CSS 中的状态 (您当前位置!)
- 继续关注本文中的想法
我看到你使用了 classList.toggle 函数,这意味着你使用了最新版本的规范,在这种情况下,你可以移除 classList.contains 函数,因为 classList.add 仅在类不存在时才添加它,而 classList.remove 仅在类存在时才移除它。
你也可以使用一个小的 switch 语句来代替 if…else if…else 语句,但这只是个人喜好;)
让我再挑一个小毛病,for (…) 周围的 if (elems.length) 应该是不必要的,因为 0 < 0 和 0 < undefined 都为假,如果我没记错的话。
谢谢,我相信有很多更优秀的开发者可以使它更高效,只要文章的重点能传达出来哈哈。
另一个有人在推特上提醒我的调整是删除了这里和那里的不必要的长度检查!不过现在已经删除了这些。
这很不错!你对这种方法和完整的 Frontend 应用程序框架之间的界限有什么看法,尤其是在你提到的额外功能,比如 JS 变量验证方面?
我认为这取决于上下文。
在一个新的项目中,对于了解 React 等前端框架的开发者来说,这始终是最佳选择。
对于使用 React 等框架不可行的项目——例如,如果开发者正在处理现有的代码库,或者开发者只是没有足够的知识来正确利用 React 等框架,那么这就是像上面这样简单的解决方案发挥作用的地方。
如果你正在构建一个框架,我认为这很有趣。但是对于一个不使用框架的小型项目来说,这可能会增加比你节省的更多的样板代码。看到新的思考方式总是很有趣的。我在 closestParent 辅助函数中发现的一个小问题是它从自身开始搜索父级。也许你会有想要这样做的理由,但我认为 jQuery 的版本并非如此(如果我错了,请纠正我,我已经有一段时间没用过 jQuery 了),如果你想要例如父 DIV 并且子元素也在一个 DIV 中,这将是一个问题(会返回自身)。简单的解决方案是
虽然类名
is-active
由于其冗余性让我有点不寒而栗;-),但我主要的批评点是,你**只**在这里切换类名。出于可访问性目的,例如你的手风琴应该使用适当的 aria 属性。一旦你开始切换
aria-expanded
属性来指示哪个手风琴项目处于活动状态,那么类名is-active
不仅在名称上变得冗余,而且完全冗余——因为项目的样式可以简单地使用属性选择器在展开状态下应用不同的格式。我承认我对 aria 属性并不特别了解,所以你可能说对了。我一定会仔细阅读它们。 :)
我同意,我一开始也使用了 SMACSS 方法,但在能够使用 ARIA 时就切换到了 ARIA。不过也有一些小问题。关于我处理状态的方法,请参阅:http://ecss.io/chapter6.html
我认为这对于稍后添加到 DOM 的元素会有一些问题。在这种情况下,你需要重复分配点击事件……我理解对吗?
如果 DOM 元素稍后添加,则必须有一种 JS 方法这样做。这意味着你有一个触发器来注册新元素。
data-click-registered=”1″
怎么样?过滤起来应该不难……
谢谢 Phil。是的,这确实是一种巧妙的方法。:) 我只是为了确认我是否正确理解了。
一个经常被忽视的方面是关于**关注点分离**和**调试**:考虑使用浏览器的实时 DOM 调试视图。使用数据属性表示状态使其更容易理解。
它是一个数据属性吗?那么它一定是 JavaScript 驱动的状态。
它是一个类名或内联样式吗?那么它只能源于 HTML。
哪个模块设置了状态?只需在你的 js/modules 目录中找到属性名称。
调整状态以进行测试?只需更改数据属性值。
这导致了严格的规则
JavaScript 从不设置类名。
JavaScript 仅设置通用样式,如“display”或“visibility”。
JavaScript 将状态写为数据属性。
状态名称尽可能唯一。更好的是:实现一个命名方案作为 JS 模块及其数据属性之间的粘合剂。
将定义状态的数据属性设置得尽可能靠近其影响的元素。这意味着:不要将所有应用程序状态都写入 body 元素。而是按容器设置它们。某些浏览器情况(例如带有复杂 DOM 的 IE)将提高性能。
CSS 可视化状态。
不同的关注点使用不同的源代码文件夹。
当然,一些预构建的框架/库(例如 iScroll)从 JS 设置样式,但这很容易与自己的代码分离。
你好,Luke,
感谢你的文章。
看看当前的网络状态,如果你要编写一些 JavaScript,那么所有像 BEM、SMACSS、OOCSS 这样的技术都已过时。有很多 CSS in JS 技术,比如 https://github.com/css-modules/css-modules 或 https://github.com/cssinjs/jss 等等,旨在帮助解决 CSS 中的任何困难。
此外,你展示的代码对于演示想法来说很好,但在生产环境中很难维护(保持整洁和清晰)。此时,任何想要使用它的人都需要某种库来控制视图(用于显示某些状态的 HTML)。我建议使用众所周知的 React,或者你可以使用新兴的 Vue。话虽如此,我相信你已经提到了这个重要点。
我使用过类似的技术有一段时间了(尽管属性名称不同)。多年来,我遇到了一些麻烦
如果您的内容是动态加载的(例如 ReactJS),您将不得不不断查询 DOM 并重新应用这些事件监听器。
HTML 开始变得非常杂乱/凌乱。尤其是在您开始构思并添加其他看似很棒的 data-* HTML API 之后。
如果您决定需要调整您的实现,则需要修改包含此实现的每个 HTML 文件。如果它散布在多个位置的数百个 HTML 文件中,这可能会变成一场噩梦。
从那以后,我回到了将此逻辑抽象到单个可重用 JavaScript 函数中。我的强迫症喜欢简洁的 HTML。