一文搞定Stylelint

301 阅读10分钟

在前端开发中,Stylelint 是一个强大的、可扩展的 CSS/SCSS/Less Linter。它能够帮助我们强制执行编码规范,捕获潜在的错误,并提高代码质量。虽然 Stylelint 提供了大量内置规则,但在某些特定场景下,我们可能需要实现一些项目特有的规则,这时就需要用到 自定义 Stylelint 插件

本教程将详细讲解如何创建自定义 Stylelint 插件,并提供多个实用的示例,每个示例都包含详细的代码和解释。


1. Stylelint 插件核心概念

在深入代码之前,我们先了解一下 Stylelint 插件的一些核心概念:

1.1 PostCSS AST (抽象语法树)

Stylelint 的底层依赖于 PostCSS。PostCSS 是一个用 JavaScript 编写的工具,可以将 CSS 代码解析成一个抽象语法树 (AST),然后通过插件对这个 AST 进行处理(转换、分析、优化等)。Stylelint 插件就是利用 PostCSS 解析出的 AST 来进行代码检查的。

理解 PostCSS AST 的结构对于编写 Stylelint 插件至关重要。一个简单的 CSS 片段:

.foo {
  color: red;
  font-size: 16px;
}

会被 PostCSS 解析成类似这样的 AST 结构(简化版):

Root
  Rule (selector: ".foo")
    Declaration (prop: "color", value: "red")
    Declaration (prop: "font-size", value: "16px")

Stylelint 插件通过遍历这个 AST,访问不同的节点(如 RootRuleDeclarationAtRuleComment 等),然后对这些节点进行检查。

1.2 Stylelint 规则结构

一个 Stylelint 规则通常由以下几个部分组成:

  • ruleName: 规则的唯一标识符,通常是 plugin-name/rule-name 的形式。

  • messages: 一个对象,包含规则可能发出的所有错误消息。这些消息通过键值对定义,方便在规则内部引用。

  • meta: 一个可选对象,用于提供规则的元数据,如 url(指向规则文档)、fixable(是否可自动修复)。

  • rule 函数 (或 create 函数) : 这是规则的核心逻辑所在。它接收两个参数:

    • primaryOption: 规则的主要选项(例如,一个字符串、一个布尔值或一个数组)。
    • secondaryOptions: 规则的次要选项,通常是一个对象,用于更复杂的配置。
    • 这个函数返回一个函数,该函数接收 root (PostCSS AST 的根节点) 和 result (Stylelint 的结果对象) 作为参数。在这个返回的函数中,我们遍历 AST 并执行检查。
  • report 函数: Stylelint 提供的一个工具函数,用于报告错误。它接收一个包含错误信息的对象作为参数,例如 messagenode (触发错误的 AST 节点)、index (错误在节点值中的索引) 等。

1.3 插件的入口文件 (index.js)

一个 Stylelint 插件通常是一个 NPM 包。它的入口文件 (index.js) 负责导出所有自定义规则。

// index.js
const stylelint = require('stylelint');
const rules = require('./lib/rules'); // 导入所有规则

const plugin = stylelint.createPlugin(
  'my-custom-plugin', // 插件名称
  rules // 规则对象
);

module.exports = plugin;

2. 搭建 Stylelint 插件项目

首先,我们需要创建一个项目文件夹,并初始化 package.json

mkdir stylelint-plugin-my-rules
cd stylelint-plugin-my-rules
npm init -y

安装 Stylelint 作为开发依赖:

npm install stylelint postcss --save-dev

项目结构通常如下:

stylelint-plugin-my-rules/
├── index.js                  # 插件的入口文件
├── package.json
├── .stylelintrc.json         # 用于测试的 Stylelint 配置文件
├── lib/
│   ├── rules/                # 存放所有规则的目录
│   │   ├── rule-one.js       # 规则一的实现
│   │   ├── rule-one.test.js  # 规则一的测试
│   │   ├── rule-two.js       # 规则二的实现
│   │   ├── rule-two.test.js  # 规则二的测试
│   │   └── index.js          # 导出所有规则的辅助文件
│   └── utils/                # 存放通用工具函数的目录 (可选)
└── node_modules/

