先看一段 CSS 代码。
css
.container {
color: black;
padding: 10px;
/* #ifdef h5 */
background-color: blue;
font-size: 16px;
/* #endif */
/* #ifdef weapp */
background-color: green;
margin: 5px;
/* #endif */
border: 1px solid #ccc;
}
/* #ifdef h5 */
.h5-only {
display: block;
color: red;
}
/* #endif */
/* #ifndef h5 */
.weapp-only {
display: flex;
color: green;
}
/* #endif */
这段 CSS 代码中包含了一些注释节点:
#ifdef h5/weapp- 如果定义h5/weapp#ifndef h5- 如果未定义h5#endif- 条件块结束
其中 h5/weapp 分别表示 H5 平台和小程序平台。
实际 Taro 已经支持了很多平台,这里只是拿这两个平台举例子。
如果不进行任何处理,上述代码在两个平台上会生成一模一样的结果,而我们期望两个平台生成不同的 CSS 代码,就像这样:
针对 H5 生成 output.h5.css:
css
// output.h5.css
.container {
color: black;
padding: 10px;
background-color: blue;
font-size: 16px;
border: 1px solid #ccc;
}
.h5-only {
display: block;
color: red;
}
针对 weapp 生成 output.weapp.css:
css
// output.weapp.css
.container {
color: black;
padding: 10px;
background-color: green;
margin: 5px;
border: 1px solid #ccc;
}
.weapp-only {
display: flex;
color: green;
}
要想实现上面的效果,我们得请出 PostCSS,大概原理如下:
- 使用 PostCSS 解析 CSS 为 AST
- 识别条件注释节点
- 根据平台移除不满足条件的样式节点
- 重新生成 CSS
下面给出详细实现步骤。
先新建两个文件 postcss-ifdef-plugin.js 和 test-plugin.js 分别用来编写这个插件和测试插件。
编写插件
首先定义整体代码结构,此插件接收一个对象参数 options,调用时可传入具体平台,比如 { platform: 'h5' }。
js
// postcss-ifdef-plugin.js
const postcss = require('postcss')
module.exports = postcss.plugin('postcss-ifdef', (options = {}) => {
// 获取当前构建的目标平台,默认值为 'h5'
const currentPlatform = options.platform || 'h5'
return (root) => {
// 核心代码,下面会讲
}
})
上述代码中的 return 语句是核心代码的实现,大概分为几个步骤:
- 定义变量
nodesToRemove存储所有待删除的节点,方便后面统一处理删除操作。
js
const nodesToRemove = []
- 定义变量
processingStack用于处理嵌套的条件块,结构是后进先出的栈(LIFO)
js
let processingStack = []
- 遍历 AST 中所有节点(深度优先),识别并标记
js
root.walk((node) => {
// 仅处理注释节点(条件编译使用注释语法)
if (node.type === 'comment') {
const commentText = node.text.trim()
// 1. 匹配条件开始标记:#ifdef 或 #ifndef
// 匹配 #ifdef h5:[ '#ifdef h5', 'h5', index: 0, input: '#ifdef h5', groups: undefined ]
// 匹配 #ifdef weapp:['#ifdef weapp', 'weapp', index: 0, input: '#ifdef weapp', groups: undefined]
const ifdefMatch = commentText.match(/^#ifdef\s+(\w+)/)
// 匹配 #ifndef h5:['#ifndef h5', 'h5', index: 0, input: '#ifndef h5', groups: undefined]
const ifndefMatch = commentText.match(/^#ifndef\s+(\w+)/)
if (ifdefMatch || ifndefMatch) {
// 解析条件类型和目标平台
const isIfndef = !!ifndefMatch // 是否为否定条件(ifndef)
const targetPlatform = isIfndef ? ifndefMatch[1] : ifdefMatch[1] // 目标平台
// 判断当前条件块是否应该保留:
// - #ifdef平台:当前平台匹配时保留,比如当前平台是 h5, ifdef 平台也是 h5 则保留
// - #ifndef平台:当前平台不匹配时保留,比如当前平台是 h5, ifndef 平台是 weapp 则保留
const shouldKeep = isIfndef
? (targetPlatform !== currentPlatform) // 否定条件:平台不同则保留
: (targetPlatform === currentPlatform) // 肯定条件:平台相同则保留
// 将条件块信息压入栈中,记录:
// - node: 开始注释节点本身
// - shouldKeep: 是否保留此条件块的内容
// - startIndex: 在父节点中的位置索引
// - parent: 父容器节点
// - endNode: 结束注释节点(暂未找到,初始为null)
processingStack.push({
node, // 条件开始注释节点
targetPlatform, // 目标平台名称
shouldKeep, // 是否保留此条件块
startIndex: node.parent.nodes.indexOf(node), // 在父节点列表中的位置
parent: node.parent, // 父容器节点
endNode: null // 将在找到匹配的 #endif 时填充
})
// 无论条件是否满足,条件注释本身都需要删除(不输出到最终 CSS)
nodesToRemove.push(node)
}
// 2. 匹配条件结束标记:#endif
if (commentText === '#endif' && processingStack.length > 0) {
// 获取栈顶的条件块(最近未匹配的 #ifdef/#ifndef)
const currentCondition = processingStack[processingStack.length - 1]
// 记录结束节点的信息
currentCondition.endNode = node // 结束注释节点
currentCondition.endIndex = node.parent.nodes.indexOf(node) // 结束位置索引
// 如果此条件块不应该保留(shouldKeep 为 false)
if (!currentCondition.shouldKeep) {
const parent = currentCondition.parent // 条件块的父容器
const startIdx = currentCondition.startIndex // 开始位置
const endIdx = currentCondition.endIndex // 结束位置
// 标记从 #ifdef/#ifndef 到 #endif 之间的所有节点
// 注意:不包含开始和结束注释本身(它们已单独标记)
for (let i = startIdx + 1; i < endIdx; i++) {
const childNode = parent.nodes[i]
// 确保节点存在且不是结束注释节点本身
if (childNode && childNode !== node) {
nodesToRemove.push(childNode)
}
}
}
// #endif 注释本身也需要删除
nodesToRemove.push(node)
// 条件块处理完成,从栈中弹出
processingStack.pop()
}
}
})
- 统一删除
js
// 遍历所有标记要删除的节点,执行删除操作
nodesToRemove.forEach(node => {
// 检查节点是否仍有父节点(避免重复删除或已删除的节点)
if (node && node.parent) {
node.remove()
}
})
这里通过举例解释下上述代码为啥要判断 node.parent。
js
// 假设 nodesToRemove 数组中包含两个节点 A 和 B
const nodesToRemove = [nodeA, nodeB]
// 情况:A 和 B 是父子关系
// nodeA 是父容器,nodeB 是子节点
// nodeB.parent === nodeA
// 当先删除 nodeA 时:
nodeA.remove() // PostCSS会同时删除 nodeA 和它的所有子节点
// 此时 nodeB 的状态:
console.log(nodeB.parent) // null(因为父节点已被移除)
console.log(nodeB.removed) // true(标记为已删除)
// 如果随后尝试删除 nodeB:
nodeB.remove() // 报错!因为 nodeB 已经没有了 parent 引用
测试插件
写完了插件,可使用 test-plugin.js 文件进行测试。
js
// test-plugin.js
const postcss = require('postcss')
const fs = require('fs')
const ifdefPlugin = require('./postcss-ifdef-plugin')
const css = fs.readFileSync('test.css', 'utf8')
// 测试 H5 平台
postcss([ifdefPlugin({ platform: 'h5' })])
.process(css, { from: undefined })
.then(result => {
console.log('=== H5 平台输出 ===')
console.log(result.css)
fs.writeFileSync('output.h5.css', result.css)
})
// 测试 微信小程序平台
postcss([ifdefPlugin({ platform: 'weapp' })])
.process(css, { from: undefined })
.then(result => {
console.log('\n=== 微信小程序平台输出 ===')
console.log(result.css)
fs.writeFileSync('output.weapp.css', result.css)
})
执行 node test-plugin,控制台输出预期结果:
bash
=== H5 平台输出 ===
.container {
color: black;
padding: 10px;
background-color: blue;
font-size: 16px;
border: 1px solid #ccc;
}
.h5-only {
display: block;
color: red;
}
=== 微信小程序平台输出 ===
.container {
color: black;
padding: 10px;
background-color: green;
margin: 5px;
border: 1px solid #ccc;
}
.weapp-only {
display: flex;
color: green;
}