使用可复用 JavaScript 函数管理 CSS 中的状态

Avatar of Luke Harrison
Luke Harrison

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

确定管理状态的最有效方法在 CSS 中可能是一个具有挑战性的问题,但幸运的是,有很多基于 OOCSS 的方法可以提供一些好的解决方案。

文章系列

  1. 使用可复用 JavaScript 函数管理 CSS 中的状态 (您当前位置!)
  2. 继续关注本文中的想法

我偏好的方法来自 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,应发生以下行为

  1. 当您点击第一个手风琴时,因为它具有设置为 js-accordion 的作用域,因此只有匹配或为该 js-accordion 实例子元素的 data-active 元素才会切换 is-active
  2. 当您点击第二个手风琴时,它没有作用域,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-elementdata-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,就会发生以下情况

  1. is-loading 将在 js-form-area 上切换。
  2. 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,将发生以下情况

  1. is-loading将在js-form-area上切换。
  2. 如果存在,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 变量,然后才能进行类更改。

同时,如果你有任何关于自己改进的想法,或者完全不同的管理状态类的方法,请务必在下面的评论中告诉我。

文章系列

  1. 使用可复用 JavaScript 函数管理 CSS 中的状态 (您当前位置!)
  2. 继续关注本文中的想法