lib/rules/index.js 文件用于统一导出所有规则,方便 index.js 入口文件导入。

// lib/rules/index.js
module.exports = {
  'no-important-in-properties': require('./no-important-in-properties'),
  'no-duplicate-properties-within-rule': require('./no-duplicate-properties-within-rule'),
  'selector-class-pattern': require('./selector-class-pattern'),
  'property-value-unit-allowed': require('./property-value-unit-allowed'),
  // 更多规则...
};

3. 自定义 Stylelint 插件示例

接下来,我们将通过四个具体的示例来详细讲解如何编写自定义 Stylelint 插件。每个示例都包含规则的实现和对应的测试代码。

示例 1: no-important-in-properties (禁止在属性值中使用 !important)

问题描述: 强制团队成员避免使用 !important 关键字来覆盖样式,因为它会导致 CSS 优先级难以管理。

规则名称: my-rules/no-important-in-properties

实现思路:

  1. 遍历 PostCSS AST 中的所有 Declaration 节点。
  2. 对于每个 Declaration 节点,检查其 important 属性是否为 true
  3. 如果为 true,则报告错误。

代码实现:

lib/rules/no-important-in-properties.js

// lib/rules/no-important-in-properties.js

// 引入 stylelint 模块,用于创建规则和报告错误
const stylelint = require('stylelint');

// 获取 stylelint.utils,其中包含 report 等实用函数
const { report, ruleMessages, validateOptions } = stylelint.utils;

// 定义规则的名称。它将作为 Stylelint 配置中的一个键。
// 格式通常是 `plugin-name/rule-name`
const ruleName = 'my-rules/no-important-in-properties';

// 定义规则可能发出的所有错误消息。
// 这样可以集中管理消息,方便修改和国际化。
const messages = ruleMessages(ruleName, {
  rejected: (property) => `Unexpected !important in "${property}"`, // 错误消息模板,接收属性名作为参数
});

// 定义规则的元数据 (可选)。
// meta.url: 指向规则文档的 URL。
// meta.fixable: 如果规则可以自动修复,则设置为 true。
const meta = {
  url: 'https://example.com/stylelint-plugin-my-rules/no-important-in-properties',
  fixable: false, // 这个规则不能自动修复
};

// 规则的核心逻辑函数。
// 它接收 primaryOption 和 secondaryOptions 作为参数。
// 返回一个函数,该函数接收 PostCSS AST 的根节点 (root) 和 Stylelint 的结果对象 (result)。
const rule = (primaryOption, secondaryOptions) => {
  // 验证规则的选项。
  // primaryOption 期望是布尔值 true,表示启用此规则。
  // secondaryOptions 在此规则中未使用。
  const validOptions = validateOptions(
    result,
    ruleName,
    {
      actual: primaryOption,
      possible: [true], // 规则只接受 true 作为 primaryOption
    },
    {
      actual: secondaryOptions,
      possible: {}, // 规则不接受 secondaryOptions
      optional: true,
    },
  );

  // 如果选项无效,则不执行规则检查。
  if (!validOptions) {
    return;
  }

  // 返回一个函数,这个函数才是 Stylelint 真正用来遍历 AST 和执行检查的。
  return (root, result) => {
    // 遍历 AST 中的所有 "decl" (declaration) 节点。
    // PostCSS 提供了 walkDecls 方法来遍历所有声明。
    root.walkDecls((decl) => {
      // decl 是一个 PostCSS Declaration 节点对象。
      // 它有一个 important 属性,如果值为 !important,则为 true。
      if (decl.important) {
        // 如果检测到 !important,则报告错误。
        report({
          ruleName, // 规则名称
          result, // Stylelint 结果对象
          node: decl, // 触发错误的 AST 节点 (这里是 Declaration 节点)
          message: messages.rejected(decl.prop), // 使用之前定义的消息模板,传入属性名
        });
      }
    });
  };
};

