element-plus 样式定制,写个 vite plugin 快速预览效果

151 阅读3分钟

引言

很多公司都有针对现有组件库进行样式定制的需求,最近作者又遇到了这个需求。

在方案上采用的是 scss 变量 + 样式覆盖来实现,在快速预览上采用的是在 element-plus 文档上实时查看,文档上没有的效果再自己写案例。

需求

  1. 下载 element-plus 文档的静态资源
  2. 在现有的 vite 样式修改项目中,劫持特定的资源请求,并映射到 element-plus 文档的资源并进行资源返回
  3. 针对 .html 文件做处理,注入 vite 热更新相关代码,达到实时预览的效果
  4. 针对 .css 文件做处理,去除所有关于 element-plus 的样式与 css 变量
  5. 在控制台输出 element-plus 文档的访问入口

实现

1. 下载 element-plus 文档的静态资源

element-plus 文档的静态资源在其 GitHub 仓库的 gh-pages 分支上。

我们将其下载下来并放到项目的 pages 目录中,并将 pages 目录添加高 .gitignore 中。

image.png

2. 劫持 vite 的资源请求

原理是使用 vite 自定义插件的 configureServer 钩子,注册一个 server 的中间件,进行请求拦截。

以下是官方的文档:

image.png

具体的实现细节还需要分两步。第一步:获取 pages 目录的文件资源路径集合并生成资源请求路;第二步:注册中间件。

2.1 资源初始化

import { normalizePath, type PluginOption } from "vite"
import { join, resolve } from "node:path"
import fastGlob from "fast-glob"

export default function vitePagesProxy(): PluginOption {
  const baseDir = resolve('./pages')
  
  let pages: string[] = []

  return {
    name: "pages-proxy",
    enforce: "pre",
    apply: "serve",
    config() {
      fastGlob.glob("**", { cwd: baseDir, onlyFiles: true, stats: false }).then((files) => {
        pages = files.map((file) => normalizePath(join("/", file)));
      })
    }
  };
}

2.2 注册中间件

整体逻辑框架

import { normalizePath, type PluginOption } from "vite"
import { join, resolve } from "node:path"
import fastGlob from "fast-glob"

export default function vitePagesProxy(): PluginOption { 
  /**
   * 校验通过的请求路径与其在 pages 目录下的资源路径的映射
   */
  const includeMap: Record<string, string> = {};
  
  /**
   * 检验不通过的路径,下次请求直接跳过
   * 默认排查掉 / 路径,后续在对请求路径判断时会对路径加上 'index.html' 或 '.html',避免拦截错误
   */
  const excludes = new Set<string>(['/']);

  return {
    name: "pages-proxy",
    enforce: "pre",
    apply: "serve",
    configureServer(server) {
      server.middlewares.use((req, res, next) => {
        if (req.method === "GET") {
          const url = req.url || "";

          if (!excludes.has(url)) {
            if (includeMap[url]) {
              // 从 pages 目录加载资源并结束本次请求
              loadSource(url, res);
              return;
            }
            // exist: 请求路径是否在 pages 目录中存在
            // target: exist 为 true 时,url 在 pages 目录中的资源路径
            const { target, exist } = normalizeUrl(url);
            if (exist) {
              includeMap[url] = target;
              // 从 pages 目录加载资源并结束本次请求
              loadSource(target, res);
              return;
            }
            excludes.add(url);
          }
        }
        next();
      });
    }
  };
}

normalizeUrl 实现

function normalizeUrl(url: string) {
  const urls = [normalizePath(url), normalizePath(url + ".html"), normalizePath(join(url, "/index.html"))];
  for (let i = 0; i < urls.length; i++) {
    const exist = pages.includes(urls[i]);
    if (exist) {
      return { exist, target: urls[i] };
    }
  }
  return { target: url, exist: false };
}

loadSource 实现

const headOccupyPosition = "<!-- Head Occupy position -->";
const bodyOccupyPosition = "<!-- Body Occupy position -->";
const entryOccupyPosition = "<!-- Entry Occupy position -->";

