专业全栈开发服务 | 前端 + 后端 + 移动端 + 小程序等 | 高质量项目定制开发

Weber博客

分享知识,记录思考

用 PostCSS 打造智能 Google Fonts 插件

2025年9月29日
前端
阅读12分钟
在前端开发中,使用漂亮的字体可以极大地提升用户体验。Google Fonts 提供了海量的免费字体,但管理它们却可能成为一件麻烦事:
  1. 手动引入:每个新项目或新字体,你都得去 Google Fonts 官网生成 URL,再复制到代码中。
  2. 字重管理:不同字体支持的字重(font-weight)各不相同,手动管理容易出错。
  3. 代码冗余:项目中可能存在不再使用的 `@import` 规则,占用带宽。
有没有一种方法可以实现“按需自动引入”字体呢?答案是肯定的!本文将带你了解如何利用 PostCSS 打造一个智能插件,彻底告别手动管理 Google Fonts 的烦恼。

我们的目标

我们希望实现一个自动化流程:
  1. 自动检测:插件能自动扫描我们 CSS 代码中使用了哪些字体。
  2. 智能配置:我们可以为每个字体配置其在 Google Fonts 上的名称和支持的字重范围。
  3. 按需导入:插件根据检测到的字体,自动在 CSS 文件顶部生成对应的 `@import` URL。
  4. 自动清理:移除代码中已经不再使用的旧字体导入。

PostCSS 工具

在我们开始之前,先简单认识一下 PostCSS。
你可以把 PostCSS 理解为一个“CSS 的加工厂”。它本身不做任何事,但它提供了一个强大的插件系统,让我们可以用 JavaScript 来分析、转换和优化 CSS 代码。
我们所有的魔法都将在 `postcss.config.js` 这个配置文件中发生。
 

最终的配置概览

在我们的项目中,`postcss.config.js` 的核心配置如下
// postcss.config.js
module.exports = {
  plugins: [
    // 1. 我们的自定义字体插件,最先运行
    require('./path/to/autoFontImport')({ /* ...配置... */ }),
    
    // 2. Tailwind CSS 处理器
    require('tailwindcss'),
    
    // 3. 自动添加浏览器前缀
    require('autoprefixer'),
  ],
};
这个配置清晰地展示了处理流程:首先运行我们的字体插件,然后处理 Tailwind CSS,最后由 Autoprefixer 完成兼容性工作。

深入核心:`autoFontImport` 插件

现在,让我们揭开 `autoFontImport` 插件的神秘面纱。

1. 插件的配置

我们为插件设计了非常直观的配置:
// 在 postcss.config.js 中调用插件
autoFontImport({
  // 字体名称映射:将 CSS 中的 font-family 映射到 Google Fonts 的 URL 名称
  fontMappings: {
    'Poppins': 'Poppins',
    'Noto Serif HK': 'Noto+Serif+HK', // 注意空格要换成 '+'
  },
  // 字体字重配置:为每个字体指定可用字重
  fontWeights: {
    'Poppins': '100..900', // 支持 100 到 900 的所有字重
    'Lato': '300;700',   // 只支持 300 和 700 两个字重
    'Righteous': '400',     // 只支持 400 字重
  },
  // 默认字重:如果某个字体没有在上面配置,就使用这个默认值
  defaultWeight: '400',
})

2. 插件实现三步走(简化版)

一个 PostCSS 插件本质上是一个返回特定对象的 JavaScript 函数。
第一步:插件的基本结构
// autoFontImport.js
const autoFontImport = (options = {}) => {
  return {
    // 必须:定义插件名称
    postcssPlugin: 'auto-font-import',
    
    // 核心逻辑:这个函数在处理每个 CSS 文件时会执行一次
    Once(root) {
      // `root` 代表整个 CSS 文件的内容
      // 我们将在这里施展魔法
    }
  };
};

// 必须:告诉 PostCSS 这是一个合法的插件
autoFontImport.postcss = true;

module.exports = autoFontImport;
第二步:扫描 CSS,找到使用的字体
我们在 `Once` 函数中遍历所有 CSS 规则,找到 `font-family` 声明。
// ... 在 Once(root) 函数内部 ...
const usedFonts = new Set(); // 使用 Set 避免重复

