Organizacija JavaScript-a u web projektima

27.07.2017 - 10:00

UVOD

Razvoj web aplikacija u većini slučajeva podrazumeva i korišćenje JavaScript-a (u buduće JS). Ponekad se koristi minimalistički, dok kod nekih slučajeva imamo jako razvijenu arhitekturu unutar web projekata. Deo tih rešenja organizuje JS u odvojene fajlove, dok neki čak pozivaju JS funkcije direktno na HTML stranicama (u slučaju ASP.NET MVC aplikacija to su .cshtml fajlovi).

Dolaskom jquery JS framework-a, implementacija dodatno postaje kompleksnija a samim tim i moćnija po pitanju kontrole DOM-a, pa čak i sistemskih delova samog browser-a u okviru čijeg okruženja se JS izvršava.

Dobra organizacija i podela JS sloja na celine koje su implementirane kroz odvojene JS objekte postaje vrlo važan zadatak razvojnih timova.
S toga je vrlo važno pojasniti na koji način možemo dobiti organizovan i lako proširiv JS sloj u web aplikacijama. Sledećim primerom ću pokušati da vam predstavim moj način razmišljanja i implementacije JS kroz ASP.NET MVC aplikaciju, na osnovu teorijskog zahteva jednog izmišljenog klijenta, i kako takva implementacija nudi prilično jasan kod koji je ujedno lako proširiv i testabilan.

KLIJENTSKI ZAHTEV

Zamislićemo fiktivnog klijenta koji nam je poslao sledeći zahtev:

TEXT

- As a user, I would like to send feedback for each page in app, using AJAX for request.
- Feedback is in the form of Yes/No answer to the question "Was this page helpfull?".
- Response should have a message "Thanks for the feedback :). Your answer was *{Yes}/{No}*" shown to the user.
- Yes and No are buttons.
- Yes and No buttons should have jqueryUI tooltip plugin attached to a Title attribute describing it's function.

Zahtev je jasan. Žele da u “Help” web aplikaciji dodaju komponentu za slanje feedback-a korisnika za svaku postojeću stranicu ovog projekta. Takođe je jasno da treba da koristimo i jqueryUI tooltip komponentu.

Nakon malo razmišljanja dolazimo do raznih rešenja implementacije ovog zahteva. Svakako nam je jasno da će ovo biti komponenta jer će se koristiti na više web stranica, s toga zaključujemo da ćemo implementaciju komponente postaviti na odvojen View, tj. PartialView (terminologija podele vrsta web stranica je u različitim web tehnologijama drugačije nazivana, ali je koncept poznat svima). Takođe shvatamo da ćemo morati da pišemo JS funkcije koje će poslati odgovor pomoću AJAX poziva. Prva ideja nam je da  funkcije stavimo u odvojeni fajl kako bi bile grupisane na jednom mestu. Nakon postavke projekta dolazimo do sledećeg sadržaja survey.js fajla:

JS

$(function () {

    $("#YesButton, #NoButton").on("click", function () {

        updateSurvey(this);

    });

    $("#YesButton, #NoButton").tooltip({
        position: {
            my: "center bottom",
            at: "center bottom+60",
            collision: "none",
            using: function (position, feedback) {
                $(this).css(position);
                $("<div>")
                    .addClass("arrow")
                    .addClass(feedback.vertical)
                    .addClass(feedback.horizontal)
                    .appendTo(this);
            }
        }
    });

});

function updateSurvey(element) {

    var url = $(element).data("url");
    var pageId = $(element).data("id");
    var answer = $(element).data("value");

    $.ajax({
        url: url,
        type: "POST",
        data: {
            pageId: pageId,
            answer: answer
        },
        error: function (xhr) {
            $("#SurveyMessage").html(xhr.responseText);
        },
        success: function (data, status, xhr) {
            $("#SurveyMessage").html(xhr.responseText);
            $("#YesButton, #NoButton").attr("disabled", true);
        }
    });
}

Odgovarajući HTML za web stranice bi bio:

GettingStarted.cshtml

@model GetMoreLibrariesModel

