用现代语法,跑在老浏览器上:ES6模块化 + Babel 的实战落地之道

你有没有遇到过这样的场景?

刚写完一段优雅的 import { useStore } from './store' ,信心满满地打开 IE11 测试——结果控制台直接报错: “SyntaxError: ‘import’ statement not supported”
那一刻,理想与现实的落差,比代码还冰冷。

这正是我们今天要解决的问题: 如何让使用 ES6 模块编写的现代化代码,安全、高效地运行在那些“还不支持 ES6”的老旧环境中?

答案是: Babel + 构建工具链 。但这不是一句口号,而是一套完整的工程实践。接下来,我会带你从问题出发,一步步拆解这套方案的核心机制、配置细节和落地技巧,让你不仅能“跑起来”,还能“跑得稳”。


为什么 ES6 模块这么香,却又不能直接用?

ES6(ECMAScript 2015)的模块系统是 JavaScript 发展史上的一个分水岭。它带来了真正意义上的 静态模块化 ,彻底改变了我们组织代码的方式。

它到底解决了什么痛点?

想象一下没有模块化的时代:
- 所有变量挂在 window 上 → 命名冲突频发
- 脚本加载顺序决定一切 → 一改就崩
- 无法追踪依赖关系 → “这个函数在哪定义的?” 成为日常灵魂拷问

而 ES6 模块用两个关键字终结了这一切:

// utils.js
export const formatPrice = (price) => `¥${price.toFixed(2)}`
export default function log(msg) {
  console.log(`[App] ${msg}`)
}

// main.js
import log, { formatPrice } from './utils.js'

log('启动成功')
console.log(formatPrice(99.9)) // ¥99.90

就这么简单?但背后的力量远不止于此。

真正的价值,在于“静态性”

特性 说明
✅ 编译时确定依赖 工具可以在打包前分析出整个依赖图,而不是等到运行时才去 require()
✅ 支持 Tree Shaking 未引用的导出可以被安全移除,最终包体积更小
✅ 单例共享 多次 import 同一个模块,拿到的是同一个实例,避免重复初始化
✅ 动态绑定 导入的是对原始值的“只读绑定”,源模块更新后,导入方也能感知(仅限非 const 变量)

📌 小知识:CommonJS 是动态的, require() 可以写在 if 语句里;而 import 必须写在顶层,不能动态拼接路径(除非用 import() 函数)。

可惜,现实很骨感

尽管现代浏览器基本都支持 <script type="module"> ,但以下情况依然常见:
- 企业内部系统需兼容 IE9/IE11
- 某些安卓低版本 WebView 不支持 ESM
- Node.js 在早期版本也不原生支持 .mjs

所以,我们不得不面对一个问题: 能不能一边享受 ES6 的开发体验,一边输出兼容性良好的代码?

能,而且已经有成熟方案了。


Babel:把未来语法翻译成现在的语言

如果说 ES6 是“新普通话”,那 Babel 就是那个精通古今的翻译官。

它的核心任务就是: 将高版本 JavaScript 转换为低版本语法 ,同时尽可能保留原有语义。

它是怎么做到的?三步走

  1. 解析(Parse)
    把你的 JS 代码转换成 AST(抽象语法树)。比如 const a = () => {} 会被解析成一个包含 VariableDeclaration ArrowFunctionExpression 的树结构。

  2. 转换(Transform)
    遍历 AST,应用各种插件规则。例如:
    - @babel/plugin-transform-arrow-functions → 把箭头函数转为普通函数
    - @babel/plugin-transform-block-scoping → 把 const / let 替换为 var
    - @babel/plugin-transform-modules-commonjs → 把 import/export 转为 require/module.exports

  3. 生成(Generate)
    把修改后的 AST 输出为字符串形式的目标代码,并可选生成 source map,方便调试。

💡 举个例子:
js import { add } from './math' export const result = add(1, 2)
经过 Babel 处理后可能变成:
js "use strict"; var _math = require("./math"); var result = (0, _math.add)(1, 2); exports.result = result;

看到没? import 没了,变成了 require —— 这就是 Babel 的魔法。


如何配置 Babel 让模块化顺利工作?

光知道原理不够,关键是要配对。

核心配置文件: .babelrc babel.config.json

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "browsers": ["> 1%", "last 2 versions", "not ie <= 8"]
        },
        "modules": "commonjs",
        "useBuiltIns": "usage",
        "corejs": 3
      }
    ]
  ],
  "plugins": []
}

我们来逐行解读这个配置的深意:

@babel/preset-env :智能降级专家

它不是一股脑全转,而是根据你指定的目标环境, 按需转换

  • "> 1%" :全球市场份额大于 1% 的浏览器
  • "last 2 versions" :每个浏览器最近两个版本
  • "not ie <= 8" :排除 IE8 及以下

这意味着:如果你的目标不需要支持 IE,那么 Promise Array.from 这些就不需要转;但如果要支持旧版 Chrome,就会自动加上 polyfill。

