Use npm packages in QML

I’ve been trying to code up some nice GUI for a hobby project which was done in JavaScript (Node.JS). I’ve looked at a few options that I have

  1. Use the not-yet-stable NodeGUI
  2. Go Web and use Electron
  3. Rewrite the core in Python or C++ to use Qt
  4. Use QML which has limited support for JavaScript

I’ve explored the option 1, however, I soon ran into the problems with the Model/View/Delegate architecture which means I would have to implement native plugins/add-ons in order to use ListView. Not to mention the framework itself is still heavily under development.

As for option 2, I’m not a web frontend engineer and personally I much prefer something that is native (or looks and feels native at least). For the third option, it feels a bit overkill but it is a possible way out.

Luckily I don’t have to do the re-implementation, because I’ve managed to get the core functionality bundled into a single JS file which works flawlessly in the QML environment. Before I start diving into the details on how you can make your npm packages work in QML, I have to emphasise that there are many limitations in the QML environment and it’s very likely that only a small subset of the npm package that you’re interested in is going to work.

Requirements

  • Qt >= 5.12.7 (This is the minimum Qt version that has the support for most of the ES6 features especially the global Promise object)
  • An npm package that is written in pure JavaScript (or others that can be transpiled into JavaScript such as TypeScript). Anything that has native add-ons will not work for obvious reasons

Technically you may be able to get away with older Qt versions by transpiling the JavaScript code into ES5. I’ll leave that to anyone who’s interested in using old versions of the software.

The moment you start digging into this path, you’ll realise that there are more requirements on the JS code. It also cannot directly or indirectly use Node.JS native APIs such as Stream. Browserify wouldn’t work because QML is not a browser and it doesn’t have the window object.

How-to

The idea is quite straightforward once we get over the limitations. We find the functions/classes that we want to use in QML, we identify its dependencies and inline all of them to create a bundled single JavaScript file that can be understood by the QML engine.

One can write such a JavaScript file from scratch (really?), or they can use a tool such as rollup which will be introduced in this post.

Restructure the npm package

This is the first step and you may do this multiple times until either you give up, or you manage to get a working structure that produces a JavaScript file that is working in the QML environment.

Take my hobby project sdapi as an example, I split the part that uses node-fetch out into a separate file so that the file of interest doesn’t transitively depend on any Node.JS APIs.

As mentioned before, you may keep repeating this step and the next one until the output is a pure portable JavaScript file.

Create the bundled JavaScript file

Add rollup and its plugins to the devDependencies in package.json (assuming you’re using npm):

    "rollup": "^2.6.0",
    "@rollup/plugin-node-resolve": "^7.1.1",
    "@rollup/plugin-typescript": "^4.0.0",
    "@rollup/plugin-commonjs": "^11.0.2",

The example in this commit shows how I did it to produce a JS file for QML. The main takeaway here is the configuration file for rollup:

import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import typescript from '@rollup/plugin-typescript';

export default {
  input: 'src/dictionary.ts',
  inlineDynamicImports: true,
  output: {
    file: 'dist/sdapi.dictionary.qml.js',
    format: 'cjs',
    esModule: false,
    intro: 'const module = {};',  // A hack for QML environment
  },
  plugins: [
    resolve(),
    commonjs(),
    typescript({
      tsconfig: false
    })
  ]
};

Note that the input is not the main entry point index.ts because that would make it depend on node-fetch which uses Node.JS APIs. The format is set to CommonJS with a hacky intro so that it won’t complain about the variable module being undefined.

The node-resolve plugin resolves all third-party modules in node_modules directory using the Node.JS mechanism. The commonjs plugin transpiles all modules that are written in CommonJS into ES6 which is the format supported by rollup. Lastly, I use TypeScript so the typescript plugin compiles the TypeScript files on the fly for me. But I also don’t want to use the tsconfig file I have in the project so I pass the option tsconfig: false to the plugin.

It’s also possible to bundle it in the ES module format, in which way it may need to have .mjs extension and be bridged with a wrapper JS for QML. See https://doc.qt.io/qt-5/qtqml-javascript-imports.html#importing-a-javascript-resource-from-another-javascript-resource

Now you can do npx rollup -c rollup.qml.config.js to roll’em up. Add a script in the npm package.json so you can do something like npm run rollup:qml for example.

Import the JavaScript file in QML

This might be the easiest step of all three. Simply copy the output file (in this example it’s sdapi.dictionary.qml.js) into your QML project directory. You may need to update the qrc file to add this file as a resource.

Now you can import it in the QML file and use it already, like it is done in kesdict/standalone.qml:

import "sdapi.dictionary.qml.js" as Dictionary

Unlike ES6 where you need to explicitly export functions, all functions in the JavaScript file are visible in QML and can be invoked.

Summary

This post shows a viable working example of bundling an npm package to import from QML. With future versions of Qt, with no doubt it’ll be easier to use JavaScript resources in the QML environment.