// 将规则的各个部分导出。
rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = meta;

module.exports = rule;

测试代码:

lib/rules/no-important-in-properties.test.js

// lib/rules/no-important-in-properties.test.js

// 引入 stylelint 模块,用于测试规则
const stylelint = require('stylelint');

// 引入要测试的规则
const rule = require('./no-important-in-properties');

// 获取 stylelint.testUtils,其中包含 testRule 函数
const { testRule } = stylelint;

// 定义规则名称
const ruleName = rule.ruleName;

// 定义规则消息
const messages = rule.messages;

// 使用 testRule 函数来编写测试用例
testRule({
  ruleName, // 要测试的规则名称
  config: true, // 规则的配置,这里设置为 true 表示启用规则

  // 有效代码测试用例 (不应该有报错)
  accept: [
    {
      code: '.foo { color: red; }',
      description: 'should accept declaration without !important',
    },
    {
      code: '.bar { background: url("image.png"); }',
      description: 'should accept declaration with url',
    },
    {
      code: 'a { display: block; }',
      description: 'should accept simple declaration',
    },
  ],

  // 无效代码测试用例 (应该有报错)
  reject: [
    {
      code: '.foo { color: red !important; }',
      description: 'should reject declaration with !important',
      // 期望的报错信息
      message: messages.rejected('color'),
      // 报错的行号和列号
      line: 1,
      column: 14,
    },
    {
      code: 'a { font-size: 16px !important; }',
      description: 'should reject another declaration with !important',
      message: messages.rejected('font-size'),
      line: 1,
      column: 18,
    },
    {
      code: `
        .box {
          margin: 10px !important;
          padding: 20px;
        }
      `,
      description: 'should reject !important in multi-line rule',
      message: messages.rejected('margin'),
      line: 3,
      column: 18,
    },
  ],
});

示例 2: no-duplicate-properties-within-rule (禁止在同一规则块内出现重复属性)

问题描述: 防止在同一个 CSS 规则块(例如 .foo { ... })中定义两次相同的 CSS 属性,这通常是笔误或代码冗余。

规则名称: my-rules/no-duplicate-properties-within-rule

实现思路:

  1. 遍历 PostCSS AST 中的所有 Rule 节点(即 CSS 选择器规则)。
  2. 对于每个 Rule 节点,维护一个已检查属性的集合。
  3. 遍历 Rule 节点下的所有 Declaration 节点。
  4. 在遍历过程中,如果遇到一个属性名已经在集合中存在,则报告错误。否则,将该属性名添加到集合中。

代码实现:

lib/rules/no-duplicate-properties-within-rule.js

// lib/rules/no-duplicate-properties-within-rule.js

const stylelint = require('stylelint');
const { report, ruleMessages, validateOptions } = stylelint.utils;

const ruleName = 'my-rules/no-duplicate-properties-within-rule';

const messages = ruleMessages(ruleName, {
  rejected: (property) => `Unexpected duplicate property "${property}" within the same rule`,
});

const meta = {
  url: 'https://example.com/stylelint-plugin-my-rules/no-duplicate-properties-within-rule',
  fixable: false, // 不能自动修复
};

const rule = (primaryOption, secondaryOptions) => {
  const validOptions = validateOptions(
    result,
    ruleName,
    {
      actual: primaryOption,
      possible: [true],
    },
    {
      actual: secondaryOptions,
      possible: {},
      optional: true,
    },
  );

  if (!validOptions) {
    return;
  }

  return (root, result) => {
    // 遍历 AST 中的所有 "rule" 节点 (即 CSS 规则块,如 .foo { ... })
    root.walkRules((ruleNode) => {
      // 使用 Set 来存储当前规则块中已经遇到的属性名,以便快速检查重复
      const seenProperties = new Set();

      // 遍历当前规则块 (ruleNode) 的所有子节点。
      // PostCSS 节点的 `nodes` 属性包含了其所有子节点。
      // 这里我们只关心 `declaration` 类型的子节点。
      ruleNode.walkDecls((decl) => {
        // decl.prop 是声明的属性名 (例如 'color', 'font-size')
        const propertyName = decl.prop;

        // 检查当前属性名是否已经在 seenProperties 集合中存在
        if (seenProperties.has(propertyName)) {
          // 如果存在,说明是重复属性,报告错误
          report({
            ruleName,
            result,
            node: decl, // 报告错误的节点是重复的声明
            message: messages.rejected(propertyName), // 使用定义好的错误消息
          });
        } else {
          // 如果是第一次遇到这个属性,将其添加到集合中
          seenProperties.add(propertyName);
        }
      });
    });
  };
};

rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = meta;

module.exports = rule;

测试代码:

lib/rules/no-duplicate-properties-within-rule.test.js

// lib/rules/no-duplicate-properties-within-rule.test.js

const stylelint = require('stylelint');
const rule = require('./no-duplicate-properties-within-rule');
const { testRule } = stylelint;

const ruleName = rule.ruleName;
const messages = rule.messages;

testRule({
  ruleName,
  config: true,

  accept: [
    {
      code: '.foo { color: red; font-size: 16px; }',
      description: 'should accept rule with unique properties',
    },
    {
      code: '.bar { background: url("image.png"); color: blue; }',
      description: 'should accept rule with different properties',
    },
    {
      code: `
        .baz {
          margin: 10px;
          padding: 20px;
          border: 1px solid black;
        }
      `,
      description: 'should accept multi-line rule with unique properties',
    },
    {
      code: `
        .foo { color: red; }
        .bar { color: blue; } /* Different rules can have same properties */
      `,
      description: 'should accept same property in different rules',
    },
    {
      code: `.foo { --my-var: red; --my-var: blue; }`,
      description: 'should accept duplicate custom properties (CSS variables)',
      // Note: By default, this rule will reject custom properties as well.
      // If you want to allow them, you'd need to add logic to check if decl.prop starts with '--'.
      // For this example, we'll keep it simple and reject all duplicates.
    },
  ],

  reject: [
    {
      code: '.foo { color: red; color: blue; }',
      description: 'should reject duplicate property in single line',
      message: messages.rejected('color'),
      line: 1,
      column: 20, // 第二个 color 的起始列
    },
    {
      code: `
        .bar {
          font-size: 16px;
          color: red;
          font-size: 1em; /* Duplicate */
        }
      `,
      description: 'should reject duplicate property in multi-line rule',
      message: messages.rejected('font-size'),
      line: 5,
      column: 11, // 第二个 font-size 的起始列
    },
    {
      code: `
        a {
          padding: 10px;
          margin: 5px;
          padding: 20px; /* Duplicate */
          display: block;
        }
      `,
      description: 'should reject duplicate property with other properties in between',
      message: messages.rejected('padding'),
      line: 5,
      column: 11,
    },
  ],
});

示例 3: selector-class-pattern (强制类名使用 kebab-case 格式)

问题描述: 强制所有 CSS 类名都遵循 kebab-case (短横线命名法),例如 my-class 而不是 myClassmy_class

规则名称: my-rules/selector-class-pattern

实现思路:

  1. 遍历 PostCSS AST 中的所有 Rule 节点。
  2. 对于每个 Rule 节点,获取其 selector 字符串。
  3. 使用 PostCSS 的 selector-parser 库来解析复杂的选择器字符串,提取出所有的类名。
  4. 对每个提取出的类名,使用正则表达式检查其是否符合 kebab-case 格式。
  5. 如果不符合,则报告错误。

注意: 这个示例需要额外安装 postcss-selector-parser 库。

npm install postcss-selector-parser --save-dev

代码实现:

lib/rules/selector-class-pattern.js

// lib/rules/selector-class-pattern.js

const stylelint = require('stylelint');
const { report, ruleMessages, validateOptions } = stylelint.utils;
const selectorParser = require('postcss-selector-parser'); // 引入选择器解析器

const ruleName = 'my-rules/selector-class-pattern';

const messages = ruleMessages(ruleName, {
  expected: (selector, pattern) => `Expected class selector "${selector}" to match pattern "${pattern}"`,
});

