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:
dependencies:- name: threeversion: 97src: htmlwidgets/lib/threescript: three.min.js- name: gioversion: 2.0src: htmlwidgets/lib/gio-2.0script: gio.min.js
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:
usethis::create_package("<package-name>")packer::scaffold_widget("<widget-name>")
The project directory tree is generated as
├── DESCRIPTION├── NAMESPACE├── R│ ├── <widget-name>.R├── inst│ └── packer├── node_modules│ └── ...├── package.json├── srcjs│ ├── config│ ├── inputs│ └── index.js├── webpack.common.js├── webpack.dev.js└── webpack.prod.js
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.
-
outputwill determine the dist file name and location, this should be named<widget-name>.jsin theinst/htmlwidgetsdirectory so that R knows to include it. -
entryPointsdetermines 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>.jsas the entry point. -
externalsdeclares the dependencies that we don’t need webpack to resolve. This includesShinyandHTMLWidgetswhich is outside of thenode_modulesfolder 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
yarn add -D typescript esbuild @types/nodeyarn add <target-package>
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
{"devDependencies": {"@types/node": "^17.0.12","esbuild": "^0.14.14","typescript": "^4.5.5"},"dependencies": {"chart.xkcd": "^1.1.13" // here goes all js dependencies}}
Next, let’s create our entry point file, I like to name it index.ts
under the srcts directory:
import * as chartXkcd from "chart.xkcd";HTMLWidgets.widget({name: "xkcd",type: "output",factory: function (el: HTMLElement, width: number, height: number) {// TODO: define shared variables for this instancereturn {renderValue: function (x: any) {plotting logic},resize: function (width: number, height: number) {resize logic when screen size changes},};},});
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.
const esbuild = require("esbuild");const path = require("path");esbuild.build({entryPoints: [path.join(__dirname, "srcts/index.ts")],bundle: true,outfile: path.join(__dirname, "inst/htmlwidgets/xkcd.js"),platform: "node",format: "cjs",external: ["Shiny", "HTMLWidgets"],watch: {onRebuild(error, result) {if (error) console.error("watch build failed:", error);else console.log("watch build succeeded:", result);},},}).catch((err) => {process.stderr.write(err.stderr);process.exit(1);});
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:
{"scripts": {"watch": "node esbuild.js"},"devDependencies": {"@types/node": "^17.0.12","esbuild": "^0.14.14","typescript": "^4.5.5"},"dependencies": {"chart.xkcd": "^1.1.13"}}
Now, run yarn watch from the command line to use build script
esbuild.js, esbuild starts with the message:
yarn watch#> yarn run v1.22.17#> $ node esbuild.js#> watch build succeeded: { errors: [], warnings: [], stop: [Function: stop] }
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.