mirror of
https://github.com/aphrody-labs/oxc.git
synced 2026-10-15 06:39:17 +00:00
feat(minifier): merge import and export statements against the same module (#25533)
Compress
```js
import { b, g } from "./res-SWvxTGCX.js";
export { g as load_css, b as start };
```
to
```js
export { g as load_css, b as start } from "./res-SWvxTGCX.js";
```
Some combinations (e.g. namespace + named specifier) are not allowed in
`export`s while it's allowed in `import`s, so that part should be
reviewed with caution.
Note that `just minsize` result does not change because the input does
not contain any import / exports.
close https://github.com/oxc-project/oxc/issues/13047
I used AI to generate the code and then reviewed the code and fixed the
behavior & added a bunch of tests.
---------
Co-authored-by: Cameron Clark <cameron.clark@hey.com>
This commit is contained in:
1 parent
ec4c2898dc
commit
cc82598ffd
8 files changed
+360
-17
No files matched your search
@@ -2008,6 +2008,11 @@ impl<'a> ImportDeclarationSpecifier<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the symbol ID of the bound local identifier.
|
||||
pub fn symbol_id(&self) -> SymbolId {
|
||||
self.local().symbol_id()
|
||||
}
|
||||
|
||||
/// Returns the name of the bound local identifier for this import declaration specifier.
|
||||
///
|
||||
/// ## Example
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
use std::iter;
|
||||
|
||||
use crate::generated::ancestor::Ancestor;
|
||||
use oxc_allocator::{ArenaBox, ArenaVec, TakeIn};
|
||||
use oxc_allocator::{ArenaBox, ArenaHashMap, ArenaVec, CloneIn, GetAllocator, TakeIn};
|
||||
use oxc_ast::ast::*;
|
||||
use oxc_ast_visit::{VisitJs, walk_js};
|
||||
use oxc_compat::ESFeature;
|
||||
use oxc_ecmascript::{
|
||||
constant_evaluation::{ConstantEvaluation, DetermineValueType, IsLiteralValue, ValueType},
|
||||
side_effects::MayHaveSideEffects,
|
||||
};
|
||||
use oxc_semantic::ScopeFlags;
|
||||
use oxc_span::{ContentEq, GetSpan, GetSpanMut, SPAN};
|
||||
use oxc_syntax::symbol::SymbolId;
|
||||
|
||||
use crate::{TraverseCtx, is_terminated::IsTerminated, keep_var::KeepVar};
|
||||
|
||||
@@ -131,6 +133,239 @@ impl<'a> PeepholeOptimizations {
|
||||
false
|
||||
}
|
||||
|
||||
/// Check whether an import can be merged with a local named export.
|
||||
fn can_merge_import_export(
|
||||
import_decl: &ImportDeclaration<'a>,
|
||||
export_decl: &ExportNamedDeclaration<'a>,
|
||||
ctx: &TraverseCtx<'a>,
|
||||
) -> bool {
|
||||
let Some(import_specifiers) = import_decl.specifiers.as_ref() else { return false };
|
||||
let is_namespace_import = matches!(
|
||||
import_specifiers.as_slice(),
|
||||
[ImportDeclarationSpecifier::ImportNamespaceSpecifier(_)]
|
||||
);
|
||||
|
||||
if import_decl.phase.is_some()
|
||||
|| import_decl.import_kind.is_type()
|
||||
|| export_decl.export_kind.is_type()
|
||||
|| import_specifiers.is_empty()
|
||||
|| import_specifiers.len() > export_decl.specifiers.len()
|
||||
|| (is_namespace_import && !ctx.supports_feature(ESFeature::ES2020ExportNamespaceFrom))
|
||||
|| (is_namespace_import && export_decl.specifiers.len() != 1)
|
||||
|| ctx.scoping().root_scope_flags().contains_direct_eval()
|
||||
|| (!is_namespace_import
|
||||
&& !import_specifiers.iter().all(|specifier| match specifier {
|
||||
ImportDeclarationSpecifier::ImportSpecifier(specifier) => {
|
||||
!specifier.import_kind.is_type()
|
||||
}
|
||||
ImportDeclarationSpecifier::ImportDefaultSpecifier(_) => true,
|
||||
ImportDeclarationSpecifier::ImportNamespaceSpecifier(_) => false,
|
||||
}))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Every local export must refer to one of the imported bindings, and
|
||||
// every imported binding must be used exactly by this export.
|
||||
for import_specifier in import_specifiers {
|
||||
let symbol_id = import_specifier.symbol_id();
|
||||
let export_count = export_decl
|
||||
.specifiers
|
||||
.iter()
|
||||
.filter(|export_specifier| {
|
||||
let ModuleExportName::IdentifierReference(id) = &export_specifier.local else {
|
||||
return false;
|
||||
};
|
||||
ctx.scoping().get_reference(id.reference_id()).symbol_id() == Some(symbol_id)
|
||||
})
|
||||
.count();
|
||||
if export_count == 0
|
||||
|| ctx.scoping().get_resolved_reference_ids(symbol_id).len() != export_count
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export_decl.specifiers.iter().all(|export_specifier| {
|
||||
if export_specifier.export_kind.is_type() {
|
||||
return false;
|
||||
}
|
||||
let ModuleExportName::IdentifierReference(id) = &export_specifier.local else {
|
||||
return false;
|
||||
};
|
||||
let Some(symbol_id) = ctx.scoping().get_reference(id.reference_id()).symbol_id() else {
|
||||
return false;
|
||||
};
|
||||
import_specifiers
|
||||
.iter()
|
||||
.any(|import_specifier| import_specifier.symbol_id() == symbol_id)
|
||||
})
|
||||
}
|
||||
|
||||
/// Merge an import and a local named export into a direct
|
||||
/// re-export.
|
||||
///
|
||||
/// ```js
|
||||
/// import { foo as bar } from "module";
|
||||
/// export { bar as baz };
|
||||
/// // becomes
|
||||
/// export { foo as baz } from "module";
|
||||
///
|
||||
/// import * as namespace from "module";
|
||||
/// export { namespace };
|
||||
/// // becomes
|
||||
/// export * as namespace from "module";
|
||||
///
|
||||
/// import defaultExport from "module";
|
||||
/// export { defaultExport };
|
||||
/// // becomes
|
||||
/// export { default as defaultExport } from "module";
|
||||
/// ```
|
||||
///
|
||||
/// The import declaration can only be removed when all of its bindings are
|
||||
/// represented by the export and have no other references. This also keeps
|
||||
/// direct `eval` from observing a binding that is removed here.
|
||||
pub fn merge_import_export(stmts: &mut ArenaVec<'a, Statement<'a>>, ctx: &mut TraverseCtx<'a>) {
|
||||
let mut import_export_cache: ArenaHashMap<'a, SymbolId, usize> =
|
||||
ArenaHashMap::new_in(ctx.allocator());
|
||||
let mut merges = ArenaVec::new_in(ctx);
|
||||
|
||||
// Collect the candidates
|
||||
for (statement_index, stmt) in stmts.iter().enumerate() {
|
||||
match stmt {
|
||||
Statement::ImportDeclaration(import_decl) => {
|
||||
if let Some(specifiers) = import_decl.specifiers.as_ref() {
|
||||
import_export_cache.extend(
|
||||
specifiers
|
||||
.iter()
|
||||
.map(|specifier| (specifier.symbol_id(), statement_index)),
|
||||
);
|
||||
}
|
||||
}
|
||||
Statement::ExportNamedDeclaration(export_decl) => {
|
||||
let Some(import_index) = export_decl.specifiers.iter().find_map(|spec| {
|
||||
let ModuleExportName::IdentifierReference(id) = &spec.local else {
|
||||
return None;
|
||||
};
|
||||
let Some(symbol_id) =
|
||||
ctx.scoping().get_reference(id.reference_id()).symbol_id()
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
import_export_cache.get(&symbol_id).copied()
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let Some(Statement::ImportDeclaration(import_decl)) = stmts.get(import_index)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !Self::can_merge_import_export(import_decl, export_decl, ctx) {
|
||||
continue;
|
||||
}
|
||||
|
||||
merges.push((import_index, statement_index));
|
||||
if let Some(specifiers) = import_decl.specifiers.as_ref() {
|
||||
for specifier in specifiers {
|
||||
let symbol_id = specifier.symbol_id();
|
||||
if import_export_cache.get(&symbol_id) == Some(&import_index) {
|
||||
import_export_cache.remove(&symbol_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply in reverse order so removing an export does not shift the
|
||||
// indices of any merge that is still waiting to be applied.
|
||||
for &(import_index, export_index) in merges.iter().rev() {
|
||||
// The old export contains references to the local import binding.
|
||||
// Mark those references before replacing them with the imported
|
||||
// module name.
|
||||
let old_export = stmts.remove(export_index);
|
||||
ctx.drop_statement(&old_export);
|
||||
let Statement::ExportNamedDeclaration(mut export_decl) = old_export else {
|
||||
unreachable!();
|
||||
};
|
||||
|
||||
let old_import = std::mem::replace(
|
||||
stmts.get_mut(import_index).unwrap(),
|
||||
Statement::new_empty_statement(SPAN, ctx),
|
||||
);
|
||||
ctx.drop_statement(&old_import);
|
||||
let Statement::ImportDeclaration(import_decl) = old_import else {
|
||||
unreachable!();
|
||||
};
|
||||
let ImportDeclaration {
|
||||
specifiers: Some(mut import_specifiers),
|
||||
source,
|
||||
with_clause,
|
||||
..
|
||||
} = import_decl.unbox()
|
||||
else {
|
||||
unreachable!();
|
||||
};
|
||||
|
||||
let is_namespace_import = matches!(
|
||||
import_specifiers.as_slice(),
|
||||
[ImportDeclarationSpecifier::ImportNamespaceSpecifier(_)]
|
||||
);
|
||||
if is_namespace_import {
|
||||
let exported = export_decl.specifiers.pop().unwrap().exported;
|
||||
ctx.replace_statement(
|
||||
stmts.get_mut(import_index).unwrap(),
|
||||
Statement::new_export_all_declaration(
|
||||
export_decl.span,
|
||||
Some(exported),
|
||||
source,
|
||||
with_clause,
|
||||
export_decl.export_kind,
|
||||
ctx,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
for export_specifier in &mut export_decl.specifiers {
|
||||
let ModuleExportName::IdentifierReference(id) = &export_specifier.local else {
|
||||
unreachable!();
|
||||
};
|
||||
let symbol_id = ctx.scoping().get_reference(id.reference_id()).symbol_id().unwrap();
|
||||
let import_specifier = import_specifiers
|
||||
.iter_mut()
|
||||
.find(|import_specifier| import_specifier.symbol_id() == symbol_id)
|
||||
.unwrap();
|
||||
export_specifier.local = match import_specifier {
|
||||
ImportDeclarationSpecifier::ImportSpecifier(import_specifier) => {
|
||||
import_specifier.imported.clone_in(ctx.allocator())
|
||||
}
|
||||
ImportDeclarationSpecifier::ImportDefaultSpecifier(import_default) => {
|
||||
ModuleExportName::IdentifierName(IdentifierName::new(
|
||||
import_default.span,
|
||||
"default",
|
||||
ctx,
|
||||
))
|
||||
}
|
||||
ImportDeclarationSpecifier::ImportNamespaceSpecifier(_) => unreachable!(),
|
||||
};
|
||||
}
|
||||
|
||||
ctx.replace_statement(
|
||||
stmts.get_mut(import_index).unwrap(),
|
||||
Statement::new_export_from_declaration(
|
||||
export_decl.span,
|
||||
export_decl.specifiers.take_in(ctx),
|
||||
source,
|
||||
export_decl.export_kind,
|
||||
with_clause,
|
||||
ctx,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn minimize_statement(
|
||||
stmt: Statement<'a>,
|
||||
stmts: &mut StatementIter<'a>,
|
||||
|
||||
@@ -388,7 +388,8 @@ impl<'a> Traverse<'a> for PeepholeOptimizations {
|
||||
ctx.state.body_frames.pop();
|
||||
}
|
||||
|
||||
fn exit_program(&mut self, _program: &mut Program<'a>, ctx: &mut TraverseCtx<'a>) {
|
||||
fn exit_program(&mut self, program: &mut Program<'a>, ctx: &mut TraverseCtx<'a>) {
|
||||
Self::merge_import_export(&mut program.body, ctx);
|
||||
// Private member usage is collected only in full optimization mode.
|
||||
debug_assert!(ctx.is_tree_shake_only() || ctx.state.private_member_usage.is_at_root());
|
||||
}
|
||||
|
||||
@@ -249,6 +249,18 @@ impl<'a> PeepholeOptimizations {
|
||||
/// import 'b'
|
||||
/// ```
|
||||
pub fn remove_unused_import_specifiers(stmt: &mut Statement<'a>, ctx: &mut TraverseCtx<'a>) {
|
||||
let Statement::ImportDeclaration(import_decl) = stmt else { return };
|
||||
|
||||
// `import {} from 'x'` -> `import 'x'`
|
||||
if import_decl.phase.is_none()
|
||||
&& import_decl.import_kind.is_value()
|
||||
&& import_decl.specifiers.as_ref().is_some_and(|specifiers| specifiers.is_empty())
|
||||
{
|
||||
import_decl.specifiers = None;
|
||||
ctx.notice_change();
|
||||
return;
|
||||
}
|
||||
|
||||
if ctx.options().treeshake.invalid_import_side_effects
|
||||
|| ctx.options().unused == CompressOptionsUnused::Keep
|
||||
{
|
||||
@@ -261,8 +273,6 @@ impl<'a> PeepholeOptimizations {
|
||||
|
||||
debug_assert!(!ctx.source_type().is_script(), "imports are not allowed in script mode");
|
||||
|
||||
let Statement::ImportDeclaration(import_decl) = stmt else { return };
|
||||
|
||||
if let Some(phase) = import_decl.phase {
|
||||
let (ImportPhase::Defer | ImportPhase::Source) = phase;
|
||||
if ctx.scoping().symbol_is_unused(
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
use crate::{test, test_same, test_target};
|
||||
|
||||
#[test]
|
||||
fn merge_import_and_export() {
|
||||
test("import { foo } from 'somewhere'; export { foo };", "export { foo } from 'somewhere';");
|
||||
test(
|
||||
"import { foo as bar } from 'somewhere'; export { bar as baz };",
|
||||
"export { foo as baz } from 'somewhere';",
|
||||
);
|
||||
test(
|
||||
"import foo from 'somewhere'; export { foo };",
|
||||
"export { default as foo } from 'somewhere';",
|
||||
);
|
||||
test(
|
||||
"import foo from 'somewhere'; export { foo as bar };",
|
||||
"export { default as bar } from 'somewhere';",
|
||||
);
|
||||
test(
|
||||
"import { default as foo } from 'somewhere'; export { foo };",
|
||||
"export { default as foo } from 'somewhere';",
|
||||
);
|
||||
test(
|
||||
"import foo, { bar } from 'somewhere'; export { foo, bar };",
|
||||
"export { default as foo, bar } from 'somewhere';",
|
||||
);
|
||||
test(
|
||||
"import foo, { bar } from 'somewhere'; export { bar, foo };",
|
||||
"export { bar, default as foo } from 'somewhere';",
|
||||
);
|
||||
test(
|
||||
"import { foo, bar as baz } from 'somewhere'; export { baz, foo as qux };",
|
||||
"export { bar as baz, foo as qux } from 'somewhere';",
|
||||
);
|
||||
test(
|
||||
"import { foo } from 'somewhere'; export { foo as bar, foo as baz };",
|
||||
"export { foo as bar, foo as baz } from 'somewhere';",
|
||||
);
|
||||
test("import {} from 'somewhere';", "import 'somewhere';");
|
||||
test("import {} from 'somewhere'; export {};", "import 'somewhere'; export {};");
|
||||
test(
|
||||
"import { foo } from 'somewhere' with { type: 'json' }; export { foo };",
|
||||
"export { foo } from 'somewhere' with { type: 'json' };",
|
||||
);
|
||||
test("import * as foo from 'somewhere'; export { foo };", "export * as foo from 'somewhere';");
|
||||
test(
|
||||
"import * as foo from 'somewhere'; export { foo as bar };",
|
||||
"export * as bar from 'somewhere';",
|
||||
);
|
||||
test_same("import * as foo from 'somewhere'; export { foo, foo as bar };");
|
||||
test(
|
||||
"import * as foo from 'somewhere'; export { foo as \"bar\" };",
|
||||
"export * as \"bar\" from 'somewhere';",
|
||||
);
|
||||
test_same("import foo, * as bar from 'foo'; export { foo, bar };");
|
||||
test_target(
|
||||
"import * as foo from 'somewhere'; export { foo };",
|
||||
"import * as foo from 'somewhere'; export { foo };",
|
||||
"es2019",
|
||||
);
|
||||
test_target(
|
||||
"import * as foo from 'somewhere'; export { foo };",
|
||||
"export * as foo from 'somewhere';",
|
||||
"es2020",
|
||||
);
|
||||
// preserve import order
|
||||
test(
|
||||
"import { foo } from 'foo'; import { bar } from 'bar'; export { bar }; export { foo };",
|
||||
"export { foo } from 'foo'; export { bar } from 'bar';",
|
||||
);
|
||||
test(
|
||||
"import { foo } from 'foo'; import 'side-effect'; import { bar } from 'bar'; export { bar }; export { foo };",
|
||||
"export { foo } from 'foo'; import 'side-effect'; export { bar } from 'bar';",
|
||||
);
|
||||
|
||||
// do not merge if it's used in the module
|
||||
test_same("import { foo } from 'somewhere'; export { foo }; console.log(foo);");
|
||||
test(
|
||||
"import { foo } from 'somewhere'; console.log(0); export { foo };",
|
||||
"export { foo } from 'somewhere'; console.log(0);",
|
||||
);
|
||||
test_same("import foo, { bar } from 'somewhere'; export { bar };");
|
||||
test_same("import * as foo from 'somewhere'; console.log(foo); export { foo };");
|
||||
test_same("import { foo } from 'somewhere'; export { foo }; eval('foo');");
|
||||
}
|
||||
@@ -8,6 +8,7 @@ mod inline;
|
||||
mod inline_single_use_variable;
|
||||
mod manual_pure_functions;
|
||||
mod merge_assignments_to_declarations;
|
||||
mod merge_import_export;
|
||||
mod minimize_binary_expression;
|
||||
mod minimize_conditional_expression;
|
||||
mod minimize_conditions;
|
||||
|
||||
@@ -1005,6 +1005,13 @@ fn keep_recursive_function_with_unused_keep_option() {
|
||||
fn remove_unused_import_specifiers() {
|
||||
let options = CompressOptions::smallest();
|
||||
|
||||
test_options("import {} from 'a';", "import 'a';", &options);
|
||||
test_options(
|
||||
"import {} from 'a' with { type: 'json' };",
|
||||
"import 'a' with { type: 'json' };",
|
||||
&options,
|
||||
);
|
||||
|
||||
test_options("import a from 'a'", "import 'a';", &options);
|
||||
test_options("import a from 'a'; foo()", "import 'a'; foo();", &options);
|
||||
test_same_options(
|
||||
@@ -1064,7 +1071,7 @@ fn remove_unused_import_specifiers() {
|
||||
test_options("import { a as b } from 'a'", "import 'a';", &options);
|
||||
test_same_options("import { a as b } from 'a'; foo(b);", &options);
|
||||
|
||||
test_same_options("import { a } from 'a'; export { a };", &options);
|
||||
test_options("import { a } from 'a'; export { a };", "export { a } from 'a';", &options);
|
||||
// Keep imports when direct eval is present
|
||||
test_same_options("import { a } from 'a'; eval('a');", &options);
|
||||
test_same_options("import a from 'a'; eval('a');", &options);
|
||||
|
||||
@@ -5,9 +5,9 @@ checker.ts:
|
||||
sys deallocs: 122
|
||||
sys alloc bytes: 14996287 # 15.00 MB
|
||||
sys peak growth: 10169560 # 10.17 MB
|
||||
arena allocs: 56214
|
||||
arena allocs: 56217
|
||||
arena reallocs: 5084
|
||||
arena size: 16317568 # 16.32 MB
|
||||
arena size: 16369368 # 16.37 MB
|
||||
|
||||
App.tsx:
|
||||
file size: 415342 # 415.34 kB
|
||||
@@ -16,9 +16,9 @@ App.tsx:
|
||||
sys deallocs: 61
|
||||
sys alloc bytes: 2128539 # 2.13 MB
|
||||
sys peak growth: 1612427 # 1.61 MB
|
||||
arena allocs: 7469
|
||||
arena allocs: 7490
|
||||
arena reallocs: 920
|
||||
arena size: 2683792 # 2.68 MB
|
||||
arena size: 2733056 # 2.73 MB
|
||||
|
||||
RadixUIAdoptionSection.jsx:
|
||||
file size: 2520 # 2.52 kB
|
||||
@@ -27,9 +27,9 @@ RadixUIAdoptionSection.jsx:
|
||||
sys deallocs: 21
|
||||
sys alloc bytes: 17400 # 17.40 kB
|
||||
sys peak growth: 11628 # 11.63 kB
|
||||
arena allocs: 15
|
||||
arena allocs: 21
|
||||
arena reallocs: 0
|
||||
arena size: 35104 # 35.10 kB
|
||||
arena size: 36112 # 36.11 kB
|
||||
|
||||
pdf.mjs:
|
||||
file size: 567296 # 567.30 kB
|
||||
@@ -40,7 +40,7 @@ pdf.mjs:
|
||||
sys peak growth: 3693355 # 3.69 MB
|
||||
arena allocs: 19585
|
||||
arena reallocs: 2574
|
||||
arena size: 5710976 # 5.71 MB
|
||||
arena size: 5710848 # 5.71 MB
|
||||
|
||||
antd.js:
|
||||
file size: 6686316 # 6.69 MB
|
||||
@@ -51,7 +51,7 @@ antd.js:
|
||||
sys peak growth: 19934725 # 19.93 MB
|
||||
arena allocs: 181528
|
||||
arena reallocs: 34406
|
||||
arena size: 43039192 # 43.04 MB
|
||||
arena size: 43039048 # 43.04 MB
|
||||
|
||||
binder.ts:
|
||||
file size: 193077 # 193.08 kB
|
||||
@@ -60,9 +60,9 @@ binder.ts:
|
||||
sys deallocs: 33
|
||||
sys alloc bytes: 963008 # 963.01 kB
|
||||
sys peak growth: 657404 # 657.40 kB
|
||||
arena allocs: 2922
|
||||
arena allocs: 2924
|
||||
arena reallocs: 244
|
||||
arena size: 1074912 # 1.07 MB
|
||||
arena size: 1083768 # 1.08 MB
|
||||
|
||||
kitchen-sink.tsx:
|
||||
file size: 732898 # 732.90 kB
|
||||
@@ -71,6 +71,6 @@ kitchen-sink.tsx:
|
||||
sys deallocs: 1854
|
||||
sys alloc bytes: 5304252 # 5.30 MB
|
||||
sys peak growth: 3707425 # 3.71 MB
|
||||
arena allocs: 31033
|
||||
arena allocs: 31043
|
||||
arena reallocs: 3012
|
||||
arena size: 8439864 # 8.44 MB
|
||||
arena size: 8440280 # 8.44 MB
|
||||
Reference in new issue
Block a user