@{
    ViewBag.Title = "Get More Libraries";
}

@section scripts
{
    @Scripts.Render("~/bundles/getmorelibraries-classic")
}

<div class="row">
    <div class="col-md-4">
        <h2>Get more libraries</h2>
        <p>NuGet is a free Visual Studio extension that makes it easy to add, remove, and update libraries and tools in Visual Studio projects.</p>
        <p><a class="btn btn-default" href="@Url.Action("GettingStarted")">Getting started &raquo;</a></p>
    </div>
    <div class="col-md-4">
        @Html.Partial("_Survey", Model.SurveyModel)
    </div>
</div>

_Survey.cshtml

@model SurveyModel
<div class="Survey">
    <h3>Was this page helpfull?</h3>
    <p>Please select option:</p>
    <p>
        <button id="YesButton" class="btn btn-default" title="This is a Yes button" data-url="@Url.Action("Update", "Survey")" data-id="@Model.PageId" data-value="Yes">Yes</button>
        <button id="NoButton" class="btn btn-default" title="This is a No button" data-url="@Url.Action("Update", "Survey")" data-id="@Model.PageId" data-value="No">No</button>
    </p>
    <p id="SurveyMessage"></p>
    @Html.HiddenFor(p=>p.PageId)
</div>

C# kod u kontrolerima bi bio:

public class ClassicController : Controller
    {
        public ActionResult GettingStarted()
        {
            var gettingStartedModel = new GettingStartedModel
            {
                SurveyModel = new SurveyModel("GettingStarted")
            };
            return View(gettingStartedModel);
        }
        
        public ActionResult GetMoreLibraries()
        {
            var getMoreLibrariesModel = new GetMoreLibrariesModel
            {
                SurveyModel = new SurveyModel("GetMoreLibraries")
            };
            return View(getMoreLibrariesModel);
        }
    }

    public class SurveyController : Controller
    {
        [HttpPost]
        public ActionResult Update(SurveyModel model)
        {
            return Content(
string.Format("Thanks for the feedback :). Your answer was *{0}*", model.Answer));
        }
    }

C# kod za Bundle & Minification bi bio:

bundles.Add(new ScriptBundle("~/bundles/gettingstarted-classic").Include(
                    "~/Scripts/app/classic/GettingStarted.js",
                    "~/Scripts/app/classic/Survey.js"));

bundles.Add(new ScriptBundle("~/bundles/getmorelibraries-classic").Include(
                    "~/Scripts/app/classic/GetMoreLibraries.js",
                    "~/Scripts/app/classic/Survey.js"));

Pretpostavimo da je za sada potrebno implementirati ovu komponentu na samo dve web stranice GettingStarted.cshtml i GetMoreLibraries.cshtml. Komponentu smo postavili na parcijalnu stranicu _Survey.cshtml. Takođe smo odlučili da survey.js bude deo bundle kolekcije koja će se učitavati na svakoj stranici. Na ovaj način smo postavili solidno rešenje koje je ujedno pregledno i gde je JS izdvojen u poseban fajl. Klijent je zadovoljan i sve funkcioniše ispravno.
Nakon završene prve faze, klijent odlučuje da pošalje dodatni zahtev:

TEXT

- Feedback component should be available on modal (overlay) pages also. Modals are initiated using .modal bootstrap function.

Zahtev je jasan. Žele da komponenta može da se postavi i na “modalnim” (overlay) stranicama koje se učitavaju pomoću AJAX-a i prikazuju pomoću .modal bootstrap funkcije. Pošto smo komponentu postavili u odvojen PartialView ovo će nam olakšati posao. Stranice koje treba da se prikazuju u “modalnom” prozoru će izgledati ovako:

GetMoreLibrariesAdvanced.cshtml

@model GetMoreLibrariesAdvancedModel

<div class="modal-dialog" role="document">
    <div class="modal-content">
        <div class="modal-header">
            <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
            <h4 class="modal-title" id="myModalLabel">Get more libraries advanced</h4>
        </div>
        <div class="modal-body">
            <div class="row">
                <div class="col-md-8">
                    <h2></h2>
                    <p>
                        Here we can show advanced information about parent page.
                    </p>
                </div>
                <div class="col-md-4">
                    @Html.Partial("_Survey", Model.SurveyModel)
                </div>
            </div>
        </div>
        <div class="modal-footer">
            <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
        </div>
    </div>
