Today, in 2017, many evergreen browsers support ES6 Modules out of the box. Some browsers have it hidden behind a flag, including Node.js. But is it possible to support old and new environments with the same npm package? Yes!
Editor’s Note: ES6 Modules are sometimes referred to as ES2015 Modules, or ESM, or
_module_
scripts, or sometimes even by the extension_.mjs_
pronounced "Michael Jackson Scripts". We are all talking about the same thing so don't get confused if you hear different terms.
I won’t go over the merits of using ES6 Modules or why TypeScript is awesome because there have been many blog posts describing these at length. Instead, I will focus on publishing a package to npm that is written in TypeScript but deployed as .mjs
(ESM) and .js
(CommonJS) so any consumer can use your package!
The first step is to setup your tsconfig.json
file so that TypeScript will use the latest and greatest JavaScript features like so:
{ "compilerOptions": { "module": "es2015", "target": "ES2017", "rootDir": "src", "outDir": "dist", "sourceMap": false, "strict": true }}
Obviously, we want modules to be es2015
because hey, its in the title of this article so we have to address it at some point! Let's target es2017
so we can use async
and await
keywords like a JS Ninja. You can name your rootDir
and outDir
to whatever you want, but its a bit of a convention to use dist
for output in JS Land. Source Maps are optional but I like to turn them off until I need them. Strict Mode is also optional but it's easier to start strict and get a little loosey goosey if you need too later. I highly recommend enabling it since it is disabled by default.
Now we can discuss the package.json
file. Here's an example for a package called copee:
{ "name": "copee", "version": "1.0.0", "description": "Copy text from browser to clipboard...natively!", "repository": "styfle/copee", "files": [ "dist" ], "main": "dist/copee", "types": "dist/copee.d.ts", "scripts": { "mjs": "tsc -d && mv dist/copee.js dist/copee.mjs", "cjs": "tsc -m commonjs", "build": "npm run mjs && npm run cjs" }, "devDependencies": { "typescript": "^2.5.3" }}
The first four lines define the package name
, version
, description
, and GitHub repository
which are self explainatory.
Next, we define files
which we simply define as a single folder, dist
. These are the files that will be published to npm.
The entry point into your package is defined as main
and this is where the magic happens. Notice there is no file extension (such as .js
) as one might expect. This will allow Node to pick the file based on the way the consumer is importing your package--either legacy CJS or the new ESM.
Next is types
which is necessary for consumers who want to import via TypeScript. If you're writing your package in TypeScript, you should most definitely include types
for your fellow TS users to get type saftey! Seriously, it's just the right thing to do.
Now comes the fun part: scripts
. These are your build steps which can be run via npm run thenameofthescriptgoeshere
. The first build step mjs
uses the TypeScript Compiler (tsc
) to build our code using the tsconfig.json
file we defined earlier, plus a -d
flag which emits our .d.ts
type definitions. Also note the mv
command which moves (or rather renames) the output file from .js
to .mjs
. This is our ESM output.
Our next script, cjs
uses the TypeScript Compiler (tsc
) to build the same source code but emit the output as a CommonJS module. This is the module system for Node.js and is understood by browserify
, webpack
, etc.
Lastly, we have devDependencies
which are your build tools. In this case, all we need is typescript
which includes the tsc
command used above.
I’m going to show you how to write a consumer that imports the package above. If you already use Node regularly, jump to the next section for ESM usage.
First, install the copee package:
npm install --save copee
Then, create a index.js
file with the following:
const { toClipboard } = require('copee'); console.log('CJS: We found a ', typeof toClipboard);
The new program can be executed like so:
node index.js
I’m going to show you how to write a consumer that imports the copee package above.
After installing copee, create a index.mjs
file. You must use the Michael Jackson Script extension (.mjs).
import { toClipboard } from 'copee';console.log('ESM: We found a ', typeof toClipboard);
The new program can be executed like so:
node --experimental-modules index.mjs
Node usage isn’t that spectacular because there have been modules since its inception, but the beauty of ESM is that the same code executing in a Node module will run unchanged in a browser! Yes, it’s true! Feast your eyes on this elegant code snippet below:
<script type="module"> import { toClipboard } from 'https://cdn.jsdelivr.net/npm/copee/dist/copee.mjs';
$('#btn').on('click', () => { const win = toClipboard('Wow, "copee" works!'); if (win) { // it worked, check your clipboard! } });</script>
We have a new script type for module
and we are using jsDelivr to automatically host our code on a CDN. This makes it easy to write a single import line and use the copee package in browsers all over the world!
What about legacy browsers, you say? Not everyone supports ESM? Well this can be solved by bundling as UMD with rollup
. After installing rollup
, add this to the scripts
section of your package.json
file.
{ "umd": "rollup -i dist/copee.mjs -o dist/copee.umd.js -f umd -n copee"}
You can include both ESM and UMD builds on the same page without conflicts. See the snippet below:
<script nomodule src="https://cdn.jsdelivr.net/npm/copee/dist/copee.umd.js"></script> <script type="module"> import { toClipboard } from 'https://cdn.jsdelivr.net/npm/copee/dist/copee.mjs';</script>
By using the nomodule
attribute, you are telling new browsers to ignore the UMD script. By using type=module
you are telling old browsers to ignore ESM. Now everyone wins!
You can see a working demo of this solution on the Demo page.
Also, please checkout the GitHub repo for more details and of course, the working source code!
Originally published at www.ceriously.com on October 16, 2017.