Loaders in the browser
Demo: Loader with ESModule example
In a frontend group, there was a discussion about how to use a template (like a Vue template or efml template) without a bundler directly.
1 | import markdown from "./markdown.md"; |
Unfortunately, there is no proposal such as a custom import handler.
(The import-map can’t load other types than JS)
TLDR: import.meta.url
is the core part.
Currently blocked by
import.meta.url
The very first demo is a simple Markdown loader.
1 | // index.js |
1 | // markdown-loader.js |
Then a CSS Loader which is a polyfill for the CSS Modules proposal.
1 | const src = new URL(import.meta.url).searchParams.get("src"); |
Then a JSON Loader for the JSON Module proposal, a problem is encountered.
The content must be loaded synchronous, or it must be put in a container.
The container in the Markdown case, is an HTMLElement
, in the CSS case, it is a CSSStyleSheet
object (Constructable Stylesheet).
What container should the JSON Module Loader use? A Promise? A getter?
1 | export default new Promise(...) |
Not good, developers except to import JSON from './json-loader.js?src=./file.json'
to get the plain JSON object.
The answer is synchronous XHR.
❌ Do not use synchronous XHR in production.
❔ After the Top-level await shipped, this limit will be resolved.
1 | const src = new URL(import.meta.url).searchParams.get("src"); |
Next step: Use Service Worker to compile the file
By using the service worker it will be easier to transform a file to JavaScript Module because it can generate the output asynchronously.
Here is the first example of the service worker transformed a CSS file into the equivalent JavaScript Module
1 | addEventListener("fetch", (e) => { |
Now, if the browser has no service worker installed, it will run the ESModule /css-loader.js?src=./file.css
and the code in the css-loader.js
will provide the CSS content.
If the browser has a service worker installed, the worker will transform the CSS in the worker and provide it directly.
Next step the service worker becomes more general. It becomes easier to extend. A “Loader” is defined:
1 | class Loader { |
By this abstraction, it is easier to create a new loader for a new type of file.
The markdown loader in Service Worker:
1 | Loader.add( |
TypeScript loader
?> Limitation: There is no way to create exports dynamically in ESModule. So it is impossible to translate the export
declaration in the entry file. Any other non-entry file handled by the transformer is not limited.
Transforming TypeScript to JavaScript in the browser is archived by import the TypeScript compiler (<script src="https://www.unpkg.com/typescript@3.6.2/lib/typescript.js">
) and call the ts.transpileModule
.
There are different module targets in the compilerOptions.module
: CommonJS, AMD, UMD, System, ES2015, and ESNext.
- The transformed code will be evaluated by
eval
, so ES2015 or ESNext is not a choice - Besides ESNext, only the SystemJS format supports the transformation of
import.meta
.
By using --target=system
with tsc,
1 | import a from "./d"; |
will be transformed into
1 | System.register(["./d"], function (exports_1, context_1) { |
By providing a System
object, the problem of the import and export are resolved.
Wrap the transformed code
- Call
ts.transpileModule
transform the ESModule / TypeScript code into SystemJS format and JavaScript (Source) - Wrap it like this: (Source)
1 | const System = new ECMAScriptModule(filePath); |
Here is the first working implementation and the final implementation.
Implement a SystemJS compatible System
object
The System
object should look like this:
1 | interface SystemJSLoader { |
In the SystemJS object implementation, it needs
- Recursively load all static dependencies
- Prepare the
import.meta
object - Prepare the dynamic
import()
function - Execute the module and save all the
exports
Load a module
Prepare an
import.meta
object with aurl
property. (Source)Prepare a dynamic
import()
function, it will call the custom module resolver internally. (Source)Provide the
import.meta
andimport()
to the 2nd parameter of theSystem.register
, it will return a{ setters, execute }
Check all dependencies of this module
i. if the module is loaded by the static import, fetch the dependencies by Sync XHR (Source)
ii. if the module is loaded by the dynamic import, fetch the dependencies by
fetch
(Source)iii. after the dependencies loaded, bind their
exports
to the dependee’simport
. (This demo doesn’t handle this well.)After all the dependencies resolved, execute the module
TypeScript loader in Service Worker
The problem becomes easier for a service worker. Compile the file content from TypeScript to JavaScript (keep it ESModule), then the browser itself will be able to run the ESModule format file.
And rewrite the import
path.
By visiting the AST tree, all static imports are transformed in the following way.
1 | // before |
By traveling through the AST tree, all function calls are checked, if the callee is ts.SyntaxKind.ImportKeyword
that means this expression is import(....)
. Transform them in the following way:
1 | // before |
Shared loader between runtime and the Service Worker loader
Define both of them in the same file!
There is an example of the shared CSS Loader.
1 | import { parseSrc } from "../loader-utils/load.js"; |
Let Service Worker itself loaded by the TypeScript loader!
The first try is to use ESModule in Service Worker directly (See: W3C: ServiceWorkerContainer > RegistrationOptions > WorkerType)
But when navigator.serviceWorker.register(src, { type: 'module' })
get called, Chrome throws:
Uncaught (in promise) DOMException: type ‘module’ in RegistrationOptions is not implemented yet.See https://crbug.com/824647 for details.
It seems Chrome is not implemented ESModule for Workers yet, so a classic Service Worker is written to load the TypeScript compiler and compile the real Service Worker.
typescript-serviceworker-loader.js
Browser Compatibility
Loaders in the browser