2
\$\begingroup\$

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:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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 to this.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.

  6. 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 use dataBindings to define what data should be retrieved when certain data changes occur.

  7. delayInit - boolean, if set to true, then the component will not automatically initialize itself (i.e., fetch data) in the constructor.

\$\endgroup\$
2
\$\begingroup\$

I like what you've created. If your main goal was to build a simple template for organizing component code and getting a handle on a pile of legacy code, then I think you've accomplished that goal. It was fairly easy to grasp the role of uiBinding -- that certain changes to the component data would trigger UI updates. It was also easy to understand dataBinding -- that changes prompt the component to fetch new data. It's pretty neat that the dataBindings trigger fetch methods that in turn can trigger uiBinding UI updates. I spent maybe 10 minutes reading and re-reading your sample component before it made sense -- not bad.

There are some things you could improve. Here are my suggestions:

  • It doesn't seem like jQuery is necessary to include. While it does make the syntax prettier and shorter, you can accomplish the rendering and updates without it. Try removing it from your base class.

  • Your ObservableSlim library uses ES6 Proxy to monitor for changes on this.data -- that's an ES6 feature that can't be completely replicated with a polyfill or transpiler. Unless you're willing to drop support for older browsers (IE11), then you may want to consider monitoring for changes to this.data by a different means.

  • Related to my suggestion above, if you keep the ES6 Proxy, then you might as well start adopting ES6 syntax and conventions in other parts of your code. For example, you could eliminate your use of var self = this by using arrow functions.

\$\endgroup\$
2
\$\begingroup\$

You, my friend, are an architecture astronaut. Holy moley, dude, 110 lines of code on top of 4 libraries to do something that would take a couple dozen lines of code to do in "vanilla" JS sounds to me like you're abstracting way too much.

I have the exact same problem and it's led to hundreds of hours of wasted coding so I know it's hard to hear, but my honest opinion is that you should come back down to Earth, take a vacation for a few days and look at this again and ask yourself, do you really need all this?

Your buttons only respond to the event listener once, it's not immediately apparent whether that was by design or if it's a bug.

Does the component make sense?

Not immediately, not to me, though I've only worked with mustache a few times.

Is it easy to follow?

No, sorry. You required a 4200 character explanation without even mentioning core observable library (which is what you should really be asking for a review on) - that's a pretty good indicator of how easy it is to follow.

Can you foresee any shortcomings or disadvantages with the design approach dictated by the framework?

No, just a little over-engineered IMO.

\$\endgroup\$
  • \$\begingroup\$ I appreciate the time you spent to respond, but it's missing a lot. 1. The buttons do respond to clicks more than once, but they won't necessarily trigger a UI change. It's supposed to work that way -- just a small example, 2. the ObservableSlim library doesn't poll every 10ms -- there's no polling at all. It uses ES6 Proxies to observe changes to objects, 3. This would not be possible to do in a couple dozen lines of vanilla JS -- the component employs one-way data binding, that alone can't be done in a couple dozen lines of vanilla JS. I think I may need to write a better example component. \$\endgroup\$ – Elliot B. Jan 10 '18 at 5:33
  • \$\begingroup\$ Why wouldn't you want it to trigger a UI change after the first click? Maybe "one way data binding" can't be done in a few lines, but the UI can certainly reproduced in a few lines. Also, I personally wouldn't be comfortable releasing something into the wild that required ES6 proxies but that's just me.. looks like you're targeting tech-minded people so you're probably fine. \$\endgroup\$ – I wrestled a bear once. Jan 10 '18 at 5:44
  • \$\begingroup\$ My bad, I thought I saw a setInterval, I should have spent a few more seconds looking at it. \$\endgroup\$ – I wrestled a bear once. Jan 10 '18 at 5:49
  • \$\begingroup\$ It's just a very simple sample component that illustrates changes to this.data can automatically trigger UI changes or XHR data retrievals. You are correct that the sample component I've written could be written with a couple dozen lines of vanilla JS, but this sample component isn't the end goal. We intend to use this framework to refactor significant portion of our company code base made up of web components that are much larger and more complex than what I've presented here. Maybe I should re-post this at a later date with a sample component that is a bit more realistic. \$\endgroup\$ – Elliot B. Jan 10 '18 at 6:02

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.