WebAssembly and DOM manipulation

    About WebAssembly in our time heard, I think, almost everything. If you have not heard, then on Habré there is a wonderful introductory material about this technology.
    image
    Another thing is that very often you can find comments like “Hooray, now we will write the frontend in C ++!”, “Let's rewrite React to Rust ” and so on, so on, so on…
    Interview with Brendan Ike very well reveals the idea of ​​WebAssembly and its purpose: WASM is not a complete replacement for JS, but only a technology that allows you to write resource-critical modules and compile them into portable bytecode with a linear memory model and static typing: this approach accelerates performance or simplify the transfer of existing code for web applications that work with multimedia, online games, and other “heavy” things.

    With a strong desire, you can implement a GUI, for example, the imgui library is ported to WASM , there are advances in porting Qt to WASM ( once or twice ). But most often a simple question is voiced:
    image



    “Still, is it possible to work with the DOM from WebAssembly?”


    So far, the categorical answer sounds like “No, you can’t”, the more accurate and correct one sounds like “You can, using Javascript functions”. And this article, in fact, is a story about the results of my little research on how this can be done as conveniently and effectively as possible.

    What, in fact, is the problem?


    Let's see how the page elements are generated and worked with from scripts using the example of the Blink (Chromium) web engine and the V8 JS engine. In fact, almost any DOM element inside Blink has its embodiment in the form of a C ++ object inherited from HTMLElement, inherited from Element, inherited from ContainerNode, inherited from Node ... in fact, this is far from the whole chain, but in our case it is not important. For example, for a tag when parsing HTML and building a tree, an object of class HTMLImageElement will be created :
    classCORE_EXPORTHTMLImageElementfinal
        :public HTMLElement,
     ...
    {
     public:
      static HTMLImageElement* Create(Document&);
      ...                                                
      unsignedwidth();
      unsignedheight();
      ...
      String AltText()const final;
      ...
      KURL Src()const;
      voidSetSrc(const String&);
      voidsetWidth(unsigned);
      voidsetHeight(unsigned);
      ...
    }

    To control the lifetime of objects and their removal, in older versions of Blink, smart pointers with reference counting were used, in modern versions, Garbage Collector called Oilpan is used .

    To access page elements from JavaScript, the object is described as an IDL to specify which fields and methods of the object will be available in JavaScript:
    [
        ActiveScriptWrappable,
        ConstructorCallWith=Document,
        NamedConstructor=Image(optional unsignedlong width, optional unsignedlong height)
    ] interface HTMLImageElement : HTMLElement {
        [CEReactions, Reflect] attribute DOMString alt;
        [CEReactions, Reflect, URL] attribute DOMString src;
        ...  
        [CEReactions] attribute unsignedlong width;
        [CEReactions] attribute unsignedlong height;
        ...  
        [CEReactions, Reflect] attribute DOMString name;
        [CEReactions, Reflect, TreatNullAs=EmptyString] attribute DOMString border;
        ...

    after which we can work with them from JavaScript code. The V8 JS engine also has its own Garbage Collector, and with C ++, Blink objects are wrapped in special wrappers, which are called Template Objects in V8 terminology . As a result, the lifetime of page objects is monitored by the Blink and V8 garbage collectors.

    Now imagine how WebAssembly modules fit into this business. At the moment, what is happening inside WASM for the Dark Forest browser. For example, if we take an element from a document, pass a pointer to the WASM module, save the link to it there, and then call removeChild for it, then according to Blink, no link will point to the object anymore and the object should be deleted - because the environment does not know that inside WASM the pointer to the element is still stored. What this situation can lead to guess, I think, is not difficult. And this is just one example.
    Work with garbage-collected objects is in the Roadmap of WebAssembly development and a special Issue is instituted on github on this issue, plus there is a documentwith details of proposals for implementing all of this.

    So, WebAssembly code is completely isolated in its “sandbox”, and today it is impossible to pass a pointer to any object of the DOM tree in the normal way into it; you cannot directly call any method in the same way. The only correct way to interact with any objects of the DOM tree or to use any other browser API is to write JS functions, transfer them to the imports field of the WebAssmebly module and call from WASM code.

    helloworld.c:
    voidshowMessage(int num);
    intmain(int num1){ 
      showMessage(num1);
      return num1 + 42;
    }


    helloworld.js:
    var wasmImports = {
          env: {
            showMessage: num => alert(num)
          }
    };
    // wasmCode должен быть загружен откужда-то извнеvar wasmModule = new WebAssembly.Module(wasmCode); 
    var wasmInstance = new WebAssembly.Instance(wasmModule, wasmImports);
    console.log(wasmInstance.exports.main(5));
    


    Everything is simple and transparent in the generated bytecode: an external function is imported, an argument is put on the stack, a function is called, the result of the addition is saved on the stack.
    (module
     (type $FUNCSIG$vi (func (param i32)))
     (import"env""showMessage" (func $showMessage (param i32)))
     (table0 anyfunc)
     (memory $01)
     (export"memory" (memory $0))
     (export"main" (func $main))
     (func $main (; 1 ;) (param $0 i32) (result i32) ;// наша int main(int)
      (call $showMessage
       (get_local $0) ;// передаем в вызов showMessage число со стека (аргумент)
      )
      (i32.add ;// суммируем число со стека (аргумент) и i32 константу
       (get_local $0)
       (i32.const 42)
      )
     )
    )
    


    You can verify in practice how this whole thing works using WasmFiddle: https://wasdk.github.io/WasmFiddle/?l7d05

    It would seem that everything is fine, but there are problems when complicating the task. But what if we need to transfer not a number, but a string from the WASM code (despite the fact that only 32- and 64-bit integers and 32- and 64-bit floating-point numbers are supported in WebAssembly)? But what if we need to perform a lot of very different DOM manipulations and browser API calls, and it is extremely inconvenient to write a separate JS function in each case?

    And here comes Emscripten to the rescue


    Emscripten was originally designed as an LLVM backend for compilation in asm.js. In addition to compiling directly in asm.js and WASM, it also contains “wrappers” that emulate the functionality of various libraries (libc, libcxx, OpenGL, SDL, etc.) through an API available in the browser, and its own set of helper functions to facilitate porting of applications and the interaction of WASM and JS code.
    The simplest example. As you know, only i32, i64, f32, f64 can be arguments and results when calling functions from the WASM module or from it. The WebAssembly module has linear memory that can be mapped to JS as Int8Array, Int16Array, etc. Therefore, in order to get the value of some non-standard type (string, array, etc.) from WASM to JS, we can put it in the address space of the WASM module, pass the pointer “out”, and already to JS code to extract the necessary bytes from the array and convert them to the desired object (for example, strings in JS are stored in UTF-16). When transferring data “inside” the WASM module, we should instead “outside” put it in a memory array at a specific address, and only then use this address in C / C ++ code. Emscripten has a large set of helper functions for these purposes. So, exceptgetValue () and setValue () (reading and writing heap values ​​of a WASM application by pointers), for example, there is a Pointer_stringify () function that converts C-strings to JavaScript string objects.

    Another convenient feature is the ability to inline javascript code directly in C ++ code. The compiler will do the rest for us.

    #include<emscripten.h>intmain(){
      char* s = "hello world";
      EM_ASM({
          alert(Pointer_stringify($0));
          }, s);
      return0;
    }
    

    After compilation, we get a .wasm file with the compiled bytecode and a .js file containing the wasm module launch code and a huge number of various auxiliary functions.
    Directly our macro-inline EM_ASM JS code turned into a .js file in the following construction:
    var ASM_CONSTS = [function($0) { alert(Pointer_stringify($0)); }];
    function_emscripten_asm_const_ii(code, a0) {
      return ASM_CONSTS[code](a0);
    }

    In the bytecode, we have almost everything the same as in the previous example, only when the function is called, the number 0 (the identifier of the function in the ASM_CONSTS array ) is also put on the stack , as well as a pointer to a string constant (char *) in the address space The WASM module, which is equal to 1024 in our case. The Pointer_stringify () method in javascript code extracts data from the “heap” represented in JS as Uint8Array and performs conversion from the UTF8 array to a String object.
    On closer inspection, the fact that the string constant (char *) located at 1024 for some reason contains not only the text “hello world” with a zero byte, but also a duplicate of the inline JS code, is a bit confusing. I can’t immediately explain the reason for the appearance of this inside the compiled wasm file, I would be grateful if someone shared their assumptions in the comments.
    (import"env""_emscripten_asm_const_ii" (func $_emscripten_asm_const_ii (param i32 i32) (result i32)))
    (data (i32.const 1024) "hello world\00{ alert(Pointer_stringify($0)); }")
    (func $_main (; 14 ;) (result i32)
      (drop
       (call $_emscripten_asm_const_ii
        (i32.const 0)
        (i32.const 1024)
       )
      )
      (i32.const 0)
     )


    In any case, the conclusion suggests itself that calling JavaScript functions from WASM code is not the fastest activity in terms of performance. At least time will take up the overhead of the interaction of the WASM code and the JS interpreter, type conversion when passing arguments, and much more.

    Cheerp


    While reading articles and studying documentation for various libraries, I came across a Cheerp compiler that generates WASM code from C / C ++, and by loud assurance on the landing page on the official site, providing “no-overhead access to HTML5 DOM”. My inner skeptic, however, said that there is no magic. First, try to compile the simplest example from the documentation:

    #include<cheerp/clientlib.h>#include<cheerp/client.h>
    [[cheerp::genericjs]] voiddomOutput(constchar* str){
            client::console.log(str);
    }
    voidwebMain(){
        domOutput("Hello World");
    }
    


    At the output, we get a .wasm file, a quick look of which tells us that it does not have a data section with a string constant.
    (module
    (type $vt_v (func ))
    (func (import"imports""__Z9domOutputPKc"))
    (table anyfunc (elem $__wasm_nullptr))
    (memory (export"memory") 1616)
    (global (mut i32) (i32.const 1048576))
    (func $__wasm_nullptr (export"___wasm_nullptr")
    (local i32)
    unreachable
    )
    (func $_Z7webMainv (export"__Z7webMainv")
    (local i32)
    call 0
    )
    )


    We look in JS:

    functionf() {
        var a = null;
        a = h();
        console.log(a);
        return;
    }
    functionh() {
        var a = null,
            d = null;
        a = String();
        d = String.fromCharCode(72);
        a = a.concat(d);
        d = String.fromCharCode(101);
        a = a.concat(d);
        d = String.fromCharCode(108);
        a = a.concat(d);
        d = String.fromCharCode(108);
        a = a.concat(d);
        d = String.fromCharCode(111);
        a = a.concat(d);
        d = String.fromCharCode(32);
        a = a.concat(d);
        d = String.fromCharCode(87);
        a = a.concat(d);
        d = String.fromCharCode(111);
        a = a.concat(d);
        d = String.fromCharCode(114);
        a = a.concat(d);
        d = String.fromCharCode(108);
        a = a.concat(d);
        d = String.fromCharCode(100);
        a = a.concat(d);
        returnString(a);
    }
    function_asm_f() {
        f();
    }
    function__dummy() {
        thrownewError('this should be unreachable');
    };
    var importObject = {
        imports: {
            __Z9domOutputPKc: _asm_f,
        }
    };
        instance.exports.i();


    image

    Honestly, it’s not at all clear why, in such a case, it is necessary to generate a WASM module, since absolutely nothing happens in it except one call to an external function, and all the logic was placed in JS code. Anyway.

    An interesting feature of the compiler is bindings to all standard DOM objects in C ++ (judging by what was written in the documentation, obtained by auto-generating code from IDL), which allows you to write C ++ code that directly manipulates the necessary objects:

    #include<cheerp/client.h>#include<cheerp/clientlib.h>usingnamespace client;
    int test = 1;
    [[cheerp::genericjs]] voiddomOutput(int a){
        constchar* str1 = "Hello world!";
        constchar* str2 = "LOL";
        Element* titleElement=document.getElementById("pagetitle");
        titleElement->set_textContent(a / 2 == 0 ? str1 : str2);
    }
    voidwebMain(){
        test++;
        domOutput(test);
        test++;
        domOutput(test);
    }


    Let's see what happened after compilation ...

    functionj(b) {
        var a = null,
            c = null;
        a = "pagetitle";
        a = document.getElementById(a);
        a = a;
        a.textContent;
        if (b + 1 >>> 0 < 3) {
            c = "Hello world!";
            a.textContent = c;
            return;
        } else {
            c = "LOL";
            a.textContent = c;
            return;
        }
    }
    functione(f, g) {
        var b = 0,
            c = 0,
            a = null,
            t = null;
        a = String();
        b = f[g] | 0;
        if ((b & 255) === 0) {
            returnString(a);
        } else {
            c = 0;
        }
        while (1) {
            t = String.fromCharCode(b << 24 >> 24);
            a = a.concat(t);
            c = c + 1 | 0;
            b = f[g + c | 0] | 0;
            if ((b & 255) === 0) {
                break;
            }
        }
        returnString(a);
    }
    var s = newUint8Array([112, 97, 103, 101, 116, 105, 116, 108, 101, 0]);
    var r = newUint8Array([76, 79, 76, 0]);
    var q = newUint8Array([72, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100, 33, 0]);
    function_asm_j(b) {
        j(b);
    }
    function__dummy() {
        thrownewError('this should be unreachable');
    };
    var importObject = {
        imports: {
            __Z9domOutputi: _asm_j,
        }
    };
    ...
        instance.exports.p();

    image

    I think I was wrong about the magic. The string constants in our script turned out to be in Uint8 arrays, and when the script is run, they are converted to String by a string of character calls to String.concat () . Despite the fact that the same lines are slightly higher than the direct text in the JavaScript code. Incrementing test is done in WASM code; in the function that sets the text content of the DOM element, you can see the wonderful “a = a” and call to the textContent getter without using its result; checking the variable for parity as a result of the optimizer’s work degenerated into the brain-carrying expression “b + 1 >>> 0 <3” (yes, exactly, with a bit shift of 0 positions).
    Can this be called “zero overhead DOM-manipulations”? Even if you consider that, in fact, all the same, all the manipulations are performed exactly the same through JS (it was difficult to expect something else, actually), at best we can talk about “zero overhead” compared to pure JS, and weird dances with a tambourine around the lines of performance, they obviously will not add, as well as the joys of debugging all this.
    As they say, do not believe advertising. But it is worth noting that the project is still actively developing. When I forgot to set the attribute [[cheerp :: genericjs]]for the void domOutput (int a) function, when compiling with the “wasm” target, the compiler simply crashed with SIGSEGV. I brought Issue to github developers about this problem, the next day they explained to me what the error was, and literally a week later a fix for this problem appeared in the master branch. Maybe you should watch Cheerp in the future.

    Stdweb


    Speaking of compilers and libraries created for interoperability between WASM, JS and WebAPI, Stdweb for Rust is worth mentioning .
    It allows you to inline JS code into Rust code with support for closures and provides wrappers for DOM objects and browser APIs that are as close as possible to what is usual in JS:

    let button = document().query_selector( "#hide-button" ).unwrap();
    button.add_event_listener( move |_: ClickEvent| {
        for anchor in document().query_selector_all( "#main a" ) {
            js!( @{anchor}.style = "display: none;"; );
        }
    });


    The delivery immediately includes examples of implementing various things on Rust / WASM, of which TodoMVC is of most interest. It can be launched via cargo-web with the command
    cargo web start –target-webasm-emscripten,
    as a result of which we get a web server on port 8000 with our application.
    After compilation, we see the same Emscripten helper functions in the .js file, but much more interesting (remembering what was in the previous paragraph) is how the JS code call from the WASM module and working with objects are implemented .
    Exactly the same as in the second example (compiling C ++ using Emscripten), the ASM_CONSTS array is filled with functions of something like this:
    var ASM_CONSTS = [ 
     function($0) { Module.STDWEB.decrement_refcount( $0 ); },
     function($0, $1, $2, $3) { 
         $1 = Module.STDWEB.to_js($1); 
         $2 = Module.STDWEB.to_js($2); 
         $3 = Module.STDWEB.to_js($3); 
         Module.STDWEB.from_js($0, (function() {
             var listener = ($1); 
             ($2). addEventListener (($3), listener); 
             return listener;
         })()); 
     },
     function($0) { 
         Module.STDWEB.tmp = Module.STDWEB.to_js( $0 ); 
     },
     function($0, $1) { 
         $0 = Module.STDWEB.to_js($0); 
         $1 = Module.STDWEB.to_js($1); 
         ($0). appendChild (($1)); 
     },
     function($0, $1, $2) { 
         $1 = Module.STDWEB.to_js($1); 
         $2 = Module.STDWEB.to_js($2); 
         Module.STDWEB.from_js($0, (function() { 
             try {
                 ($1). removeChild (($2)); 
                 returntrue; 
             }  catch (exception) { 
                 if (exception instanceof NotFoundError) { 
                     returnfalse; 
                 } else { 
                 throw exception; 
                 }
             }
          })()); 
     },
     function($0, $1) { 
         $1 = Module.STDWEB.to_js($1); 
         Module.STDWEB.from_js($0, (function() { 
              return ($1). classList; 
         })()); 
     },
     function($0, $1, $2) { 
         $1 = Module.STDWEB.to_js($1); 
         $2 = Module.STDWEB.to_js($2); 
         Module.STDWEB.from_js($0, (function(){ 
              return ($1). querySelector (($2)); 
         })()); 
     },
     function($0) { 
          return (Module.STDWEB.acquire_js_reference( $0 ) instanceof HTMLElement) | 0; 
     },
     function($0) { 
         return (Module.STDWEB.acquire_js_reference( $0 ) instanceof HTMLInputElement) | 0; 
     },
     function($0) { 
          $0 = Module.STDWEB.to_js($0);($0). blur (); 
     },


    In other words, for example,
    let label = document().create_element( "label" );
    label.append_child( &document().create_text_node( text ) );


    will be implemented using helpers
    function($0, $1, $2) { 
          $1 = Module.STDWEB.to_js($1);
          $2 = Module.STDWEB.to_js($2); 
          Module.STDWEB.from_js($0, (function() {
             return ($1). createElement (($2));
          })()); 
     },
    function($0, $1, $2) { 
         $1 = Module.STDWEB.to_js($1);
         $2 = Module.STDWEB.to_js($2); 
         Module.STDWEB.from_js($0, (function() {
              return ($1). createTextNode (($2));
         })()); 
     },
    function($0, $1) { 
         $0 = Module.STDWEB.to_js($0); 
         $1 = Module.STDWEB.to_js($1); 
         ($0). appendChild (($1)); 
     },
    


    moreover, as you can see, it is not translated into one complete JavaScript method, and “pointers” to the objects used are constantly transmitted between the WASM and JS code. Given that the WASM code cannot work directly with JS objects, this trick is performed in a rather interesting way, and you can look at the implementation in the stdweb sources .

    When transferring a JS / DOM object to WASM, the object is added to the “key-value” containers in JS that store correspondences of the form “JS object ← → unique RefId” and vice versa, where the unique RefId is essentially an auto-increment number:

    Module.STDWEB.acquire_rust_reference = function( reference ) {
        ...
        ref_to_id_map.set( reference, refid );
        ...
        id_to_ref_map[ refid ] = reference;
        id_to_refcount_map[ refid ] = 1;
        ...
    };


    It is checked that this object has never been transmitted (otherwise, not a new record will be created, but the reference counter will be increased). An identifier of the type of the object is written into the memory of the WASM application (for example, 11 for Object, 12 for Array), followed by the entry RefId of the object. When transferring an object in the opposite direction, the desired object is simply extracted from the map by a unique ID and used.

    Without tests, it is impossible to say for sure how strong JS function calls for each of WASM, type conversions (and string conversion), coupled with constant searches for objects in tables, will slow down the work, but in general, this approach to the interaction between the “worlds” seems to me much more beautiful than the incomprehensible mishmash of code from previous examples.

    asm-dom



    And finally, the most delicious: asm-dom . This is a library of virtual DOM (read more about the concept of Virtual DOM can be found in the article on Habré), inspired by the JavaScript VDOM-library Snabbdom and intended for the development of SPA (Single-page applications) on a C ++ / WebAssembly.
    The page element description code looks something like this:
      VNode* newVnode = h("div",
        Data(
          Callbacks {
            {"onclick", [](emscripten::val e) -> bool {
              emscripten::val::global("console").call<void>("log", emscripten::val("another click"));
              returntrue;
            }}
          }
        ),
        Children {
          h("span",
            Data(
              Attrs {
                {"style", "font-weight: normal; font-style: italic"}
              }
            ),
            std::string("This is now italic type")
          ),
          h(" and this is just normal text", true),
          h("a",
            Data(
              Attrs {
                {"href", "/bar"}
              }
            ),
            std::string("I'll take you places!")
          )
        }
      );
    patch(
        emscripten::val::global("document").call<emscripten::val>(
          "getElementById",
          std::string("root")
        ),
        vnode
      );

    There is also gccx , a converter that generates code like the one above from CPX, which, in turn, is an analogue of JSX, much known by ReactJS, which allows you to describe components directly inside C ++ code:
      VNode* vnode = (
        <divonclick={[](emscripten::vale) -> bool {
            emscripten::val::global("console").call<void>("log", emscripten::val("clicked"));
            return true;
          }}
        >
          <spanstyle="font-weight: bold">This is bold</span>
          and this is just normal text
          <ahref="/foo">I'll take you places!</a></div>
      );

    The “distillation” of VirtualDOM into a real DOM, as well as the interaction between the WASM code and the Web API, occurs either through the generation of HTML and setting the innerHTML properties of objects, or similarly to the previous example:
    var addPtr = functionaddPtr(node) {
      if (node === null) return0;
      if (node.asmDomPtr !== undefined) return node.asmDomPtr;
      var ptr = ++lastPtr;
      nodes[ptr] = node;
      node.asmDomPtr = ptr;
      return ptr;
    };
    exports['default'] = {
    …
    'appendChild': functionappendChild(parentPtr, childPtr) {
      nodes[parentPtr].appendChild(nodes[childPtr]);
    },
    'removeAttribute': functionremoveAttribute(nodePtr, attr) {
      nodes[nodePtr].removeAttribute(attr);
    },
    'setAttribute': functionsetAttribute(nodePtr, attr, value) {
    ...
    


    Also on the Github of the project there is a link to performance tests compared to the Jab VDOM Snabbdom library, which shows that in some test cases the WASM version loses to JS, in some it outperforms it, and only in one test when launched in Firefox serious acceleration is seen. In principle, such results are not surprising, given the fact that JS calls are still used to update the “real” DOM tree, plus when the JS code is executed, “garbage” from remote objects remains on the heap until Garbage Collector fires , and asm-dom honestly deletes objects immediately when necessary, which also leaves a mark on performance.
    image
    The author of the library in README.md himself laments that for the time being GC / DOM integration in WebAssembly is impossible, but optimistic about the implementation of this functionality - let's hope that then asm-dom will shine in all its glory.

    Useful links:
    1. Introduction to WASM
    2. Interview with Brendan Ike about WebAssembly
    3. Native ImGui in the Browser
    4. Qt for WebAssembly
    5. Emscripten documentation
    6. Cheerp - the C ++ compiler for the Web
    7. Stdweb: A standard library for the client-side Web
    8. asm-dom: A minimal WebAssembly virtual DOM to build C ++ SPA

    Also popular now: