loading...
Cover image for The magic behind the ember modifiers

The magic behind the ember modifiers

_raja_sk_ profile image Raja SK ・6 min read

Prerequisites:

Before moving on to our topic, let's recap some basic stuff about ember modifiers from my previous blogs.

  1. Explanation about ember modifiers and how they are used to achieve reusable DOM behavior are in the following post.

  2. Each ember modifier should be managed by an ember modifier manager. To explore more, check out my other blog.

If you're familiar with these topics, feel free to skip this part.



Now, let's move on to our topic.

So far, IMO, ember modifiers are one of the extraordinary features of ember octane. While working with ember modifiers recently, I was questioning myself on:

How does ember Modifiers do the magic on Reusable DOM behavior?

Come on! Let's solve the mystery together by replicating the ember modifiers through plain javascript.

Ember has both functional and class-based modifiers.

Let's break down the functional modifiers.

Create an autofocus modifier to just focus an element.

File: modifiers/autofocus.js

export default function autofocus(element) {
  element.focus();
  return () => {
    console.log("destroy"); // Dummy log to simulate the teardown logic
  };
}

As we know, every ember modifiers will be managed by a modifier manager. Also, every modifier manager should have these four methods:

  1. createModifier
  2. installModifier
  3. updateModifier
  4. destroyModifier

Create a functional modifier manager.

Now, we need to create a functional modifier manager class with the above-mentioned methods.

Before that, we need two WeakMaps here:

  1. MODIFIER_ELEMENTS - to map the element with the modifier.
  2. MODIFIER_TEARDOWNS - to map the teardown logic with the modifier
const MODIFIER_ELEMENTS = new WeakMap();
const MODIFIER_TEARDOWNS = new WeakMap();

Teardown logic is nothing but the piece of code given in the return of modifier function. To set this in MODIFIER_TEARDOWNS, we need to have a setup method that runs the modifier and maps the returned value with the modifier.

function setup(modifier, element, args) {
  const { positional, named } = args;
  const teardown = modifier(element, positional, named);
  MODIFIER_TEARDOWNS.set(modifier, teardown);
}

Some modifiers won't have any teardown logic. So we need a separate function teardown to check if the teardown present in MODIFIER_TEARDOWNS is a function and then access it.

function teardown(modifier) {
  const teardown = MODIFIER_TEARDOWNS.get(modifier);
  if (teardown && typeof teardown === "function") {
    teardown();
  }
}

Now, Let's create the FunctionalModifierManager class with the above-mentioned lifecycle methods.

class FunctionalModifierManager {
  createModifier(factory) {
    return (...args) => factory(...args);
  }
  installModifier(modifier, element, args) {
    MODIFIER_ELEMENTS.set(modifier, element);
    setup(modifier, element, args);
  }
  updateModifier(modifier, args) {
    teardown(modifier);
    const element = MODIFIER_ELEMENTS.get(modifier);
    setup(modifier, element, args);
  }
  destroyModifier(modifier) {
    teardown(modifier);
  }
}

Let's see what these four methods trying to do.

  1. createModifier - simply returns the modifier instance.
  2. installModifier - maps the element with the modifier in the MODIFIER_ELEMENTS WeakMap. Also calls the setup method to map the teardown logic with the modifier in MODIFIER_TEARDOWNS WeakMap.
  3. updateModifier - runs the teardown logic once to remove the outdated modifier mapping and maps the element with the updated modifier in the MODIFIER_ELEMENTS WeakMap.
  4. destroyModifier - runs the teardown logic to completely remove the mapping between the modifier and the element.

Combining the above snippets will form our functional-modifier-manager.js file.

File: functional-modifier-manager.js

const MODIFIER_ELEMENTS = new WeakMap();
const MODIFIER_TEARDOWNS = new WeakMap();

function setup(modifier, element, args) {
  const { positional, named } = args;
  const teardown = modifier(element, positional, named);
  MODIFIER_TEARDOWNS.set(modifier, teardown);
}

function teardown(modifier) {
  const teardown = MODIFIER_TEARDOWNS.get(modifier);
  if (teardown && typeof teardown === "function") {
    teardown();
  }
}

export default class FunctionalModifierManager {
  createModifier(factory) {
    return (...args) => factory(...args);
  }
  installModifier(modifier, element, args) {
    MODIFIER_ELEMENTS.set(modifier, element);
    setup(modifier, element, args);
  }
  updateModifier(modifier, args) {
    teardown(modifier);
    const element = MODIFIER_ELEMENTS.get(modifier);
    setup(modifier, element, args);
  }
  destroyModifier(modifier) {
    teardown(modifier);
  }
}

How do ember modifier and ember modifier manager communicate?

Custom Modifier Manager acts as a middleman between the ember modifier and the ember modifier manager.

We need CustomModifierState to maintain the state for the CustomModifierManager.

class CustomModifierState {
  constructor(element, delegate, modifier, args) {
    this.element = element;
    this.delegate = delegate;
    this.modifier = modifier;
    this.args = args;
  }

  destroy() {
    var { delegate, modifier, args } = this;
    delegate.destroyModifier(modifier, args);
  }
}

Let's decode the properties of this class.

  • element - element on which the modifier is applied.
  • delegate - an instance of the modifier manager (functional modifier manager).
  • modifier - modifier definition (autofocus function).
  • args - a snapshot of the arguments passed while invoking the modifier in hbs.
  • destroy() - used to trigger destroyModifier method of the delegate(functional modifier manager).

Now, Let's create our CustomModifierManager class.

