先记住这个答案
会执行。import "./polyfill.js" 这种写法叫副作用导入,它不引入任何变量,但会让这个模块被加载并完整执行一次顶层代码。执行时机和普通导入一样:在当前模块的代码之前,按导入语句的书写顺序执行。所以它适合用在只需要「执行一下」的场景,比如给老浏览器打补丁、注册插件、引入全局样式。和其它导入一样,同一个模块被副作用导入多少次都只执行一次。
- 副作用导入会完整执行模块顶层代码,但不引入任何名字
- 在当前模块代码之前、按导入顺序执行
- 和其它导入一样,同一个模块只执行一次
- 错设
sideEffects: false会误删代码
执行时机:永远在当前模块代码之前
main.js 里先写了一行日志,再写副作用导入。
// setup.js
console.log('setup 执行了')
globalThis.appReady = true
// main.js
console.log('main 开始', globalThis.appReady)
import './setup.js'
console.log('main 结束', globalThis.appReady)
// 输出:
// setup 执行了
// main 开始 true
// main 结束 true虽然导入语句写在第二行,setup.js 还是在 main.js 的第一行代码之前就执行完了。这是因为 import 声明在模块开始执行前就已经处理好:先加载、执行所有依赖,再执行当前模块。所以「写在哪一行」不影响时机,影响的只是多个导入之间的先后顺序。
这对 polyfill 很重要:import "./polyfill.js" 要写在其它依赖前面。如果它排在某个依赖后面,那个依赖的顶层代码执行时补丁还没打上。
常见用途和打包时的坑
项目里几乎每天都在写副作用导入,只是很少有人注意到它是一类单独的写法。
- 典型场景和
sideEffects字段 - 打补丁:
import "core-js/stable"修改全局对象,补齐老环境缺的 API。注册:import "dayjs/locale/zh-cn"把中文语言包注册进 dayjs,自定义元素的定义文件也是导入即注册。样式:Vite、Webpack 里import "./global.css",打包器把 CSS 插进页面。 打包器的 tree shaking 会参考包package.json里的sideEffects字段。写成false表示「这个包的所有文件都没有副作用,没用到导出就可以删」。副作用导入恰好一个导出都没用,于是整行被删。组件库最容易踩这个坑:声明了"sideEffects": false,用户项目里import "lib/style.css"打包后样式全没了。正确做法是把有副作用的文件列出来,写成"sideEffects": ["*.css", "./src/polyfill.js"]。
容易答错的地方
- 以为不用导出就不会执行
- 执行模块和使用导出是两件事。只要模块被导入,无论用什么形式,它的顶层代码都会执行。不想执行就只能不导入,或者改用动态
import()按需加载。 - 以为多写几次就会执行多次
- 模块按 URL 缓存,十个文件都写
import "./setup.js",它也只执行一次。想要每次都执行的逻辑,应该导出一个函数让调用方去调用,而不是放在模块顶层。 - 以为它能用在任意位置
- 副作用导入也是
import声明,只能写在模块顶层,不能放进if里。需要按条件加载时用if (needPolyfill) await import("./polyfill.js")。
面试官还会怎么问?
CommonJS 里怎么写副作用导入?
直接 require("./setup"),不接收返回值就行,效果一样:执行一次并缓存。区别在时机,require 在代码执行到那一行时才加载,写在哪一行就在哪一行执行。
副作用导入和 import * as x 在执行上有区别吗?
没有,都会让模块执行一次。区别只是后者额外创建了一个命名空间对象的绑定。打包器看到 import * as x 但 x 没被用到时,同样要参考 sideEffects 决定能不能删。
怎么判断一个模块有没有副作用?
看它的顶层代码有没有改动模块外部的东西:修改全局对象或原型、操作 DOM、注册事件、发请求、往别的模块的对象里写数据。只有声明函数、常量、类并导出的模块,才算没有副作用。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。