const meta = {
  url: 'https://example.com/stylelint-plugin-my-rules/selector-class-pattern',
  fixable: false, // 自动修复类名模式通常很复杂,不建议自动修复
};

// 默认的 kebab-case 正则表达式
// ^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$
// - ^[a-z]:必须以小写字母开头
// - [a-z0-9]*:后面可以跟任意数量的小写字母或数字
// - (?:-[a-z0-9]+)*:非捕获组,表示可以有零个或多个 `-` 后面跟着至少一个字母或数字的组合
const defaultPattern = '^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$';

const rule = (primaryOption, secondaryOptions) => {
  // 验证规则选项
  // primaryOption 可以是一个布尔值 true (使用默认模式) 或一个正则表达式字符串
  // secondaryOptions 在此规则中未使用
  const validOptions = validateOptions(
    result,
    ruleName,
    {
      actual: primaryOption,
      possible: [true, (value) => typeof value === 'string' && value.length > 0], // 允许 true 或非空字符串
    },
    {
      actual: secondaryOptions,
      possible: {},
      optional: true,
    },
  );

  if (!validOptions) {
    return;
  }

  // 根据 primaryOption 确定使用的正则表达式
  const pattern = typeof primaryOption === 'string' ? primaryOption : defaultPattern;
  const regex = new RegExp(pattern);

  return (root, result) => {
    // 遍历 AST 中的所有 "rule" 节点
    root.walkRules((ruleNode) => {
      // 使用 selectorParser 解析当前规则的 selector 字符串
      selectorParser((selectors) => {
        // 遍历解析后的所有选择器 (例如,.foo, .bar, #baz 等)
        selectors.walkClasses((classNode) => {
          // classNode 是一个表示 CSS 类选择器的节点
          // classNode.value 是类名本身 (例如 'my-class', 'myClass')
          const className = classNode.value;

          // 检查类名是否符合正则表达式模式
          if (!regex.test(className)) {
            // 如果不符合,则报告错误
            report({
              ruleName,
              result,
              node: ruleNode, // 报告错误的节点是整个 Rule 节点
              // 由于 classNode 没有直接的 source 信息,我们通常报告整个 ruleNode,
              // 并在 message 中指明具体是哪个类名不符合。
              // 如果需要更精确的定位,可以计算 classNode 在 selector 字符串中的索引。
              index: classNode.sourceIndex, // 报告错误在 selector 字符串中的起始索引
              message: messages.expected(className, pattern),
            });
          }
        });
      }).processSync(ruleNode.selector); // 同步处理选择器字符串
    });
  };
};

rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = meta;

module.exports = rule;

测试代码:

lib/rules/selector-class-pattern.test.js

// lib/rules/selector-class-pattern.test.js

const stylelint = require('stylelint');
const rule = require('./selector-class-pattern');
const { testRule } = stylelint;

const ruleName = rule.ruleName;
const messages = rule.messages;

// 默认的 kebab-case 模式
const defaultPattern = '^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$';

