Skip to content

样式与类操作:动态控制元素外观

样式操作的两种思路

在 JavaScript 中控制元素样式有两种主要方式:操作 CSS 类和直接修改内联样式。

操作 CSS 类更符合"关注点分离"的原则——样式定义在 CSS 中,JavaScript 只负责添加或移除类名。这种方式便于维护,也更容易实现复杂的样式变化。

直接修改样式适用于需要动态计算的场景,比如根据鼠标位置设置元素坐标,或者实现一些简单的即时效果。

两种方式各有优劣,实际开发中经常结合使用。

classList:类名操作

classList 是操作元素类名的现代 API,提供了直观的方法来添加、移除和切换类名。

基本操作

javascript
const element = document.querySelector(".box");

// 添加类
element.classList.add("active");
element.classList.add("highlight", "visible"); // 同时添加多个

// 移除类
element.classList.remove("hidden");
element.classList.remove("old", "deprecated"); // 同时移除多个

// 切换类(有则移除,无则添加)
element.classList.toggle("expanded");

// 强制添加或移除
element.classList.toggle("visible", true); // 强制添加
element.classList.toggle("visible", false); // 强制移除

// 检查是否包含某个类
if (element.classList.contains("active")) {
  console.log("Element is active");
}

// 替换类
element.classList.replace("old-class", "new-class");

遍历类名

javascript
const element = document.querySelector(".multi-class");

// 获取类名数量
console.log(element.classList.length);

// 通过索引访问
console.log(element.classList[0]);
console.log(element.classList.item(0)); // 相同效果

// 遍历所有类名
for (const className of element.classList) {
  console.log(className);
}

// 转换为数组
const classArray = [...element.classList];
const classArray2 = Array.from(element.classList);

className 属性

classList 出现之前,使用 className 操作类名:

javascript
const element = document.querySelector(".box");

// 获取完整的类名字符串
console.log(element.className); // "box active highlight"

// 设置类名(会覆盖原有的所有类)
element.className = "new-class another-class";

// 追加类名(需要手动处理空格)
element.className += " extra-class";

className 的问题在于它返回和接受的是完整的类名字符串,添加或移除单个类需要进行字符串操作,容易出错。现代开发中应该优先使用 classList

实际应用:切换主题

javascript
class ThemeManager {
  constructor() {
    this.body = document.body;
    this.themes = ["light", "dark", "sepia"];
    this.currentIndex = 0;

    // 从本地存储恢复主题
    const savedTheme = localStorage.getItem("theme");
    if (savedTheme && this.themes.includes(savedTheme)) {
      this.currentIndex = this.themes.indexOf(savedTheme);
    }

    this.applyTheme();
  }

  applyTheme() {
    // 移除所有主题类
    this.themes.forEach((theme) => {
      this.body.classList.remove(`theme-${theme}`);
    });

    // 添加当前主题类
    const currentTheme = this.themes[this.currentIndex];
    this.body.classList.add(`theme-${currentTheme}`);

    // 保存到本地存储
    localStorage.setItem("theme", currentTheme);
  }

  setTheme(themeName) {
    const index = this.themes.indexOf(themeName);
    if (index !== -1) {
      this.currentIndex = index;
      this.applyTheme();
    }
  }

  cycleTheme() {
    this.currentIndex = (this.currentIndex + 1) % this.themes.length;
    this.applyTheme();
  }

  getCurrentTheme() {
    return this.themes[this.currentIndex];
  }
}

// 使用
const themeManager = new ThemeManager();

document.querySelector("#theme-toggle").addEventListener("click", () => {
  themeManager.cycleTheme();
});

style 属性:内联样式

每个元素都有一个 style 属性,用于读写内联样式。

基本操作

javascript
const element = document.querySelector(".box");

// 设置单个样式
element.style.color = "red";
element.style.backgroundColor = "white";
element.style.fontSize = "16px";
element.style.marginTop = "20px";

// 读取样式
console.log(element.style.color); // "red"

// 清除样式(设为空字符串)
element.style.color = "";

属性名转换

CSS 属性名使用连字符(如 background-color),而 JavaScript 使用驼峰命名(如 backgroundColor):

CSS 属性JavaScript 属性
background-colorbackgroundColor
font-sizefontSize
border-radiusborderRadius
margin-topmarginTop
z-indexzIndex
-webkit-transformwebkitTransform
javascript
// CSS: background-color → JS: backgroundColor
element.style.backgroundColor = "blue";

// CSS: border-top-left-radius → JS: borderTopLeftRadius
element.style.borderTopLeftRadius = "10px";

