8
\$\begingroup\$

I'm developing a JS-based app that allows you to customize a product. I still consider myself a super newbie to javascript and especially to object oriented programming. So far, so good, the app does what I want it to do, so no trouble there.

My background is mostly in jQuery, so my main concern is how I'm sharing and reusing variables between functions, and how I can make this code prettier and more maintainable through optimizing those functions and vars.

The point of the app is:

  1. Click a product option
  2. Select a finish type
  3. Select a finish color
  4. If there is an additional price for that finish, update the price

I broke up what was once one big file into 3 modules and am loading them using LABjs:

$LAB
    .script(homeUrl + "/assets/js/product/product.js").wait()
    .script(homeUrl + "/assets/js/product/product-color.js")
    .script(homeUrl + "/assets/js/product/product-events.js")

PRODUCT.JS

(function (window, document, $) {
    "use strict";

    // Set some general element variables used throughout this script
    var $nodes = {
        list: $('.cust-list'),
        steps: $('.steps'),
        step: $('.cust-step'),
        stepsSlide: $('.steps-slide'),
        subtotal: $('.customizer .total .price'),
        allTypes: $('.finish-type a'),
        allColors: $('.finish-color a'),
        options: $('.cust-option'),
        checks: $('.cust-option-checklist a')
    };

    function Product (name) {
        this.name = name;
        this.options = [];
    }

    // Loops through the options slide divs
    function optionsLoop($options, callback) {
        for(var i = 0; i < $options.length; i++) {
            callback(i, $options[i]);
        }
    }

    // Loops through the array of product options within the product object
    function productOptionsLoop(productOptions, callback) {
        for(var i = 0; i < productOptions.length; i++) {
            callback(i, productOptions[i]);
        }
    }

    // Populate the product object with an array of options
    function getProductOptions($nodes, product) {
        optionsLoop($nodes.options, function(index,value) {
            var $me = $(value),
                name = $me.attr('data-option'),
                type = $me.attr('data-option-type'),
                option = {
                    option: name,
                    type: type
                };

            product.options.push(option);
        });
    }

    // Change the cost according to the added options / variations
    function updateCost(addCost, productOptions, totalPrice, $subtotal, productPrice, $nodes) {
        var currentSubtotal = $subtotal.attr('data-subtotal');

        addCost = 0;

        // Go through all the product options, if an additional cost has been set, add them up
        productOptionsLoop(productOptions, function(index,value){
            var $me = value,
                cost = $me.cost;

            if(cost) {
                addCost += cost;
            }
        });

        productPrice = +productPrice;
        totalPrice = productPrice + addCost;

        animateNumber($nodes.subtotal, currentSubtotal, totalPrice);

        // Update the data attribute on the subtotal to reflect the user's choices
        $nodes.subtotal.attr('data-subtotal',totalPrice);

        // animating number produces rounding errors, so shortly after we animate, update the text with the exact total
        setTimeout(function(){
            $nodes.subtotal.text(totalPrice).digits();
        },325);
    }

    function updateOptions(productOptions, myOption, myName, myCost, myType) {
        // Go through the array of options and add the selected color and cost to this option
        productOptionsLoop(productOptions, function(index,value) {
            var $this = value;
            if($this.option === myOption){
                $this.name = myName;
                $this.cost = Math.floor(myCost);
                if(myType) {
                    $this.type = myType;
                }
                return false;
            }
        });
    }

    $.extend(window, {
        '$nodes': $nodes,
        'Product': Product,
        'optionsLoop': optionsLoop,
        'productOptionsLoop': productOptionsLoop,
        'getProductOptions': getProductOptions,
        'updateCost': updateCost,
        'updateOptions': updateOptions
    });
}(window, document, jQuery));

PRODUCT-COLOR.JS