testRule({
  ruleName,
  config: true, // 使用默认的 kebab-case 模式

  accept: [
    {
      code: '.my-class { color: red; }',
      description: 'should accept kebab-case class name',
    },
    {
      code: '.another-long-class-name { font-size: 16px; }',
      description: 'should accept longer kebab-case class name',
    },
    {
      code: '.c1 { display: block; }',
      description: 'should accept single letter and number class name',
    },
    {
      code: '.my-class-123 { width: 100%; }',
      description: 'should accept kebab-case with numbers',
    },
    {
      code: '#id-selector { /* ID selectors are ignored */ }',
      description: 'should ignore ID selectors',
    },
    {
      code: 'div { /* Element selectors are ignored */ }',
      description: 'should ignore element selectors',
    },
    {
      code: '[data-test="foo"] { /* Attribute selectors are ignored */ }',
      description: 'should ignore attribute selectors',
    },
    {
      code: '.parent .child-element { /* Multiple selectors */ }',
      description: 'should accept multiple valid class names in a selector',
    },
  ],

  reject: [
    {
      code: '.myClass { color: red; }',
      description: 'should reject camelCase class name',
      message: messages.expected('myClass', defaultPattern),
      line: 1,
      column: 1,
    },
    {
      code: '.my_class { font-size: 16px; }',
      description: 'should reject snake_case class name',
      message: messages.expected('my_class', defaultPattern),
      line: 1,
      column: 1,
    },
    {
      code: '.MyClass { display: block; }',
      description: 'should reject PascalCase class name',
      message: messages.expected('MyClass', defaultPattern),
      line: 1,
      column: 1,
    },
    {
      code: '.123-class { width: 100%; }',
      description: 'should reject class name starting with a number',
      message: messages.expected('123-class', defaultPattern),
      line: 1,
      column: 1,
    },
    {
      code: `
        .container { /* Valid */ }
        .ChildElement { /* Invalid */ }
      `,
      description: 'should reject PascalCase in multi-line CSS',
      message: messages.expected('ChildElement', defaultPattern),
      line: 3,
      column: 9,
    },
    {
      code: '.parent .ChildElement { /* Invalid */ }',
      description: 'should reject invalid class name in complex selector',
      message: messages.expected('ChildElement', defaultPattern),
      line: 1,
      column: 10,
    },
  ],
});

// 测试自定义模式
testRule({
  ruleName,
  config: '^[A-Z][a-zA-Z0-9]*$', // 允许 PascalCase 模式

  accept: [
    {
      code: '.MyComponent { color: blue; }',
      description: 'should accept PascalCase with custom pattern',
    },
    {
      code: '.AnotherOne { font-size: 1em; }',
      description: 'should accept another PascalCase with custom pattern',
    },
  ],

  reject: [
    {
      code: '.my-component { color: red; }',
      description: 'should reject kebab-case with PascalCase pattern',
      message: messages.expected('my-component', '^[A-Z][a-zA-Z0-9]*$'),
      line: 1,
      column: 1,
    },
    {
      code: '.myComponent { font-size: 16px; }',
      description: 'should reject camelCase with PascalCase pattern',
      message: messages.expected('myComponent', '^[A-Z][a-zA-Z0-9]*$'),
      line: 1,
      column: 1,
    },
  ],
});

示例 4: property-value-unit-allowed (限制特定属性的单位)

问题描述: 强制某些 CSS 属性只能使用特定的单位。例如,font-size 只能使用 pxrem,而不能使用 empt

规则名称: my-rules/property-value-unit-allowed

实现思路:

  1. 遍历 PostCSS AST 中的所有 Declaration 节点。
  2. 获取属性名和属性值。
  3. 根据配置,检查当前属性是否在需要限制单位的列表中。
  4. 如果需要限制,则从属性值中提取单位。
  5. 检查提取的单位是否在允许的单位列表中。
  6. 如果不符合,则报告错误。

注意: 提取单位需要一些正则表达式或字符串处理。


代码实现:

lib/rules/property-value-unit-allowed.js

// lib/rules/property-value-unit-allowed.js

const stylelint = require('stylelint');
const { report, ruleMessages, validateOptions } = stylelint.utils;

const ruleName = 'my-rules/property-value-unit-allowed';

const messages = ruleMessages(ruleName, {
  rejected: (property, unit, allowedUnits) =>
    `Unexpected unit "${unit}" for property "${property}". Allowed units are: ${allowedUnits.join(', ')}`,
});

const meta = {
  url: 'https://example.com/stylelint-plugin-my-rules/property-value-unit-allowed',
  fixable: false, // 自动修复单位需要改变值,通常不自动修复
};

// 辅助函数:从属性值中提取单位
// 匹配数字后紧跟的非空白字符,直到遇到空白或字符串结束
// 示例: "16px" -> "px", "1.5rem" -> "rem", "20%" -> "%", "auto" -> ""
function extractUnit(value) {
  const match = value.match(/(\d+.?\d*|.\d+)([a-zA-Z%]+)/);
  return match ? match[2] : '';
}