class CustomModifierManager {
  create(element, definition, args) {
    var { delegate, ModifierClass } = definition;
    var instance = delegate.createModifier(ModifierClass, args);
    return new CustomModifierState(element, delegate, instance, args);
  }
  install(state) {
    var { delegate, modifier, element, args } = state;
    delegate.installModifier(modifier, element, args);
  }
  update(state) {
    var { delegate, modifier, args } = state;
    delegate.updateModifier(modifier, args);
  }
  getDestructor(state) {
    return state;
  }

Here,

  • create() - triggers the createModifier method of the FunctionalModifierManager which will provide the instance of the modifier function. Also, this method returns an instance of the CustomModifierState which has information about the element, delegate, instance, and args.
  • install() - triggers the installModifier method of the FunctionalModifierManager.
  • update() - triggers the updateModifier method of the FunctionalModifierManager.
  • getDestructor() - returns the state in which we can access the destroy method to trigger the destroyModifier method of the FunctionalModifierManager.

Combining these two classes, our custom-modifier-manager.js file would look like

File: custom-modifier-manager.js

class CustomModifierState {
  constructor(element, delegate, modifier, args) {
    this.element = element;
    this.delegate = delegate;
    this.modifier = modifier;
    this.args = args;
  }

  destroy() {
    var { delegate, modifier, args } = this;
    delegate.destroyModifier(modifier, args);
  }
}

export default class CustomModifierManager {
  create(element, definition, args) {
    var { delegate, ModifierClass } = definition;
    var instance = delegate.createModifier(ModifierClass, args);
    return new CustomModifierState(element, delegate, instance, args);
  }
  install(state) {
    var { delegate, modifier, element, args } = state;
    delegate.installModifier(modifier, element, args);
  }
  update(state) {
    var { delegate, modifier, args } = state;
    delegate.updateModifier(modifier, args);
  }
  getDestructor(state) {
    return state;
  }

Implementing index.js file.

As of now, we have created the overall logic behind the ember functional modifiers. Let's move on to our main fileindex.js

1. Import necessary files

import autofocus from "./modifiers/autofocus";
import FunctionalModifierManager from "./functional-modifier-manager";
import CustomModifierManager from "./custom-modifier-manager";

2. Ember component implementation

Let's assume this as an ember component with a render method which helps to render the elements in the DOM.

class EmberComponent {
  render(...elements) {
    document.getElementById("app").innerHTML = "<div>Hello!</div>";
    document.getElementById("app").append(...elements);
  }
}

3. Instantiate the EmberComponent and call render with an input element.

var componentInstance = new EmberComponent();
var inputElement = document.createElement("input");
componentInstance.render(inputElement);

4. Instantiate CustomModifierManager

var FUNC_CUSTOM_MODIFIER = new CustomModifierManager();

5. Get the state using FUNC_CUSTOM_MODIFIER.create()

var funcModifierState = FUNC_CUSTOM_MODIFIER.create(
  inputElement,
  {
    delegate: funcManager,
    ModifierClass: autofocus
  },
  {}
);

Here, create() method accepts three arguments.

  • element - inputElement is the element on which modifier is applied.
  • definition - delegate and modifierClass together.
  • args - {}

This will return an instance of the customModifierState.

6. Install the modifier on the element using FUNC_CUSTOM_MODIFIER.install()

FUNC_CUSTOM_MODIFIER.install(funcModifierState);

Finally, our index.js file will look like

File: index.js

import autofocus from "./modifiers/autofocus";
import FunctionalModifierManager from "./functional-modifier-manager";
import CustomModifierManager from "./custom-modifier-manager";

class EmberComponent {
  render(...elements) {
    document.getElementById("app").innerHTML = "<div>Hello!</div>";
    document.getElementById("app").append(...elements);
  }
}

var componentInstance = new EmberComponent();
var inputElement = document.createElement("input");
componentInstance.render(inputElement);

var FUNC_CUSTOM_MODIFIER = new CustomModifierManager();

var funcModifierState = FUNC_CUSTOM_MODIFIER.create(
  inputElement,
  {
    delegate: funcManager,
    ModifierClass: autofocus
  },
  {}
);

FUNC_CUSTOM_MODIFIER.install(funcModifierState);

Additionally, if we want to simulate the update and destroy,

update

FUNC_CUSTOM_MODIFIER.update(funcModifierState);

destroy

We can access the destroy() method through the getDestructor() method which returns the CustomModifierState containing the destroy() method.

FUNC_CUSTOM_MODIFIER.getDestructor(funcModifierState).destroy();

We are only one step ahead to see the magic ✨ of ember modifiers.

Include the index.js script in index.html and see the magic.

File: index.html

<!DOCTYPE html>
<html>
<head>
  <title>Parcel Sandbox</title>
  <meta charset="UTF-8" />
</head>
<body>
  <div id="app"></div>
  <script src="src/index.js">
  </script>
</body>
</html>

Hurray! The input element was focused automatically.

Apart from this, I've experimented with on modifier as a class-based-modifier through plain javascript and combined with the implementation we've done so far in this codesandbox

Normally, we won't come across these implementations because Ember, as a framework, does its job in keeping those implementations away from us. But still, I find it interesting and useful to learn the internal working and redoing it with plain javascript. It was pretty exciting! 🤩


Hope you enjoyed getting to know the behind the scenes of ember modifiers. See you in my next blog! 👋

Posted on by:

_raja_sk_ profile

Raja SK

@_raja_sk_

A front-end developer, works with Ember and React. Keen on designing as well.

Discussion

markdown guide