Something old, something new
It feels like my personal path through learning web development sometimes mirrors the history of the web itself. For instance, this blog lacks many hallmarks of the modern web. For instance, it's a collection of html files. How quaint.
Many modern websites are 'single page applications' - basically one page that create the illusion of multiple pages with JavaScript trickery. While I don't appreciate being forced to allow JS, even I must admit that SPAs feel smoother.
I decided to play around a bit with JS and mimic the idea of an SPA for part of my current personal project. A user with JS enabled will be able to login, register, or request a pasword reset from a single page. JS will also take do some client side form validation so the user gets feedback before hitting the submit
button.
Fundamentals - Event Listeners
There are several ways to attach event listeners to HTML elements. One of the oldest ways is using HTML attributes
<!-- HTML attribute event listener (BAD) -->
<input type="button" id="mybtn" value="Click Me!"
onclick="console.log("e;Clicked"e;)"/>
This isn't great for a few reasons, the most obvious of which being that you're closely coupling the JavaScript code and the HTML, and that the element may be visible before the JS has loaded. The situation can be improved with the DOM level 0 method
<!-- DOM level 0 event listener (BETTER) -->
<input type="button" id="mybtn" value="Click Me!"/>
<script>
let btn = document.getElementById('mybtn');
btn.onclick = function(){
console.log("clicked");
}
</script>
which has the advantage of having close to universal support, but the disadvantage that you're limited to one listener per event for each element.
A more modern approach that doesn't have this limitation is the DOM Level 2 event handler,
<!-- DOM level 2 event listener (GOOD) -->
<input type="button" id="mybtn" value="Click Me!"/>
<script>
let btn = document.getElementById('mybtn');
btn.addEventListener('click', myClickFunction, false);
function myClickfunction() {
console.log("clicked");
}
</script>
which permits multiple responses to the event.
Historically .addEventListener()
wasn't supported by Internet Explorer, but that situation seems to have improved. This means we can skip a discussion of cross browser event handling. The boolean argument deals with event flow and is false "for historical reasons".
Form Switching
Creating the illusion of switching between form pages can be done either adding and removing elements from the DOM, or by changing the CSS display value. I choose the latter approach. However, as far as I know, real SPA frameworks do create and destroy elements instead of hiding them. But they also use a virtual DOM to optimize this process. Implementing a virtual DOM is left as an exercise for the reader ;)
This means the login page really has three forms, two of which are hidden at any given time. The user can create an account or reset their password by clicking on the links below the submit button. If JS is allowed and the DOM level 2 methods are available, then the default behavior of the links is prevented and a different form becomes visible without leaving the page.
Since we're staying on the same page, the URL in all cases remains "login.php". This causes the funny behavior that hitting the "back
" button once in the browser after going "index→login→register" will bring us to "index" instead of to "login". Fortunately, we can manipulate the URL as well as the behavior triggered by hitting the "back
" button.
Manipulating History
The browser session history is stored in a history object that can be manipulated via the history API. You can directly see this using the development console (ctrl+shift+k in Firefox) on a tab after clicking through a few pages. You can see the number of pages on the stack in your history object with history.length
, or navigate back and forth with history.go(n)
, with n being a positive or negative integer.
Whenever you click 'back
' in a browser a popstate event fires on the window. The last page added is popped off the stack and the previous page on the stack is request.
A page can be added to the stack by visiting it or by using the history.pushState()
method. pushState()
takes three arguments: the state object, a title value that is ignored by most browsers, and an optional url to display. The state object isn't needed in this case, so we can simply pass a null
value. Since the user could directly bookmark this url, it should also work when the user directly navigates to it.
First, we need to push the current state onto the history stack when we click on the register or reset "links". A listener for the popstate event on the window will trigger when the user clicks the "back
" button and reset the forms.
// in script file
function hookupPageSwitch(register, reset){
register.addEventListener('click', (event) => {
event.preventDefault();
history.pushState(null, null, '?page=register');
setupRegister();
}, false);
reset.addEventListener('click', (event) => {
event.preventDefault();
history.pushState(null, null, '?page=reset');
setupReset();
}, false);
window.addEventListener('popstate', (event) => {
setupLogin();
}, false);
}
function setupRegister(){
hideForms(['resetForm', 'loginForm']);
showForms(['registerForm']);
}
// same idea for other setups
function hideForms(forms) {
forms.forEach((form) => {
document.getElementById(form).className = "hidden";
});
};
// same idea for 'showForms'
Then we just need to run this hookup function when the login page loads.
event.preventDefault()
prevents the anchor element from acting as a link. Also note that parameters are added to the URL. Clicking the register link with JS enabled changes the URL from domain/login.php
to domain/login.php?page=register
instead of to domain/register.php
, which is where you would wind up if you clicked the link with JS disabled.
If we changed the URL to "register.php" with pushState()
, then register.php would be requested from the server when it is at the top of the stack. So if the user goes "index→ login→ register→ privacyPolicy" and then starts hitting the back button, the result looks like "privacyPolicy→ register→ register→ index".
We can't get any information about what page the user was on before hitting the "back
" button to prevent this behavior, but we can easily check query strings:
// in login.php
window.addEventListener('load', function() {
let page = location.search.substr(1).split("=")[1];
if (page === 'reset'){
setupReset();
} else if (page === 'register') {
setupRegister();
} else {
setupLogin();
}
});
then the navigation will work as expected.
Basic Validation
Once everything is set up with the general event utility function, adding validation is pretty uneventful. First some CSS needs to be set up. I used box shadow for a blue glow when the input is in focus. If the input loses focus and fails its validation check, then the error-input
class is added to the element to give it a red glow. Also, the default input:invalid
CSS rule needs to be overriden so that the fields do not have the 'invalid' style before the user has a chance to click into them.
Next comes the JavaScript. Every field gets an event handler added that removes error-input
from the class list when the element is in focus. The CSS rule for the :focus
pseudoselector then takes care of adding the blue glow
function gainFocus(){
event.target.classList.remove("error-input");
}
and each field has its own validate function that is triggered when it loses focus (on 'blur'). I used a loop to cut down a bit on repetition.
const fields = {
newPass: {name: newPass, func: validateNewPassword},
...
};
for (let f in fields){
if (fields[f].name){
fields[f].name.addEventListener("focus",
gainFocus, false);
fields[f].name.addEventListener("blur",
fields[f].func, false);
}
}
One field was a little more exciting to hookup: new username. Here validation requires checking that the username is unique. However, that information is on the server while our JS is running on the client. Luckily for us, it's possible to request information from the server without reloading the page.
Some rather fetching code
Requests to a server are generally done asynchronously to avoid blocking the flow of the script. An early approach used XMLHTTPRequest objects wrapped in callback functions. Callbacks are passed to another function and do not execute immediately.
This permits asynchronous code, but you quickly land in deeply nested callbacks once you start dealing with error handling and async requests that depend on other async requests. This problem has been generally mitigated by the introduction of Promises, which provide a smoother interface between asynchronous and synchronous JS code.
Initially I wrote a bare bones XMLHTTPRequest wrapped in a Promise. I reassured myself that it was ok to ignore the advice to use the modern fetch API because clearly my code would be IE friendly.
It was not.
As caniuse helpfully indicates, anyone hoping to use Promises on IE is out of luck. This leaves three possibilities: wrap in callbacks for legacy support, leave it as a half baked mess that doesn't really do anything well, or use the Fetch API.
Switching to the Fetch API drastically reduced the lines of code, so this wasn't a tough choice. We can just wrap it in a check to see if 'fetch' is defined for the window and IE users can live with the server side check.
function validateUsername(){
let field = event.target;
if (field.value.length === 0){
field.classList.add("error-input");
return;
}
if('fetch' in window){
fetch('/validate_username.php?user='+ field.value)
.then(response => {
if (!response.ok){
throw Error(response.statusText);
}
return response.json();
})
.then(data => {
if (data.status === "taken"){
field.classList.add("error-input");
} else {
field.classList.remove("error-input");
}
})
.catch(() => console.log('problem'));
}
}
A few quick notes about this:
First off, fetch() will consider any round trip to the server to be successful, even if the resource wasn't found. So you really should check that the response object status is ok.
Fetch also needs the url of the script that will actually handle the request on the server. It seems that, given a relative url, it cannot look above the website root. I haven't yet found a more elegant solution than just leaving this PHP script in a location where fetch() can reach it.
Finally, PHP needs to echo whatever information JS should get.
<?php
$isUser = check_newUsername($_GET['user']);
if ($isUser){
echo json_encode(array('status' => 'taken',
'message' =>'user exists'));
} else {
echo json_encode(array('status' => 'free',
'message' =>'valid username'));
}
?>
Return statements won't help anything, the resulting value in the JavaScript script is undefined. I'm thinking of it as printing a line into the JS code.
Parting thoughts
Who knew there was so much to think about for a simple login! I didn't even get up to actually submitting anything! The email + one time pin system for stuff exchange was so simple, I think that was up and running in an afternoon. This... has taken a bit more effort and is still rough.
What makes this so much worse than the OTP system? Suddenly there is no guarantee that the username is unique, the password needs to be controlled and reasonably hashed, there should be a possibility to display the password in plain text, there needs to be 'nice' error feedback, there must be a password reset capability... oje! But it certainly is an interesting exercise :)