UnoCSS 源码学习(1)

122 阅读3分钟

前言

什么是UnoCss?
UnoCSS 是一个轻量级、高性能的 原子化 CSS 引擎,由 Anthony Fu(Vue 和 Vite 核心团队成员)开发。它灵感来源于 Tailwind CSS 和 Windi CSS,但设计更加灵活,性能更优,旨在提供更快速的样式开发体验。

项目地址:UnoCSS
作者为 UnoCSS 写的文章:重新构想原子化CSS


我刚开始用 UnoCss 的时候,觉得挺神奇的,很有意思!
现在准备学习一下源码,之前也没有接触过 vite 插件,这次正好一起学习下

本次目标:MVP

前期准备工作

先创建一个vite+vue的项目
pnpm create vite
在根目录下创建这么一个UnocssPlugin/vite.ts

import type { Plugin } from "vite";

export default function UnocssPlugin(): Plugin {
  return {
    name: "unocss-plugin",
    enforce: "post",
    load(id) {
      console.log("11111111111id", id);
      return null;
    },
    transform(code, id) {
      console.log("22222222222id", id);
      return null;
    },
    resolveId(id) {
      console.log("33333333333id", id);
      return null;
    },
  };
}

创建了这么一个插件后,当然是要在vite.config.ts注册使用了,所以

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import UnocssPlugin from "./UnocssPlugin/vite";

// https://vite.dev/config/
export default defineConfig({
  plugins: [vue(), UnocssPlugin()],
});

如果你也没有学过 vite 插件,可以在这里运行起来并打开网页,就能在控制台看到插件的打印内容了;vite 所有要处理的代码都会经过插件

开始核心代码开发

创建以下文件 UnocssPlugin\generator\index.ts
创建生成器的函数

import { UnocssRule } from "../types";