const rule = (primaryOption, secondaryOptions) => {
  // 验证规则选项
  // primaryOption 期望是一个对象,键是属性名,值是允许的单位数组
  // 例如: { 'font-size': ['px', 'rem'], 'width': ['%', 'px', 'vw'] }
  const validOptions = validateOptions(
    result,
    ruleName,
    {
      actual: primaryOption,
      possible: [
        (value) =>
          typeof value === 'object' &&
          value !== null &&
          !Array.isArray(value) &&
          Object.keys(value).every(
            (prop) => Array.isArray(value[prop]) && value[prop].every((unit) => typeof unit === 'string'),
          ),
      ],
    },
    {
      actual: secondaryOptions,
      possible: {},
      optional: true,
    },
  );

  if (!validOptions) {
    return;
  }

  return (root, result) => {
    // 遍历 AST 中的所有 "decl" (declaration) 节点
    root.walkDecls((decl) => {
      const property = decl.prop;
      const value = decl.value;

      // 检查当前属性是否在配置中需要限制单位
      if (primaryOption[property]) {
        const allowedUnits = primaryOption[property];
        const unit = extractUnit(value);

        // 如果提取到了单位,并且该单位不在允许的列表中
        if (unit && !allowedUnits.includes(unit)) {
          report({
            ruleName,
            result,
            node: decl,
            message: messages.rejected(property, unit, allowedUnits),
          });
        }
      }
    });
  };
};

rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = meta;

module.exports = rule;

测试代码:

lib/rules/property-value-unit-allowed.test.js

// lib/rules/property-value-unit-allowed.test.js

const stylelint = require('stylelint');
const rule = require('./property-value-unit-allowed');
const { testRule } = stylelint;

const ruleName = rule.ruleName;
const messages = rule.messages;

testRule({
  ruleName,
  config: {
    'font-size': ['px', 'rem'],
    'width': ['%', 'px', 'vw'],
    'line-height': [''], // 允许无单位值 (例如 '1.5')
  },

  accept: [
    {
      code: '.foo { font-size: 16px; }',
      description: 'should accept px for font-size',
    },
    {
      code: '.bar { font-size: 1.2rem; }',
      description: 'should accept rem for font-size',
    },
    {
      code: '.baz { width: 100%; }',
      description: 'should accept percentage for width',
    },
    {
      code: '.qux { width: 200px; }',
      description: 'should accept px for width',
    },
    {
      code: '.abc { width: 50vw; }',
      description: 'should accept vw for width',
    },
    {
      code: '.def { line-height: 1.5; }',
      description: 'should accept unitless value for line-height',
    },
    {
      code: '.ghi { margin: 10em; }',
      description: 'should ignore properties not in config',
    },
    {
      code: '.jkl { font-size: 0; }',
      description: 'should accept zero value without unit',
    },
    {
      code: '.mno { padding: auto; }',
      description: 'should accept keyword values without unit',
    },
  ],

  reject: [
    {
      code: '.foo { font-size: 1em; }',
      description: 'should reject em for font-size',
      message: messages.rejected('font-size', 'em', ['px', 'rem']),
      line: 1,
      column: 15,
    },
    {
      code: '.bar { font-size: 12pt; }',
      description: 'should reject pt for font-size',
      message: messages.rejected('font-size', 'pt', ['px', 'rem']),
      line: 1,
      column: 15,
    },
    {
      code: '.baz { width: 100em; }',
      description: 'should reject em for width',
      message: messages.rejected('width', 'em', ['%', 'px', 'vw']),
      line: 1,
      column: 12,
    },
    {
      code: `
        .qux {
          font-size: 1.25em; /* Invalid */
          width: 50vh; /* Invalid */
        }
      `,
      description: 'should reject multiple invalid units in multi-line CSS',
      warnings: [
        {
          message: messages.rejected('font-size', 'em', ['px', 'rem']),
          line: 3,
          column: 18,
        },
        {
          message: messages.rejected('width', 'vh', ['%', 'px', 'vw']),
          line: 4,
          column: 16,
        },
      ],
    },
    {
      code: '.abc { line-height: 16px; }',
      description: 'should reject px for line-height (only unitless allowed)',
      message: messages.rejected('line-height', 'px', ['']),
      line: 1,
      column: 18,
    },
  ],
});