// 带厂商前缀的属性
element.style.webkitTransform = "rotate(45deg)";
element.style.MozTransform = "rotate(45deg)"; // 注意 Moz 的大小写

cssText:批量设置样式

使用 cssText 可以一次性设置多个样式:

javascript
const element = document.querySelector(".box");

// 设置多个样式(会覆盖原有的 style 属性)
element.style.cssText = "color: red; background: white; font-size: 16px;";

// 追加样式
element.style.cssText += "padding: 10px; margin: 20px;";

注意 cssText 使用 CSS 语法(连字符命名),而且会覆盖之前的所有内联样式。

使用 setProperty 和 getPropertyValue

这些方法是 style 对象提供的标准化接口:

javascript
const element = document.querySelector(".box");

// 设置样式(使用 CSS 属性名)
element.style.setProperty("background-color", "blue");
element.style.setProperty("--custom-color", "#ff0000"); // CSS 变量

// 获取样式
console.log(element.style.getPropertyValue("background-color"));

// 移除样式
element.style.removeProperty("background-color");

// 设置优先级
element.style.setProperty("color", "red", "important");

style 属性的局限性

element.style 只能读取和设置内联样式,无法获取通过 CSS 文件或 <style> 标签定义的样式:

html
<style>
  .box {
    color: blue;
  }
</style>
<div class="box" style="background: white;">Content</div>
javascript
const box = document.querySelector(".box");

console.log(box.style.color); // ""(空字符串,不是 "blue")
console.log(box.style.background); // "white"(内联样式可以读取)

getComputedStyle:获取计算样式

要获取元素的最终渲染样式(包括继承的样式、CSS 规则应用后的结果),需要使用 getComputedStyle

javascript
const element = document.querySelector(".box");
const styles = getComputedStyle(element);

// 读取样式(使用 CSS 属性名或驼峰命名)
console.log(styles.color); // "rgb(255, 0, 0)"
console.log(styles.getPropertyValue("color")); // "rgb(255, 0, 0)"
console.log(styles.fontSize); // "16px"
console.log(styles.backgroundColor); // "rgb(255, 255, 255)"

