javascriptc++qtqtscriptqjsengine

Result of QJSEngine evaluation doesn't contain a function


I'm migrating QScriptEngine code over to QJSEngine, and have come across a problem where I can't call functions after evaluating scripts:

#include <QCoreApplication>
#include <QtQml>

int main(int argc, char *argv[])
{
    QCoreApplication a(argc, argv);

    QJSEngine engine;
    QJSValue evaluationResult = engine.evaluate("function foo() { return \"foo\"; }");

    if (evaluationResult.isError()) {
        qWarning() << evaluationResult.toString();
        return 1;
    }

    if (!evaluationResult.hasProperty("foo")) {
        qWarning() << "Script has no \"foo\" function";
        return 1;
    }

    if (!evaluationResult.property("foo").isCallable()) {
        qWarning() << "\"foo\" property of script is not callable";
        return 1;
    }

    QJSValue callResult = evaluationResult.property("foo").call();
    if (callResult.isError()) {
        qWarning() << "Error calling \"foo\" function:" << callResult.toString();
        return 1;
    }

    qDebug() << "Result of call:" << callResult.toString();

    return 0;
}

The output of this script is:

 Script has no "activate" function

That same function could be called when I was using QScriptEngine:

 scriptEngine->currentContext()->activationObject().property("foo").call(scriptEngine->globalObject());

Why doesn't the function exist as a property of the evaluation result, and how do I call it?