</div>

Doradimo kontroler koji kasnije izgleda ovako:

public class ClassicController : Controller
    {
        public ActionResult GettingStarted()
        {
            var gettingStartedModel = new GettingStartedModel
            {
                SurveyModel = new SurveyModel("GettingStarted")
            };
            return View(gettingStartedModel);
        }
        
        public ActionResult GetMoreLibraries()
        {
            var getMoreLibrariesModel = new GetMoreLibrariesModel
            {
                SurveyModel = new SurveyModel("GetMoreLibraries")
            };
            return View(getMoreLibrariesModel);
        }

        public ActionResult GettingStartedAdvanced()
        {
            var gettingStartedAdvancedModel = new GettingStartedAdvancedModel
            {
                SurveyModel = new SurveyModel("GettingStartedAdvanced")
            };
            return PartialView(gettingStartedAdvancedModel);
        }

        public ActionResult GetMoreLibrariesAdvanced()
        {
            var getMoreLibrariesAdvanced = new GetMoreLibrariesAdvancedModel
            {
                SurveyModel = new SurveyModel("GetMoreLibrariesAdvanced")
            };
            return PartialView(getMoreLibrariesAdvanced);
        }
    }

Takođe dodamo dva nova JS fajla:

GetMoreLibraries.js

$(function () {

    $("#GetMoreLibrariesAdvanced").on("click", function () {

        var url = $(this).attr("href");

        $.ajax({
            url: url,
            type: "GET",
            success: function (data, status, xhr) {

                $("#GetMoreLibrariesAdvancedModal").html(xhr.responseText).modal("show");

            }
        });

        return false;
    });
});

GettingStarted.js

$(function () {

    $("#GettingStartedAdvanced").on("click", function () {

        var url = $(this).attr("href");

        $.ajax({
            url: url,
            type: "GET",
            success: function (data, status, xhr) {

                $("#GettingStartedAdvancedModal").html(xhr.responseText).modal("show");

            }
        });

        return false;
    });
});

Za sada sve deluje u redu. Nakon pokretanja aplikacije, klikom se otvaraju dodatni modalni prozori. Međutim, primećujemo da na modalnim prozorima ne funkcionišu dugmad na samoj komponenti. Naravno da nam je jasno da click event nije ni inicijalizovan nad survey komponentom. Deo koji inicijalizuje click event u Survey.js fajlu izvršava se jednokratno nakon učitavanja svake stranice, a nama je potrebno da se takođe izvrši i nakon učitavanja modalnih prozora. U ovom trenutku možemo smisliti nekoliko različitih rešenja ovog problema, ali svaki od njih nosi svoje posledice. Jedno od rešenja bi bilo koristiti .live jquery funkciju koja bi obezbedila click event u svakom trenutku, ali i dalje nam ostaje inicijalizacija .tooltip plugina koja ne može da se reši pomoću .live funkcije. Potrebno nam je rešenje koje bi bilo generalizovano i bez dupliranja koda ali u isto vreme jednostavno za dalje proširenje.

U ovom trenutku na scenu stupa Revealing Module Patttern.

REVEALING MODULE PATTERN

Revealing Module Pattern nam omogućava da određeni skup povezanih js funkcija objedinimo unutar jednog objekta, i da u okviru tog objekta imamo kontrolu vidljivosti svih njegovih funkcija (da li su private ili public). Takođe, ovaj pristup odvaja celine u module koji mogu da se instanciraju po potrebi a ne da se funkcije pišu globalno.

Pogledajmo primer kako bi naši js fajlovi izgledali nakon refaktorisanja i primene ovog paterna.

GetMoreLibraries.js

