3、动态 import() 语法:ES2020 提案的 import() 函数,实现按需加载的核心 API

好,咱们进入正题。

前面聊了静态 import 的局限,说白了就是「必须提前声明,不能变」。那有没有办法让代码「想加载时才加载」?有,就是 import() 函数。

这个 API 是 ES2020 正式进入标准的。但说实话,我在 2018 年的项目中就已经通过 Webpack 的 require.ensure 在用了。后来标准统一,大家都用 import(),舒服多了。

3.1 什么是动态 import()?

动态 import() 是一个函数调用。它接收一个模块路径作为参数,返回一个 Promise。

你想想看,Promise 意味着什么?意味着你可以决定「什么时候去执行这个函数」。点击按钮时执行、路由切换时执行、甚至滚动到某个位置时执行——全由你控制。

核心区别一句话:

  • 静态 import:编译时确定,必须写在文件顶部
  • 动态 import():运行时确定,可以在任意位置调用

3.2 基本用法

语法非常简单:

// 静态导入(传统方式)
import { formatDate } from './utils/date'

// 动态导入(按需加载)
const module = await import('./utils/date')
module.formatDate(new Date())

嗯,这里要注意:动态 import() 返回的是一个模块的命名空间对象。也就是说,你拿到的 module 包含了这个模块所有的导出。

如果你只想用某个默认导出:

const { default: myFunction } = await import('./myModule')

我个人习惯用解构赋值,代码更干净。

3.3 实战场景:点击按钮加载大模块

我在项目中遇到过这样一个场景:一个图表页面,里面引用了 ECharts。ECharts 打包后将近 1MB。如果所有用户进来都加载,首屏时间直接爆炸。

解决方案?只有用户点击「查看图表」按钮时,才去加载 ECharts。

// 按钮点击事件
document.getElementById('showChart').addEventListener('click', async () => {
  try {
    const echarts = await import('echarts')
    const myChart = echarts.init(document.getElementById('chartContainer'))
    myChart.setOption({ /* 图表配置 */ })
  } catch (error) {
    console.error('图表模块加载失败:', error)
    // 可以展示一个友好的错误提示
  }
})

你看,代码逻辑非常直观。用户不点按钮,ECharts 永远不会被加载。这就是按需加载的核心思想。

小技巧:动态 import() 配合 try/catch 使用,可以优雅处理网络异常。我曾经遇到过 CDN 资源临时挂掉的情况,catch 里做了降级处理,用户完全没感知。

3.4 动态 import() 与静态 import 的对比

我整理了一张对比表,方便你快速理解差异:

对比维度 静态 import 动态 import()
加载时机 编译时(模块解析阶段) 运行时(代码执行到该行时)
代码位置 必须位于文件顶部 任意位置(函数内、条件语句内)
返回值 同步,直接使用导出 Promise,异步获取模块
Tree Shaking 支持(构建工具可分析) 部分支持(取决于工具实现)
适用场景 核心模块、首屏必需模块 非首屏模块、条件性加载模块

3.5 动态路径与变量拼接

静态 import 的路径必须是字符串字面量,不能拼接。但动态 import() 可以。

为什么会这样?因为静态 import 需要在构建时就确定模块依赖关系,而动态 import 是在运行时才去解析路径。

// 静态 import 不允许这样
// import { Component } from `./pages/${pageName}`  // 语法错误

// 动态 import() 可以
const page = await import(`./pages/${pageName}.js`)

不过这里有个坑:如果你用 Webpack 或 Vite 打包,动态路径可能会导致打包出很多 chunk。我曾经踩过这个坑——用变量拼接路径,结果把所有页面都打包进去了,按需加载变成了「全量加载」。

避坑指南:使用动态路径时,尽量让路径的「可变部分」保持在最小范围。比如 import(`./pages/${pageName}`)import(`./${folder}/${pageName}`) 更安全。因为构建工具会尝试解析所有可能的路径,路径越模糊,打包出来的 chunk 越多。

3.6 动态 import() 的常见模式

在实际项目中,我总结了几种常用模式:

  1. 路由懒加载:每个路由对应一个 chunk,用户访问时才加载
  2. 条件加载:根据用户权限、设备类型等条件决定加载哪个模块
  3. 预加载:在空闲时提前加载,但不用立即执行
  4. 错误重试:加载失败后自动重试

举个预加载的例子:

// 用户鼠标悬停在按钮上时,提前加载模块
button.addEventListener('mouseenter', () => {
  // 预加载,但不阻塞
  const modulePromise = import('./heavyModule')
  // 可以存下来,等真正需要时直接用
  window.__preloadedModule = modulePromise
})

button.addEventListener('click', async () => {
  const module = await (window.__preloadedModule || import('./heavyModule'))
  module.doSomething()
})

这个技巧我在一个数据大屏项目里用过。用户鼠标移到「查看详情」按钮上时,后台已经开始加载数据模块了。等用户真正点击时,几乎零等待。体验非常好。

3.7 浏览器兼容性

动态 import() 已经是现代浏览器的标配了。Chrome 63+、Firefox 67+、Safari 11.1+ 都支持。如果你需要兼容 IE 之类的老古董,那就得用 Babel 加插件转译,或者直接用 Webpack 的 require.ensure

不过说真的,2024 年了,还在纠结 IE 的项目应该不多了吧?

总结一下:动态 import() 是前端代码分割的基石。它把「加载模块」这件事从编译时解放到了运行时,让我们可以精细控制每一段代码的加载时机。配合构建工具的分割策略,就能实现真正的按需加载。

下一章,我们会聊 Webpack 和 Vite 是如何处理动态 import() 的,以及如何配置 chunk 名称、分割策略。到时候我会分享一些我在大厂项目里的实战配置,保证干货满满。