Solution

  • That code will result in foo() being evaluated as a function declaration in the global scope. Since you don't call it, the resulting QJSValue is undefined. You can see the same behaviour by opening the JavaScript console in your browser and writing the same line:

    javascript-evaluate-result

    You can't call the function foo() of undefined, because it doesn't exist. What you can do, is call it through the global object:

    javascript-call

    This is the same as what your C++ code sees. Therefore, to access and call the foo() function, you need to access it through the globalObject() function of QJSEngine:

    #include <QCoreApplication>
    #include <QtQml>
    
    int main(int argc, char *argv[])
    {
        QCoreApplication a(argc, argv);
    
        QJSEngine engine;
        QJSValue evaluationResult = engine.evaluate("function foo() { return \"foo\"; }");
    
        if (evaluationResult.isError()) {
            qWarning() << evaluationResult.toString();
            return 1;
        }
    
        if (!engine.globalObject().hasProperty("foo")) {
            qWarning() << "Script has no \"foo\" function";
            return 1;
        }
    
        if (!engine.globalObject().property("foo").isCallable()) {
            qWarning() << "\"foo\" property of script is not callable";
            return 1;
        }
    
        QJSValue callResult = engine.globalObject().property("foo").call();
        if (callResult.isError()) {
            qWarning() << "Error calling \"foo\" function:" << callResult.toString();
            return 1;
        }
    
        qDebug() << "Result of call:" << callResult.toString();
    
        return 0;
    }
    

    The output of this code is:

    Result of call: "foo"
    

    This is roughly the same as the line you posted that uses QScriptEngine.

    The benefit of this approach is that you don't need to touch your scripts to get it to work.

    The downside is that writing JavaScript code this way can cause issues if you're planning on reusing the same QJSEngine to call multiple scripts, especially if the functions therein have identical names. Specifically, the objects that you evaluated will stick around in the global namespace forever.

    QScriptEngine had a solution for this problem in the form of QScriptContext: push() a fresh context before you evaluate your code, and pop() afterwards. However, no such API exists in QJSEngine.

    One way around this problem is to just create a new QJSEngine for every script. I haven't tried it, and I'm not sure how expensive it would be.

    The documentation looked like it might hint at another way around it, but I didn't quite understand how it would work with multiple functions per script.

    After speaking with a colleague, I learned of an approach that solves the problem using an object as an interface:

    #include <QCoreApplication>
    #include <QtQml>
    
    int main(int argc, char *argv[])
    {
        QCoreApplication a(argc, argv);
    
        QJSEngine engine;
        QString code = QLatin1String("( function(exports) {"
            "exports.foo = function() { return \"foo\"; };"
            "exports.bar = function() { return \"bar\"; };"
        "})(this.object = {})");
    
        QJSValue evaluationResult = engine.evaluate(code);
        if (evaluationResult.isError()) {
            qWarning() << evaluationResult.toString();
            return 1;
        }
    
        QJSValue object = engine.globalObject().property("object");
        if (!object.hasProperty("foo")) {
            qWarning() << "Script has no \"foo\" function";
            return 1;
        }
    
        if (!object.property("foo").isCallable()) {
            qWarning() << "\"foo\" property of script is not callable";
            return 1;
        }
    
        QJSValue callResult = object.property("foo").call();
        if (callResult.isError()) {
            qWarning() << "Error calling \"foo\" function:" << callResult.toString();
            return 1;
        }
    
        qDebug() << "Result of call:" << callResult.toString();
    
        return 0;
    }
    

    The output of this code is:

    Result of call: "foo"
    

    You can read about this approach in detail in the article that I just linked to. Here's a summary of it:

    However, as the article states, this approach does still use the global scope:

    The previous pattern is commonly used by JavaScript modules intended for the browser. The module will claim a single global variable and wrap its code in a function in order to have its own private namespace. But this pattern still causes problems if multiple modules happen to claim the same name or if you want to load two versions of a module alongside each other.

    If you want to take it further, follow the article through to its end. As long as you're using unique object names, though, it will be fine.

    Here's an example of how a "real life" script would change to accommodate this solution:

    Before

    function activate(thisEntity, withEntities, activatorEntity, gameController, activationTrigger, activationContext) {
        gameController.systemAt("WeaponComponentType").addMuzzleFlashTo(thisEntity, "muzzle-flash");
    }
    
    function equipped(thisEntity, ownerEntity) {
        var sceneItemComponent = thisEntity.componentOfType("SceneItemComponentType");
        sceneItemComponent.spriteFileName = ":/sprites/pistol-equipped.png";
    
        var physicsComponent = thisEntity.componentOfType("PhysicsComponentType");
        physicsComponent.width = sceneItemComponent.sceneItem.width;
        physicsComponent.height = sceneItemComponent.sceneItem.height;
    }
    
    function unequipped(thisEntity, ownerEntity) {
        var sceneItemComponent = thisEntity.componentOfType("SceneItemComponentType");
        sceneItemComponent.spriteFileName = ":/sprites/pistol.png";
    
        var physicsComponent = thisEntity.componentOfType("PhysicsComponentType");
        physicsComponent.width = sceneItemComponent.sceneItem.width;
        physicsComponent.height = sceneItemComponent.sceneItem.height;
    }
    
    function destroy(thisEntity, gameController) {
    }
    

    After

    ( function(exports) {
        exports.activate = function(thisEntity, withEntities, activatorEntity, gameController, activationTrigger, activationContext) {
            gameController.systemAt("WeaponComponentType").addMuzzleFlashTo(thisEntity, "muzzle-flash");
        }
    
        exports.equipped = function(thisEntity, ownerEntity) {
            var sceneItemComponent = thisEntity.componentOfType("SceneItemComponentType");
            sceneItemComponent.spriteFileName = ":/sprites/pistol-equipped.png";
    
            var physicsComponent = thisEntity.componentOfType("PhysicsComponentType");
            physicsComponent.width = sceneItemComponent.sceneItem.width;
            physicsComponent.height = sceneItemComponent.sceneItem.height;
        }
    
        exports.unequipped = function(thisEntity, ownerEntity) {
            var sceneItemComponent = thisEntity.componentOfType("SceneItemComponentType");
            sceneItemComponent.spriteFileName = ":/sprites/pistol.png";
    
            var physicsComponent = thisEntity.componentOfType("PhysicsComponentType");
            physicsComponent.width = sceneItemComponent.sceneItem.width;
            physicsComponent.height = sceneItemComponent.sceneItem.height;
        }
    
        exports.destroy = function(thisEntity, gameController) {
        }
    })(this.Pistol = {});
    

    A Car script can have functions with the same names (activate, destroy, etc.) without affecting those of Pistol.


    As of Qt 5.12, QJSEngine has support for proper JavaScript modules:

    For larger pieces of functionality, you may want to encapsulate your code and data into modules. A module is a file that contains script code, variables, etc., and uses export statements to describe its interface towards the rest of the application. With the help of import statements, a module can refer to functionality from other modules. This allows building a scripted application from smaller connected building blocks in a safe way. In contrast, the approach of using evaluate() carries the risk that internal variables or functions from one evaluate() call accidentally pollute the global object and affect subsequent evaluations.

    All that needs to be done is to rename the file to have an .mjs extension, and then convert the code like so:

    export function activate(thisEntity, withEntities, activatorEntity, gameController, activationTrigger, activationContext) {
        gameController.systemAt("WeaponComponentType").addMuzzleFlashTo(thisEntity, "muzzle-flash");
    }
    
    export function equipped(thisEntity, ownerEntity) {
        var sceneItemComponent = thisEntity.componentOfType("SceneItemComponentType");
        sceneItemComponent.spriteFileName = ":/sprites/pistol-equipped.png";
    
        var physicsComponent = thisEntity.componentOfType("PhysicsComponentType");
        physicsComponent.width = sceneItemComponent.sceneItem.width;
        physicsComponent.height = sceneItemComponent.sceneItem.height;
    }
    
    export function unequipped(thisEntity, ownerEntity) {
        var sceneItemComponent = thisEntity.componentOfType("SceneItemComponentType");
        sceneItemComponent.spriteFileName = ":/sprites/pistol.png";
    
        var physicsComponent = thisEntity.componentOfType("PhysicsComponentType");
        physicsComponent.width = sceneItemComponent.sceneItem.width;
        physicsComponent.height = sceneItemComponent.sceneItem.height;
    }
    
    export function destroy(thisEntity, gameController) {
    }
    

    The C++ to call one of these functions looks something like this:

    QJSvalue module = engine.importModule("pistol.mjs");
    QJSValue function = module.property("activate");
    QJSValue result = function.call(args);