// 遍历所有 CSS 声明
root.walkDecls('font-family', (decl) => {
  // decl.value 是字体名称,如 "Poppins", sans-serif
  // 我们需要解析并提取主字体
  const mainFont = decl.value.split(',')[0].trim().replace(/['"]/g, '');
  usedFonts.add(mainFont);
});

// 现在 usedFonts 里就有了:{'Poppins', 'Roboto', ...}
第三步:生成并插入 `@import` 语句
有了字体列表,我们就可以根据插件的配置生成 Google Fonts URL,并将其插入到 CSS 文件的顶部。
// ... 在 Once(root) 函数内部,扫描之后 ...

usedFonts.forEach((fontName) => {
  // 1. 从配置中查找字体信息
  const googleFontName = options.fontMappings[fontName];
  const fontWeight = options.fontWeights[fontName] || options.defaultWeight;

  if (!googleFontName) return; // 如果没在配置里,就跳过

  // 2. 构建 Google Fonts URL
  const url = `https://fonts.googleapis.com/css2?family=${googleFontName}:wght@${fontWeight}&display=swap`;

  // 3. 创建一个新的 @import 规则
  const newImportRule = postcss.atRule({
    name: 'import',
    params: `url('${url}')`,
  });

  // 4. 将新规则插入到 CSS 文件顶部
  root.prepend(newImportRule);
});

实现效果

现在,当你在 CSS 中这样写:
/* 你的 CSS 代码 */
.title {
  font-family: 'Poppins', sans-serif;
  font-weight: 700;
}

.content {
  font-family: 'Noto Serif HK', serif;
}
经过 PostCSS 处理后,你的 CSS 文件会自动变成:
/* PostCSS 自动生成的内容 */
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@100..900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+HK:wght@200..900&display=swap');

/* 你原本的 CSS 代码 */
.title {
  font-family: 'Poppins', sans-serif;
  font-weight: 700;
}

.content {
  font-family: 'Noto Serif HK', serif;
}
是不是非常智能和方便?
最后附上我配置好的 postcss.config.js 代码:
// 自定义插件:自动字体导入
const autoFontImport = (options = {}) => {
  // 默认配置
  const config = {
    // 字体映射:CSS中的字体名 -> Google Fonts URL中的字体名
    fontMappings: {
      // 'WDXL Lubrifont SC': 'WDXL+Lubrifont+SC',
      // 'WDXL Lubrifont TC': 'WDXL+Lubrifont+TC',
      // 可以添加更多字体映射
    },
    // 默认字体权重
    defaultWeight: '400',
    // 是否启用调试日志
    debug: false,
    ...options
  };

  return {
    postcssPlugin: 'auto-font-import',
    Once(root) {
      const usedFonts = new Set();
      const existingImports = new Set();
      
      // 收集已存在的 @import 语句
      root.walkAtRules('import', (rule) => {
        Object.entries(config.fontMappings).forEach(([cssName, urlName]) => {
          if (rule.params.includes(urlName)) {
            existingImports.add(cssName);
            if (config.debug) {
              console.log(`✓ 发现已存在的字体导入: ${cssName}`);
            }
          }
        });
      });
      
      // 扫描所有 font-family 声明,收集使用的字体
      root.walkDecls('font-family', (decl) => {
        Object.keys(config.fontMappings).forEach(fontName => {
          if (decl.value.includes(fontName)) {
            usedFonts.add(fontName);
            if (config.debug) {
              console.log(`✓ 发现使用的字体: ${fontName} 在 ${decl.parent.selector || 'unknown'}`);
            }
          }
        });
      });

      // 为使用但未导入的字体添加 @import 语句
      usedFonts.forEach(fontName => {
        if (!existingImports.has(fontName)) {
          const urlName = config.fontMappings[fontName];
          if (urlName) {
            // 获取字体的字重配置,优先使用fontWeights中的配置,否则使用defaultWeight
            const fontWeight = config.fontWeights && config.fontWeights[fontName] 
              ? config.fontWeights[fontName] 
              : config.defaultWeight;
            
            const importUrl = `url('https://fonts.googleapis.com/css2?family=${urlName}:wght@${fontWeight}&display=swap')`;
            const importRule = require('postcss').atRule({
              name: 'import',
              params: importUrl
            });
            
            // 将 @import 添加到文件开头
            root.prepend(importRule);
            
            if (config.debug) {
              console.log(`✓ 自动添加字体导入: ${fontName} -> ${importUrl} (字重: ${fontWeight})`);
            }
          }
        }
      });

      // 移除未使用字体的 @import 语句
      root.walkAtRules('import', (rule) => {
        let shouldRemove = false;
        
        Object.entries(config.fontMappings).forEach(([cssName, urlName]) => {
          if (rule.params.includes(urlName) && !usedFonts.has(cssName)) {
            shouldRemove = true;
            if (config.debug) {
              console.log(`✗ 移除未使用的字体导入: ${cssName}`);
            }
          }
        });

        if (shouldRemove) {
          rule.remove();
        }
      });

      if (config.debug) {
        console.log('自动字体导入完成');
        console.log('使用的字体:', Array.from(usedFonts));
        console.log('已存在的导入:', Array.from(existingImports));
      }
    }
  };
};
autoFontImport.postcss = true;

console.log('postcss.config.js 加载成功');

module.exports = {
  plugins: [
    // postcss-import 用于处理条件性的 @import 语句
    // require("postcss-import")({
    //   // 可以在这里添加过滤器来控制哪些 @import 被处理
    // }),
    
    // 自动字体导入插件
    autoFontImport({
      debug: true, // 开启调试日志以便验证功能
      fontMappings: {
        // 基础英文字体
        'Poppins': 'Poppins',
        'Roboto': 'Roboto',
        'Inter': 'Inter',
        'Montserrat': 'Montserrat',
        'Open Sans': 'Open+Sans',
        'Parkinsans': 'Parkinsans',
        'Righteous': 'Righteous',
        'Work Sans': 'Work+Sans',
        'Noto Sans': 'Noto+Sans',
        'Noto Sans Mono': 'Noto+Sans+Mono',
        'Lato': 'Lato',
        'Sniglet': 'Sniglet',
        'Poetsen One': 'Poetsen+One',
        'Dancing Script': 'Dancing+Script',
        'Monsieur La Doulaise': 'Monsieur+La+Doulaise',
        'DM Serif Text': 'DM+Serif+Text',
        'Merriweather': 'Merriweather',
        'Ubuntu': 'Ubuntu',
        'Lobster': 'Lobster',
        'Oswald': 'Oswald',
        'Raleway': 'Raleway',
        'Funnel Display': 'Funnel+Display',
        'Poiret One': 'Poiret+One',
        'Oooh Baby': 'Oooh+Baby',
        'Google Sans Code': 'Google+Sans+Code',
        'Special Gothic Expanded One': 'Special+Gothic+Expanded+One',
        
        // 中文字体
        'Noto Sans HK': 'Noto+Sans+HK',
        'Noto Serif HK': 'Noto+Serif+HK',
        'Chiron GoRound TC': 'Chiron+GoRound+TC',
        'LXGW WenKai TC': 'LXGW+WenKai+TC',
        'Cactus Classical Serif': 'Cactus+Classical+Serif',
        'WDXL Lubrifont TC': 'WDXL+Lubrifont+TC',
        'WDXL Lubrifont SC': 'WDXL+Lubrifont+SC',
        'ZCOOL XiaoWei': 'ZCOOL+XiaoWei',
        'ZCOOL KuaiLe': 'ZCOOL+KuaiLe',
        'Noto Serif SC': 'Noto+Serif+SC',
      },
      fontWeights: {
        // 基础英文字体字重配置
        'Poppins': '100..900',
        'Roboto': '100..900',
        'Inter': '100..900',
        'Montserrat': '100..900',
        'Open Sans': '100..900',
        'Parkinsans': '300..800',
        'Righteous': '400',
        'Work Sans': '100..900',
        'Noto Sans': '100..900',
        'Noto Sans Mono': '100..900',
        'Lato': '100;300;400;700;900',
        'Sniglet': '400;800',
        'Poetsen One': '400',
        'Dancing Script': '400..900',
        'Monsieur La Doulaise': '400',
        'DM Serif Text': '400',
        'Merriweather': '300..900',
        'Ubuntu': '300;400;500;700',
        'Lobster': '400',
        'Oswald': '200..700',
        'Raleway': '100..900',
        'Funnel Display': '300..800',
        'Poiret One': '400',
        'Oooh Baby': '400',
        'Google Sans Code': '300..800',
        'Special Gothic Expanded One': '400',
        
        // 中文字体字重配置
        'Noto Sans HK': '100..900',
        'Noto Serif HK': '200..900',
        'Chiron GoRound TC': '200..900',
        'LXGW WenKai TC': '400',
        'Cactus Classical Serif': '400',
        'WDXL Lubrifont TC': '400',
        'WDXL Lubrifont SC': '400',
        'ZCOOL XiaoWei': '400',
        'ZCOOL KuaiLe': '400',
        'Noto Serif SC': '200..900',
      },
      defaultWeight: '400' // 未指定字体的默认字重
    }),
    
    // Tailwind CSS
    require("tailwindcss"),
    
    // Autoprefixer
    require("autoprefixer"),
    
  ],
};

总结

通过编写一个简单的 PostCSS 插件,我们成功地将繁琐的字体管理工作自动化了。这不仅提升了开发效率,也让我们的项目代码更加干净、性能更佳。
PostCSS 的能力远不止于此,它打开了用现代工程化思维改造 CSS 的大门。希望这篇文章能启发你,去探索更多可能性,让你的开发工作流更加顺畅!

订阅博客更新

喜欢这篇文章吗?订阅我们的博客,第一时间获取最新文章和技术分享

我们承诺不会发送垃圾邮件,您可以随时取消订阅

文章评论

发表评论

全部评论 (0)

加载评论中...