模拟 Taro 实现编译多端样式文件

先看一段 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,大概原理如下:

  1. 使用 PostCSS 解析 CSS 为 AST
  2. 识别条件注释节点
  3. 根据平台移除不满足条件的样式节点
  4. 重新生成 CSS

下面给出详细实现步骤。

先新建两个文件 postcss-ifdef-plugin.jstest-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 语句是核心代码的实现,大概分为几个步骤:

  1. 定义变量 nodesToRemove 存储所有待删除的节点,方便后面统一处理删除操作。
js 复制代码
const nodesToRemove = []
  1. 定义变量 processingStack 用于处理嵌套的条件块,结构是后进先出的栈(LIFO)
js 复制代码
let processingStack = []
  1. 遍历 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()
        }
    }
})
  1. 统一删除
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;
}
相关推荐
用户12039112947267 小时前
使用 Tailwind CSS 构建现代登录页面:从 Vite 配置到 React 交互细节
前端·javascript·react.js
阿珊和她的猫8 小时前
React Hooks:革新组件开发的优势与实践
前端·react.js·状态模式
全栈技术负责人8 小时前
AI时代前端工程师的转型之路
前端·人工智能
花归去8 小时前
echarts 柱状图曲线图
开发语言·前端·javascript
喝拿铁写前端8 小时前
当 AI 会写代码之后,我们应该怎么“管”它?
前端·人工智能
老前端的功夫8 小时前
TypeScript 类型魔术:模板字面量类型的深层解密与工程实践
前端·javascript·ubuntu·架构·typescript·前端框架
Nan_Shu_6148 小时前
学习: Threejs (2)
前端·javascript·学习
G_G#8 小时前
纯前端js插件实现同一浏览器控制只允许打开一个标签,处理session变更问题
前端·javascript·浏览器标签页通信·只允许一个标签页
@大迁世界9 小时前
TypeScript 的本质并非类型,而是信任
开发语言·前端·javascript·typescript·ecmascript