This post assumes familiarity R package development, JavaScript and Node.js. I recommend the second chapter of JavaScript for R as a starter.
The htmlwidgets
R package provides a friendly interface for developing
R packages that wraps JavaScript libraries. An htmlwidget is nothing
more than a normal R plot plus interactivity powered by JavaScript. The
package abstracts away many of the details of juggling with both
JavaScript and R, most notable of which being dependency management.
An example from the JavaScript for
R book shows the development of the
gior
package, which corresponds
to the gio.js
JavaScript library. The
inst/htmlwidgets
directory contains necessary dependencies required by gio.js
. This
file is the entry point of creating the widget. It depends on JavaScript
libraries including gio.js
, three.js
, HTMLWidgets
and Shiny
. We
don’t need to worry about including HTMLWidgets
or Shiny
ourselves,
since R will do it for us.
For the first two dependencies, we can download it from CDN and include
it in the
inst/htmlwidgets/lib
directory. Lastly, we include a file
gior.yaml
to declare locations of the dependencies that looks like:
Now, whenever we create a widget from R, the rendering context will
automatically serve all the javascript files. This workflow is
convenient for developing pacakges that does not require much work on
the JavaScript side, all we need to do is calling some initialization
functions in gior.js
. However, if more work on the javascript side is
involved, more than just passing a few lines of options, this setup is
not sufficient. Since javascript dependencies are managed from R and
never decalred in gior.js
, we won’t be getting all the nice features a
modern text editor can provide, such as autocompletion, snippets,
linking and intellisense. Moreover, when our package gets larger we
might want to split the javascript code into separate modules rather
than cluttering the gior.js
file, and it’s not so fun to do bundling
ourselves.
For this reason, it makes sense to have more control over how javascript
dependencies are managed, rather than just downloding and including a
dist file. The end result is still the same, we need to include one or
several javascript files for the plot. It’s just we will not be using
files already provided by cdn, but to download the javascript package
and do the bundling ourselves. The
packer
package provides an
solution to this.
The packer
Package
In the JavaScript world, dependency management is done through node and
a package manager of choice, like npm, yarn or pnpm. These package
managers call be used to create a project-specific environment into
which various packages will be insalled. Then, we would use a bundler
like webpack to extract all files into a single file, which is served
every time a widget is created from R. packer
can be used to scaffold
a project structure for this need, and provides an R interface so that
we can still do all the work through R commands. The following two
commands scaffold an htmlwidgets package powered by packer:
The project directory tree is generated as
A node_modules
folder is created for storing javascript dependencies.
Note that we are managing javascript dependencies ourselves now, and we
can install them with packer::yarn_install
from R or simply yarn add
from the command line.
The three files started with webpack
are webpack configurations for
bundling. The webpack.common.js
file stores shared options for both
development and production. The webpack.dev.js
is used for
development, and the webpack.prod.js
is used for production. There are
3 most important webpack options for our purposes, which packer sets in
the srcjs/config
directory.
-
output
will determine the dist file name and location, this should be named<widget-name>.js
in theinst/htmlwidgets
directory so that R knows to include it. -
entryPoints
determines the starting point of the bundling process. This can be set to any top-level file that imports other dependencies that callsHTMLWidgets.widget()
. packer use the convention ofsrcjs/widgets/<widget-name>.js
as the entry point. -
externals
declares the dependencies that we don’t need webpack to resolve. This includesShiny
andHTMLWidgets
which is outside of thenode_modules
folder and added by R. If we don’t declare them webpack will be report an error as it can’t find them.
Besides, there is also a loaders
option that tells webpack how to
preprocess each file type. If we are developing a regular javascript
website, this will include different preprocessors for javascript, css,
scss, etc. Though in the context of htmlwidgets it’s all setup by
packer.
Now, if we run packer::bundle_dev()
, it will invoke
npm run development
specified in the scripts
section in
package.json
, which then runs webpack with development configurations.
webpack will include all necessary files and bundle them into a
inst/htmlwidgets
. Anytime we make a change to the srcjs
directory,
we need to run packer::bundle_dev()
to update the dist file.
This time, since our project follows standard javascript project
structure with package.json
and node_modules
. When we are writing
JavaScript code, our text editor will be able to resolve them and
provide intellisense. And we can have arbitrary code structure to to
better organize our code, as long as it is imported by the entry file.
Using TypeScript and esbuild
packer produces decent boilderplate if you are happy with simple JavaScript libraries and webpack. However, if you need to include TypeScript, Sass or frameworks like React and Svelte, webpack configurations can be notoriously time-consuming. Although packer also provides templates for the JavaScript version of React and Vue, but they still require a handful of customization in my opinion. Further, webpack is sometimes considered outdated with bigger bundle size and slow bundling speed.
So if you are like me who goes out of his way to have an as “optimized”
package as possible, it may be better off to have a personal setup
similar to packer with optimized replacements. In essence it’s just a
matter of producing a dist file in the inst/htmlwidgets/
directory
that guarantees the best development experience, and I will share one
combo I find most comfortable. TypeScript is used to replace javascript
for static typing, and esbuild to replace webpack with hundreds times
faster performance , simpler configurations, and native support for
TypeScript.
During my recent development of the xkcd htmlwidgets package, I migrate a packer-generated setup to one with TypeScript and esbuild.
The first thing is to remove webpack related dependencies in
package.json
and run yarn update
. Then we can install TypeScript,
esbuild and whatever JavaScript library you want to work with
We can also remove all the webpack configurations in srcjs/config
and
webpack.*.js
files in the root directory.
At this point, our package.json
file should look like this
Next, let’s create our entry point file, I like to name it index.ts
under the srcts
directory:
Now, let’s configure esbuild to meet the requirments of htmlwidgets.
Since esbuild does not have a configuration file that will be
automatically pick up when invoked from the command line, we’ll create a
normal esbuild.js
file in the root directory and then run it through
node
.
Note that esbuild share similar configurations with webpack, we are
again declaring entry file (entryPoints
), where the bundled file
should go (outfile
), and external dependencies (external
). The last
step is adding a command that invokes this script and do the bundling:
Now, run yarn watch
from the command line to use build script
esbuild.js
, esbuild starts with the message:
This will create the <widget-name>.js
dist file under
inst/htmlwidgets/
. Because we set watch
in esbuild.js
, esbuild
will watch for changes in the entry file and related modules and rebuild
the bundle whenever it changes. This means if we are making only making
a change on the JavaScript side, the widget should update automatically
next time it’s created. So there is no similar need to call
packer::bundle_dev()
again.
With this setup, it’s easy to include any additional libraries you like
to use. For example, if you want to include
tailwindcss in your widget, you can simply
yarn add
tailwind and look up the corresponding tailwind-esbuild
configuration.