Đọc thêm bài này Mô-đun ECMAScript và khả năng tương thích trình duyệt (ok)
https://molily.de/ecmascript-modules/
Hai cách load module ngoài trình duyệt dễ nhất :)
C:\Users\Administrator\Desktop\amd-dependencies\test.html
Hoặc
C:\Users\Administrator\Desktop\amd-dependencies\test.html
C:\Users\Administrator\Desktop\amd-dependencies\index.js
C:\Users\Administrator\Desktop\amd-dependencies\helloWorld.js
ECMAScript modules and browser compatibility
How do we ship modern JavaScript in a compatible way?
April 21, 2023
One of the biggest changes in JavaScript in the last decade was the switch from loosely-connected scripts to ECMAScript modules (ESM). This affected both client-side and server-side JavaScript code.
JavaScript programmers today take it for granted that they can pull a library dependency into client-side or server-side JavaScript code with npm install
or other package managers that build on the npm registry. A decade ago, this crucial infrastructure was still in its infancy.
ECMAScript modules
In 2015, ECMAScript 6 standardized a declarative syntax for JavaScript files to import and export values.
index.js:
helloWorld.js:
Node.js experimentally supported ECMAScript modules in version 8.5.0 (September 2017). Since version 14 (April 2020), the implementation is no longer experimental. Since version 15.3.0 (November 2020), it is considered stable.
ECMAScript modules in the browsers
However, browsers did not support ECMAScript modules natively yet. They did not understand the import
/export
syntax yet. So the bundlers produced JavaScript files without ECMAScript module syntax.
Webpack used its own mechanism for splitting code into chunks and loading them dynamically. Rollup relies on the module loaders RequireJS or SystemJS to be present at runtime.
This allows web developers to ship two different versions of their code:
One modern build with native ECMAScript modules for new browsers.
One legacy build, backwards-compatible with old browsers that lack support for ECMAScript modules.
Today, 95.69 % of all browsers worldwide fully support <script type="module">
, according to Can I Use. The remaining 4.31 % have no support. Bear in mind that these values are global means. In your user base, in your audiences, the values may be lower or higher.
ECMAScript modules as a baseline
The switch to ECMAScript modules for client-side code seems small at first sight. We do not need a module loader like SystemJS any longer and the shipped production code is more closer to the written code. This is it, or not?
To support older browsers, transpiling ECMAScript 6+ code to ECMAScript 5 using Babel is a common practice. For our modern browser build, there is no need to. The targeted browsers fully support ECMAScript 6 and even some features of ECMAScript 7 (2016) and 8 (2017). We can skip the transpilation completely if we stick to supported syntax features. We can also skip certain feature detection and omit polyfills for supported APIs.
The resulting modern build is smaller and faster. It is a clear win to ship ECMAScript module code to browsers that support it. But it requires developers to compile and embed two builds: the ECMAScript modules build and the legacy build for browsers without ECMAScript module support. Since the code of the two builds may differ considerably, developers need to test and debug both.
Dynamic imports
There is one missing piece in the puzzle: We still want to split the bundle into smaller chunks and load them lazily when the page actually needs the JavaScript code. Static imports load code eagerly. Bundlers like Webpack and Rollup would emit one gigantic JavaScript file even when an individual page just needs a tiny fraction of JavaScript.
Dynamic imports are supported by all major browser engines since Chrome 63 (December 2017), Safari 11.1 (April 2018) and Firefox 67 (May 2019). Edge 79 (January 2020) was the last browser to support them, when Microsoft switched from EdgeHTML to Chromium and the Blink engine.
Compare this to the support for <script type="module">
: Chrome 61 (September 2017) / Edge 79 (January 2020), Safari 11 (September 2017) and Firefox 60 (May 2018).
Intermediate browsers
There is a small range of browser versions that do support ECMAScript modules but not dynamic imports: Chrome 61 to 62, Firefox 60 to 66, Edge 16 to 78 and Safari 10.3 to 11.0.
Luckily, these browser versions are hardly relevant in 2023. Only few users are stuck on old versions of Chrome, new Edge and Firefox. These “evergreen” browsers are updated automatically to a new major version every month. Safari does not update that often, but the version range 11 (September 2017) to 11.1 (April 2018) is tiny.
How should we deal with these intermediate browsers? We have two options:
Serve them the modern build, but do not use native dynamic imports. Use a module loader like SystemJS for importing code dynamically.
Serve them the legacy build without ECMAScript module code and without dynamic imports. Use a module loader like SystemJS for dynamic imports.
Because of the small market share of intermediate browsers, most projects use native dynamic imports and put intermediate browsers into one bucket with old browsers.
Shifting the baseline to browsers that support dynamic imports gives us even more freedom for what is possible in the modern build.
Detecting support for dynamic imports
But how do we serve the legacy build to intermediate browser? The combination of <script type="module">
and <script nomodule>
allows us to create a conditional loader: If the browser supports ECMAScript modules, load the modern build, otherwise load the legacy build.
Unfortunately, there is no simple conditional loader for scripts using dynamic imports. This feature cannot be detected easily. The reason is that import
is a reserved word in ECMAScript. Reserved words cannot be used in expressions where an identifier is expected.
If an intermediate browser parses a script with import('…')
, it fails with a syntax error. When the parsing fails, the browser cannot execute the code.
Luckily, the syntax error that prevents execution already hints at a possible solution. To detect support for dynamic imports – we perform a dynamic import! If the browser executes the imported code and the code after the import()
call, it is a new browser. If the browser does not execute the code, it is an intermediate browser.
We use dynamic imports to load the bundle that in turn uses dynamic imports. This guarantees us that only capable browsers execute the code. All other browsers are served the legacy bundle.
Vite’s legacy plugin
The modern build with an entry script and chunks for modern browsers that support ECMAScript module and dynamic imports.
The legacy build with an entry script and chunks for legacy browsers. The code is transpiled with Babel.
The actual cross-browser Vite embed code is much more sophisticated: Vite detects support for import.meta.url
, dynamic imports and async generators in one script. If these three features are supported, it sets the flag __vite_is_modern_browser
. If this flag is not set, it first loads the legacy polyfill which contains SystemJS and, well, polyfills. Using SystemJS, it then loads the legacy entry. (This is the equivalent to legacy-build.js
in the example above.)
Module preload
If you use dynamic imports frequently in your code base or have several entry points that share library code, bundlers like Rollup will create many small chunks. An individual page will load numerous small JavaScript files even if there is little JavaScript interactivity.
This is not necessarily a performance penalty since HTTP/2 multiplexing can request and receive many resources in parallel. Once these small chunks with reused library code are cached in the browser, subsequent pages have an excellent loading performance.
But often the dependency graph is a chain: a.js imports b.js, b.js imports c.js, for example. If there are other lazy-loaded modules pointing to b.js and c.js, the code does not end up in one chunk, but in three chunks.
When a page embeds a.js, a request waterfall happens. The browser downloads a.js, b.js and c.js subsequently, not in parallel. It downloads a.js, finds the import of b.js, downloads b.js, finds the import of c.js, downloads c.js. Finally, the browser is able to execute a.js after all dependencies have been loaded in sequence.
This improves the cold boot performance while using many small chunks with the minimal amount of JavaScript necessary for a certain page.
Pragmatic compatibility with old browsers
Vite is an example for a mature build tool that helps you ship ECMAScript modules. It supports old as well as intermediate browsers and optimizes the performance for modern browsers. This blog post cannot get into all details of Vite’s strategy.
For your project, you need to decide which browser capabilities are required and which browsers you can support easily.
For example, I am currently working on a JavaScript library for a client. The library does not actively support old browsers without ECMAScript module support, it does so passively. With little effort, old browsers get a basic functionality. We have a pragmatic approach:
These polyfills and transformations typically affect the legacy bundle only since the baseline for the modern build is already quite high.
There is ECMAScript syntax that cannot be transpiled and there are browser APIs that cannot be polyfilled with reasonable effort.
If we do not strictly need these capabilities, we do not use them. We check browser support before using a JavaScript feature to avoid raising the bar for no important reason.
How frameworks employ ECMAScript modules
When all major browser engines implemented support for <script type="module">
in 2018, JavaScript programmers and projects started to switch.
How is the situation today? What is the strategy of popular build tools and frameworks?
Here is a quick survey:
Vite: ECMAScript modules per default. Option to support older browsers with
@vitejs/plugin-legacy
(see above).create-vue@3: Uses Vite and has the same defaults, supports
@vitejs/plugin-legacy
.create-react-app: Does not use ECMAScript modules. Uses Babel and supports polyfills.
Astro: Does not use ECMAScript modules. But the
astro-island
custom element uses dynamic imports without feature detection or fallback. So the minimal required browser needs to support bothcustomElements.define()
and dynamic imports.
This overview merely describes, but does not judge the different approaches. While backwards compatibility is a virtue, each framework and site author need to decide on the minimum browser requirements and how to support browsers that do not meet them.
Cutting the mustard with ECMAScript modules
This technique absolved developers from writing convoluted, backwards-compatible JavaScript. It made the JavaScript simpler and more robust since it did not have to deal with every eventuality.
Not all frameworks use Progressive Enhancement or Graceful Degradation. Not all frameworks have a strategy for browsers that do not meet the minimum requirements. For some frameworks, the site is just empty or broken in these browsers.
It is hard to even find out the minimum requirements of popular frameworks today. Only few state them explicitly. Most tacitly use JavaScript APIs or ECMAScript features introduced recently without realizing that this raises the bar of entry.
Using new ECMAScript syntax safely: The optional chaining operator
Technically, optional chaining is “syntactic sugar”: a shorter, more readable way to write a logic that was already possible before. Syntactic sugar can easily be transpiled into older syntax with broader support:
Historically, optional chaining is a relatively new addition to ECMAScript. It was introduced in ECMAScript 11, published in June 2020. The major browser engines already shipped support in February or March 2020 when the corresponding proposal was finished.
The real-life impact of our technical decisions really hit home to me once again: my Mom had trouble volunteering and participating in her local community because somebody shipped the optional chaining operator in their production JavaScript.
If you use new syntax features, do so consciously and mind the consequences. New syntax raises the bar and may thwart previous efforts to support older browsers.
Or we detect the browser support by using optional chaining and setting a flag. If the browser parses the code and sets the flag, we load the modern build that may use optional chaining right away.
We did similar with window.__browserSupportsDynamicImports
above. As we have learned, Vite checks for multiple syntax features and sets __vite_is_modern_browser
. Let us integrate two syntax checks into one script that sets window.__isModernBrowser
:
The future
The ECMAScript standard and browser APIs keep evolving. Many innovations make it easier to deliver small, fast, custom-fit JavaScript to the browser.
Even if we take ECMAScript modules, dynamic imports and potentially more features as a baseline for our JavaScript, we constantly need to think about browser compatibility. The nature of the web has not changed. We always have to deal with diverse browsers with hugely different capabilities.
Therefore we still need Progressive Enhancement and Graceful Degradation. If we use the latest JavaScript features and want to support slightly older browsers, we still need two builds. We will still need transpilation, feature detects and polyfills in the future.
Last updated
Was this helpful?