If you just want to see the code:
Here's our finished project: https://codesandbox.io/s/component-example-9796w
Here's the project with my very lightweight library (note the fewer lines): https://codesandbox.io/s/domponent-example-ij1zs
Hey, so I'm a Senior Front-end Developer. I've built performant web UI Components for enterprise-level apps and multi-national companies with AngularJS, React, Vue, jQuery Plugins, and Vanilla JS.
They all have their pluses. Let's go over them quickly:
- AngularJS, you can just augment your HTML and build full-fledged complex UIs.
- jQuery Plugins, you can just add some classes and attributes to HTML and the plugins will do the rest.
- React, the entire app is component based, easy to read, and trivial to reuse.
- Vue, you can implement an AngularJS-type solution with an entirely component-driven approach.
- Vanilla JS, you don't have pull in any libraries and you can choose whatever lightweight solution you want.
For each approach you can implement UI as a function of state. For some (Vue and React) it's easier done with use of Virtual DOM (look it up if you need. It's super cool).
However, what if you're stuck with older tech? What if you're working with Razor or Pug or Thymeleaf? And additionally, you're not using REST APIs? You have some advantages (SSR by default, SEO-friendly), but you have a TON of drawbacks (lack of Virtual DOM, ergo difficult/verbose rerenders).
With classic front-end web development you lack simplified component state, component lifecycles, scoped models, granular control over model changes. These are all complex to implement and a built-in part of React, Vue, Knockout, Angular, etc.
But with some build tools (webpack, parcel, rollup, grunt, gulp) and some incredibly battle-tested template languages (Thymeleaf, Pug, Razor) you can build UI Components with incredible ease.
Here's how I do it with my older tech stack:
The directory structure
FrontEnd
|
|___components
|
|__MyComponent
| |
| |___MyComponent.pug/.html/.cshtml
| |___MyComponent.scss
| |___MyComponent.js
|
|__MyOtherComponent
|
|___MyOtherComponent.pug/.html/.cshtml
|___MyOtherComponent.scss
|___MyOtherComponent.js
Let's run through this.
In a React app, you would have 1 less file. You might even have two less files.
You'd remove the html
and possibly the scss
. You'd have your HTML as part of a JSX
file. You may even have CSS in JS. So it might be a single file component. This is similar to a .vue
file.
We're just actually breaking it out here. Screw 1 file, let's go classic and have 3. Logic in JS, Structure in HTML, Look in SCSS. Now, each file:
HTML
Let's make a simple Counter. It's going to show the count and offer and increment and decrement option
<div>
<p>Your Count:
<span>0</span>
</p>
<button type="button">
-
</button>
<button type="button">
+
</button>
</div>
Cool! This is gonna look terrible and make people cry. So let's write some styles.
SCSS
We will be using SCSS and BEM syntax. It will be imported into the .js file for the component. Let's boogie:
.Counter{
padding: 1rem;
&__count{
font-size: 2.5rem;
}
&__btn{
padding:.5rem;
margin: .5rem;
&--increment{
background: lightgreen;
}
&--decrement{
background: lightblue;
}
}
}
And let's update our HTML
<div class="Counter">
<p>Your Count:
<span class="Counter__count">0</span>
</p>
<button type="button" class="Counter__btn Counter__btn--decrement">
-
</button>
<button type="button" class="Counter__btn Counter__btn--increment">
+
</button>
</div>
Hold up! What's with the capitalized class name?
This is simply a preference of mine since it's standard practice in React apps to name your components Capitalized. But you can do whatever you want.
JS
Ok, let's make this reusable JS with a default count
of 0
. We're going to do this poorly at first and then fix it up slowly. So stay with me here :)
import './Counter.scss'
class Counter {
constructor() {
this.count = 0;
this.countEl = document.querySelector(".Counter__count");
this.incBtn = document.querySelector(".Counter__btn--increment");
this.decBtn = document.querySelector(".Counter__btn--decrement");
this.incBtn.addEventListener("click", this.increment.bind(this));
this.decBtn.addEventListener("click", this.decrement.bind(this));
}
increment() {
++this.count;
this.updateDOM();
}
decrement() {
--this.count;
this.updateDOM();
}
updateDOM() {
this.countEl.textContent = this.count;
}
}
new Counter();
NOTE: I'm using bind
under the assumption you are not using Babel... yet
Read this:
https://www.freecodecamp.org/news/react-binding-patterns-5-approaches-for-handling-this-92c651b5af56/
Ok there are more than a few issues with this approach. Let's focus on one:
Using CSS classes (meant for styling only) to handle UI
This is a big one. Relying on CSS classes or even HTML element types to access DOM is a big boo boo. If you change your class name or the element type you could be breaking functionality of your app!
So how do we address this? There are a couple approaches:
- JS-specific classes in your HTML
- Using special
data-
attributes
We're going to use method #2:
<div class="Counter">
<p>Your Count:
<span class="Counter__count" data-count="true">0</span>
</p>
<button type="button" data-dec-btn="true" class="Counter__btn Counter__btn--decrement">
-
</button>
<button type="button" data-inc-btn="true" class="Counter__btn Counter__btn--increment">
+
</button>
</div>
import './Counter.scss'
class Counter {
constructor() {
this.count = 0;
this.countEl = document.querySelector("[data-count]");
this.incBtn = document.querySelector("[data-inc-btn]");
this.decBtn = document.querySelector("[data-dec-btn]");
this.incBtn.addEventListener("click", this.increment.bind(this));
this.decBtn.addEventListener("click", this.decrement.bind(this));
}
increment() {
++this.count;
this.updateDOM();
}
decrement() {
--this.count;
this.updateDOM();
}
updateDOM() {
this.countEl.textContent = this.count;
}
}
new Counter();
Ok a little better. The DOM is looking slightly more declarative and we can mess with our CSS all we want now. We just added super blunt and really poorly thought-out attributes.
We can make this even better. What if we set our own standard for DOM querying attributes? Moreover, what if the values of those attributes meant something too?
Let's enhance our HTML.
We're going to draw from React and Vue by using something called refs
. refs
are short for "reference" as in DOM reference. It's simply caching a DOM element in JS. So let's use a standard data-ref
attribute:
<div class="Counter">
<p>Your Count:
<span class="Counter__count" data-ref="count">0</span>
</p>
<button type="button" data-ref="decrement" class="Counter__btn Counter__btn--decrement">
-
</button>
<button type="button" data-ref="increment" class="Counter__btn Counter__btn--increment">
+
</button>
</div>
import './Counter.scss'
class Counter {
constructor() {
this.count = 0;
this.countEl = document.querySelector('[data-ref="count"]');
this.incBtn = document.querySelector('[data-ref="increment"]');
this.decBtn = document.querySelector('[data-ref="decrement"]');
this.incBtn.addEventListener("click", this.increment.bind(this));
this.decBtn.addEventListener("click", this.decrement.bind(this));
}
increment(){
++this.count;
this.updateDOM();
}
decrement(){
--this.count;
this.updateDOM();
}
updateDOM(){
this.countEl.textContent = this.count;
}
}
new Counter();
Ok this isn't the worst thing in the world. The DOM is slightly more declarative.
Let's address one minor issue:
- How do we differentiate state fields from DOM fields?
Let's wrap state fields in a state
object and refs
in a $refs
object ( a la Vue):
import './Counter.scss'
class Counter {
constructor() {
this.state = {
count: 0
};
this.$refs = {
countEl: document.querySelector('[data-ref="count"]'),
incBtn: document.querySelector('[data-ref="increment"]'),
decBtn: document.querySelector('[data-ref="decrement"]')
};
this.$refs.incBtn.addEventListener("click", this.increment.bind(this));
this.$refs.decBtn.addEventListener("click", this.decrement.bind(this));
}
increment(){
++this.state.count;
this.updateDOM();
}
decrement(){
--this.state.count;
this.updateDOM();
}
updateDOM(){
this.$refs.countEl.textContent = this.count;
}
}
new Counter();
But we have at least two major issues:
- How do we know what object
data-ref
belongs to? - How can we get rid of these
.bind
calls?
Enter Babel!
Babel can take modern and proposed syntax and make is ES5 readable.
We're going to rely on two things:
class-public-fields
https://github.com/tc39/proposal-class-public-fieldstemplate literals
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals
Let's tackle #1:
import './Counter.scss'
class Counter {
constructor() {
this.state = {
count: 0
};
this.$refs = {
countEl: document.querySelector('[data-ref="count"]'),
incBtn: document.querySelector('[data-ref="increment"]'),
decBtn: document.querySelector('[data-ref="decrement"]'),
};
this.$refs.incBtn.addEventListener("click", this.increment);
this.$refs.decBtn.addEventListener("click", this.decrement);
}
increment = () => {
++this.state.count;
this.updateDOM();
}
decrement = () =>{
--this.state.count;
this.updateDOM();
}
updateDOM = () => {
this.$refs.countEl.textContent = this.count;
}
}
new Counter();
bingo bongo! No more bind
!
Now let's tackle #2.
For this we are going to assume we may want to update the attribute name data-ref
in the future (it's far-fetched, but trust me these refactors happen!)
Let's preface our HTML attribute values with our component name
<div class="Counter">
<p>Your Count:
<span class="Counter__count" data-ref="Counter.count">0</span>
</p>
<button type="button" data-ref="Counter.decrement" class="Counter__btn Counter__btn--decrement">
-
</button>
<button type="button" data-ref="Counter.increment" class="Counter__btn Counter__btn--increment">
+
</button>
</div>
Let's update the JS
import './Counter.scss'
const ref = 'data-ref'
class Counter {
constructor() {
this.state = {
count: 0
};
this.$refs = {
countEl: document.querySelector(`[${ref}="Counter.count"]`),
incBtn: document.querySelector(`[${ref}="Counter.increment"]`),
decBtn: document.querySelector(`[${ref}="Counter.decrement"]`)
};
this.$refs.incBtn.addEventListener("click", this.increment);
this.$refs.decBtn.addEventListener("click", this.decrement);
}
increment = () => {
++this.state.count;
this.updateDOM();
}
decrement = () =>{
--this.state.count;
this.updateDOM();
}
updateDOM = () => {
this.$refs.countEl.textContent = this.count;
}
}
new Counter();
This is pretty darn good so far. But it's not reusable. What if we have multiple Counters? The fix is pretty simple. We're going to create a $root
DOM reference.
<div class="Counter" data-component="Counter">
<p>Your Count:
<span class="Counter__count" data-ref="Counter.count">0</span>
</p>
<button type="button" data-ref="Counter.decrement" class="Counter__btn Counter__btn--decrement">
-
</button>
<button type="button" data-ref="Counter.increment" class="Counter__btn Counter__btn--increment">
+
</button>
</div>
Let's update the JS
import './Counter.scss'
const ref = 'data-ref'
class Counter {
constructor(root) {
this.$root = root;
this.state = {
count: 0
};
this.$refs = {
countEl: this.$root.querySelector(`[${ref}="Counter.count"]`),
incBtn: this.$root.querySelector(`[${ref}="Counter.increment"]`),
decBtn: this.$root.querySelector(`[${ref}="Counter.decrement"]`)
};
this.$refs.incBtn.addEventListener("click", this.increment);
this.$refs.decBtn.addEventListener("click", this.decrement);
}
increment = () => {
++this.state.count;
this.updateDOM();
}
decrement = () =>{
--this.state.count;
this.updateDOM();
}
updateDOM = () => {
this.$refs.countEl.textContent = this.state.count;
}
}
Now we can instantiate multiple Counters like so:
const counters = Array.from(document
.querySelectorAll('[data-component="Counter"]'))
.map(element => new Counter(element));
So there is a framework-less way to make components. You can prepopulate your DOM using HTML fragments/mixins/partials (whatever your template language refers to as "chunks reusable of HTML".
There are obviously some bigger things to deal with here:
Passing state in, scoping components, etc. And that's where I've made a small 2kb library for handling all those things and more without you having to manually scrape any DOM and bind any events. You can declare it all in your HTML and let the library take over.
Check it out. Let me know your thoughts! I find this is a pretty decent solution for enterprise applications:
My Library for Handling Above Code and MORE!
https://github.com/tamb/domponent
And here is the end result of what we just made:
Top comments (0)