This article is the fifth in a series of deep dives into JavaScript. You can view previous articles by visiting the Github repository associated with this project.
This series does not comprehensively cover every JavaScript feature. Instead, features are covered as they crop up in solutions to various problems. Also, every post is based on tutorials and open source libraries produced by other developers, so like you, I too am also learning new things with each article.
At this stage in our project we have built a basic UI framework (Aprender), test library (Examinar) and module bundler (Maleta). We have not touched our framework for a while so in this post we will return to it. The most exciting thing Aprender can do is create and render DOM elements, so what more can we make it do?
Every development tool is built to solve a particular problem and our framework is no different. Its primary purpose is to be an educational tool but for that education to be effective, it needs to happen in the context of something. That something will be a search application that allows users to choose from a selection of these free public APIs, search for something and then display the results. We will incrementally build functionality which handles this specific use case instead of worrying about our framework meeting the large number of requirements for a production level tool. For example, production standard UI libraries have to handle all the various quirks and requirements of every DOM element. Aprender will only handle the elements needed to create the application.
The first order of business is to list the user stories for our search app:
- As a user, I can view the search app
- As a user, I can select an API
- As a user, after selecting an API, I can view information explaining the API and what search parameters I can use
- As a user, I can type in the search field and click the search button
- As a user, after clicking the search button I can view the search results
- As a user, I can clear the search results
We will also refactor our demo app to reflect the new goal:
const aprender = require('../src/aprender');
const Button = aprender.createElement('button', {
attrs: {
type: 'submit'
},
children: ['Search']
}
);
const Search = aprender.createElement('input', { attrs: { type: 'search' }});
const Form = aprender.createElement('form', {
attrs: {
id: 'form',
onsubmit: (e) => {
e.preventDefault();
console.log('I am being submitted..')
}
},
children: [
Search,
Button
]
},
);
const App = aprender.render(Form);
aprender.mount(App, document.getElementById('app'));
The only new addition in the code above is the function assigned to the onsubmit
property of the form's attrs
object and it is this functionality we will explore next.
Events and DOM elements
Adding event handling functionality to DOM elements is straightforward. You grab a reference to an element using a method such as getElementById()
and then use the addEventListener
method to set up the function that is called whenever an event is triggered.
For Aprender's event handling functionality, we will take inspiration from Mithril. In our framework, the renderElement
function is responsible for attaching attributes to DOM elements, so we will put the event code there:
const EventDictionary = {
handleEvent (evt) {
const eventHandler = this[`on${evt.type}`];
const result = eventHandler.call(evt.currentTarget, evt);
if (result === false) {
evt.preventDefault();
evt.stopPropagation();
}
}
}
function renderElement({ type, attrs, children }) {
const $el = document.createElement(type);
for (const [attribute, value] of Object.entries(attrs)) {
if (attribute[0] === 'o' && attribute[1] === 'n') {
const events = Object.create(EventDictionary);
$el.addEventListener(attribute.slice(2), events)
events[attribute] = value;
}
$el.setAttribute(attribute, value);
}
for (const child of children) {
$el.appendChild(render(child));
}
return $el;
};
We are only interested in registering on-event
handlers. Mithril and Preact both screen for these event types by checking if the first two letters of the attribute name begin with 'o' and 'n' respectively. We will do the same. addEventListener
takes the event name as its first argument and either a function or object as the second argument. Typically, it is written like this:
aDomElement.addEventListener('click,' () => console.log('do something'));
Like Mithril, we will use an object but its creation will be different. Mithril's source has some comments which explain their approach and offer great insight into the considerations framework authors make when building their tools.
First, the event object is created using the new EventDict()
constructor pattern as opposed to our Object.create(EventDictionary)
approach. In Mithril, the object created whenever new EventDict()
is called is prevented from inheriting from Object.prototype
by this line:
EventDict.prototype = Object.create(null);
Mithril maintainer Isiah Meadows said one of the reasons this was done was to guard against third parties adding properties such as onsubmit
or onclick
to Object.prototype
.
We are not worried about this so we create an object called EventDictionary
which implements the EventListener
interface. We then use Object.create
to specify EventDictionary
as the prototype and create an object which will hold a list of on-event
handlers for the DOM element in question. Finally, the newly created object is assigned the attribute value.
After this, whenever an event is triggered on the DOM element in question, the handleEvent
function on EventDictionary
will be called and given the event object. If the event exists on the event object, it is invoked using call
and we specify the DOM element as the this
context and pass the event object as the only argument. If our handler's return value is false
, the result === false
clause will stop the browser's default behaviour and also prevent the event from propagating.
There is an excellent in-depth post which explains the differences of the Object.create
approach over new Func()
when creating objects. This Stack Overflow question also has some interesting thoughts on the two patterns.
A little bit about events
If we run our application, we should see an input field with a button next to it. Typing some text and clicking the button should log I am being submitted..
in our console. But if we remember, the first line in our form's onsubmit
function is:
const Form = aprender.createElement('form', {
// ...
onsubmit: (e) => {
e.preventDefault();
console.log('I am being submitted..')
}
// ...
},
);
What is e.preventDefault()
and what does it do? The default behaviour when a form's onsubmit
handler is called is for its data to be sent to the server and the page to be refreshed. Obviously, this is not always ideal. For one, you might want to validate the data before its sent or you might want to send the data via another method. The preventDefault
function is a method on the Event object and it tells the browser to prevent the default action. However, if you were to programmatically create a form like this:
const form = document.createElement('form');
form.action = 'https://google.com/search';
form.method = 'GET';
form.innerHTML = '<input name="q" value="JavaScript">';
document.body.append(form);
Submitting the form by calling form.submit()
would not generate the submit
event and the data would be sent.
The next event we will look at is on our input field. We need to capture the input value so we can use it to make a request to the selected API. We have a few events we can choose from for this: oninput
, onblur
and onchange
.
The onblur
event fires when a focused element loses focus. In our case, it would fire only when the user focused away from the input field. The onchange
event fires when the user changes the value of a form control, like our input field, and then focuses away from it. Finally, oninput
is fired each time the value changes. This means every keystroke would fire the event. We will use the oninput
event because it best suits our purposes. onchange
and likewise onblur
would be useful if we wanted to validate the input each time the search element lost focus. Note: if you were like me and did not know much about events when you first started using React, you would have been surprised to know that React's onchange
event behaves exactly like oninput
. There is even an issue about it.
Our final act will be to create a select
element for our list of API options and attach an onchange
event handler to it. And with that, our application code should look like this:
const aprender = require('../src/aprender');
const Button = aprender.createElement('button', {
attrs: {
type: 'submit'
},
children: ['Search']
}
);
const Search = aprender.createElement('input', {
attrs: {
type: 'search',
oninput: (e) => console.log(e.target.value)
}
});
const Form = aprender.createElement('form', {
attrs: {
id: 'form',
onsubmit: (e) => {
e.preventDefault();
console.log('I am being submitted..')
}
},
children: [
Search,
Button
]
},
);
const Dropdown = aprender.createElement('select', {
attrs: {
onchange: (e) => console.log(e.target.value)
},
children: [
aprender.createElement('option', {
children: ['--Please select an API--']
}),
aprender.createElement('option', {
children: ['API 1']
}),
aprender.createElement('option', {
children: ['API 2']
})
]
});
const SelectAPI = aprender.createElement('div', {
children: [
aprender.createElement('h2', { children: ['Select API: ']}),
Dropdown
]
})
const Container = aprender.createElement('div', {
children: [
SelectAPI,
Form
]
})
const App = aprender.render(Container);
aprender.mount(App, document.getElementById('app'));
Summary
We have completed our first user story:
- As a user, I can view the search app
In the next post we will tackle:
- As a user, I can select an API.
This feature will expose us to the core reason why UI frameworks exist - keeping the user interface in sync with the application state.
Top comments (0)