var getMoreLibraries = function() {

    var self = this;
    var _survey;
    var _surveyModal;

    function init() {

        function _initDom() {
            self._dom = {};
            self._dom.GetMoreLibrariesAdvanced = $("#GetMoreLibrariesAdvanced");
            self._dom.GetMoreLibrariesAdvancedModal = $("#GetMoreLibrariesAdvancedModal");
        }

        function _bindEvents() {
            self._dom.GetMoreLibrariesAdvanced.on("click", _onGetMoreLibrariesAdvancedClick);
        }

        function _initComponents() {
            _survey = new survey("GetMoreLibraries");
            _survey.init();
        }

        _initDom();
        _bindEvents();
        _initComponents();
    }

    function _onGetMoreLibrariesAdvancedClick() {
        var url = $(this).attr("href");

        $.ajax({
            url: url,
            type: "GET",
            success: function (data, status, xhr) {

                self._dom.GetMoreLibrariesAdvancedModal.html(xhr.responseText).modal("show");

                _surveyModal = new survey("GetMoreLibrariesAdvanced");
                _surveyModal.init();
            }
        });

        return false;
    }

    return {
        init: init
    }
}();

$(function () {
    getMoreLibraries.init();
});

GettingStarted.js

var gettingStarted = function () {

    var self = this;
    var _survey;
    var _surveyModal;

    function init() {

        function _initDom() {
            self._dom = {};
            self._dom.GettingStartedAdvanced = $("#GettingStartedAdvanced");
            self._dom.GettingStartedAdvancedModal = $("#GettingStartedAdvancedModal");
        }

        function _bindEvents() {
            self._dom.GettingStartedAdvanced.on("click", _onGettingStartedAdvancedClick);
        }

        function _initComponents() {
            _survey = new survey("GettingStarted");
            _survey.init();
        }

        _initDom();
        _bindEvents();
        _initComponents();
    }

    function _onGettingStartedAdvancedClick() {
        var url = $(this).attr("href");

        $.ajax({
            url: url,
            type: "GET",
            success: function (data, status, xhr) {

                self._dom.GettingStartedAdvancedModal.html(xhr.responseText).modal("show");

                _surveyModal = new survey("GettingStartedAdvanced");
                _surveyModal.init();

            }
        });

        return false;
    }

    return {
        init: init
    }
}();

$(function () {
    gettingStarted.init();
});

_Survey.cshtml

@model SurveyModel
<div class="Survey">
    <h3>Was this page helpfull?</h3>
    <p>Please select option:</p>
    <div>
        <button id="YesButton-@Model.PageId" class="btn btn-default" title="This is a Yes button" data-name="SurveyButton" data-url="@Url.Action("Update", "Survey")" data-id="@Model.PageId" data-value="Yes">Yes</button>
        <button id="NoButton-@Model.PageId" class="btn btn-default" title="This is a No button" data-name="SurveyButton" data-url="@Url.Action("Update", "Survey")" data-id="@Model.PageId" data-value="No">No</button>
        <div id="Message-@Model.PageId" class="message"></div>
    </div>
</div>

GetMoreLibraries.cshtml

@model GetMoreLibrariesModel

@{
    ViewBag.Title = "Get More Libraries";
}

@section scripts
{
    @Scripts.Render("~/bundles/getmorelibraries-module")
}

<div class="row">
    <div class="col-md-4">
        <h2>Get more libraries</h2>
        <p>NuGet is a free Visual Studio extension that makes it easy to add, remove, and update libraries and tools in Visual Studio projects.</p>
        <p>
            <a id="GetMoreLibrariesAdvanced" class="btn btn-default" href="@Url.Action("GetMoreLibrariesAdvanced")">Get more libraries Advanced &raquo;</a>
        </p>
        <p><a class="btn btn-default" href="@Url.Action("GettingStarted")">Getting started &raquo;</a></p>
    </div>
    <div class="col-md-4">
        @Html.Partial("_Survey", Model.SurveyModel)
    </div>
</div>

<div class="modal" id="GetMoreLibrariesAdvancedModal" tabindex="-1" aria-labelledby="myModalLabel"></div>

GetMoreLibrariesAdvanced.cshtml

@model GetMoreLibrariesAdvancedModel

