At my work, we have quite a bit of legacy front-end code written with jQuery and Mustache templates -- some of it dating back to 2011. For a long time, we've needed a framework that would:
- Provide a common structure and organization.
- Reduce repetitive boiler plate code.
- Encourage adoption of best practices and functionality of modern JS frameworks (e.g., templating, one-way data binding, no explicit DOM manipulations, etc).
- Allow for easy re-factors of our existing legacy code.
We've considered adopting one of the major JS frameworks (e.g., React, Angular, etc), but our dev resources are already stretched thin and the effort required for refactors would be too great/expensive. We needed a framework that would 'play nice' with the style of our existing legacy code.
As a possible solution, I recently put together a simple JavaScript front-end framework for building web components. The framework is comprised of three libraries and a base class.
Those three libraries are:
- jQuery
- Mustache.js
- ObservableSlim (a library I wrote for observing changes made to objects via ES6 Proxy).
The framework is pretty simple. Components are written as classes and they inherit the base class using classical inheritance with Object.create(). The framework offers templating, one-way data binding, standardized organization and eliminates the need for any explicit DOM manipulations after the component has rendered. Most importantly, it will allow for relatively low-effort refactors of our legacy code base.
I'm looking for feedback on a sample component that I've written on jsFiddle using the framework. It's not a particularly realistic example of a component, it just illustrates how the framework is used and how the different features work. I'm looking for answers to these types of questions:
- Does the component make sense?
- Does this seem like a reasonable way to organize code?
- Are the one-way data bindings easy to follow and understand?
- Can you foresee any shortcomings or disadvantages with the design approach dictated by the framework?
- Do you understand the difference between uiBindings and dataBindings?
Here's the code of the sample component:
HTML: (just a single Mustache template)
<script type="text/template" id="t4m_template_1">
<div>
<p class="hello_user_container">
{{^have_name}}Hello world!{{/have_name}}
{{#have_name}}Hello <b>{{user_name}}</b>!{{/have_name}}
</p>
<p>
Set your name:
<input type="text" value="" class="user_name_text">
</p>
<p class="patient_data_container">
Now viewing: {{patient_name}} <br>
Date of birth: {{birth_date}} <br>
Date of admission: {{admit_date}}<br>
</p>
<p>
<input type="button" value="this.data.patient_name = 'Bobby Smith';" class="patient_name_change_btn"> <br><br>
<input type="button" value="Load next patient" class="load_next_patient_btn">
</p>
</div>
</script>
JavaScript:
// declare a sample component
var HelloWorld = (function() {
// declare the default definition for this component
var defaults = {
// what templates will this component use? this sample component only uses one template
"templates":["t4m_template_1"]
// if this component needs to display a loading message or a loading spinner, specify that template here
,"loadingTemplate":null
// what _fetch* methods do we need to execute when this component is instantiated?
,"initList":["_fetchPatient"]
// define what changes to this.data.* should trigger what portions of the component to update.
// in the example below, a change to this.data.user_name would trigger a refresh
// of <div class="hello_user_container">...</div>
,"uiBindings":{
"user_name":[".hello_user_container"]
,"patient_name":[".patient_data_container"]
,"admit_date":[".patient_data_container"]
,"birth_date":[".patient_data_container"]
}
// define what changes to this.data.* should trigger what _fetch* methods. when the _fetch* method(s)
// return, they will store new data on this.data which could trigger UI updates if there's a matching
// uiBinding entry above. delay_refresh:true tells the framework that we don't want to update the UI
// while one or more fetch methods are still in progress. this prevents UI updates from triggering in rapid
// succession if multiple _fetch* methods are invoked
,"dataBindings":{
"person_id":{"delay_refresh":true
,"methods":["_fetchNewPatient"]
}
}
// this is the default data passed into the component. often times this data is just null because it
// must first be populated by the _fetch* methods defined in the initList above.
,"data":{
"user_name":""
,"person_id":3453456
,"patient_name":null
,"birth_date":null
,"admit_date":null
}
// if set to true, then we do not fire off the _fetch* methods defined in the initList automatically
// when the component is initialized -- we would have do it manually at a later time using the this.init() method.
,"delayInit":false
};
var constructor = function(options) {
// overwrite the defaults with any options that were manually passed into the constructor
var options = $.extend(true, defaults, options);
// invoke the base class constructor
CMBase.call(this,"T4MHelloWorld",options);
};
// extend this class with the base class
constructor.prototype = Object.create(CMBase.prototype);
constructor.prototype.constructor = constructor;
// the render method is the only place where the UI for the component is generated. no other portion
// of the component is allowed to modify the display or make any manual DOM manipulations. this gives
// non-author devs a single place to inspect when they want to understand the display logic and figure
// out why a component looks the way it does
constructor.prototype._render = function() {
var self = this;
var tplData = {
"have_name":(this.data.user_name.length > 0 ? true : false)
,"user_name":this.data.user_name
,"patient_name":this.data.patient_name
,"birth_date":this.data.birth_date
,"admit_date":this.data.admit_date
};
// render using the first template defined by our component
var jqDom = $(Mustache.render(this.templates[0], tplData));
// when the user types in their name, we update this.data.user_name which then
// triggers a uiBinding to refresh the .hello_user_container div
jqDom.find(".user_name_text").on("keyup", function() {
self.data.user_name = $.trim($(this).val());
});
// when the user clicks this button, we change this.data.patient_name to "Bobby Smith"
// which in turn triggers a uiBinding to refresh the .patient_data_container div
jqDom.find(".patient_name_change_btn").on("click", function() {
self.data.patient_name = "Bobby Smith";
});
// when the user clicks this button, we update this.data.person_id. unlike the two buttons above,
// there is no uiBinding for this.data.person_id, but there is a dataBinding. The dataBinding for "person_id"
// invokes the _fetchLarry method. THe _fetchNewPatient method fires off an XHR request that retrieves
// new patient data once that new patient data is stored on this.data it triggers a uiBinding
// which updates the display automatically
jqDom.find(".load_next_patient_btn").on("click", function() {
self.data.person_id = 5555555;
});
return jqDom;
};
// this is a fetch method retrives the data set for our imaginary Charlie patient. this is the first patient we load
// because _fetchPatient is listed in the "initList" above so this fetch method gets executed when the component
// is initialized. we're using the jsfiddle echo request -- it simply echos back the data in the URI
constructor.prototype._fetchPatient = function(resolve, reject) {
var self = this;
$.ajax({
url:'/echo/js/?js={"patient_name":"Charlie Smith","birth_date":"August 9th, 1987","admit_date":"January 1st, 2018"}',
dataType:"json",
success: function (response) {
// at this point we've successfully retrieved the patient data. now we need to store the patient data
// on the component by updating this.data. when we make these updates to this.data, it will trigger
// uiBindings that will refresh the appropriate parts of the component with the patient info
self.data.patient_name = response.patient_name;
self.data.birth_date = response.birth_date;
self.data.admit_date = response.admit_date;
resolve();
},
error: function (error) {
console.error(error);
reject();
}
});
};
// this is a fetch method retrives the data set for our next patient, Larry. this method is invoked whenever
// any change is made to this.data.person_id because of the dataBinding we've defined above. this XHR is just a
// hard-coded example, but in reality a _fetch* method would use this.data.person_id to request the correct data
// for whichever patient was just selected.
constructor.prototype._fetchNewPatient = function(resolve, reject) {
var self = this;
$.ajax({
url:'/echo/js/?js={"patient_name":"Larry Anderson","birth_date":"October 13h, 1985","admit_date":"January 2nd, 2018"}',
dataType:"json",
success: function (response) {
// now that we've successfully retrieved the patient data, we need to store it on the component by
// updating this.data. when we modify this.data it will trigger uiBindings to update the appropriate
// part of the DOM
self.data.patient_name = response.patient_name;
self.data.birth_date = response.birth_date;
self.data.admit_date = response.admit_date;
resolve();
},
error: function (error) {
console.error(error);
reject();
}
});
};
return constructor;
})();
// instantiate the component, accept the default config, not passing in any custom options
var test = new HelloWorld();
// render the component
var rendered = test.render();
// insert the component to the page
$("body").append(rendered);
Please bear in mind that this is just a sample 'Hello World' type of component. It has been written for the purpose of illustrating how the framework works -- particularly the usage of initList, uiBindings and dataBindings. I am aware that a component this small would've been easier to write with vanilla JS.
My co-workers like the framework, but I'd like to hear some feedback from developers outside of our shop -- developers with a different perspective and less familiarity with how we write code.
Each component has a definition (see var defaults
above). Hopefully the jsFiddle example is simple and straightforward enough that it's possible to understand what's going on without explanation. But if that's not the case, here's an explanation of what each part of the component definition does:
templates
- Array of strings - all Mustache templates that are used by the component are included here identified by their element ID. The jsFiddle example I've written only uses one template. Listing all the templates in one place allows other non-author developers to quickly ascertain what templates a given component uses. This becomes important when you have large web apps with hundreds of different templates. Listing all the templates in one place also allows the base class to verify that all the templates are present and throw an error if one is missing at load time.loadingTemplate
- String - our components often need to load up more data via XHR before they can render. While this is occurring, it's beneficial to displaying a loading spinner or loading message of some kind. You can define which template you want to use for that. If you don't define a template, then the base class will just use an empty<div></div>
as a placeholder.initList
- Array of strings - a list of the 'fetch' methods that must be executed before the component can render itself. A quick explanation on 'fetch' methods: if the child component must retrieve data via XHR, then those requests go into individual "fetch" methods. By placing these XHR requests into defined fetch methods, it is possible to handle them in a more judicious manner and link them up to Promise chains. This is not to say that all XHR requests go into their own 'fetch' method -- just the requests that load up external data required for the component to render.data
- Object, this is the default data passed into the component. Using MVC parlance, this would be the model data. During the initialization of the component,data
is often just null -- the data must first be retrieved via XHR (see the_fetch*
methods). But even if the data is null, it's helpful to map out here what values are expected to be populated later. This gives other developers a blueprint of what model data this component works with.One of the most important features of this framework is that all data used by the component for rendering is stored on the
this.data
property. Keeping all data in one place makes it possible to monitor for changes and trigger UI updates automatically.uiBindings
- this is where we define our one-way data bindings. uiBindings is where we define which parts of the component should be updated when a given data change occurs. In the jsFiddle, you'll see that a change tothis.data.username
will trigger a refresh of.hello_user_container
. You'll notice that some of the entries don't have a CSS selector but are instead set to true. This implies that a data change to that item should trigger a full refresh of the component. For example, if 'coverage_list' is modified, then the entire component should refresh.This is the same kind of one-way data binding that ReactJS and other frameworks offer -- except that ReactJS provides virtual DOM diff'ing so you don't need to make these explicit definitions of what data changes should trigger what portions of the UI to update. React automatically figures out which portions of the page need updated for you. Eventually I plan to implement virtual DOM diffing that will play nice with our legacy code base, but it will take some time. React made some sacrifices and complicated abstractions to get their virtual DOM diffing and event handling to work properly.
dataBindings
- this works similar to uiBindings, but instead of changes to this.data triggering UI refreshes, these are changes to this.data that trigger the retrieval of new data via_fetch
methods. These types of situations come up often in our components. Let's say we've written a component that allows doctors to view patient demographics. The doctor just clicked the 'next' button to view the next patient. We update this.data.patient_id to the new patient, but now we need to fetch data about the patient before we can display it. We can usedataBindings
to define what data should be retrieved when certain data changes occur.delayInit
- boolean, if set to true, then the component will not automatically initialize itself (i.e., fetch data) in the constructor.