export function createGenerator(rules: UnocssRule[]) {
  const cache = new Map<string, string | null>();
  return (code: string) => {
    // 把代码片段拆分成 token
    const tokens = new Set(code.split(/[\s'"`;]/g));
    // 生成的 CSS 代码
    const css: string[] = [];
    // 先从缓存中获取已经生成过的 token 的 CSS样式写入css数组
    // 并从待处理 token 集合中删除
    tokens.forEach((token) => {
      if (cache.has(token)) {
        const r = cache.get(token);
        if (r) css.push(r);
        tokens.delete(token);
      }
    });
    // 处理剩余的 token
    for (const [matcher, handler] of rules) {
      tokens.forEach((token) => {
        const match = token.match(matcher);
        if (match) {
          const r = handler(...Array.from(match));
          if (r) {
            css.push(r);
            cache.set(token, r);
            tokens.delete(token);
          }
        }
      });
    }
    // 把剩余的 token 都缓存为 null,表示没有匹配到任何规则
    tokens.forEach((token) => cache.set(token, null));
    return css.join("\n");
  };
}

创建UnocssPlugin\presets\index.ts
添加一个生成规则,只添加一个 padding 的样式,用于验证测试

import { UnocssRule } from "../types";
import { directionMap, e } from "../utils";
export const defaultRules: UnocssRule[] = [
  [
    /^p([trlb]?)-(\d+)$/,
    (f, d, s) => `.${e(f)} { padding${directionMap[d] || ""}: ${+s / 4}rem; }`,
  ],
];

创建UnocssPlugin\utils\maps.ts

export const directionMap: Record<string, string> = {
  'l': '-left',
  'r': '-right',
  't': '-top',
  'b': '-bottom',
  '': '',
}

创建UnocssPlugin\utils\object.ts

export function objToCss(obj: any) {
  return `{${objToCssPart(obj)}}`
}

export function objToCssPart(obj: any) {
  return Object.entries(obj)
    .map(([key, value]) => value ? `${key}: ${value};` : undefined)
    .filter(Boolean)
    .join(' ')
}

创建UnocssPlugin\utils\index.ts

export * from "./cssEscape";
export * from "./maps";
export * from "./object";

创建UnocssPlugin\types.ts

export type UnocssRule = [RegExp, (...args: string[]) => string | undefined];

创建UnocssPlugin\utils\cssEscape.ts (这个可以不看,直接跳过)
对CSS 选择器转义,避免语法错误或安全问题

// https://drafts.csswg.org/cssom/#serialize-an-identifier
/**
 * 这个函数的作用是对传入的字符串进行 CSS 选择器转义(escape),以确保字符串可以安全地作为 CSS 选择器的一部分使用。它会根据 CSSOM 规范 对特殊字符、控制字符、数字、连字符等进行转义,避免语法错误或安全问题。
 * @param str
 * @returns
 */
export function cssEscape(str: string): string {
  const length = str.length;
  let index = -1;
  let codeUnit;
  let result = "";
  const firstCodeUnit = str.charCodeAt(0);
  while (++index < length) {
    codeUnit = str.charCodeAt(index);
    // Note: there’s no need to special-case astral symbols, surrogate
    // pairs, or lone surrogates.

    // If the character is NULL (U+0000), then the REPLACEMENT CHARACTER
    // (U+FFFD).
    if (codeUnit === 0x0000) {
      result += "\uFFFD";
      continue;
    }

    // Comma
    if (codeUnit === 44) {
      result += "\\2c ";
      continue;
    }

    if (
      // If the character is in the range [\1-\1F] (U+0001 to U+001F) or is
      // U+007F, […]
      (codeUnit >= 0x0001 && codeUnit <= 0x001f) ||
      codeUnit === 0x007f ||
      // If the character is the first character and is in the range [0-9]
      // (U+0030 to U+0039), […]
      (index === 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
      // If the character is the second character and is in the range [0-9]
      // (U+0030 to U+0039) and the first character is a `-` (U+002D), […]
      (index === 1 &&
        codeUnit >= 0x0030 &&
        codeUnit <= 0x0039 &&
        firstCodeUnit === 0x002d)
    ) {
      // https://drafts.csswg.org/cssom/#escape-a-character-as-code-point
      result += `\\${codeUnit.toString(16)} `;
      continue;
    }

    if (
      // If the character is the first character and is a `-` (U+002D), and
      // there is no second character, […]
      index === 0 &&
      length === 1 &&
      codeUnit === 0x002d
    ) {
      result += `\\${str.charAt(index)}`;
      continue;
    }

    // If the character is not handled by one of the above rules and is
    // greater than or equal to U+0080, is `-` (U+002D) or `_` (U+005F), or
    // is in one of the ranges [0-9] (U+0030 to U+0039), [A-Z] (U+0041 to
    // U+005A), or [a-z] (U+0061 to U+007A), […]
    if (
      codeUnit >= 0x0080 ||
      codeUnit === 0x002d ||
      codeUnit === 0x005f ||
      (codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
      (codeUnit >= 0x0041 && codeUnit <= 0x005a) ||
      (codeUnit >= 0x0061 && codeUnit <= 0x007a)
    ) {
      // the character itself
      result += str.charAt(index);
      continue;
    }

    // Otherwise, the escaped character.
    // https://drafts.csswg.org/cssom/#escape-a-character
    result += `\\${str.charAt(index)}`;
  }
  return result;
}

export const e = cssEscape;

创建统一导出UnocssPlugin\index.ts

export * from "./types";
export * from "./generator";
export * from "./presets";
export * from "./utils";

使用

我们已经写好了 CSS 代码的生成器,那现在用一下看看
开始改写插件 UnocssPlugin\vite.ts

import type { Plugin } from "vite";
import { createGenerator, defaultRules } from ".";

const VIRTUAL_PREFIX = "/@virtual/unocss/";
function getHash(input: string, length = 8) {
  let hash = 0;
  for (let i = 0; i < input.length; i++) {
    hash = (hash << 5) - hash + input.charCodeAt(i);
    hash |= 0; // Convert to 32bit integer
  }
  return Math.abs(hash).toString(36).slice(0, length);
}
export default function UnocssPlugin(): Plugin {
  const generate = createGenerator(defaultRules);
  const map = new Map<string, [string, string]>();

  return {
    name: "unocss-plugin",
    enforce: "post",
    load(id) {
      if (!id.startsWith(VIRTUAL_PREFIX)) return null;
      const hash = id.slice(VIRTUAL_PREFIX.length, -".css".length);
      const [source, css] = map.get(hash) || [];
      if (source) this.addWatchFile(source);
      return css;
    },
    transform(code, id) {
      // 不处理css文件
      if (id.endsWith(".css")) return null;
      const style = generate(code);
      // 不处理没有生成样式的文件
      if (!style) return null;
      // 对id进行hash,生成虚拟模块id
      const hash = getHash(id);
      // 将虚拟模块id和原始文件id、生成的样式映射起来
      map.set(hash, [id, style]);
      const virtualId = `${VIRTUAL_PREFIX}${hash}.css`;
      // 生成虚拟模块的导入语句,并将其插入到代码的最前面
      return `import "${virtualId}";${code}`;
    },
    resolveId(id) {
      // 处理虚拟模块的请求
      return id.startsWith(VIRTUAL_PREFIX) ? id : null;
    },
  };
}

最后简易改造下 App.vue

<script setup lang="ts"></script>
<template>
  <div class="p-10">内容</div>
</template>
<style scoped></style>

运行起来结果如下 image.png


完成MVP
学到的

  • 可以多用 Map, Set,优化效率
  • 正则的使用
  • vite 插件开发入门

通过这个简易实现,深入理解了 UnoCSS 的基本原理和 Vite 插件的工作机制。