??|| 的区别,不只是两个字符那么简单

背景

我们的项目是一个基于 uni-app 的微信小程序,使用 GitLab CI/CD 自动构建并发布体验版。某天测试反馈:表单中明明设置了 required: false 的选填字段,在体验版上仍然显示必填星号,且提交时会触发必填校验。

更诡异的是——本地开发工具运行完全正常,只有 CI 自动构建后的版本才有问题。

排查过程

第一反应:代码没生效?

首先确认了 dev 分支上的代码确实已经把相关字段改成了 required: false

js

const formFields = computed(() => [
  { label: '用户名', prop: 'username', type: 'input', required: true },
  { label: '昵称', prop: 'nickname', type: 'input', required: false },
  { label: '邮箱', prop: 'email', type: 'input', required: false },
  { label: '手机号', prop: 'phone', type: 'input', required: false },
]);

代码没问题,commit 也在 dev 分支上,排除了"没合进去"的可能。

第二反应:CI 缓存?

清除了 GitLab CI 缓存,手动触发重新构建,问题依旧。排除缓存因素。

深挖:CI 构建和本地构建到底差在哪?

仔细审查 CI 部署脚本 build-deploy.js,发现了一个"优化"步骤——postProcessJS,它会在构建完成后对所有 JS 文件做一轮处理:

js

async function postProcessJS(projectPath) {
  // ...遍历所有 JS 文件
  for (const filePath of jsFiles) {
    let content = fs.readFileSync(filePath, 'utf-8');

    // 修复空值合并运算符 - 旧版微信不支持
    content = content.replace(/\?\?/g, '||');

    // ...其他处理
    fs.writeFileSync(filePath, content, 'utf-8');
  }
}

就是这行:

js

content = content.replace(/\?\?/g, '||');

它把构建产物中所有的 ??(空值合并运算符)全局替换成了 ||(逻辑或运算符)。

问题根因

??|| 看起来相似,但语义完全不同:

| 表达式 | ??(空值合并) | ||(逻辑或) |
| ------------------------ | ---------------- | --------------- |
| null ?? 'default' | 'default' | 'default' |
| undefined ?? 'default' | 'default' | 'default' |
| false ?? 'default' | false | 'default' |
| 0 ?? 'default' | 0 | 'default' |
| '' ?? 'default' | '' | 'default' |

核心区别:

  • ?? 只在值为 nullundefined 时取右侧值
  • || 在所有 falsy 值(false0''nullundefinedNaN)时都取右侧值

我们的表单组件中,判断字段是否必填的逻辑是:

js

function isRequired(field) {
  return field.hidden === true ? false : field.required ?? defaultRequired;
}

其中 defaultRequired = true(组件默认所有字段必填)。

当我们显式设置 required: false 时:

text

// 源码(正确)
false ?? true  →  false  ✅  识别为选填

// CI 构建后(错误)
false || true  →  true   ❌  变成了必填!

false 是一个合法的、有意义的值,?? 会保留它,但 || 会把它当作"空"值而丢弃掉。

影响范围

这个替换不仅影响了 required 字段,还波及了所有使用 ?? 的场景:

js

// 表单验证逻辑
if (!field.required || field.hidden || ...) { ... }

// 占位符生成
field.required ?? defaultRequired ? '' : '(选填)'

// 其他组件中的默认值处理
const value = config.enabled ?? true;  // 被替换后,enabled=false 时会变成 true

任何用 ?? 来处理 false0'' 等 falsy 默认值的代码,在 CI 构建后行为都会发生变化。

修复方案

删除这行全局替换:

diff

- // 修复空值合并运算符 - 旧版微信不支持
- content = content.replace(/\?\?/g, '||');

现代微信基础库(2.14.0+)已原生支持 ?? 语法。如果确实需要兼容更老的基础库版本,应该使用 Babel 插件 @babel/plugin-proposal-nullish-coalescing-operator 来做正确的语法降级,而不是简单的字符串替换。

正确的 Babel 配置示例

js

// babel.config.js
module.exports = {
  plugins: [
    '@babel/plugin-proposal-nullish-coalescing-operator',
    '@babel/plugin-proposal-optional-chaining'
  ]
};

Babel 会将:

js

a ?? b

正确转换为:

js

a !== null && a !== void 0 ? a : b

教训总结

1. 字符串替换不是语法转换

正则替换是"文本操作",它不理解代码语义。??|| 的区别不在于字符,而在于对 falsy 值的处理。正确的做法是使用 AST 级别的工具(如 Babel)进行转换,它能理解上下文并生成等价代码。

2. CI 和本地环境差异是最难排查的 Bug 类型

当"本地正常、线上不行"时,重点排查 CI 流程中构建之外的额外步骤:后处理脚本、代码压缩、文件替换等。建议在 CI 流程中增加产物对比步骤,自动检测异常变更。

3. "兼容性修复"可能引入更大的问题

这行代码的初衷是解决旧版微信不支持 ?? 的兼容性问题,但简单粗暴的替换引入了逻辑错误。兼容性处理应该交给专业的编译工具链,而不是自己动手做字符串替换。

4. 关注 falsy 值的语义

在 JavaScript 中,false0'' 都是合法的业务值。选择 ?? 还是 || 不是风格偏好,而是语义选择。代码审查时遇到 ??,要意识到作者是刻意区分"空值"和"falsy 值"的。

5. 建立 CI 脚本的代码审查机制

这类"后处理脚本"往往被认为是"临时方案"或"小优化",容易逃过严格的代码审查。建议:

  • 所有 CI 脚本变更需要 Senior 审批
  • 禁止在 CI 中使用正则替换修改代码逻辑
  • 建立 CI 脚本的单元测试

附:快速自查

如果你的项目中也有类似的后处理脚本,可以快速检查:

bash

# 搜索是否存在 ?? 到 || 的替换
grep -rn '\?\?' ci-scripts/ | grep replace

# 搜索是否存在其他危险的正则替换
grep -rn '\.replace(' ci-scripts/ | grep -E '(\?\?|\&\&|\|\|)'

如果有,尽快移除并改用 Babel 等工具来处理语法兼容性。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