<div class="modal-dialog" role="document">
    <div class="modal-content">
        <div class="modal-header">
            <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
            <h4 class="modal-title" id="myModalLabel">Get more libraries advanced</h4>
        </div>
        <div class="modal-body">
            <div class="row">
                <div class="col-md-8">
                    <h2></h2>
                    <p>
                        Here we can show advanced information about parent page.
                    </p>
                </div>
                <div class="col-md-4">
                    @Html.Partial("_Survey", Model.SurveyModel)
                </div>
            </div>
        </div>
        <div class="modal-footer">
            <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
        </div>
    </div>
</div>

Pogledajmo prvo GetMoreLibraries.js fajl. Primećujemo da u njemu umesto globalnih funkcija imamo samo jednu globalnu promenljivu “getMoreLibraries” koja je u stvari funkcija koja vraća JS objekat sa određenim osobinama. Primetimo takođe da ova funkcija poziva samu sebe na kraju sa “()”, što nam obezbeđuje da se automatski kreira u memoriji čim se fajl učita u browser. Takođe, na samom kraju primećujemo klasičnu jquery.ready inicijalizaciju u okviru koje se poziva samo jedna public “init” metoda.

Ovako kreiran objekat se može videti u memoriji browsera, ali se nad njim mogu izvršiti samo funkcije koje su definisane kroz objekat iz “return” bloka. To znači da unutar ovog “modula” možemo definisati neograničen broj funkcija koje se međusobno mogu pozivati, ali samo one koje su definisane u “return” bloku biće vidljive spolja. Na ovaj način možemo da kontrolišemo koje funkcije će biti private a koje public. Ovakav pristup je odličan za module koji odgovaraju web stranicama koje se odmah kompletno učitavaju, jer će se učitati zajedno sa njima i postaviti u memoriji browsera. Onog trenutka kada se uradi redirekcija na drugu web stranu, ovaj modul se neće učitati (osim ako se eksplicitno na toj stranici ne referencira), što znači da će se i stranica brže učitavati. Takođe ne postoji mogućnost sukoba između istih naziva funkcija u različitim JS fajlovima, jer na ovaj način svaki modul dobija svoj “namespace”. Primetimo takođe i da public “init” funkcija unutar sebe definiše 3 privatne metode “_initDom”, “_bindEvents” i “_initComponents”.

Ideja prve metode je da sve HTML elemente koji se koriste za event-e i manipulaciju DOM-a smesti u promenljive koje su deo ovog modula, kako bi se kasnije kroz modul koristile isključivo promenljive. Na ovaj način ne dupliramo JS kod gde god koristimo jquery selektore, već ih na jednom mestu definišemo i po potrebi menjamo.

Druga metoda radi isključivo inicijalizaciju svih potrebnih event-a, dok treća služi za inicijalizaciju drugih modula ili plugin-ova.

Sve ovo što smo naveli i uradili izgleda lepo i dobro za našu aplikaciju, ali i dalje ne rešava problem koji smo imali, a to je inicijalizacija na modalnim prozorima. Pogledajmo zatim kako je refaktorisan Survey.js fajl.

_Survey.js

var survey = function (pageId) {

    var self = this;
    var _pageId = pageId;

    function init() {

        function _initDom() {
            self._dom = {};
            self._dom.Buttons = $("#YesButton-" + _pageId + ", #NoButton-" + _pageId);
            self._dom.Message = $("#Message-" + _pageId);
        }

        function _bindEvents() {
            self._dom.Buttons.on("click", _onUpdateSurvey);
        }

        function _initComponents() {
            self._dom.Buttons.tooltip({
                position: {
                    my: "center bottom",
                    at: "center bottom+60",
                    collision: "none",
                    using: function (position, feedback) {
                        $(this).css(position);
                        $("<div>")
                            .addClass("arrow")
                            .addClass(feedback.vertical)
                            .addClass(feedback.horizontal)
                            .appendTo(this);
                    }
                }
            });
        }

        _initDom();
        _bindEvents();
        _initComponents();
    }

    function _onUpdateSurvey() {
        var url = $(this).data("url");
        var pageId = $(this).data("id");
        var answer = $(this).data("value");

        $.ajax({
            url: url,
            type: "POST",
            data: {
                pageId: pageId,
                answer: answer
            },
            error: function (xhr) {
                self._dom.Message.html(xhr.responseText);
            },
            success: function (data, status, xhr) {
                self._dom.Message.html(xhr.responseText);
                self._dom.Buttons.attr("disabled", true);
            }
        });
    }

    return {
        init: init
    }
};

