Sharing some reusable javascript is hard - even moreso if you're trying to share between node and the browser. I'm just trying to share some 10 lines of code. Why is this taking me more than a day to figure out?! Its times like these I have to talk myself out of doing a copy/paste. I know its wrong, and reusable packages are better, but ugh... at what cost.
- Some JS code is meant to run in the browser, some JS code can only run on the server-side.
- es6 != es2016, es5 != 2015, esNext/es8, HOW OFTEN IS THIS CHANGING?! I found this explanation helpful
- Webpack? Babel? I didn't think I'd need to deal with this just to share some code!
I mean, I get it. But why do I need 147 MB in node_modules just to share 10 lines of code!
Recently at work, I tried to extract some 20-something lines of javascript into a re-usable npm library to share between a few projects. I thought it would be easy and straightforward, and when I found out it wasn't, I started putting together this post to help guide people through what it takes to build a reusable npm package.
In this guide, I'm going to create a react app that loads random images of dogs, cats, and goats. Why those 3? :shrug: They were 3 random things I found on public-apis. Then I'll take that code, and turn it into a re-usable npm package that can be used in the browser and node with webpack, babel, and typescript.
I'm also including the intricatecloud/reusable-js-demo repo here so you can take a look at the project. Best way to follow along is to look at each commit to see each change that I'm specifically making (they are also linked after each section in the post). I've shortened some of the interesting bits here for brevity.
Building Random Animal Demo
Step 1. Create the app and run it
$ npx create-react-app random-animal-demo
$ cd random-animal-demo
$ npm start
Check out the full commit 582d2b
Step 2. Hook it up to some apis
In the app, I'm going to display a dog, cat, and goat picture. I'll tweak some of the boilerplate in the react demo app. I took the default react stuff that was there, and refactored it to show an image and some text. Now I'll integrate these free apis by using axios
to make requests (it works in the browser, and in node).
We'll use these APIs which allow us to call them from the browser.
https://aws.random.cat/meow
https://random.dog/woof.json
http://placegoat.com/200
You'll notice each API works slightly differently. The cat API returns a JSON object with a file property. The dog API returns a JSON object with a url property. And the goat API returns the image itself. I added some logic to componentDidMount()
to run it on a timer so that it cycles every few seconds
const DOG = 0
const CAT = 1
const GOAT = 2
switch(newIndex) {
case CAT:
axios.get('https://aws.random.cat/meow').then((response) => {
const imageSrc = response.data.file
const text = 'CAT'
this.setState({currentAnimal: {imageSrc, text}})
})
break;
case DOG:
...
Check out the full commit 3dd98c5
Step 3: Choose how you want to import your file
Once you want to pull the logic into another file, you have to decide how you want to import it. There are a few options for how you want to import it:
ES6 Imports - if you want to use import AnimalApi from 'animal-api'
animal-api.js
export default {
getDog: () => ....
getCat: () => ....
getGoat: () => ....
}
ES6 Destructured Import - if you want to use import { getDog, getCat, getGoat } from 'animal-api'
animal-api.js
export const getCat = () => ....
export const getDog = () => ....
export const getGoat = () => ....
CommonJS - if you want to use const AnimalApi = require('animal-api')
animal-api.js
module.exports = {
getDog, getCat, getGoat
}
When would you choose one over the other?
If your app only needs to work in a browser, and only in the context of React (or Angular2+ or environment that uses ES6 modules), then you're fine with using an ES6 import.
If your lib is meant to be used in the browser, and you need to include it in a vanilla JS HTML app, you need to use a bundler like webpack to bundle your app as a lib.
If you use webpack and take advantage of code splitting and tree shaking, you can use ES6 Destructured imports. What this means is rather than include all of lodash in your app, you can only include the functions you want and you'll have a smaller built app.
If you're writing an app or a library that needs to run in BOTH the browser, and in node, then you'll need to produce a few different versions of your library: one meant for the browser (as a script tag), one as an es6 module, and one for using in node.
For this guide, we're going to be writing an ES6 module, so we can target using import AnimalApi from 'animal-api'
.
Step 4: Extract some of this out into an npm library
Would you really pull some of this out into a lib? Probably not. But this is an example of how complex something that looks so simple could be. So bear with me.
I can modify my App.js file to use this new file:
import React from 'react';
import axios from 'axios';
+import AnimalApi from './animal-api';
import './App.css';
class App extends React.Component {
@@ -28,23 +29,19 @@ class App extends React.Component {
switch(newIndex) {
case CAT:
- axios.get('https://aws.random.cat/meow').then((response) => {
- const imageSrc = response.data.file
- const text = 'CAT'
- this.setState({currentAnimal: {imageSrc, text}})
+ AnimalApi.getCat().then((animal) => {
+ this.setState({currentAnimal: animal})
})
break;
case DOG:
...
src/animal-api.js
const getCat = () => {
return axios.get('https://aws.random.cat/meow').then((response) => {
const imageSrc = response.data.file
const text = 'CAT'
return {imageSrc, text}
})
}
...
export default {
getDog,
getCat,
getGoat
}
Check out the full commit 37c3a3a
Next, we're going to move this logic to its own npm package.
How to package your library so that it can be used
Create a new npm library locally
Create a new folder outside of your React project for the npm package we're going to make.
$ mkdir animal-api
$ cd animal-api
$ npm init
You can use all the defaults for the npm init command which creates a package.json file like this:
{
"name": "animal-api",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC"
}
Then I'll copy paste all of the code thats in my random-animal-demo into animal-api/index.js, ready to be used with import AnimalApi from 'animal-api'
.
Because this is a library, we'll want to add some tests to this to make sure this library is working. I like using jest, so I'll pull it in: npm install --save-dev jest
I'll then create animal-api/spec/index.spec.js:
import AnimalApi from './index'
describe('animal-api', () => {
it('gets dogs', () => {
return AnimalApi.getDog()
.then((animal) => {
expect(animal.imageSrc).not.toBeUndefined()
expect(animal.text).toEqual('DOG')
})
})
})
Check out the full commit 6d797c
Now run jest
:
☁ animal-api [master] ⚡ jest
FAIL spec/index.spec.js
● Test suite failed to run
/Users/dperez/workspace/animal-api/spec/index.spec.js:1
({"Object.<anonymous>":function(module,exports,require,__dirname,__filename,global,jest){import AnimalApi from '../index';
^^^^^^^^^
SyntaxError: Unexpected identifier
at Runtime.createScriptFromCode (node_modules/jest-runtime/build/index.js:1059:14)
Test Suites: 1 failed, 1 total
Tests: 0 total
Snapshots: 0 total
Time: 0.834s
Ran all test suites.
And I thought this would have been the easiest part. Apparently, jest
doesn't like the use of the import
statement.
At the moment, with just an index.js file and jest, its going to be running inside a node.js environment where import
is not yet supported. (This walkthrough is using node 10).
This is where babel
comes into play. babel
will transpile ("translate/compile") your js files from ES6 (where you can use import
) and ES5 which node.js supports. Jest has a section on enabling babel support here. We'll have to install a few more packages, and a little configuration.
Run npm install --save-dev babel-jest @babel/core @babel/preset-env
and then add babel.config.js
babel.config.js
module.exports = {
presets: [
[
'@babel/preset-env',
{
targets: {
node: 'current',
},
},
],
],
};
Then when you run jest
, you get to the next error
☁ animal-api [master] ⚡ jest
FAIL spec/index.spec.js
● Test suite failed to run
Cannot find module 'axios' from 'index.js'
Aha, progress. Now it knows how to read the import
statement, and the test runs. Now i'm missing a dependency - axios. npm install --save axios
. Now re-run jest and all green!
☁ animal-api [master] ⚡ jest
PASS spec/index.spec.js
animal-api
✓ gets dogs (161ms)
✓ gets cats (69ms)
✓ gets goats
Test Suites: 1 passed, 1 total
Tests: 3 passed, 3 total
Snapshots: 0 total
Time: 1.148s
Ran all test suites.
Check out the full commit fafc23a
Next up, we'll go through a few scenarios for how to include this package in another project
Scenario 1: A lib that only needs to work in a browser, and you're using React/Angular2+ (ES6 modules).
I'm at a good spot now where I can try including this library in my react app. Rather than publishing the npm package publicly, I'll just "install" it from the folder on my machine. I'll switch back to my react app and run npm install ../animal-api
My package.json now looks like this:
"@testing-library/user-event": "^7.2.1",
+ "animal-api": "file:../animal-api",
"axios": "^0.19.2",
I can try using it from the React app. Back to App.js
import React from 'react';
-import axios from 'axios';
-import AnimalApi from './animal-api';
+import AnimalApi from 'animal-api';
import './App.css';
Check out the full commit a802898
Boom! Everything works! Fantastic, I've now got a publish-able package. By coincidence, theres already an animal-api
published to npm so I wont bother adding yet-another-one. If all you need to do is share some code thats going to be used in a React/Angular framework (where you can use import
), then you can stop right here. Move on with your life and be grateful you don't need to read Scenario 2.
Scenario 2. Using the library in a browser using a script tag
This is where we want to create a file that be included as a script tag.
In your animal-api directory, create index.html, run a local webserver with npx http-server
and visit your page http://localhost:8080
:
index.html
...
<div id="imageSrc"></div>
<div id="text"></div>
...
<script src="./index.js"></script>
<script>
AnimalApi.getDog().then(function(animal) {
document.querySelector('#imageSrc').textContent = animal.imageSrc
document.querySelector('#text').textContent = animal.text
})
</script>
Check out the full commit df79f05
The first error I get with this is:
index.js:1 Uncaught SyntaxError: Cannot use import statement outside a module
index.html:11 Uncaught ReferenceError: AnimalApi is not defined
at index.html:11
(anonymous) @ index.html:11
This is saying that the browser can't run your file that contains import statements. We used babel
before so that jest
was able to use the import
keyword. We have to do something similar here. The only difference is that we need to save the "transpiled" output so we can include it in the browser. Since we already have babel
as a dependency in animal-api
, we can use it to convert to a UMD module
$ npm install --save-dev @babel/plugin-transform-modules-umd @babel/core @babel/cli
Then we can run babel index.js -d lib
which will create a lib/index.js file ready to be consumed as a script tag in your browser. Now I can update my script tag to point to this other file.
</body>
-<script src="./index.js"></script>
+<script src="./lib/index.js"></script>
<script>
AnimalApi.getDog().then(function(animal) {
Once I refresh the page, one error goes away, the other one stays.
index.html:11 Uncaught ReferenceError: AnimalApi is not defined
at index.html:11
We haven't configured the babel plugin that creates the UMD module to use the name that we want. Because our file is named index.js
, we'll update our babel.config.js to this:
babel.config.js
module.exports = {
+ plugins: [
+ ["@babel/plugin-transform-modules-umd", {
+ exactGlobals: true,
+ globals: {
+ index: 'AnimalApi'
+ }
+ }]
+ ],
presets: [
Re-run babel index.js -d lib
, and refresh the page
And now I get a new error! Progress!
Uncaught TypeError: AnimalApi.getDog is not a function
at index.html:8
This one is a head scratcher. Whatever babel is doing to this file, I'm losing access to my functions. Lets take a look at what the transpiled file looks like lib/index.js
. In this file, I notice 2 things:
global.AnimalApi = mod.exports;
...
var _default = {
getDog,
getCat,
getGoat
};
_exports.default = _default;
This suggests that there is a .default
property on my AnimalApi global object. This is a small detail thats usually invisible when you're using the import axios from 'axios'
syntax. An ES6 package will provide a "default" export, and when you use the import
statement, it knows to look at .default
property for you, so you don't need to write it.
Lets update our code to match that:
AnimalApi.default.getDog().then(function(animal) {
...
})
Now, when we refresh the browser, we get our next error:
index.js:36 Uncaught TypeError: Cannot read property 'get' of undefined
at Object.getDog (index.js:36)
at index.html:8
Check out the full commit 5da97d8
This is failing to call axios.get
because axios
is undefined. All we did was transpile the index.js file. The code for axios isn't there, and the browser doesn't know that its supposed to search your node_modules directory for it (nor can it).
What we need to do is create one js file, that has all the dependencies of that library built into it, so that you only need to add the 1 script tag to your site. Babel can't handle your dependencies, it can only translate.
We now need to introduce our 2nd tool: webpack!
We'll use webpack to take a look at our file, and everything that is import'd/require'd will then get "packed" into one file.
We'll install webpack
$ npm install --save-dev webpack webpack-cli
And add the following to webpack.config.js
const path = require('path');
module.exports = {
entry: './index.js',
output: {
path: path.resolve(__dirname, 'lib'),
filename: 'animal-api.js',
library: 'AnimalApi',
libraryTarget: 'var'
},
};
This config will look at ./index.js
and create ./lib/animal-api.js
which will make the variable AnimalApi
available within your javascript, using the var
target.
Now run webpack
and you'll notice that lib/animal-api.js
got a lot bigger, with more gibberish.
Go back to your index.html
and update the script tag:
-<script src="./lib/index.js"></script>
+<script src="./lib/animal-api.js"></script>
Check out the full commit 8f865c5
Refresh the page, and success! The data shows up! If thats all you needed, then great. You can stop here. Otherwise....
Scenario 3: Using the library in both the browser AND node
I'll create a plain javascript file that I'll run with node: node node-test.js
node-test.js
const AnimalApi = require('./index.js')
AnimalApi.getCat().then(animal => {
console.log(animal)
})
I get this error:
/Users/dperez/workspace/animal-api/index.js:1
(function (exports, require, module, __filename, __dirname) { import axios from 'axios';
^^^^^
SyntaxError: Unexpected identifier
We saw this when we tried to run our index.spec.js file with jest before using babel. We're trying to import an ES6 module with require
which won't work because import
is part of ES6 and node is running against ES5 (for node 10 anyways). We built a lib/index.js
using babel earlier in Scenario 1 which contains an ES5 module (that was transpiled from ES6). Lets use that instead:
I now get this error:
☁ animal-api [master] ⚡ node node-test.js
/Users/dperez/workspace/animal-api/node-test.js:3
AnimalApi.getCat().then(animal => {
^
TypeError: AnimalApi.getCat is not a function
We saw this earlier in the browser, and saw that we needed to add AnimalApi.default.getCat()
because of the way that babel transpiles your ES6 module to a UMD module. Once we update it....
☁ animal-api [master] ⚡ node node-test.js
{ logo: 'https://purr.objects-us-east-1.dream.io/i/v0SoPzk.jpg',
text: 'CAT' }
Check out the full commit 3cdea87
Success! We can use the ES5 module lib/index.js
that was created by babel in order to use the library in node.js.
Scenario 4: You want to use Typescript in your library, but still include it as normal in a React/node project
What if you like using Typescript and would rather create your library using Typescript, but still use it in other non-typescript projects? No fear, there's an easy solution.
I'm going to add an index.ts
file which contains the library, only using Typescript. Here's what it looks like compared to our index.js file. We added an interface
and added a function signature.
+interface Animal {
+ imageSrc: string
+ text: string
+}
+
+const getCat = (): Promise<Animal> => {
return axios.get('https://aws.random.cat/meow').then((response) => {
const imageSrc = response.data.file
const text = 'CAT'
- return {imageSrc, text}
+ return {imageSrc, text} as Animal
})
}
If we try to run this .ts
file straight from react by adding:
App.js
import React from 'react';
-import AnimalApi from 'animal-api';
+import AnimalApi from 'animal-api/index.ts';
import './App.css';
When we refresh the app, we see this error:
Module parse failed: The keyword 'interface' is reserved (3:0)
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
| import axios from 'axios';
|
> interface Animal {
| logo: string
| text: string
The problem is that React doesn't know what to do with your .ts
file. So now you need to convert your index.ts
file into something that React can read which would be either an ES6 module, or an ES5 module.
Typescript is able to do this without the help of babel. We can compile our file to an ES5 module that we can then import
or require
. First, install typescript npm install --save-dev typescript
Add a tsconfig.js to configure your Typescript compiler
{
"compilerOptions": {
"outDir": "./dist",
"lib":["ES2015", "ES2016"],
"sourceMap": true,
"noImplicitAny": true,
"target": "es5",
"module": "commonjs"
}
}
When we run tsc
, we'll get dist/index.js
and that file we'll be able to include from our react project or node-test.js
file.
index.html
import React from 'react';
-import AnimalApi from 'animal-api';
+import AnimalApi from 'animal-api/dist/index';
import './App.css';
Check out the full commit 2625124
Now when you refresh your pages, success! Now, for the last bit...
Scenario 5: You want to use Typescript to build a lib included as a script tag
When we want to include a library or project using a script tag, we need to use a bundler to make sure that our projects dependencies are included with our library. We used webpack earlier to do this, and without much mucking around, we were able to produce a js bundle that can be included as a <script src="./lib/animal-api.js">
We'll update webpack.config.js to add a Typescript loader which will be able to read your Typescript file and do things with it. We'll still use webpack to bundle it.
Run npm install --save-dev ts-loader
to install the loader and then update the webpack config.
webpack.config.js
module.exports = {
- entry: './index.js',
+ entry: './index.ts',
+ module: {
+ rules: [
+ {
+ test: /\.ts$/,
+ use: 'ts-loader',
+ exclude: /node_modules/,
+ }
+ ]
+ },
+ resolve: {
+ extensions: ['.ts', '.js'],
+ },
output: {
Thats all we have to add to our webpack configuration to get this project built and bundled. Once you run webpack
, you'll get a new file ./lib/animal-api.js
. Go back to your test index.html
file to test it out. Thats it!
Check out the last full commit cc18f80
Theres a few things to be aware of. Our very simple library has multiple entry points that need to be maintained.
- If you can consume ES6 modules using
import AnimalApi from 'animal-api'
then that will rely on your./index.js
file. - If you want to consume your ES6 module in the browser, you need to use the
./lib/animal-api.js
file which will make AnimalApi globally available using the babel plugin, and bundles all the dependencies along with it using webpack. - If you want to use your ES6 module in node, then you need to use
./lib/index.js
, the UMD module that we built with babel. - If you want to use your Typescript file from another Typescript project, you need to use
./lib/index.ts
- If you want to use your Typescript file in a node.js environment, you have to use
tsc
to compile it down to an ES5 module that you can then re-use. - If you want to use your Typescript file in a
<script src="mylib.js">
, you'll need to usewebpack
withts-loader
to compile your typescript, bundle all the dependencies, and produce a UMD bundle.
Both your source files, and built files can be distributed with your npm package and left to the user to choose which one they need. But it does require you to put a fair amount of tooling in place to create that convenience.
Wrapping up
Remember, you can follow along commit by commit by checking out the repo here intricatecloud/reusable-js-demo. Drop a ⭐️ if you found it helpful!
Thanks for reading along, hope this helped you understand the differences between the different module types, and how to go about extracting a library from our application that we can use both in and outside of the browser.
If you're interested in more things react - definitely check out my in-depth guide to adding google sign-in to a react app, or my guide to deploying a static website using S3 + Cloudfront with terraform