引言
很多公司都有针对现有组件库进行样式定制的需求,最近作者又遇到了这个需求。
在方案上采用的是 scss 变量 + 样式覆盖来实现,在快速预览上采用的是在 element-plus 文档上实时查看,文档上没有的效果再自己写案例。
需求
- 下载
element-plus
文档的静态资源 - 在现有的
vite
样式修改项目中,劫持特定的资源请求,并映射到element-plus
文档的资源并进行资源返回 - 针对
.html
文件做处理,注入vite
热更新相关代码,达到实时预览的效果 - 针对
.css
文件做处理,去除所有关于element-plus
的样式与css
变量 - 在控制台输出
element-plus
文档的访问入口
实现
1. 下载 element-plus 文档的静态资源
element-plus
文档的静态资源在其 GitHub
仓库的 gh-pages 分支上。
我们将其下载下来并放到项目的 pages
目录中,并将 pages
目录添加高 .gitignore
中。
2. 劫持 vite 的资源请求
原理是使用 vite
自定义插件的 configureServer
钩子,注册一个 server
的中间件,进行请求拦截。
以下是官方的文档:
具体的实现细节还需要分两步。第一步:获取 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. 在控制台输出访问入口
其原理是重写 server
的 printUrls
函数
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 样式,热更新的响应有点儿慢,需要针对性的进行优化