$(document).ready(function(){
    var productName = $('#product').attr('data-product-name'),
        // Create a new product object with the name of the currently viewed product
        product = new Product(productName),
        // This is set to true when the slide animation has completed, to avoid multiple fast clicks
        animReady = true,
        productPrice,
        totalPrice,
        productOptions,
        addCost,
        inAnim = {},
        outAnim = {opacity: 0},
        outCss = {opacity: 1};

    getProductOptions($nodes, product);
    productOptions = product.options;

    productPrice = $nodes.subtotal.attr('data-subtotal');

    // Color selecting
    $nodes.checks.add($nodes.allColors).add($nodes.allTypes).on('click',function(){
        if($(this).hasClass('current')) {
            return false;
        }

        var $me = $(this),
            $parent = $me.parent(),
            $granpa = $me.parents('.cust-step'),
            granpaEq = $granpa.index() - 1,
            myOption = $granpa.attr('data-option'),
            $myCheck = $('.option-list li:eq(' + granpaEq + ')'),

            myCost = $me.attr('data-option-cost'),
            myName = $me.attr('data-option-name'),
            myType = null,

            $optTypes,
            $optColors,
            $curColor,
            $curType,
            $myParentType,
            $myColors,

            className,
            $add,
            $remove,

            isCheck = $me.is('.cust-option-checklist a'),
            isColor = $me.is('.finish-color a'),
            isType = $me.is('.finish-type a');

        if(isCheck) {
            var $curCheck = $granpa.find('a.selected');

            className = 'selected';
            $add = $me;
            $remove = $curCheck;
        }

        if(isColor || isType) {
            if(isColor) {
                // If we're clicking a color, select the <a> links
                myType = $parent.attr('data-finish-type');
                $optColors = $granpa.find('.finish-color a');
            } else {
                // If we're clicking a color, select the divs containing each color <a>
                myType = $me.attr('data-finish-type');
                $optColors = $granpa.find('.finish-color');
            }

            // All types and colors for the current option
            $optTypes = $granpa.find('.finish-type a');

            $curColor = $optColors.filter('.current');
            $curType = $optTypes.filter('.current');

            if(isColor) {
                var myBg = $me.css('backgroundColor'),
                    myBgImg = $me.css('backgroundImage'),
                    bg = myBgImg;

                $myParentType = $optTypes.filter('[data-finish-type=' + myType + ']');
                $remove = $curColor.add($optTypes);
                $add = $me.add($myParentType);
                className = 'current ic-check selected';
            } else {
                $myColors = $optColors.filter('[data-finish-type=' + myType + ']');

                className = 'current';
                $remove = $curColor.add($curType);
                $add = $me.add($myColors);
            }
        }

        // Add selected class to chosen finish + type
        setCurrent($add,$remove,className);

        if(isColor) {       
            $curType = $optTypes.filter('.current');

            // Set the background image for parent type to reflect chosen color
            if(myBgImg === 'none') {
                bg = myBg;
            }

            $curType.css({'background' : bg});
        }

        // If you select a color or a checkbox, mark the list item as selected
        if( (isColor || isCheck) && !$myCheck.hasClass('.selected') ) {
            $myCheck.add($granpa).addClass('selected');
        }

        updateOptions(productOptions, myOption, myName, myCost, myType);
        updateCost(addCost, productOptions, totalPrice, $nodes.subtotal, productPrice, $nodes);

        // Remove existing price indicator
        $myCheck.find('.price').remove();

        // Add price indicator to checklist if there is an extra cost, or else
        if(myCost > 0) {
            $myCheck.addClass('extra').find('a').append('<span class="f9 price">$' + myCost + '</span>');
        } else {
            $myCheck.removeClass('extra');
        }
    });

    // Navigation
    $('.cust-btn:not(.go-back)').on('click',function(){
        var $me = $(this),
            $curStep = $nodes.step.filter('.cust-step-cur'),
            $nextStep = $curStep.next(),
            $prevStep = $curStep.prev(),
            isPrev = $me.hasClass('prev'),
            $tar = $nextStep,
            curIndex,
            offset,
            speed = 350;

        if(isPrev) {
            $tar = $prevStep;
            if($tar.length === 0) {
                $tar = $nodes.step.filter(':last');
            }
        } else {
            if($tar.length === 0) {
                $tar = $nodes.step.filter(':first');
                speed = 0;
            }
        }

        setCurrent($tar, $curStep, 'cust-step-cur');

        $curStep = $nodes.step.filter('.cust-step-cur');
        curIndex = $curStep.index('.cust-step');
        offset = curIndex * 160;

        $nodes.stepsSlide.animate({
            right: -offset
        },speed);
    });

    // Checklist Click
    $('.option-list a').on('click',function(){
        var $me = $(this),
            myIndex = ($me.parent().index()) + 1,
            $mySlide = $nodes.step.eq(myIndex),
            offset = myIndex * 160;

        setCurrent($mySlide, $nodes.list, 'cust-step-cur');

        $nodes.stepsSlide.animate({
            right: -offset
        }, 0);
    });

    $('.cust-btn.go-back').on('click',function(){
        var $curStep = $nodes.step.filter('.cust-step-cur');
        setCurrent($nodes.list, $curStep, 'cust-step-cur');

        $nodes.stepsSlide.animate({
            right: 0
        }, 0);      
    });
});

PRODUCT-EVENTS.JS

$(document).ready(function(){
    var $productImg = $('.customizer-wrap .image'),
        productImgUrl = $productImg.find('img').attr('src'),
        spinnner,
        $stepsSlide = $nodes.stepsSlide,
        slideCount = $stepsSlide.find('.cust-step').length,
        slidesLength = slideCount * 160;

    $stepsSlide.css({width: slidesLength});

    // Initialize loading graphic
    if(!productImgUrl) {
        return false;
    }

    spinner = startSpinner('product-image', 10, 4, 15, false);

    // Preload the big image, when loaded, fade in
    $.imgpreload(productImgUrl, function(){
        $productImg
            .zoom();

        $('.spinner').fadeOut(2000, function(){
            spinner.stop();
            $('html').removeClass('img-loading');
        });
    });
});
\$\endgroup\$
  • \$\begingroup\$ In updateCost(), why do you pass addCost when you set it to 0? \$\endgroup\$ – Larry Battle Sep 3 '12 at 21:16
