slimjs / slim.js

Fast & Robust Front-End Micro-framework based on modern standards
http://slimjs.com
MIT License
1.02k stars 62 forks source link

How does the property directive work? #111

Open maerteijn opened 3 years ago

maerteijn commented 3 years ago

When I create a simplified example of the example on the Creating an Element documentation page:

import { Slim } from 'slim-js';
import 'slim-js/property-directive';

Slim.element(
  'my-greeting',
  /*html*/ `
    <h1>Hello, {{this.who}}!</h1>
  `,
  class MyGreeting extends Slim {
    constructor(who) {
      super()
      this.who = who;
    }
  }
);

Slim.element(
  'my-app',
  /*html*/ `
  <my-greeting .who="{{this.who}}">
  </my-greeting>
  `,
  class MyApp extends Slim {
    who = "I am the one and only"
  }
);

I expect that this.who will be set with the .who property, but whatever I do, it stays undefined.

I guess I don't understand the concept of the .who property, could you explain how this works?

maerteijn commented 3 years ago

I see now when I import the property-directive directly that this doesn't work

import  'slim-js/property-directive'

Could it be that I need a newer version of Node (I have 14.17)?

Edit: User error in my attempt to add some debugging statements. Original questions still applies (for now).

maerteijn commented 3 years ago

So two things I figured out:

Working example:

import { Slim } from 'slim-js';
import 'slim-js/property-directive';

Slim.element(
  'my-greeting',
  /*html*/ `
    <h1>Hello, {{this.who}}!</h1>
  `,
  class MyGreeting extends Slim {
    constructor() {
      super()
      if (this.hasOwnProperty("who")) {
        console.log(`inside constructor:  ${this.who}`);
      }
    }
  }
);

Slim.element(
  'my-app',
  /*html*/ `
  <my-greeting .who="{{this.who}}"></my-greeting>
  <my-greeting .who="{{this.another}}"></my-greeting>

  `,
  class MyApp extends Slim {
    who = "I am the one and only"
    another = "And I'm another one!"
  }
);

I know that pre-binding properties in javascript before the constructor is called is a common pattern in some frameworks, but it still feels a bit hackish to me. Maybe you could elaborate more on this?

eavichay commented 3 years ago

Hi. Are you using any babel/transpiling tools? These affect the way class properties are declared. Some tools use Object.defineProperty and thus destroy the internals.

maerteijn commented 3 years ago

I use webpack 5 with @babel/preset-env, so that could indeed be the case. However, as stated above, the 'slim-js/property-directive is called before the constructor, so that would not make a difference right?

eavichay commented 3 years ago

Regarding your question. Every class that extends Slim has a static template property. It is just a string, that should represent a decent valid HTML Markup. During the construction phase, the template is parsed by the browser (in-memory, inside a document fragment). There is a tree-walker (native, implemented by the browser) that iterates over the constructed tree. Every node that has "{{ ... }}" is being analyzed by a dedicated function. These expressions are executed every time the change is required. How Slim knows when a change is required? Simple. Aggregating all the expression (per class instance), everything that has this.* is now a known reactive property. Slim replaces the proeprty with a getter/setter functions that triggers the change.

For example, an expression like <element attribute="{{ this.whatever }}"></element> will bind the attribute's value to the whatever setter. Every other change does not affect that node.

When the class is constructed, there is a dedicated function that executed the expression as-is, bound to the class' instance. Since templates are re-used, and even properties are re-used across the same template, these functions are hoisted and memoized, to save runtime and memory.

If your tool intervenes in the native class property declarations, it may destroy the mechanics. Either avoid transpiling class properties, or just initialize those in the constructor.

eavichay commented 3 years ago

The directives are optional, therefore can be consumed separately. Directives are a unique form of attributes, usually with a special (but valid) character. The property directive is called every time there is an attribute that starts with a period.

The property simply executes the content of the expression every time a detected (and relevant) change occurs, and updates the property on the target element.

In that case, when you change the parent's component another property, the directive targets the child node's instance and replaces the who property with the value.

<parent>
  <child .who="{{this.username}}"></child>
</parent>

The parent class gets a username setter function, that triggers a changeset. When you change the username property's value, that changeset includes the property directive execution that receives the new value, executes the statement (this.username), and executes child.who = newValue;

maerteijn commented 3 years ago

The property simply executes the content of the expression every time a detected (and relevant) change occurs, and updates the property on the target element.

Yes and this is a really nice feature I like. It's just how the initial value of the property is set as you say:

Either avoid transpiling class properties, or just initialize those in the constructor.

So initially, in the first load the constructor of the element is called after the properties are bound to the instance (with the property directive), so should I detect that in the constructor then?

maerteijn commented 3 years ago

Before anything else: Thank you for answering this (and thanks for this awesome framework too!!)

So, to rule out any webpack or transpiling, a pure browser version:

<!DOCTYPE html>
<html>
  <head>
    <title>Properties with Slim.js</title>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="description" content="">
    <script type="module">
      import { Slim } from 'https://unpkg.com/slim-js@5.0.10/dist/index.js';
      import 'https://unpkg.com/slim-js@5.0.10/dist/directives/property.directive.js';

      Slim.element(
        'my-greeting',
        `
          <h1>Hello, {{this.who}}!</h1>
        `,
        class MyGreeting extends Slim {
          constructor(who) {
            super()
            if (this.hasOwnProperty("who")) {
              console.log(`who property already set: ${this.who}`);
            }
          }
        }
      );

      Slim.element(
        'my-app',
        `
        <my-greeting .who="{{this.who}}"></my-greeting>
        <my-greeting .who="{{this.another}}"></my-greeting>
        `,
        class MyApp extends Slim {
          who = "I am the one and only"
          another = "And I'm another one!"
        }
      );
    </script>
  </head>
  <body>
    <my-app></my-app>
  </body>
</html>

When I define a class property called who:

class MyGreeting extends Slim {
  who = "my default value
  ...
}

or when I initialize it in the constructor

class MyGreeting extends Slim {
  constructor() {
    super()
    this.who = "my default who"
    ...
  }
}

It will overwrite the initial who property defined with<my-greeting .who="{{this.who}}"></my-greeting>. So this will only be updated again when this.who is updated in the parent app:

var app = document.querySelector("my-app")
app.who = "Updated who!"

It will update the component as expected.

So to get around the initial value of this who value with the .who property is to check for the existance of the .who property in the constructor:

if (!this.hasOwnProperty("who")) {
  this.who = "My default who"
}

I don't think this is how it is intended?

eavichay commented 2 years ago

It sounds like something needs to be fixed. I'll try your example as-is.

maerteijn commented 2 years ago

Interestingly enough, the test in https://github.com/slimjs/slim.js/pull/112/commits/f5b5fbf5c30e85e38a7cb508683a9264cc72ec4a is passing, so in the JSDOM browser, the constructor is called before the directive is handling the property.

eavichay commented 2 years ago

The constructor should always be called first.