几点需要注意:

  1. 返回的是只读对象,不能用于设置样式
  2. 值是计算后的结果,单位会被转换(如 em 转为 px
  3. 颜色会被转换为 rgb/rgba 格式
  4. 简写属性可能不可用,需要使用具体属性
javascript
const styles = getComputedStyle(element);

// ❌ 简写属性可能不工作
console.log(styles.margin); // 可能是空字符串或分开的值

// ✅ 使用具体属性
console.log(styles.marginTop);
console.log(styles.marginRight);
console.log(styles.marginBottom);
console.log(styles.marginLeft);

获取伪元素的样式

getComputedStyle 可以获取伪元素的样式:

javascript
const element = document.querySelector(".box");

// 获取 ::before 伪元素的样式
const beforeStyles = getComputedStyle(element, "::before");
console.log(beforeStyles.content);

// 获取 ::after 伪元素的样式
const afterStyles = getComputedStyle(element, "::after");
console.log(afterStyles.color);

计算实际尺寸

结合 getComputedStyle 和元素的几何属性获取准确的尺寸信息:

javascript
const element = document.querySelector(".box");
const styles = getComputedStyle(element);

// 方法一:使用 getComputedStyle
const width = parseFloat(styles.width);
const height = parseFloat(styles.height);
const paddingLeft = parseFloat(styles.paddingLeft);
const paddingRight = parseFloat(styles.paddingRight);

console.log(`Content width: ${width}px`);
console.log(`Padding: ${paddingLeft}px + ${paddingRight}px`);

// 方法二:使用元素属性(通常更方便)
console.log(element.offsetWidth); // 包含边框和内边距
console.log(element.clientWidth); // 包含内边距,不含边框
console.log(element.scrollWidth); // 包含溢出内容

// getBoundingClientRect 提供最完整的尺寸信息
const rect = element.getBoundingClientRect();
console.log(rect.width); // 元素宽度(包含边框)
console.log(rect.height); // 元素高度(包含边框)
console.log(rect.top); // 距视口顶部的距离
console.log(rect.left); // 距视口左侧的距离

CSS 变量操作

CSS 自定义属性(CSS 变量)可以通过 JavaScript 动态修改:

css
:root {
  --primary-color: #007bff;
  --secondary-color: #6c757d;
  --font-size-base: 16px;
  --spacing-unit: 8px;
}

.box {
  background-color: var(--primary-color);
  font-size: var(--font-size-base);
  padding: calc(var(--spacing-unit) * 2);
}
javascript
// 获取根元素的 CSS 变量
const root = document.documentElement;
const styles = getComputedStyle(root);
const primaryColor = styles.getPropertyValue("--primary-color").trim();
console.log(primaryColor); // "#007bff"

// 设置 CSS 变量
root.style.setProperty("--primary-color", "#ff0000");

// 在特定元素上设置(会覆盖继承的值)
const element = document.querySelector(".box");
element.style.setProperty("--primary-color", "#00ff00");

// 移除 CSS 变量
root.style.removeProperty("--primary-color");

实际应用:动态主题配色

javascript
class ColorSchemeManager {
  constructor() {
    this.root = document.documentElement;
    this.defaultScheme = {
      "--primary-color": "#007bff",
      "--primary-light": "#4dabf7",
      "--primary-dark": "#0056b3",
      "--background": "#ffffff",
      "--surface": "#f8f9fa",
      "--text-primary": "#212529",
      "--text-secondary": "#6c757d",
    };
  }

  setScheme(scheme) {
    Object.entries(scheme).forEach(([property, value]) => {
      this.root.style.setProperty(property, value);
    });
  }

  setColor(property, value) {
    this.root.style.setProperty(property, value);
  }

  getColor(property) {
    return getComputedStyle(this.root).getPropertyValue(property).trim();
  }

  reset() {
    this.setScheme(this.defaultScheme);
  }

  // 从用户选择的颜色生成配色方案
  generateFromPrimary(hexColor) {
    const rgb = this.hexToRgb(hexColor);

    this.setScheme({
      "--primary-color": hexColor,
      "--primary-light": this.lighten(rgb, 20),
      "--primary-dark": this.darken(rgb, 20),
      ...this.defaultScheme,
    });
  }

  hexToRgb(hex) {
    const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
    return result
      ? {
          r: parseInt(result[1], 16),
          g: parseInt(result[2], 16),
          b: parseInt(result[3], 16),
        }
      : null;
  }

  rgbToHex({ r, g, b }) {
    return (
      "#" +
      [r, g, b]
        .map((x) => {
          const hex = Math.round(x).toString(16);
          return hex.length === 1 ? "0" + hex : hex;
        })
        .join("")
    );
  }

  lighten({ r, g, b }, percent) {
    return this.rgbToHex({
      r: Math.min(255, r + (255 - r) * (percent / 100)),
      g: Math.min(255, g + (255 - g) * (percent / 100)),
      b: Math.min(255, b + (255 - b) * (percent / 100)),
    });
  }

  darken({ r, g, b }, percent) {
    return this.rgbToHex({
      r: Math.max(0, r * (1 - percent / 100)),
      g: Math.max(0, g * (1 - percent / 100)),
      b: Math.max(0, b * (1 - percent / 100)),
    });
  }
}

// 使用
const colorManager = new ColorSchemeManager();

document.querySelector("#color-picker").addEventListener("input", (e) => {
  colorManager.generateFromPrimary(e.target.value);
});

动画与过渡

通过 JavaScript 可以触发 CSS 动画和过渡效果。

触发过渡

javascript
const element = document.querySelector(".box");

// CSS 中定义过渡
// .box { transition: transform 0.3s ease, opacity 0.3s ease; }

// JavaScript 改变样式会触发过渡
element.style.transform = "translateX(100px)";
element.style.opacity = "0.5";

// 监听过渡结束
element.addEventListener("transitionend", (e) => {
  console.log(`Transition ended: ${e.propertyName}`);
});

强制触发动画

有时需要重新触发动画,可以通过移除并重新添加类来实现:

javascript
function replayAnimation(element, animationClass) {
  element.classList.remove(animationClass);

  // 强制重排以使动画重新开始
  void element.offsetWidth; // 读取 offsetWidth 触发重排

  element.classList.add(animationClass);
}

// 使用
const button = document.querySelector(".animated-button");
button.addEventListener("click", () => {
  replayAnimation(button, "shake");
});

使用 Web Animations API

现代浏览器支持更强大的 Web Animations API:

javascript
const element = document.querySelector(".box");

// 创建动画
const animation = element.animate(
  [
    { transform: "translateY(0)", opacity: 1 },
    { transform: "translateY(-50px)", opacity: 0.5, offset: 0.5 },
    { transform: "translateY(0)", opacity: 1 },
  ],
  {
    duration: 1000,
    easing: "ease-in-out",
    iterations: Infinity,
  }
);

// 控制动画
animation.pause();
animation.play();
animation.reverse();
animation.cancel();

// 调整播放速率
animation.playbackRate = 2; // 双倍速

// 监听事件
animation.onfinish = () => console.log("Animation finished");

实际应用示例

交互式卡片效果

javascript
class InteractiveCard {
  constructor(element) {
    this.card = element;
    this.shine = this.createShineElement();
    this.card.appendChild(this.shine);

    this.bindEvents();
  }

  createShineElement() {
    const shine = document.createElement("div");
    shine.className = "card-shine";
    shine.style.cssText = `
      position: absolute;
      top: 0;
      left: 0;
      right: 0;
      bottom: 0;
      background: linear-gradient(
        135deg,
        rgba(255,255,255,0) 0%,
        rgba(255,255,255,0.1) 50%,
        rgba(255,255,255,0) 100%
      );
      opacity: 0;
      pointer-events: none;
      transition: opacity 0.3s;
    `;
    return shine;
  }

  bindEvents() {
    this.card.addEventListener("mouseenter", () => this.onEnter());
    this.card.addEventListener("mouseleave", () => this.onLeave());
    this.card.addEventListener("mousemove", (e) => this.onMove(e));
  }

  onEnter() {
    this.shine.style.opacity = "1";
    this.card.style.transition = "transform 0.1s ease-out";
  }

  onLeave() {
    this.shine.style.opacity = "0";
    this.card.style.transform = "perspective(1000px) rotateX(0) rotateY(0)";
    this.card.style.transition = "transform 0.5s ease-out";
  }

  onMove(e) {
    const rect = this.card.getBoundingClientRect();
    const x = e.clientX - rect.left;
    const y = e.clientY - rect.top;

    const centerX = rect.width / 2;
    const centerY = rect.height / 2;

    const rotateX = (y - centerY) / 10;
    const rotateY = (centerX - x) / 10;

    this.card.style.transform = `
      perspective(1000px)
      rotateX(${rotateX}deg)
      rotateY(${rotateY}deg)
      scale3d(1.02, 1.02, 1.02)
    `;

    // 更新光泽位置
    const percentX = (x / rect.width) * 100;
    const percentY = (y / rect.height) * 100;
    this.shine.style.background = `
      radial-gradient(
        circle at ${percentX}% ${percentY}%,
        rgba(255,255,255,0.3) 0%,
        rgba(255,255,255,0) 60%
      )
    `;
  }
}

// 使用
document.querySelectorAll(".interactive-card").forEach((card) => {
  new InteractiveCard(card);
});

滚动进度指示器

javascript
class ScrollProgress {
  constructor() {
    this.progressBar = this.createProgressBar();
    document.body.prepend(this.progressBar);

    window.addEventListener("scroll", () => this.updateProgress());
    window.addEventListener("resize", () => this.updateProgress());

    this.updateProgress();
  }

  createProgressBar() {
    const bar = document.createElement("div");
    bar.className = "scroll-progress";
    bar.style.cssText = `
      position: fixed;
      top: 0;
      left: 0;
      height: 4px;
      background: linear-gradient(90deg, #007bff, #00bcd4);
      z-index: 9999;
      transition: width 0.1s ease-out;
      box-shadow: 0 0 10px rgba(0, 123, 255, 0.5);
    `;
    return bar;
  }

  updateProgress() {
    const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
    const docHeight =
      document.documentElement.scrollHeight - window.innerHeight;
    const progress = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0;

    this.progressBar.style.width = `${progress}%`;
  }
}

// 使用
new ScrollProgress();

响应式样式管理

javascript
class ResponsiveStyleManager {
  constructor() {
    this.breakpoints = {
      mobile: 576,
      tablet: 768,
      desktop: 992,
      wide: 1200,
    };

    this.currentBreakpoint = null;
    this.callbacks = [];

    this.check();
    window.addEventListener("resize", () => this.check());
  }

  check() {
    const width = window.innerWidth;
    let newBreakpoint;

    if (width < this.breakpoints.mobile) {
      newBreakpoint = "xs";
    } else if (width < this.breakpoints.tablet) {
      newBreakpoint = "mobile";
    } else if (width < this.breakpoints.desktop) {
      newBreakpoint = "tablet";
    } else if (width < this.breakpoints.wide) {
      newBreakpoint = "desktop";
    } else {
      newBreakpoint = "wide";
    }

    if (newBreakpoint !== this.currentBreakpoint) {
      const oldBreakpoint = this.currentBreakpoint;
      this.currentBreakpoint = newBreakpoint;

      // 更新 body 类
      document.body.classList.remove(
        "bp-xs",
        "bp-mobile",
        "bp-tablet",
        "bp-desktop",
        "bp-wide"
      );
      document.body.classList.add(`bp-${newBreakpoint}`);

      // 更新 CSS 变量
      document.documentElement.style.setProperty(
        "--current-breakpoint",
        `"${newBreakpoint}"`
      );

      // 触发回调
      this.callbacks.forEach((cb) => cb(newBreakpoint, oldBreakpoint));
    }
  }

  onChange(callback) {
    this.callbacks.push(callback);
    // 立即调用一次
    callback(this.currentBreakpoint, null);

    // 返回取消订阅函数
    return () => {
      const index = this.callbacks.indexOf(callback);
      if (index > -1) this.callbacks.splice(index, 1);
    };
  }

  is(breakpoint) {
    return this.currentBreakpoint === breakpoint;
  }

  isAtLeast(breakpoint) {
    const order = ["xs", "mobile", "tablet", "desktop", "wide"];
    return order.indexOf(this.currentBreakpoint) >= order.indexOf(breakpoint);
  }
}

// 使用
const responsive = new ResponsiveStyleManager();

responsive.onChange((current, previous) => {
  console.log(`Breakpoint changed from ${previous} to ${current}`);

  if (current === "mobile" || current === "xs") {
    // 移动端特定逻辑
    document.querySelector(".sidebar")?.classList.add("collapsed");
  } else {
    document.querySelector(".sidebar")?.classList.remove("collapsed");
  }
});

样式验证器

javascript
class StyleValidator {
  constructor(element) {
    this.element = element;
    this.rules = [];
  }

  addRule(property, validator, message) {
    this.rules.push({ property, validator, message });
    return this;
  }

  validate() {
    const styles = getComputedStyle(this.element);
    const errors = [];

    this.rules.forEach((rule) => {
      const value = styles.getPropertyValue(rule.property);
      if (!rule.validator(value)) {
        errors.push({
          property: rule.property,
          value,
          message: rule.message,
        });
      }
    });

    return {
      valid: errors.length === 0,
      errors,
    };
  }

  // 预设验证规则
  static minFontSize(pixels) {
    return (value) => parseFloat(value) >= pixels;
  }

  static hasColor(value) {
    return value && value !== "rgba(0, 0, 0, 0)" && value !== "transparent";
  }

  static minContrast(background, foreground, ratio = 4.5) {
    // 简化的对比度检查
    return true; // 实际实现需要计算 WCAG 对比度
  }
}

// 使用
const validator = new StyleValidator(document.querySelector(".content"));

validator
  .addRule(
    "font-size",
    StyleValidator.minFontSize(14),
    "Font size should be at least 14px"
  )
  .addRule("color", StyleValidator.hasColor, "Text color should be visible");

const result = validator.validate();
if (!result.valid) {
  console.warn("Style validation failed:", result.errors);
}

性能优化建议

避免频繁读写样式

javascript
// ❌ 不好:读写交替会触发多次重排
elements.forEach((el) => {
  const height = el.offsetHeight; // 读取
  el.style.height = height * 2 + "px"; // 写入
});

// ✅ 好:先批量读取,再批量写入
const heights = elements.map((el) => el.offsetHeight); // 批量读取
elements.forEach((el, i) => {
  el.style.height = heights[i] * 2 + "px"; // 批量写入
});

使用 CSS 类而非内联样式

javascript
// ❌ 较慢:多次修改内联样式
element.style.color = "red";
element.style.fontSize = "20px";
element.style.fontWeight = "bold";
element.style.marginTop = "10px";

// ✅ 更快:添加预定义的 CSS 类
element.classList.add("highlighted");

使用 transform 和 opacity 进行动画

javascript
// ❌ 会触发布局重排
element.style.left = "100px";
element.style.top = "50px";
element.style.width = "200px";

// ✅ 只触发合成,性能更好
element.style.transform = "translate(100px, 50px) scale(2)";
element.style.opacity = "0.8";

总结

样式与类操作是 JavaScript 与 CSS 交互的重要方式。选择合适的方法可以让代码更清晰、性能更好:

需求推荐方法
添加/移除/切换类classList.add/remove/toggle
检查是否有某个类classList.contains
设置单个样式element.style.property
批量设置样式element.style.cssText
获取计算后的样式getComputedStyle
操作 CSS 变量setProperty/getPropertyValue
获取元素尺寸getBoundingClientRect

最佳实践:

  1. 优先使用 CSS 类,将样式定义留在 CSS 中
  2. 使用 getComputedStyle 读取最终渲染的样式
  3. 批量读写样式,避免频繁触发重排
  4. 动画优先使用 transformopacity