3
+25
\$\begingroup\$

Here are few things that I noticed with Product.js:

  1. The Module design pattern is normally used to create private functions and variables. Unless if "use strict" is required, then there's no point in wrapping your code inside a closure when all the functions and variables are appended to the global namespace.

  2. Variables shouldn't be passed to a function that won't get used. This is a problem in updateCost() for the variables addCost and totalPrice. This is also a problem for the document variable.

  3. Try to only create varaibles for complex or redundant object references. So name and type aren't needed.

    Previous code:

    var $me = $(value),
        name = $me.attr('data-option'),
        type = $me.attr('data-option-type'),
        option = {
            option: name,
            type: type
        };
    product.options.push(option);
    

    New code:

    var $me = $(value),
        option = {
            option: $me.attr('data-option'),
            type: $me.attr('data-option-type')
        };
    product.options.push(option);
    
  4. Instead of extending to the window, just add window. to the desired global variable or function name.

    Example:

    window.$nodes = {};
    
  5. You should rename getProductOptions() to addOptionsToProduct() since the word get implies a return value.

  6. This is redundant:

    productPrice = +productPrice;
    totalPrice = productPrice + addCost;
    

    Just add productPrice to the end of a numeric operation for it to convert to a number:

    totalPrice = addCost + productPrice;
    
  7. Use existing functions instead of writing new ones. For example, take advance of jQuery.each and jQuery.map for interating through collections.

    Example:

    Previous code:

    function productOptionsLoop(productOptions, callback) {
        for(var i = 0; i < productOptions.length; i++) {
            callback(i, productOptions[i]);
        }
    }
    

    New code (productOptionsLoop() is the same as below):

    // if jQuery object
    productOptions.each( callback );
    // else
    $.each( productOptions, callback );
    
  8. Just add the cost, since 0 doesn't affect the value. But it would be even better if you got rid of addCost and used totalCost instead.

    productOptionsLoop(productOptions, function(index,value){
        var $me = value,
            cost = $me.cost;
        if(value.cost) {
            addCost += value.cost;
        }
    });
    

    Becomes

    productOptions.each(function(index,value){
        addCost += value.cost;
    });
    
  9. animateNumber() should call a callback once the animation part is complete. This way you don't need a setTimeout function.

Final code

// Set some general element variables used throughout this script
var $nodes = {
    list: $('.cust-list'),
    steps: $('.steps'),
    step: $('.cust-step'),
    stepsSlide: $('.steps-slide'),
    subtotal: $('.customizer .total .price'),
    allTypes: $('.finish-type a'),
    allColors: $('.finish-color a'),
    options: $('.cust-option'),
    checks: $('.cust-option-checklist a')
};
var Product = function(name) {
    this.name = name;
    this.options = [];
};
// Populate the product object with an array of options
var addProductOptions = function($nodes, product) {
    $nodes.options.each(function(index,value) {
        var $me = $(value),
            option = {
                option: $me.attr('data-option'),
                type: $me.attr('data-option-type')
            };

        product.options.push(option);
    });
};
// Change the cost according to the added options / variations
var updateCost = function(productOptions, $subtotal, productPrice, $nodes) {
    var totalPrice = productPrice, currentSubtotal = $subtotal.attr('data-subtotal');

    productOptions.each(function(index,value){
        totalPrice += value.cost;
    });
    animateNumber($nodes.subtotal, currentSubtotal, totalPrice, function(){
        $nodes.subtotal.text(totalPrice).digits();
    });
    $nodes.subtotal.attr('data-subtotal',totalPrice);
};
var updateOptions = function(productOptions, myOption, myName, myCost, myType) {
    // Go through the array of options and add the selected color and cost to this option
    productOptions.each(function(index,$this) {
        if($this.option !== myOption){
            return;
        }
        $this.name = myName;
        $this.cost = Math.floor(myCost);
        if(myType) {
            $this.type = myType;
        }
    });
};
\$\endgroup\$
3
\$\begingroup\$

Larry has already mentioned avoiding adding your functions/variables to the global namespace. One way you can do this is to create an object and use it in your extend method like so -

(function (window, document, $) {
    "use strict";

    var myNamespace = myNamespace || {};

    // @param {string} name
    myNamespace.Product = function (name) {
        // do stuff
    };

    // etc
    ...

    $.extend(window, myNamespace);

}(window, document, jQuery));
\$\endgroup\$

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.