javascriptvuejs3reactive-programmingnative-web-component

integrating native web components in Vue: properties are not reactive


I'm trying to integrate a vanilla js web component in Vue. The code is super contrived and just renders a name and surname as a proof of concept.

class HelloWorld extends HTMLElement {
    
  static observedAttributes = ["name", "surname"];

    constructor() {
      super();
    }
    
    connectedCallback() {
    const 
    shadow = this.attachShadow({ mode: 'closed' }),
    hwMsg = `Hello ${ this.name } ${ this.surname }`;
  
    shadow.append(hwMsg);  
  }
  
  // attribute change
  attributeChangedCallback(property, oldValue, newValue) {
    console.log('property ', property, ' changed from ', oldValue, ' to ' newValue);
    if (oldValue === newValue) return;
    this[ property ] = newValue;
    }
}
  
  customElements.define('hello-world', HelloWorld);

I registered the hello-world tag as custom in my vite.config file:

export default defineConfig({
  plugins: [
      vue({
        template: {
          compilerOptions: {
            isCustomElement: (tag) => tag.includes('hello-world')
          }
        }
      })
      ...]
I am including the webcomponent in the component where I want to use it:

<script setup lang="ts">
import '@/web_components/prova_web';
import { ref } from 'vue';

const name = ref('John');
const surname = ref('Smith');
</script>
<template>
  <main class="main-view">
    <form>
    <input id="model_name" v-model="name"/>
    <input  id="model_surname" v-model="surname">
   </form>
    Current values: {{ name }} {{ surname }} <br/>
    <hello-world :name="name" :surname="surname"></hello-world>
  </main>
</template>
<style scoped>
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/3.5.4/vue.global.min.js"></script>

The code renders just fine the first time, and the attributeChangedCallback is correctly called for both properties:

console log

Unfortunately, when I change a property using the form inputs, the attributeChangedCallback is not firing again, even though the property values have changed (and properly re-rendered above the custom tag). Screenshot

It's weird that the first time the ref value is correctly unpacked but subsequent changes to the refs' values are not recognized as such. Can I achieve reactive property binding with plain javascript or am I forced to use a framework? (Lit seems very popular).


Solution

  • There are two different issues in your code here.

    The first seems the be with template literals:

    const hwMsg = `Hello ${ this.name } ${ this.surname }`;
    

    This line will evaluate this.name and this.surname and create the string with it - once.

    I.e., hwMsg is just a simple string after that and there is no reactivity involved here. So since this line is part of the connectedCallback it is only executed once when attaching the custom element. Thus, updating this.name or this.surname won't have any effect.

    For attribute changes to be reflected in the text content you need to update the content of the element in the attributeChangedCallback.


    Secondly, since you are setting this.name and this.surname in your attributeChangedCallback your custom element will also have the object properties name and surname which are independent from the element attributes.

    With the reactive binding

    <hello-world :name="name" :surname="surname"></hello-world>
    

    vue first checks if there are properties with the same name (name/surname) - which there are in this case - and binds to them instead of the attributes of the same name.

    See also What is the difference between properties and attributes in HTML?

    So the reactive binding will only change the value of the properties and thus not trigger a call of the attributeChangedCallback.

    A working implementation of your example could thus be

    class HelloWorld extends HTMLElement {
      static observedAttributes = ['name', 'surname'];
      #shadowRoot;
    
      constructor() {
        super();
    
        this.#shadowRoot = this.attachShadow({
          mode: 'closed',
        });
      }
    
      attributeChangedCallback(attribute, oldValue, newValue) {
        console.log( 'attribute ', attribute, ' changed from ', oldValue, ' to ', newValue );
        if (oldValue === newValue) return;
    
        const hwMsg = `Hello ${this.getAttribute('name')} ${this.getAttribute('surname')}`;
        this.#shadowRoot.replaceChildren(hwMsg);
      }
    }
    

    This will