Modern JS with CLJS: class and template literals

I added the ability to create JS class and more recently support for JS template strings (optionally tagged) to shadow-cljs. I think they’d be useful additions to CLJS core at some point, but they’ll definitely need more discussion and testing first. I hope to start this discussion here first before taking it to the proper official places. Maybe these features are best kept in a library instead too. The code I have now does not actually depend on shadow-cljs in any way, it just was easier for me to distribute them with shadow-cljs vs making a separate library.

class via defclass

class has become very widespread in its use in JS and I expect that to keep growing. With that comes uses of constructors required to call super() with class ... extends AThingFromALibrary. Not always can this be emulated via ES5 code. Once such case where class becomes more or less required is writing web components. The answer if you wanted to do this in CLJS has always been very unsatisfactory to me so I changed it.

<script>
  class HelloWorld extends HTMLElement {
    constructor() {
      // custom elements MUST call super as per spec
      super();
    }

    // called by the JS engine
    connectedCallback() {
      console.log("hey I was added to the DOM", this);
    }
  }

  customElements.define("hello-world", HelloWorld);
</script>

<hello-world></hello-world>

The example is kinda pointless but I dare you to implement it in CLJS. :wink:

To support this I added the defclass “special form” that enables you to write class definitions in pure CLJS that generates an actual class without trying to emulate it in any way.

(ns foo.bar
  (:require [shadow.cljs.modern :refer (defclass)]))
  
(defclass HelloWorld
  (extends js/HTMLElement)
  
  (constructor [this]
    (super))
    
  Object
  (connectedCallback [this]
    (js/console.log "hey from CLJS" this)))
    
(js/window.customElements.define "hello-world" HelloWorld)

I do think that pretty much everyone should stick with default CLJS deftype and defrecord in most places but sometimes you just can’t. This is in no way meant to replace them.

More complex examples might look like this

(defprotocol AProtocol
  (example-fn [this a b]))

(defclass Example
  (extends a-library/Thing)
  
  ;; fields don't need to match constructor args
  ;; and can have default values, initialized automatically
  ;; in the constructor, without default they need to be
  ;; set! or they'll default to undefined
  (field foo)
  (field bar "default value")
  
  (constructor [this some arg]
    (let [x (+ some arg)]
      (super x "something")
      (set! foo (calculate-something this x))))
      
  ;; for uses from within CLJS
  ;; just like extend-protocol/extend-type
  AProtocol
  (example-fn [this a b]
    (str foo bar a b))
  
  ;; for when a library will call thing.jsLibraryMethod(x)
  ;; and you are supposed to give it a thing implementing that
  Object
  (jsLibraryMethod [this x]
    foo))

Of course I wrote this out of my own needs in case you are looking for an actual example in the wild.

Template Strings via js-template

The use of template strings in JS libraries is also spreading rapidly and they are rather annoying to replicate from CLJS. Template literals in JS serve as a regular string on steroids but also have a special “tag” mechanism in which case they totally do not act like strings at all. You might have seen them used in some JS code like

lit-html

import {html, render} from 'lit-html';

let sayHello = (name) => html`<h1>Hello ${name}</h1>`;

render(sayHello('World'), document.body);

styled-components

const Button = styled.button`
  color: grey;
`;

@apollo/client

import { gql, useQuery } from '@apollo/client';

const GET_DOGS = gql`
  query GetDogs {
    dogs {
      id
      breed
    }
  }
`;

Note all the weird backticks. Basically none of this code is “accessible” from CLJS since the semantics of tagged template literals are rather specific and annoying to emulate.

The new js-template form is meant to address this and enables you to directly generate those template literals with optional tags.

(require '[shadow.cljs.modern :refer (js-template)])
(require '["lit-html" :as lit])

(defn say-hello [name]
  (js-template lit/html "<h1>Hello " name "</h1>"))

At first sight this looks just like str and that is basically what it is API wise. The above would however give you the tagged template literal which is quite different from the regular str.

cljs.user.say_hello = function(name) {
  lit.html`<h1>Hello ${name}</h1>`;
}

Basically if the first argument to js-template is a string that will end up emitting just the plain literal in which case you might just as well use str. If the first argument however is not a string that will be treated as the tag and called as such. I don’t expect to see much use of this in regular CLJS code but some JS library code may expect to be passed one of these.

ES6 and beyond

Note that both of these features were readily available as of ES6 (or ES2016) and work in all modern platforms. CLJS and shadow-cljs however still default to outputting ES5, so you’ll need to bump that via :compiler-options {:output-feature-set :es6} in your build config. The Closure Compiler will otherwise try to transpile which will break in cases where it can’t be transpiled.

I don’t plan to add all modern JS features to via the shadow.cljs.modern namespace/lib but maybe there are other that might end up being useful in some way. Before you ask async/await cannot be added in this way and would require some actual compiler work.

Please don’t make these part of any CLJS API/library you write, they are strictly meant to ease some interop issues to make some JS code accessible that otherwise isn’t currently.

25 Likes

Thank you Thomas! This is more important than what first meets the eye. In CLJS, we often have a better way for doing things than JS or alternative langs. However, not being able to directly leverage basic JS features such as (so-called) classes is an important reason explaining why some people fled from CLJS to Typescript, for example.

(Tackling WebComponents from CLJS is on my TODO list and I am glad to see this!)

2 Likes

One warning when it comes to web components with CLJS: It is downright hostile to hot-reload and REPL development. A custom element may only be registered once and subsequent customElement.define calls will throw if the name was already registered before. Kinda not fun since you cannot unregister or replace the implementation. Since it keeps a direct reference to the class used in the register call it is not replaceable at all. Kinda annoying to do an actual hard-reload.

Yes, essentially, in a dev scenario, I was wondering about transparently registering a WebComponent class under a random tag with a bit of macro magic. If the class is recompiled, it is registered again under another random tag so the browser does not complain. Might be useful for a few things.

But I probably won’t work on this anytime soon.

2 Likes

Thanks for this feature. I currently experimenting creating a custom element with the macro and struggling to create the static getter observedAttributes. Is this supported by your macro? How do I have to implement the getter?

This is not currently covered by defclass. I’d suggest using Object.defineProperty since static properties are not yet part of any spec (IIRC).

Something like

(defclass Foo
  (constructor [this a b] ...))

(js/Object.defineProperty Foo "observedAttributes" #js {:get (fn [] ...)})
;; maybe just
(set! (.-observedAttributes Foo) #js ["foo" "bar"])
    (set! (.-observedAttributes Foo) #js ["foo" "bar"])

worked like a charm. Thanks.

Preformatted textThere is CustomElementRegistry.upgrade, wouldn’t that allow for redifinition?

No? That is for upgrading previously created DOM elements. It does not replace the definition of registered web component.

Oh, I tried it and that’s actually surprising. How do people even develop WebComponents?

I wonder if one could register the element once, but still modify the underlying object prototype to allow for new properties and update the function implementations.

Lots of page reloads I’d guess :wink: