JavaScript 传统脚本 vs 模块脚本(附:Tree-Shaking、Polyfill 详解)
本回答由Deepseek AI 生成,内容仅供参考,请仔细甄别。
JavaScript 传统脚本 vs 模块脚本对比
| 特性 | 传统脚本 | 模块脚本 |
|---|---|---|
| 严格模式 | 可选(默认非严格) | 强制启用严格模式 |
| 作用域 | 全局作用域 | 模块作用域(局部) |
| 导入/导出 | 不支持 ES6 模块语法 | 支持 import/export |
| 执行时机 | 立即加载并执行 | 延迟执行(自动 defer) |
| 加载行为 | 同步加载,阻塞渲染 | 异步加载,不阻塞渲染 |
| CORS 限制 | 宽松,可跨域加载 | 严格,受 CORS 策略限制 |
| 文件要求 | 任意扩展名,不验证 MIME |
需要有效 JavaScript MIME 类型。 推荐 .mjs 扩展名 |
| 顶层 await | 不支持 | 支持 |
| this 指向 | window(全局对象) |
undefined(严格模式) |
| 变量污染 | 容易污染全局命名空间 | 隔离的模块作用域 |
| 依赖管理 | 手动管理,依赖顺序敏感 | 自动解析,声明式依赖 |
| 重复执行 | 相同脚本会重复执行 | 相同模块只执行一次 |
| 默认延迟 | 需要显式添加 defer 属性 |
自动具有 defer 行为 |
| HTML 中的使用 | <script src="file.js"> |
<script type="module" src="file.js"> |
详细说明表格
| 方面 | 传统脚本 | 模块脚本 |
|---|---|---|
| 代码示例 |
|
|
| 变量声明 | 变量成为全局属性 | 变量仅在模块内可见 |
| 错误处理 | 宽松,可能静默失败 | 严格,立即抛出错误 |
| 性能优化 | 需要手动合并和压缩 | 支持 Tree-shaking,优化更好 |
| 开发体验 | 全局命名冲突常见 | 清晰的模块边界,易于维护 |
| 浏览器支持 | 所有浏览器完全支持 | 现代浏览器支持(IE 不支持) |
| 静态分析 | 难以进行静态分析 | 可静态分析,利于工具优化 |
| 循环依赖 | 可能导致运行时错误 | 有明确的循环依赖处理机制 |
补充:JavaScript ES6 模块不共享全局命名空间详解
ES6 模块不共享全局命名空间 意味着每个模块都有自己独立的作用域,在模块中声明的变量、函数、类等不会自动成为全局对象的属性,其他模块无法直接访问。
核心概念对比表格
| 方面 | 传统脚本 | ES6 模块 |
|---|---|---|
| 作用域 | 全局作用域 | 模块作用域 |
| 变量污染 | 容易污染全局 | 隔离的,不会污染 |
| 变量访问 | 所有脚本都可访问 | 只能通过导出导入 |
| this 指向 | window (浏览器) |
undefined (严格模式) |
总结表格
| 特性 | 传统脚本 | ES6 模块 |
|---|---|---|
| 变量作用域 | 全局 | 模块局部 |
| 全局污染 | 容易发生 | 不会发生 |
| 依赖管理 | 隐式,通过script标签顺序 | 显式,通过import语句 |
| 代码复用 | 通过全局变量 | 通过导出导入 |
| 测试难度 | 困难(全局状态) | 容易(隔离状态) |
| 严格模式 | 可选 | 强制启用 |
| Tree-shaking | 不支持 | 支持 |
| 循环依赖 | 可能导致错误 | 有明确的处理机制 |
关键理解点:
-
🚫 不自动共享 - 模块内的声明不会自动成为全局属性
-
🛡️ 作用域隔离 - 每个模块有自己的词法作用域
-
🔒 封装性 - 实现细节被隐藏,只暴露接口
-
📦 明确依赖 - 必须通过import/export显式声明依赖关系
-
🌐 可访问全局 - 仍然可以主动访问全局对象,但不推荐修改
核心概念对比表格
| 方面 | 传统脚本 | ES6 模块 |
|---|---|---|
| 作用域 | 全局作用域 | 模块作用域 |
| 变量污染 | 容易污染全局 | 隔离的,不会污染 |
| 变量访问 | 所有脚本都可访问 | 只能通过导出导入 |
| this 指向 | window (浏览器) |
undefined (严格模式) |
执行时机对比表格
详情推荐阅读
<script> 标签属性(defer async)和 type=“module“ 对比
| 场景 | 传统脚本 | 模块脚本 |
|---|---|---|
| 内联脚本 | 立即执行 | 延迟到文档解析后执行 |
| 外部脚本 | 同步下载并执行 | 异步下载,按顺序执行 |
| 多个脚本 | 按顺序阻塞执行 | 并行下载,按顺序执行 |
| DOM 访问 | 可能访问不到未解析的 DOM | 总是在 DOM 解析完成后执行 |
| async 属性 | 支持,异步执行 | 支持,但执行顺序不确定 |
实际影响表格
| 开发方面 | 传统脚本的影响 | 模块脚本的影响 |
|---|---|---|
| 代码组织 | 需要命名空间模式 | 自然的模块化组织 |
| 团队协作 | 容易产生冲突 | 清晰的接口边界 |
| 构建工具 | 需要复杂配置 | 与现代构建工具天然契合 |
| 代码复用 | 通过全局变量共享 | 通过导出/导入共享 |
| 测试维护 | 依赖全局状态,测试复杂 | 模块隔离,易于测试 |
补充:Tree-shaking 详解
Tree-shaking 是一种通过静态代码分析来移除 JavaScript 项目中未使用代码的优化技术。它的名字来源于"摇树"的比喻——就像摇动果树让成熟的果实掉落一样,通过"摇动"代码树来去除无用的代码。
核心原理对比
| 方面 | 传统打包 | Tree-shaking |
|---|---|---|
| 工作原理 | 包含所有导入的代码 | 只包含实际使用的代码 |
| 代码分析 | 动态分析(运行时) | 静态分析(编译时) |
| 依赖检测 | 无法检测未使用的导出 | 识别未引用的导出 |
| 最终体积 | 较大,包含死代码 | 较小,移除未使用代码 |
工作流程表格
| 步骤 | 描述 | 示例 |
|---|---|---|
| 1. 静态分析 | 在编译时分析 import/export 关系 | 解析 ES6 模块语法 |
| 2. 依赖图构建 | 创建模块间的依赖关系图 | 确定哪些导出被实际使用 |
| 3. 死代码检测 | 标记未被引用的导出和函数 | 标记未使用的变量、函数、类 |
| 4. 代码移除 | 从最终打包文件中删除死代码 | 从 bundle 中删除未使用的代码 |
技术前提表格
| 前提条件 | 为什么重要 | 示例 |
|---|---|---|
| ES6 模块语法 | 静态结构可分析 | import/export |
| 静态分析能力 | 编译时确定依赖关系 | 不能有动态导入(部分支持) |
| 副作用标记 | 识别有副作用的代码 | /*#__PURE__*/ 注释 |
| 构建工具支持 | 需要打包器实现 | Webpack、Rollup、Vite |
支持 Tree-shaking 的工具
| 工具 | 支持程度 | 配置方式 |
|---|---|---|
| Webpack | ✅ 完整支持 | mode: 'production' 自动启用 |
| Rollup | ✅ 原生支持 | 默认启用 |
| Vite | ✅ 基于 Rollup | 生产构建自动启用 |
| Parcel | ✅ 支持 | 默认启用 |
| Browserify | ❌ 不支持 | 无 |
代码编写最佳实践
// 推荐写法(Tree-shaking 友好)
// 具名导出 - 易于 Tree-shaking
export const util1 = () => { /* ... */ };
export const util2 = () => { /* ... */ };
// 按需导入
import { util1 } from './utils';
// 避免的写法(不利于 Tree-shaking)
// 默认导出对象 - 整个对象都会被打包
export default {
util1: () => { /* ... */ },
util2: () => { /* ... */ }
};
// 全局导入
import * as utils from './utils'; // 全部导入,无法优化
副作用处理表格
| 情况 | 描述 | Tree-shaking 影响 |
|---|---|---|
| 无副作用函数 | 纯函数,可安全移除 | ✅ 完全可移除 |
| 有副作用模块 | 修改全局状态、polyfill | ❌ 必须保留 |
| 未知副作用 | 工具无法确定是否有副作用 | ⚠️ 保守保留 |
副作用标记示例
// 告诉打包器这个函数是纯函数
/*#__PURE__*/
const pureFunction = (a, b) => a + b;
// package.json 中标记副作用文件
{
"sideEffects": [
"*.css",
"polyfill.js"
]
}
实际收益表格
| 项目类型 | Tree-shaking 前 | Tree-shaking 后 | 体积减少 |
|---|---|---|---|
| 小型工具库 | 完整库体积 | 只包含使用函数 | 60-80% |
| UI 组件库 | 全部组件 | 只使用组件 | 50-70% |
| 大型应用 | 所有依赖 | 实际使用功能 | 30-50% |
局限性
| 局限性 | 核心问题 | 现实影响 | 解决方案 |
|---|---|---|---|
| CommonJS 模块 | 动态结构无法静态分析 | 整个模块被打包 | 转 ES6 模块或换库 |
| 动态导入 | 运行时确定依赖关系 | 所有可能模块都保留 | 尽量用静态导入 |
| 原型方法 | 可能通过字符串调用 | 类中所有方法都保留 | 改用函数导出 |
| 外部依赖 | 第三方库打包方式差 | 引入未使用代码 | 选择(Tree-shaking)友好的库,按需导入 |
| 副作用 | 工具无法确定代码影响 | 保守保留潜在副作用 | 明确标记纯函数 |
CommonJS 模块为什么无法 Tree-shaking
| 特性 | ES6 模块 | CommonJS |
|---|---|---|
| 导入方式 | import { func } from 'module' |
const { func } = require('module') |
| 分析时机 | 编译时可确定 | 运行时才能确定 |
| 依赖关系 | 静态,可分析 | 动态,不可预测 |
原型方法问题
// 问题描述
// 类定义
class MyClass {
method1() { /* 可能被使用 */ }
method2() { /* 可能未被使用 */ }
}
// 使用方式1: 直接调用(可分析)
const instance = new MyClass();
instance.method1(); // ✅ Tree-shaking 知道这个方法被使用了
// 使用方式2: 通过字符串调用(不可分析)
const methodName = 'method' + someVariable;
instance[methodName](); // ❌ 无法静态分析
// 使用方式3: 传递给高阶函数
['method1', 'method2'].forEach(method => {
instance[method](); // ❌ 保守处理,所有方法都保留
});
// 具体例子
class ArrayUtils {
static chunk(array, size) { /* 分割数组 */ }
static shuffle(array) { /* 随机排序 */ }
static unique(array) { /* 去重 */ }
}
// 明确的调用 - 可优化
ArrayUtils.chunk([1,2,3], 2); // ✅ 只有 chunk 被保留
// 动态调用 - 无法优化
const method = getMethodFromConfig(); // 'shuffle'
ArrayUtils[method]([1,2,3]); // ❌ 所有方法都被保留
//解决方案
// 方案1: 使用函数代替类方法
export const chunk = (array, size) => { /* ... */ };
export const shuffle = (array) => { /* ... */ };
export const unique = (array) => { /* ... */ };
// 方案2: 明确导入
import { chunk } from './array-utils.js'; // ✅ 只导入需要的
// 而不是
import ArrayUtils from './array-utils.js'; // ❌ 导入整个类
外部依赖问题
// 问题描述
// 情况1: 库未提供 ES6 模块版本
import _ from 'lodash'; // CommonJS 版本
// 整个 lodash 都被打包,即使用只用了 _.get
// 情况2: 库的打包方式不友好
import { Button } from 'antd';
// 可能整个 antd 都被打包,而不仅仅是 Button
// 情况3: 副作用严重的库
import 'old-jquery-plugin'; // 有全局副作用,必须全部保留
// 不友好的库
// 不友好的导出方式
export default {
componentA: /* ... */,
componentB: /* ... */,
utils: /* ... */
};
// 使用
import { componentA } from 'unfriendly-lib';
// 但整个 default 对象都被打包
// 友好的库
// 友好的导出方式
export componentA from './components/A';
export componentB from './components/B';
export utils from './utils';
// 使用
import { componentA } from 'friendly-lib';
// 只有 componentA 及其依赖被打包 ✅
// 解决方案
// 方案1: 使用按需导入的库
import Button from 'antd/lib/button'; // 直接导入具体文件
import { get } from 'lodash-es'; // 使用 ES6 模块版本
// 方案2: 使用 babel-plugin-import (Antd)
// .babelrc
{
"plugins": [
["import", { "libraryName": "antd", "style": true }]
]
}
// 然后可以这样写
import { Button } from 'antd'; // 自动转换为按需导入
// 方案3: 选择 Tree-shaking 友好的库
import { z } from 'zod'; // ✅ 友好的库
import { motion } from 'framer-motion'; // ✅ 友好的库
副作用识别问题
// 问题描述
// 明显的副作用
window.globalConfig = { ... }; // 修改全局对象
Array.prototype.newMethod = function() { ... }; // 修改原型
// 不明显的副作用
let cache = {};
export function expensiveOperation(input) {
if (cache[input]) return cache[input]; // 有内部状态
// ...
}
// CSS 导入
import './styles.css'; // 有渲染副作用,必须保留
// 构建工具的保守策略
// 工具无法确定这个函数是否有副作用
export function complexFunction(obj) {
// 工具看到 for...in 和赋值操作
for (const key in obj) {
this[key] = obj[key]; // 可能修改 this,有副作用?
}
// 保守策略:保留整个函数
}
// 解决方案
// 方案1: 明确标记纯函数
/*#__PURE__*/
export const pureFunction = (a, b) => a + b;
// 方案2: package.json 中声明副作用
{
"sideEffects": [
"**/*.css",
"**/*.scss",
"polyfill.js"
],
"sideEffects": false // 如果整个包都没有副作用
}
// 方案3: 分离副作用代码
// pure-module.js
export const pureUtil = () => { /* 无副作用 */ };
// side-effects.js
import './styles.css';
window.myLib = { /* 注册到全局 */ };
总结
Tree-shaking 是现代 JavaScript 开发中的重要优化手段,它通过:
-
✅ 静态分析 ES6 模块依赖
-
✅ 移除死代码 减少打包体积
-
✅ 提升性能 减少下载和执行时间
-
✅ 改善缓存 更小的文件更易缓存
要充分利用 Tree-shaking,应该使用 ES6 模块语法、遵循纯函数原则、并选择支持该功能的构建工具。
补充:Polyfill 详解
Polyfill 是一段代码(通常是 JavaScript),用于在现代浏览器中实现那些不被支持的新特性。它"填充"了浏览器功能的空白,让开发者能够使用新的 API 和语法,同时保持对旧浏览器的兼容性。
腻子脚本(polyfill)是JavaScript代码,用于为不支持新特性的老旧浏览器提供HTML5和CSS3功能,如视频播放和阴影效果。这些脚本让旧版浏览器也能体验到现代网页的精彩。
Polyfill 基本概念表格
| 方面 | 描述 | 示例 |
|---|---|---|
| 核心目的 | 在旧环境中实现新功能 | 让 IE 浏览器支持 Promise |
| 工作原理 | 检测特性是否存在,不存在时添加实现 | if (!window.Promise) { /* 实现 Promise */ } |
| 与 Shim 区别 | Polyfill 是 Shim 的一种,专门用于 Web API | Shim 是更广义的兼容层 |
| 使用时机 | 当目标浏览器不支持某个标准特性时 | 需要支持旧版浏览器时 |
常见的 Polyfill 类型
// JavaScript 新语法
// ES6 Promise - 需要为 IE 添加 polyfill
if (typeof Promise === 'undefined') {
// 添加 Promise 实现
window.Promise = class Promise {
constructor(executor) { /* 实现 Promise */ }
then(onFulfilled) { /* 实现 then */ }
catch(onRejected) { /* 实现 catch */ }
};
}
// 使用
const promise = new Promise((resolve) => {
setTimeout(() => resolve('done'), 1000);
});
// 为什么 Polyfill 有副作用
// 典型的 Polyfill 结构
if (!Array.prototype.includes) {
Array.prototype.includes = function(searchElement) {
// 这个操作修改了全局的 Array 原型!
// 这就是副作用
for (let i = 0; i < this.length; i++) {
if (this[i] === searchElement) {
return true;
}
}
return false;
};
}
// 即使你的代码没有使用 includes
// 这个 polyfill 仍然会修改全局环境
// 因此构建工具必须保留它
Tree-shaking 的影响
| 代码类型 | Tree-shaking 效果 | 原因 |
|---|---|---|
| 工具函数 | ✅ 可移除未使用的 | 没有副作用 |
| Polyfill | ❌ 无法移除 | 修改全局状态 |
// utils.js - 无副作用,可 Tree-shaking
export const helper1 = () => { /* ... */ };
export const helper2 = () => { /* ... */ };
// polyfill.js - 有副作用,无法 Tree-shaking
if (!window.Promise) {
window.Promise = class { /* ... */ }; // 修改全局对象!
}
现代 Polyfill 解决方案
// 1. 按需 Polyfill
// 传统方式:引入所有 polyfill
import '@babel/polyfill'; // ❌ 包含所有 polyfill,体积大
// 现代方式:按需引入
import 'core-js/features/promise'; // ✅ 只引入 Promise
import 'core-js/features/array/flat'; // ✅ 只引入 flat
// 2. babel-preset-env + useBuiltIns
// .babelrc
{
"presets": [
["@babel/preset-env", {
"useBuiltIns": "usage", // ✅ 按使用情况自动添加 polyfill
"corejs": 3
}]
]
}
// 源代码
const promise = new Promise(); // babel 自动添加 Promise polyfill
const flatArray = [].flat(); // babel 自动添加 flat polyfill
// 3. polyfill.io 服务
<!-- 根据浏览器 User-Agent 动态返回需要的 polyfill -->
<script src="https://polyfill.io/v3/polyfill.min.js"></script>
<!--
现代浏览器:返回空文件或很少的 polyfill
旧浏览器:返回完整的 polyfill 集合
-->
Polyfill 最佳实践
// 推荐做法
// 1. 检测后再添加
if (typeof Object.entries === 'undefined') {
// 添加 polyfill
}
// 2. 使用现代构建工具配置
// webpack.config.js
module.exports = {
entry: [
'core-js/stable', // 按需加载的 core-js
'regenerator-runtime/runtime', // async/await 支持
'./src/index.js'
]
};
// 3. 在 package.json 中声明
{
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}
// 避免的做法
// ❌ 无条件添加
Array.prototype.myMethod = function() { ... }; // 污染所有数组
// ❌ 引入整个 polyfill 包
import '@babel/polyfill'; // 包含所有,体积巨大
// ❌ 重复添加
// 多个库都添加了相同的 polyfill
Polyfill 与 Tree-shaking 的关系
// 问题根源
// polyfill.js
// 即使没有显式导出,也有隐式副作用
if (!window.fetch) {
window.fetch = function() { ... }; // 副作用!
}
// main.js
import './polyfill'; // 这个导入看起来"未使用"
// 但实际上有重要的副作用,必须保留
// 解决方案
// package.json - 告诉构建工具这些文件有副作用
{
"sideEffects": [
"**/*.polyfill.js",
"**/*.css",
"src/polyfills/**/*"
]
}
总结
Polyfill 的核心特点:
-
🔧 功能填补:在旧浏览器实现新功能
-
🌍 全局影响:通过修改内置原型或全局对象实现
-
🚫 副作用明显:因此无法被 Tree-shaking 移除
-
📦 体积考虑:需要谨慎选择,避免过度使用
现代开发建议:
-
使用
core-js和regenerator-runtime作为基础 -
配置
browserslist明确目标浏览器范围 -
利用构建工具的按需 polyfill 功能
-
对必要的 polyfill 文件标记副作用
更多推荐

所有评论(0)