4. 整合与使用自定义插件

  1. package.json 中配置脚本:
    为了方便测试,可以在 package.json 中添加一个 lint 脚本:

    {
      "name": "stylelint-plugin-my-rules",
      "version": "1.0.0",
      "description": "A collection of custom Stylelint rules.",
      "main": "index.js",
      "scripts": {
        "test": "jest",
        "lint": "stylelint "**/*.css""
      },
      "keywords": [
        "stylelint",
        "stylelint-plugin",
        "css"
      ],
      "author": "Your Name",
      "license": "MIT",
      "devDependencies": {
        "jest": "^29.7.0",
        "postcss": "^8.4.39",
        "postcss-selector-parser": "^6.1.0",
        "stylelint": "^16.7.0"
      }
    }
    

    注意: 运行测试需要安装 jest

    npm install jest --save-dev
    
  2. 配置 .stylelintrc.json:
    在你的项目根目录(或者插件项目根目录用于测试)创建 .stylelintrc.json 文件,并添加你的自定义插件和规则。

    {
      "plugins": [
        "./index.js"
        // 如果你的插件发布到 npm,这里可以是 "stylelint-plugin-my-rules"
      ],
      "rules": {
        "my-rules/no-important-in-properties": true,
        "my-rules/no-duplicate-properties-within-rule": true,
        "my-rules/selector-class-pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$",
        "my-rules/property-value-unit-allowed": {
          "font-size": ["px", "rem"],
          "width": ["%", "px", "vw"],
          "line-height": [""]
        },
        // 也可以配置 Stylelint 内置规则
        "indentation": 2,
        "color-no-invalid-hex": true
      }
    }
    

    这里,"plugins": ["./index.js"] 指向了你本地的插件入口文件。如果你将插件发布到 npm,并安装到其他项目中,那么路径会是 stylelint-plugin-my-rules (假设你的插件包名为 stylelint-plugin-my-rules)。

  3. 运行 Stylelint:
    在你的项目根目录创建一个 test.css 文件,包含一些符合和不符合你规则的代码:

    /* test.css */
    .my-valid-class {
      color: red;
      font-size: 16px;
      width: 100%;
      line-height: 1.5;
    }
    
    .InvalidClass { /* Should be rejected by selector-class-pattern */
      color: blue !important; /* Should be rejected by no-important-in-properties */
      font-size: 1.2em; /* Should be rejected by property-value-unit-allowed */
      width: 200px;
      color: green; /* Should be rejected by no-duplicate-properties-within-rule */
    }
    
    .another-valid-class {
      margin: 10px; /* This property is not covered by property-value-unit-allowed */
    }
    

    然后运行:

    npm run lint
    

    你将看到 Stylelint 报告的错误,这些错误包含了你自定义规则的报错信息。


总结

通过以上示例,我们详细了解了 Stylelint 自定义插件的开发流程:

  1. 理解 PostCSS AST: 这是所有 Stylelint 规则的基础。
  2. 定义规则结构: 包括 ruleNamemessagesmeta 和核心 rule 函数。
  3. 遍历 AST 节点: 使用 root.walkDecls()root.walkRules() 等方法。
  4. 检查节点属性: 根据业务逻辑检查 decl.propdecl.valuedecl.importantruleNode.selector 等。
  5. 报告错误: 使用 stylelint.utils.report() 函数。
  6. 编写测试: 使用 stylelint.testUtils.testRule() 确保规则的正确性。
  7. 配置和使用: 在 .stylelintrc.json 中引用插件和规则。

自定义 Stylelint 插件为前端团队提供了极大的灵活性,可以根据项目或团队的特定需求,创建高度定制化的代码规范检查,从而进一步提升代码质量和开发效率。