"modules": "commonjs" :模块格式的选择

这是关键!
Babel 默认会把 import/export 转成 CommonJS,因为很多打包工具(如 Webpack)更容易处理这种格式。

但注意:
- 如果你是给浏览器直接用 ESM,可以设为 false
- 如果你在做库开发且希望支持多种模块格式,可以用 Rollup 配合不同输出格式

"useBuiltIns": "usage" + corejs: 3 :精准注入 Polyfill

以前很多人喜欢这样写:

import 'core-js/stable'
import 'regenerator-runtime/runtime'

结果导致整个 polyfill 库都被引入,哪怕只用了 Promise.all

而现在,只要开启 "useBuiltIns": "usage" ,Babel 会在检测到使用了某个 API 时, 自动导入对应的 polyfill 模块 ,真正做到“按需加载”。


实战集成:Babel + Webpack 构建流程

大多数项目不会单独用 Babel,而是把它嵌入到构建流程中。最常见的是配合 Webpack 使用。

第一步:安装依赖

npm install --save-dev \
  @babel/core \
  @babel/cli \
  @babel/preset-env \
  babel-loader \
  webpack \
  webpack-cli

第二步:配置 webpack.config.js

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: __dirname + '/dist'
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            cacheDirectory: true // 启用缓存,提升二次构建速度
          }
        }
      }
    ]
  },
  devtool: 'source-map' // 开发环境必备,调试原始代码
}

第三步:运行构建

npx webpack --mode development

此时你会发现:
- dist/bundle.js 已经是 ES5 兼容代码
- 文件头部出现了大量 require _interopRequireDefault 包装逻辑
- Source Map 让你在浏览器中仍能看到原始的 ES6 代码进行调试

完美!


常见坑点与避坑指南

再好的工具也有陷阱。以下是我在多个项目中踩过的坑,帮你提前绕开。

❌ 坑点 1:忘记排除 node_modules

如果你不对 node_modules exclude ,Babel 会对所有第三方库也进行转译,后果是:
- 构建速度暴跌
- 可能破坏某些库的原始逻辑(尤其是 UMD 格式)

✅ 正确做法:

{
  test: /\.js$/,
  exclude: /node_modules/, // 必加!
  use: 'babel-loader'
}

❌ 坑点 2:误以为 Babel 能解决所有兼容问题

Babel 只负责语法转换,比如:
- class function
- async/await regeneratorRuntime

但它 不会自动帮你处理 DOM API 兼容性 。例如:
- Element.classList 在 IE9 才支持
- fetch() 需要手动引入 whatwg-fetch

✅ 解决方案:
- 对于全局 API,使用 polyfill.io 动态注入
- 或在入口文件显式引入所需 polyfill

❌ 坑点 3:Tree Shaking 失效

你以为写了 export 就能自动摇掉无用代码?不一定!

前提条件是:
- 模块必须是 ES6 模块(不能被 Babel 提前转成 CommonJS)
- 引入方式必须是静态 import ,不能是动态 require()

✅ 最佳实践:
- 在库项目中,保留 modules: false ,让 Rollup/Webpack 自己处理模块
- 使用 sideEffects: false 告诉打包工具哪些文件无副作用

// package.json
{
  "sideEffects": false
}

更进一步:不只是模块化

Babel 的能力远不止处理 import/export 。结合其他插件,你可以:

  • 使用 React JSX: @babel/plugin-transform-react-jsx
  • 支持 TypeScript: @babel/preset-typescript
  • 实验性特性尝鲜: @babel/plugin-proposal-decorators
  • 自动按需加载组件:配合 import() 动态导入实现路由懒加载

甚至,你可以在不升级 Node.js 版本的情况下,在服务端运行 ES6+ 代码:

// server.js
require('@babel/register')({
  presets: ['@babel/preset-env']
})

require('./app') // 现在就可以用 import/export 了!

写在最后:这条路还会走多久?

随着 ESM 在浏览器和 Node.js 中的普及(Node.js 14+ 已稳定支持),有一天我们或许真的不再需要 Babel 来转换模块语法。

但在那一天到来之前——

对于任何需要兼容旧环境的企业级前端项目,Babel + ES6 模块化仍然是不可替代的黄金组合。

它让我们既能拥抱语言演进带来的红利,又不至于被历史包袱拖垮。更重要的是,它教会我们一种思维方式: 技术升级不必激进重构,渐进式迁移才是可持续之道

你现在正在使用的 Vue CLI、Create React App、Vite……它们的背后,都有 Babel 默默工作的身影。

理解它,掌握它,你就能真正掌控自己的构建流程,而不是被脚手架牵着鼻子走。


如果你也在维护一个需要兼顾现代开发体验与广泛兼容性的项目,欢迎留言交流你的配置策略或遇到的挑战。我们一起把这条路走得更稳、更远。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