Prvo što primećujemo je da je i on prebačen u modul patern. Međutim, postoji jedna bitna razlika u odnosu na prethodni modul, a to je da ovaj nema automatsku inicijalizaciju jquery.ready, kao ni automatsko pozivanje samog sebe “()”. Ovaj deo je jako važan, jer na ovaj način dobijamo mogućnost da modul instanciramo eksplicitno, i pozovemo njegovu inicijalizaciju u trenutku koji nama odgovara, a to je upravo ono što želimo. Želimo da imamo jedinstven JS kod za našu komponentu, da ga ne dupliramo, ali da možemo da ga instanciramo i inicijalizujemo u bilo kom trenutku.

To se realizuje tako što smo u getMoreLibraries modulu dodali dve privatne promenljive: “_survey” i “_surveyModal”.

Primetimo kako se _survey promenljiva inicijalizuje odmah u “_initComponents” funkciji. Njoj se u stvari dodeljuje instanca “Survey” modula pri čemu se prosleđuje parameter kroz konstruktor (da bi modul znao nad kojim HTML elementima radi). Ovakav pristup koristimo za parcijalne komponente koje se renderuju zajedno sa stranicama (u ASP.NET MVC je to PartialView koji se renderuje na View sa Html.Partial funkcijom).

Druga promenljiva “_surveyModal” se inicijalizuje tek nakon završenog AJAX poziva koji vraća HTML za modalni prozor, i na isti način joj se dodeljuje nova instanca “Survey” modula koja je potpuno nezavisna od prethodne instance i izvršava se nad komponentom u modalnom prozoru!

Cilj je postignut! Nemamo duplirani kod, već jedan JS modul koji može da se iskoristi na više različitih mesta. Doradili smo JS na nivo da je pregledan i lako proširiv, a u isto vreme dovoljno fleksibilan da može da se upotrebljava na različite načine.

ZAKLJUČAK

Revealing Module patern je sjajan način da se organizuje JS kroz celu web aplikaciju. Ne samo što ćemo dobiti unificirani način pisanja i organizovanja JS koda, već dobijamo i na performansama. Takođe, otklanjanje grešaka u JS kodu više nije noćna mora, jer je sav kod dostupan za debug u browser-u, pa nam je samim tim i lakše da prepoznamo bagove i rešimo ih na adekvatan način. Ozbiljan problem u pisanju JS je i dupliranje koda kako bi se rešili pojedini zahtevi vezani za AJAX pozive. Taj problem takođe rešava ovaj patern.

Sve u svemu, moje mišljenje je da bi ozbiljno trebalo razmisliti o implementaciji ovog paterna kroz trenutne i buduće projekte. Iskreno se nadam da sam ovim primerom makar probudio Vašu maštu i pokrenuo gomilu pitanja na koja valja odgovoriti.

Kompletan Visual Studio projekat za ovaj primer možete preuzeti sa:

https://github.com/salmarko/RevealingModulePattern

Plain text

  • HTML tagovi nisu dozvoljeni
  • Web i email adrese automatski postaju linkovi
  • Redovi i paragrafi se prelamaju automatski.
NAPOMENA:
IT-KONEKT zadržava pravo izbora i skraćivanja komentara koji će biti objavljeni na veb sajtu.
Neće biti objavljivani komentari koji sadrže govor mržnje, pretnje, uvrede i psovke.
Očekujemo da tekstovi budu pravopisno i gramatički ispravni.
Komentari objavljeni na ovom veb sajtu predstavljaju privatno mišljenje njihovih autora a ne zvaničan stav IT-KONEKT tima.
CAPTCHA
This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.
Image CAPTCHA
Enter the characters shown in the image.