export default function vitePagesProxy(): PluginOption {

  const baseDir = resolve('./pages');

  function normalizeType(url: string) {
    const postfix = url.split(".").pop();
    switch (postfix) {
      case "html":
        return { contentType: "text/html", isHtml: true };
      case "css":
        return { contentType: "text/css", isCss: true };
      case "js":
        return { contentType: "text/javascript", isJs: true };
      case "png":
        return { contentType: "image/png", isImage: true };
      case "svg":
        return { contentType: "image/svg+xml", isImage: true };
      default:
        return { contentType: "text/plain" };
    }
  }

  async function loadSource(url: string, res: ServerResponse<IncomingMessage>) {
    try {
      const { contentType, isHtml, isCss, isImage, isJs } = normalizeType(url);

      res.setHeader("Access-Control-Allow-Origin", "*");
      res.setHeader("Content-Type", contentType);

      const filePath = join(baseDir, url);

      if (isImage || isJs) {
        const file = await readFile(filePath);
        // 结束本次请求并返回资源
        res.end(file);
        return;
      }

      let text = await readFile(filePath, "utf-8");
      if (isHtml) {
        // 注入 vite 热更新代码等
        text = normalizeHtml(text, options);
      } else if (isCss) {
        // 去除 element-plus 文档中所有关于 element-plus 的样式
        text = await normalizeCss(text);
      }

      // 结束本次请求并返回资源
      res.end(text);
    } catch (error) {
      res.end((error as Error).message || String(error));
    }
  }
}

3. 注入 vite 热更新相关代码

function normalizeHtml(html: string) {
  return lhtml.replace(
      '</body>',
      `<script type="module" src="/@vite/client">
        </script><script type="module" src="/src/style.js?t=${Date.now()}"></script>`
    )
}

src/style.js

import "./custom-elplus.scss"

4. 去除样式与 css 变量

其原理是使用 postcss 的插件机制,在自定义插件中移除不需要的 css 代码

import type { AcceptedPlugin, Rule } from "postcss";
import postcss from "postcss"

async function normalizeCss(css: string) {
  const result = await postcss([postcssRemoveEl()]).process(css)
  return result.css
}

// 自定义 postcss 插件,移除 css 中指定的代码
function postcssRemoveEl(): AcceptedPlugin {
  function isElRule({ selector }: Rule) {
    return selector.includes(".el-");
  }

  function isGlobalElRule(rule: Rule) {
    return isElRule(rule) && !rule.selector.includes("[data-v-");
  }

  return {
    postcssPlugin: "postcss-el",
    Rule(rule) {
      if (isGlobalElRule(rule)) {
        // 移除 element-plus 样式
        rule.remove();
      }
    },
    Declaration(decl) {
      const { prop, parent } = decl
      // 移除 css 变量
      if (prop.includes("--el-") && parent?.type === "rule" && !isElRule(parent as unknown as Rule)) {
        decl.remove()
      }
    },
  };
}

5. 在控制台输出访问入口

其原理是重写 serverprintUrls 函数

import colors from "picocolors"

export default function vitePagesProxy(): PluginOption {
  return {
    name: "pages-proxy",
    enforce: "pre",
    apply: "serve",
    configureServer(server) {
      const _printUrls = server.printUrls;

      server.printUrls = () => {
        _printUrls();

        const urls = server.resolvedUrls!;
        const colorUrl = (url: string) => {
          url = url.replace(/:(\d+)\//, (_, port) => `:${colors.bold(port)}/`);
          return url.endsWith("/") ? url + 'index.html' : url + '/index.html';
        };
        for (const url of urls.local) {
          console.log(`  ${colors.green("➜")}  ${colors.bold("Local Pages Entry")}:   ${colors.cyan(colorUrl(url))}`);
        }
        for (const url of urls.network) {
          console.log(`  ${colors.green("➜")}  ${colors.bold("Network Pages Entry")}: ${colors.cyan(colorUrl(url))}`);
        }
      };

      server.middlewares.use((req, res, next) => {
        // ......
      });
    },
  };
}

预览

在热更新这一块儿,由于导入了全量的 scss 样式,热更新的响应有点儿慢,需要针对性的进行优化

PixPin_2025-05-18_11-32-48.gif