For your convenience Apress has placed some of the front
matter material after the index. Please use the Bookmarks
and Contents at a Glance links to access them.
www.allitebooks.com
iv
Contents at a Glance
AbouttheAuthor................................................................................................... xiiAbouttheTechnicalReviewer ............................................................................. xiiiAcknowledgments ............................................................................................... xivChapter1:GettingReady ........................................................................................1Chapter2:GettingStarted ....................................................................................15Chapter3:AddingaViewModel...........................................................................47Chapter4:UsingURLRouting...............................................................................77Chapter5:CreatingOfflineWebApps.................................................................109Chapter6:StoringDataintheBrowser ..............................................................137Chapter7:CreatingResponsiveWebApps.........................................................169Chapter8:CreatingMobileWebApps ................................................................195Chapter9:WritingBetterJavaScript..................................................................229Index ...................................................................................................................261
www.allitebooks.com
C H A P T E R 1
1
Getting Ready
Client-sidewebappdevelopmenthasalwaysbeenthepoorcousintoserver-sidecoding.Thisstartedbecausebrowsersandthedevicestheyrunonhavebeenlesscapablethanenterprise-classservers.Toprovideanykindofseriouswebappfunctionality,theserverhadtodoalloftheheavyliftingforthebrowsers,whichwasprettydumbandsimplebycomparison.
Overthelastfewyears,browsershavegotsmarter,morecapable,andmoreconsistentinhowtheyimplementwebtechnologyandstandards.Whatusedtobeafighttocreateuniquefeatureshasbecomeabattletocreatethefastestandmostcompliantbrowser.Theproliferationofsmartphonesandtabletshascreatedahugemarketforhigh-qualitywebapps,andthegradualadoptionofHTML5provideswebapplicationdeveloperswithasolidfoundationforbuildingrichandfluidclient-sideexperiences.
Sadly,whiletheclient-sidetechnologyhascaughtupwiththeserverside,thetechniquesthatclient-sideprogrammersusestilllagbehind.Thecomplexityofclient-sidewebappshasreachedatippingpointwherescale,elegance,andmaintainabilityareessentialandthedaysofhackingoutaquicksolutionhavepassed.Inthisbook,Ileveltheplayingfield,showingyouhowtostepupyourclient-sidedevelopmenttoembracethebesttechniquesfromtheserver-sideworldandcombinethemwiththelatestHTML5features.
AboutThisBookThisismy15thbookabouttechnology,andtomarkthis,Apressaskedmetodosomethingdifferent:sharethetools,tricks,andtechniquesthatIusetocreatecomplexclient-sidewebapps.Theresultissomethingthatismorepersonal,informal,andeclecticthanmyregularwork.Ishowyouhowtotakeindustrial-strengthdevelopmentconceptsfromserver-sidedevelopmentandapplythemtothebrowser.Byusingthesetechniques,youcanbuildwebappsthatareeasiertowrite,areeasiertomaintain,andofferbetterandricherfunctionalitytoyourusers.
WhoAreYou?Youareanexperiencedwebdeveloperwhoseprojectshavestartedtogetoutofcontrol.ThenumberofbugsinyourJavaScriptcodeisincreasing,andittakeslongertofindandfixeachone.Youaretargetinganever-widerrangeofdevice,includingdesktops,tablets,andsmartphones,andkeepingitallworkingisgettingtougher.Yourworkingdaysarelonger,butyouhavelesstimetospendonnewfeaturesbecausemaintainingthecodeyoualreadyhavesucksupabigchuckofyourtime.
Theexcitementthatcomesfromyourworkhasfaded,andyouhaveforgottenwhatitfeelsliketohaveareallyproductivedayofcoding.Youknowsomethingiswrong,youknowthatyouarelosingyourgrip,andyouknowyouneedtofindadifferentapproach.Ifthissoundsfamiliar,thenyouaremytargetreader.
www.allitebooks.com
CHAPTER1GETTINGREADY
2
WhatDoYouNeedtoKnowBeforeYouReadThisBook?Thisisanadvancedbook,andyouneedtobeanexperiencedwebprogrammertounderstandthecontent.YouneedaworkingknowledgeofHTML,youneedtoknowhowtowriteJavaScript,andyouhaveusedbothtocreateclient-sidewebapps.Youwillneedtounderstandhowabrowserworks,howHTTPfitsintothepicture,andwhatAjaxrequestsareandwhyyoushouldcareaboutthem.
WhatIfYouDon’tHaveThatExperience?Youmaystillgetsomebenefitfromthisbook,butyouwillhavetofigureoutsomeofthebasicsonyourown.Ihavewrittenacoupleofotherbooksyoumightfindusefulasprimersforthisone.IfyouarenewtoHTML,thenreadTheDefinitiveGuidetoHTML5.Thisexplainseverythingyouneedtocreateregularwebcontentandbasicwebapps.IexplainhowtouseHTMLmarkupandCSS3(includingthenewHTML5elements)andhowtousetheDOMAPIandtheHTML5APIs(includingaJavaScriptprimerifyouarenewtothelanguage).ImakealotofuseofjQueryinthisbook.Iprovidealloftheinformationyouneedforeachtopic,butifyouwantabettergroundinginhowjQueryworksandhowitrelatestotheDOMAPI,thenreadProjQuery.BothofthesebooksarepublishedbyApress.
Booksaside,youcanlearnalotaboutHTMLandthebrowserAPIsbyreadingthespecificationspublishedbytheW3Catwww.w3.org.Thespecificationsareauthoritativebutcanbehard-goingandarenotalwaysthatclear.AmorereadilyaccessibleresourceistheMozillaDeveloperNetworkathttp://developer.mozilla.org.ThisisanexcellentsourceofinformationabouteverythingfromHTMLtoJavaScript.ThereisageneralbiastowardFirefox,butthisisn’tusuallyaproblemsincethemainstreambrowsersaregenerallycompliantandconsistentinthewaytheyimplementwebstandards.
IsThisaBookAboutHTML5?No,althoughIdotalkaboutsomeofthenewHTML5JavaScriptAPIs.Mostofthisbookisabouttechnique,mostofwhichwillworkwithHTML4justasitdoeswithHTML5.SomechaptersarebuiltpurelyonHTML5APIs(suchasChapters5and6,whichshowyouhowtocreatewebappsthatworkofflineandhowtostoredatainthebrowser),buttheotherchaptersarenottiedtoanyparticularversionofHTML.Idon’tgetintoanydetailaboutthenewelementsdescribedinHTML5.Thisisabookaboutprogramming,andthenewelementsdon’thavemuchimpactonJavaScriptprogramming.
WhatIstheStructureofThisBook?InChapter2,IbuildasimplewebappforafictitiouscheeseretailercalledCheeseLux,buildingonthebasicexampleIintroducelaterinthischapter.Ifollowsomeprettystandardapproachesforcreatingthiswebappandspendtherestofthebookshowingyouhowtoapplyindustrial-strengthtechniquestoimprovedifferentaspects.Ihavetriedtokeepeachchapterreasonablyseparate,butthisisareasonablyinformalbook,andIdointroducesomeconceptsgraduallyoveranumberofchapters.Eachchapterbuildsonthetechniquesintroducedinthechaptersthatgobeforeit.Youshouldreadthebookinchapterorderifyoucan.Thefollowingsectionssummarizethechaptersinthisbook.
Chapter1:GettingReadyAsidefromdescribingthisbook,IintroducethestaticHTMLversionoftheCheeseLuxexample,whichIusethroughoutthisbook.Ialsolistthesoftwareyouwillneedifyouwanttore-createtheexamplesonyourownorexperimentwiththelistingsthatareincludedinthesourcecodedownloadthataccompaniesthisbook(andwhichisavailablefreefromApress.com).
www.allitebooks.com
CHAPTER1GETTINGREADY
3
Chapter2:GettingStartedInthischapter,IusesomebasictechniquestocreateamoredynamicversionoftheCheeseLuxexample,movingfromawebsitetoawebapp.IusethisasanopportunitytointroducesomeofthetoolsandconceptsthatyouwillneedfortherestofthebookandtoprovideacontextsothatIcanshowbettertechniquesinlaterchapters.
Chapter3:AddingaViewModelThefirstadvancedtechniqueIdescribeisintroducingaclient-sideviewmodelintoawebapp.ViewmodelsareakeycomponentindesignpatternssuchasModelViewController(MVC)andModel-View-ViewModel.Ifyouadoptonlyonetechniquefromthisbook,thenmakeitthisone;itwillhavethebiggestimpactonyourdevelopmentpractices.
Chapter4:UsingURLRoutingURLroutingallowsyoutoscaleupthenavigationmechanismsinyourwebapps.Youmaynothaverealizedthatyouhaveanavigationproblem,butwhenyouseehowURLroutingcanworkontheclientside,youwillseejusthowpowerfulandflexibleatechniqueitcanbe.
Chapter5:CreatingOfflineWebAppsInthischapter,IshowyouhowtousesomeofthenewHTML5JavaScriptAPIstocreatewebappsthatworkevenwhentheuserisoffline.Thisisapowerfultechniquethatisincreasinglyimportantassmartphonesandtabletsgainmarketpenetration.Theideaofanalways-onnetworkconnectionischanging,andbeingabletoaccommodateofflineworkingisessentialformanywebapps.
Chapter6:StoringDataBeingabletorunthewebappofflineisn’tmuchuseunlessyoucanalsoaccessstoreddata.Inthischapter,IshowyouthedifferentHTML5APIsthatareavailableforstoringdifferentkindsofdata,rangingfromsimplename/valuepairstosearchablehierarchiesofpersistedJavaScriptobjects.
Chapter7:CreatingResponsiveWebAppsThereareentirecategoriesofweb-enableddevicesthatfalloutsideofthetraditionaldesktopandmobiletaxonomy.Oneapproachtodealingwiththeproliferationofdifferentdevicetypesistocreatewebappsthatadaptdynamicallytothecapabilitiesofthedevicetheyarebeingusedon,tailoringtheirappearance,functionality,andinteractionmodelsasrequired.Inthischapter,Ishowyouhowtodetectthecapabilitiesyoucareaboutandrespondtothem.
www.allitebooks.com
CHAPTER1GETTINGREADY
4
Chapter8:CreatingMobileWebAppsAnalternativetocreatingresponsivewebappsistocreateaseparateversionthattargetsaspecificrangeofdevices.Inthischapter,IshowyouhowtousejQueryMobiletocreatesuchawebappandhowtoincorporateadvancedfeaturessuchasURLroutingintoamobilewebapp.
Chapter9:WritingBetterJavaScriptThelastchapterinthisbookisaboutimprovingyourcode—notintermsofusingJavaScriptbetterbutintermsofcreatingeasilymaintainedcodemodulesthatareeasiertouseinyourownprojectsandeasiertosharewithothers.Ishowyousomeconvention-basedapproachesandintroducetheAsynchronousModuleDefinition,whichsolvessomecomplexproblemswhenexternallibrarieshavedependenciesonotherfunctionality.Ialsoshowyouhowyoucaneasilyapplyunittestingtoyourclient-sidecode,includinghowtounittestcomplexHTMLtransformations.
DoYouDescribeDesignPatterns?Idon’t.Thisisn’tthatkindofbook.Thisisabookaboutgettingresults,andIdon’tspendalotoftimediscussingthedesignpatternsthatunderpineachtechniqueIdescribe.Ifyouarereadingthisbook,thenyouwanttoseethoseresultsandgetthebenefitstheyprovidenow.Myadviceistosolveyourimmediateproblemsandthenstartresearchingthetheory.Alotofgoodinformationisavailableaboutdesignpatternsandtheassociatedtheory.Wikipediaisagoodplacetostart.SomereadersmaybesurprisedattheideaofWikipediaasasourceofprogramminginformation,butitoffersawealthofwell-balancedandwell-writtencontent.
Ilovedesignpatterns.Ithinktheyareimportantandusefulandavaluablemechanismforcommunicatinggeneralsolutionstocomplexproblems.Sadly,theyarealltoooftenusedasakindofreligion,whereeveryaspectofapatternmustbeappliedexactlyasspecifiedandlongandnastyconflictsbreakoutaboutthemeritsandapplicabilityofcompetingpatterns.
Myadviceistoconsiderdesignpatternsasthefoundationfordevelopingtechniques.Mixandmatchdifferentdesignpatternstosuityourprojectsandcherry-pickthebitsthatsolvetheproblemsyouface.Don’tletanyonedictatethewaythatyouusepatterns,andalwaysremainfocusedonfixingrealproblemsinrealprojectsforrealusers.Thedayyoustartarguingaboutsolutionstotheoreticalproblemsisthedayyougoovertothedarkside.Bestrong.Stayfocused.Resistthepatternzealots.
DoYouTalkAboutGraphicDesignandLayouts?No.Thisisn’tthatkindofbook,either.Thelayoutoftheexamplewebappsisprettysimple.Thereareacoupleofreasonsforthis.Thefirstisthatthisisabookaboutprogramming,andwhileIspendalotoftimeshowingyoutechniquesformanagingmarkupdynamically,theactualvisualeffectisverymuchasideeffect.
ThesecondreasonisthatIhavetheartisticabilitiesofalemon.Idon’tdraw,Idon’tpaint,andIdon’thaveasidelinebusinesssellingmyoil-on-canvasworkatalocalgallery.Infact,asachildIwasexcusedfromartlessonsbecauseofatotalandabsolutelackoftalent.Iamaprettygoodprogrammer,butmydesignskillssuck.Inthisbook,IsticktowhatIknow,whichisheavy-dutyprogramming.
www.allitebooks.com
CHAPTER1GETTINGREADY
5
WhatIfYouDon’tLiketheTechniquesorToolsIDescribe?Thenyouadaptthetechniquesuntilyoudolikethemandfindalternativetoolsthatworkthewayyouprefer.Thecriticalinformationinthisbookisthatyoucanapplyheavy-dutyserver-sidetechniquestocreatebetterwebapps.Thefineimplementationdetailisn’timportant.Mypreferredtoolsandtechniquesworkwellforme,andifyouthinkaboutcodeinthewayIdo,theywillworkwellforyoutoo.Butifyourmindworksinadifferentway,changethebitsofmyapproachthatdon’tfit,discardthebitsthatdon’twork,andusewhat’sleftasafoundationforyourownapproaches.We’llbothcomeoutaheadaslongasyouendupwithwebappsthatscalebetter,makeyourcodingmoreenjoyable,andreducetheburdenofmaintenance.
IsThereaLotofCodeinThisBook?Yes.Infact,thereissomuchcodethatIcouldn’tfititallin.Bookshaveapagebudget,whichissetrightatthestartoftheproject.Thepagebudgetaffectsthescheduleforthebook,theproductioncost,andthefinalpricethatthebooksellsfor.Stickingtothepagebudgetisabigdeal,andmyeditorgetsuncomfortablewheneverhethinksIamgoingtorunlong(hi,Ben!).IhadtodosomeeditingtofitinallofthecodeIwantedtoinclude.So,whenIintroduceanewtopicormakealotofchangesinonego,I’llshowyouacompleteHTMLdocumentorJavaScriptcodefile,justliketheoneshowninListing1-1.
Listing1-1.ACompleteHTMLDocument
<!DOCTYPE html> <html> <head> <title>CheeseLux</title> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script src="jquery.mobile-1.0.1.js" type="text/javascript"></script> <link rel="stylesheet" type="text/css" href="jquery.mobile-1.0.1.css"/> <link rel="stylesheet" type="text/css" href="styles.mobile.css"/> <script> function setCookie(name, value, days) { var date = new Date(); date.setTime(date.getTime()+(days * 24 * 60 * 60 *1000)); document.cookie = name + "="+ value + "; expires=" + date.toGMTString() +"; path=/"; } $(document).bind("pageinit", function() { $('button').click(function(e) { var useMobile = e.target.id == "yes"; var useMobileValue = useMobile ? "mobile" : "desktop"; if (localStorage) { localStorage["cheeseLuxMode"] = useMobileValue; } else { setCookie("cheeseLuxMode", useMobileValue, 30); } location.href = useMobile ? "mobile.html" : "example.html"; }); }); </script>
www.allitebooks.com
CHAPTER1GETTINGREADY
6
</head> <body> <div id="page1" data-role="page" data-theme="a"> <img class="logo" src="cheeselux.png"> <span class="para"> Would you like to use our mobile web app? </span> <div class="middle"> <button data-inline="true" data-theme="b" id="yes">Yes</button> <button data-inline="true" id="no">No</button> </div> </div> </body> </html>
ThislistingisbasedononefromChapter8.Thefulllistinggivesyouawidercontextabouthowthetechniqueathandfitsintothewebappworld.WhenIamshowingasmallchangeoremphasizingaparticularregionofcode,thenI’llshowyouacodefragmentliketheoneinListing1-2.
Listing1-2.ACodeFragment
... <title>CheeseLux</title> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script src="jquery.mobile-1.0.1.js" type="text/javascript"></script> <link rel="stylesheet" type="text/css" href="jquery.mobile-1.0.1.css"/> <link rel="stylesheet" type="text/css" href="styles.mobile.css"/> <meta name="viewport" content="width=device-width, initial-scale=1"> <script> ...
ThesefragmentsarecumulativelyappliedtothelastfulllistingsothatthefragmentinListing1-2showsametaelementbeingaddedtotheheadsectionofListing1-1.Youdon’thavetoapplythesechangesyourselfifyouwanttoexperimentwiththeexamples.Instead,youcandownloadacompletesetofeverycodelistinginthisbookfromApress.com.Thisfreedownloadalsoincludestheserver-sidecodethatIrefertolaterinthischapterandusethroughoutthisbooktocreatedifferentaspectsofthewebapp.
WhatSoftwareDoYouNeedforThisBook?Youwillneedafewpiecesofsoftwareifyouwanttore-createtheexamplesinthisbook.Therearelotsofchoicesforeachtype,andtheonesthatIuseareallavailablewithoutcharge.Idescribeeachinthesectionsthatfollowalongwithmypreferredtoolineachcategory.
GettingtheSourceCodeYouwillneedtodownloadthesourcecodethataccompaniesthisbook,whichisavailablewithoutchargefromApress.com.Thesourcecodedownloadcontainsallofthelistingsorganizedbychapterandallofthesupportingresources,suchasimagesandstylesheets.Youwillneedthecontentsofthisdownloadifyouwanttocompletelyre-createanyoftheexamples.
www.allitebooks.com
CHAPTER1GETTINGREADY
7
GettinganHTMLEditorAlmostanyeditorcanbeusedtoworkwithHTML.Idon’trelyonanyspecialfeaturesinthisbook,sousewhatevereditorsuitsyou.IuseKomodoEditfromActiveState.ItisfreeandsimpleandhasprettygoodsupportforHTML,JavaScript,jQuery,andNode.js.IhavenoaffiliationwithActiveStateotherthanasahappyuser.YoucangetKomodoEditfromhttp://activestate.com,andthereareversionsforWindows,Mac,andLinux.
GettingaDesktopWebBrowserAnymodernmainstreamdesktopbrowserwillruntheexamplesinthisbook.IlikeGoogleChrome;Ifinditquick,IlikethesimpleUI,andthedevelopertoolsareprettygood.MostofthescreenshotsinthisbookareofGoogleChrome,althoughtherearetimeswhenIuseFirefoxbecauseChromedoesn’timplementanHTML5featurefully.(ThesupportforHTML5APIsisabitmixedasIwritethis,buteverybrowserreleaseimprovesthesituation.)
GettingaMobileBrowserEmulatorInChapters7and8,Italkabouttargetingdifferentkindsofdevices.Itcanbeslowandfrustratingworkdealingwithrealdevicesduringtheearlystagesofdevelopment,soIuseamobilebrowseremulatortogetstartedandputthemajorfunctionalitytogether.Itisn’tuntilIhavesomethingfunctionalandsolidthatIstarttestingonrealmobiledevices.
IliketheOperaMobileemulator,whichyoucangetforfreefromwww.opera.com/developer/tools/mobile;thereareversionsavailableforWindows,Mac,andLinux.Theemulatorusesthesamecodebaseastherealand,widelyused,OperaMobile,andwhiletherearesomequirks,theexperienceisprettyfaithfultotheoriginal.Ilikethispackagebecauseitletsmecreateemulatorsfordifferentscreensizesfromsmall-screenedsmartphonesrightthroughtoHDtablets.Thereissupportforemulatingtoucheventsandchangingtheorientationofthedevice.YoucanruntheexamplesinChapters7and8inanybrowser,butpartofthepointofthesechaptersistoelegantlydetectmobiledevices,andyou’llgetthebestresultsbyusinganemulator,evenifitisn’ttheoneforOpera.
GettingtheJavaScriptLibrariesIdon’tbelieveinre-creatingfunctionalitythatisavailableinawell-written,publicallyavailableJavaScriptlibrary.Tothatend,thereareanumberoflibrariesthatIuseineachchapter.Some,suchasjQuery,jQueryUI,andjQueryMobile,arewell-known,buttherearealsosomethatprovidesomenichefeaturesorcoveragapinbrowsersthatdon’timplementcertainHTML5APIs.ItellyouhowtoobtaineachlibraryasIintroduceit,andtheycanallbefoundinthesourcecodedownloadthatisavailablefromApress.com.Youdon’tneedtousethelibrariesthatIlikeinordertousethetechniquesIdiscuss,butyouwillneedthemtore-createtheexamples.
GettingaWebServerTheexamplesinthisbookarefocusedontheclient-sidewebapps,butsometechniquesrequirecertainbehaviorsfromtheserver.Mostoftheexampleswillworkwithcontentservedupbyanywebserver,butyouwillneedtouseNode.jsifyouwanttore-createeveryexampleinthisbook.
ThereasonthatIchoseNode.jsisthatitiswritteninJavaScriptandissupportedonawiderangeofplatforms.Thismeansthatanyreaderofthisbookwillbeabletosetuptheserverandreadandunderstandthecodethatdrivestheserver.
www.allitebooks.com
CHAPTER1GETTINGREADY
8
Theserver-sidecodeisincludedinthesourcecodedownloadfromApress.com,inafilecalledserver.js.Iamnotgoingtogointoanydetailaboutthiscode,andIamnotevengoingtolistit.Itdoesn’tdoanythingspecial;itjustservesupcontentandhasafewspecialURLsthatallowmetopostdatafromtheexamplewebappandgetatailoredresponse.TherearesomeotherURLsthatcreateparticulareffects,suchasaddingadelaytosomerequests.Takealookatserver.jsifyouwanttoseewhat’sthere,butyoudon’tneedtounderstand(orevenlookat)theserver-sidecodetogetthebestfromthisbook.
Youwill,however,needtoinstallandsetupNode.jssothatitisrunningonyournetwork.Iprovideinstructionsforgettingupandrunninginthesectionsthatfollow.
GettingandPreparingNode.jsYoucandownloadNode.jsfromhttp://nodejs.org.InstallationpackagesareavailableforWindows,Mac,andLinux,andthesourcecodeisavailableifyouwanttocompileforadifferentplatform.TheinstructionsforsettingupNodechangeoften,andthebestwaytogetstartedisbyreadingFelixGeisendörfer’sbeginner’sguidetoNode,whichyoucanfindathttp://nodeguide.com/beginner.html.
Irelyonsomethird-partymodules,sorunthefollowingcommandafteryouhaveinstalledtheNode.jspackage:
npm install node-static jqtpl
Thiscommanddownloadsandinstallsthenode-staticandjqtplpackagesthatIusetodeliverstaticandtemplatedcontentintheexamples.Thecommandwillgenerateoutputsimilartothis(butyoumayseesomeadditionalwarnings,whichcanbeignored):
npm http GET https://registry.npmjs.org/node-static npm http GET https://registry.npmjs.org/jqtpl npm http 200 https://registry.npmjs.org/jqtpl npm http 200 https://registry.npmjs.org/node-static [email protected] ./node_modules/node-static [email protected] ./node_modules/jqtpl
Thesourcecodedownloadisorganizedbychapter.YouwillneedtocreateadirectorycalledcontentinyourNode.jsdirectoryandcopythechaptercontentintoit.Thereisn’tmuchstructuretothecontentdirectory;tokeepthingssimple,almostalloftheresourcesandlistingsareinthesamedirectory.
CautionTherearechangesintheresourcefilesbetweenchapters,somakesureyouclearyourbrowser’shistorywhenyoumovebetweenchaptercontent.
Youwillalsoneedtocopytheserver.jsfilefromthesourcecodedownloadintoyourNode.jsdirectory.ThisNodescriptisonlyforservingtheexamplesinthebook;don’trelyonitforanyotherpurpose,andcertainlydon’tuseittohostrealprojects.Onceyouhaveeverythinginplace,simplyrunthefollowingcommand:
CHAPTER1GETTINGREADY
9
node server.js
Youwillseethefollowingoutput(orsomethingveryclosetoit):
The "sys" module is now called "util". It should have a similar interface. Ready on port 80
IfyouareusingWindows,youmaybepromptedtoallowNodetocommunicatethroughtheWindowsFirewall,whichyoushoulddo.Andwiththat,yourserverisupandrunning.Thescriptlistensforrequestsonport80.Ifyouneedtochangethis,thenlookforthefollowinglineintheserver.jsfile:
http.createServer(handleRequest).listen(80);
CautionNode.jsisveryvolatile,andnewversionsarereleasedoften.TheversionthatIhaveusedinthisbookis0.6.6,butitwillhavebeensupersededbythetimeyoureadthis.IhavestucktothemorestableNodeAPIs,butyoumightneedtomakesomeminortweakstogeteverythingworking.
IntroducingtheCheeseLuxExampleMostoftheexamplesinthisbookarebasedonawebappforafictionalcheeseretailercalledCheeseLux.Iwantedtofocusontheindividualtechniquesinthisbook,soIhavekeptthewebappassimpleaspossible.Tobeginwith,Ihavecreatedastaticwebsitethatofferslimitedproductstotheuser.Theentrypointtothesiteistheexample.htmlfile.Iuseexample.htmlforalmostallofthelistingsinthisbook.Listing1-3showstheinitialstaticversionofexample.html.
CHAPTER1GETTINGREADY
10
Listing1-3.TheStaticexample.html
<!DOCTYPE html> <html> <head> <title>CheeseLux</title> <link rel="stylesheet" type="text/css" href="styles.css"/> </head> <body> <div id="logobar"> <img src="cheeselux.png"> <span id="tagline">Gourmet European Cheese</span> </div> <form action="/basket" method="post"> <div class="cheesegroup"> <div class="grouptitle">French Cheese</div> <div class="groupcontent"> <label for="camembert" class="cheesename">Camembert ($18)</label> <input name="camembert" value="0"/> </div> <div class="groupcontent"> <label for="tomme" class="cheesename">Tomme de Savoie ($19)</label> <input name="tomme" value="0"/> </div> <div class="groupcontent"> <label for="morbier" class="cheesename">Morbier ($9)</label> <input name="morbier" value="0"/> </div> </div> <div id="buttonDiv"> <input type="submit" /> </div> </form> </body> </html>
Ihavestartedwithsomethingbasic.Therearefourpagesinthestaticversionofthewebapp,althoughItendtofocusonthefunctionalityofonlythefirsttwoinlaterchapters.Thesearetheproductlistingandabasketshowingauser’sselections(whichishandledinthestaticversionbybasket.html).Youcanseehowexample.htmlandbasket.htmlaredisplayedinthebrowserinFigure1-1.
CHAPTER1GETTINGREADY
11
Figure1-1.Theexample.htmlandbasket.htmlfilesdisplayedinthebrowser
Youdon’tneedtodoanythingwiththestaticfiles,butifyoulookatthecontentsofbasket.html,forexample,youwillseethatIusetemplatestogeneratethecontentbasedonthedatasubmittedviatheHTMLforms,asshowninListing1-4.
Listing1-4.UsingaTemplatetoGenerateContent
<html> <head> <title>CheeseLux</title> <link rel="stylesheet" type="text/css" href="styles.css"/> </head> <body> <div id="logobar"> <img src="cheeselux.png"> <span id="tagline">Gourmet European Cheese</span> </div> <form action="/shipping" method="post"> <div class="cheesegroup"> <div class="grouptitle">Your Basket</div> <table class="basketTable" border=0> <thead> <tr><th>Cheese</th><th>Quantity</th><th>Subtotal</th></tr>
CHAPTER1GETTINGREADY
12
<tr><td class="sumline" colspan=3></td></tr> </thead> <tbody> {{each properties}} {{if $value.propVal > 0}} <tr> <td>${$data.getProp($value.propName, "name")}</td> <td>${$value.propVal}</td> <td> $${$data.getSubtotal($value.propName, $value.propVal)} </td> </tr> {{/if}} {{/each}} </tbody> <tfoot> <tr><td class="sumline" colspan=3></td></tr> <tr><th colspan=2>Total:</th><td>$${$data.total}</td> </tfoot> </table> <div class="cornerplaceholder"></div> </div> <div id="buttonDiv"> <input type="submit" /> </div> {{each properties}} <input type="hidden" name="${$value.propName}" value="${$value.propVal}"/> {{/each}} </form> </body> </html>
ThesetemplatesareprocessedbythejqtplmodulethatyoudownloadedforNode.js.ThismoduleisaNode-compliantversionofasimpletemplatelibrarythatiswidelyusedwiththejQuerylibrary.Idon’tusethisstyleoftemplateintheclient-sideexamples,butIwantedtoexplainthemeaningofthosetagsincaseyouweretemptedtopeekatthestaticcontent.
Inthenextchapter,I’llusesomebasicJavaScripttechniquestocreateamoredynamicversionofthissimpleappandthenspendtherestofthebookshowingyoumoreadvancedtechniquesyoucanusetocreatebetter,morescalable,andmoreresponsivewebappsforyourownprojects.
FontAttributionIusesomecustomwebfontsthroughoutthisbook.ThefontfilesareincludedinthesourcecodedownloadavailablefromApress.com.ThefontsIusecomefromTheLeagueofMovableType(www.theleagueofmoveabletype.com)andfromtheGoogleWebFontsservice(www.google.com/webfonts).
CHAPTER1GETTINGREADY
13
SummaryInthischapter,Ioutlinedthecontentandstructureofthisbookandsetoutthesoftwarerequiredifyouwanttoexperimentwiththeexamplesinthisbooks.IalsointroducedtheCheeseLuxexample,whichisusedthroughoutthisbook.Inthenextchapter,I’llusesomebasictechniquestoenhancethestaticwebpagesandintroducesomeofthecoretoolsthatIusethroughoutthisbook.Fromthenon,I’llshowyouaseriesofbetter,industrial-strengthtechniquesthataretheheartofthisbook.
C H A P T E R 2
15
Getting Started
Inthischapter,IamgoingtoenhancetheexamplewebappIintroducedinChapter1.Thesearetheentry-leveltechniques,andmostoftherestofthebookisdedicatedtoshowingyoudifferentwaystoimproveupontheresult.That’snottosaythattheexamplesinthischapterarenotuseful;theyareabsolutelyfineforsimplewebapps.Buttheyarenotsufficientforlargeandcomplexwebapps,whichiswhythechaptersthatfollowexplainhowyoucantakekeyconceptsfromtheworldofserver-sidedevelopmentandapplythemtoyourwebapps.
ThischapteralsoletsmesetthefoundationforsomewebappdevelopmentprinciplesthatIwillbeusingthroughoutthisbook.First,IwillberelyingonJavaScriptlibrarieswheneverpossiblesoastoavoidcreatingcodethatsomeoneelsehasproducedandmaintained.ThelibraryIwillbemakingmostuseofisjQueryinordertomakeworkingwiththeDOMAPIsimplerandeasier(IexplainsomejQuerybasicsintheexamplesinthischapters).Second,IwillbefocusingonasingleHTMLdocument.
UpgradingtheSubmitButtonTogetstarted,IamgoingtouseJavaScripttoreplacethesubmitbuttonfromthebaselineexampleinChapter1.Thebrowsercreatesthisbuttonfromaninputelementwhosetypeissubmit,andIamgoingtoswitchitoutforsomethingthatisvisuallyconsistentwiththerestofthedocument.Morespecifically,IamgoingtousejQuerytoreplacetheinputelement.
PreparingtoUsejQueryTheDOMAPIiscomprehensivebutawkwardtouse—soawkwardthatthereareanumberofJavaScriptconveniencelibrariesthatwraparoundtheDOMAPIandmakeiteasiertouse.Inmyexperience,thebestoftheselibrariesisjQuery,whichiseasytouseandactivelydevelopedandsupported.jQueryisalsothefoundationformanyotherJavaScriptlibraries,someofwhichI’llbeusinglater.jQueryisjustawrapperaroundtheDOMAPI,andthisallowstheuseoftheunderlyingDOMobjectsandmethodsifitisrequired.
YoucandownloadthejQuerylibraryfromjQuery.com.jQuery,likemostJavaScriptlibraries,isavailableintwoversions.Theuncompressedversioncontainsthefullsourcecodeandisusefulfordevelopmentanddebugging.Thecompressedversion(alsoknownastheminimizedorminifiedversion)ismuchsmallerbutisn’thuman-readable.Thesmallersizemakestheminimizedversionidealforsavingbandwidthwhenawebappisdeployedintoproduction.Bandwidthcanbeexpensiveforpopularwebapps,andanysavingsisworthmaking.
Downloadtheversionyouwantandputitinyourcontentdirectory,alongsideexample.html.I’llbeusingtheuncompressedversioninthisbook,soIhavedownloadedafilecalledjquery-1.7.1.js.
CHAPTER2GETTINGSTARTED
16
TipIamusingtheuncompressedversionsbecausetheymakedebuggingeasier,whichyoumayfindusefulasyouexploretheexamplesinthisbook.Forrealwebapplications,youshouldswitchtotheminimizedversionpriortodeployment.
ThefilenameincludesthejQueryversion,whichis1.7.1asIwritethis.YouimportthejQuerylibraryintotheexampledocumentusingascriptelement,asshowninListing2-1.Ihaveaddedthescriptelementintheheadsectionofthedocument.
Listing2-1.ImportingjQueryintotheExampleDocument
... <head> <title>CheeseLux</title> <link rel="stylesheet" type="text/css" href="styles.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script> </head> ...
USING A CDN FOR JQUERY
AnalternativetohostingthejQuerylibraryonyourownwebserversistouseapubliccontentdistributionnetwork(CDN)thathostsjQuery.ACDNisadistributednetworkofserversthatdeliverfilestotheuserusingtheserverthatisclosesttothem.ThereareacoupleofbenefitstousingaCDN.Thefirstisafasterexperiencetotheuser,becausethejQuerylibraryfileisdownloadedfromtheserverclosesttothem,ratherthanfromyourservers.Oftenthefilewon’tberequiredatall.jQueryissopopularthattheuser’sbrowsermayhavealreadycachedthelibraryfromanotherapplicationthatalsousesjQuery.ThesecondbenefitisthatnoneofyourpreciousandexpensivebandwidthisspentdeliveringjQuerytotheuser.
WhenusingaCDN,youmusthaveconfidenceintheCDNoperator.Youwanttobesurethattheuserreceivesthefiletheyaresupposedtoandthattheservicewillalwaysbeavailable.GoogleandMicrosoftbothprovideCDNservicesforjQuery(andotherpopularJavaScriptlibraries)freeofcharge.BothcompanieshavesolidexperienceofrunninghighlyavailableservicesandareunlikelytodeliberatelytamperwiththejQuerylibrary.YoucanlearnabouttheMicrosoftserviceatwww.asp.net/ajaxlibrary/cdn.ashxandabouttheGoogleserviceathttp://code.google.com/apis/libraries/devguide.html.
TheCDNapproachisn’tsuitableforapplicationsthataredeliveredtouserswithinanintranetbecauseitcausesallthebrowserstogototheInternettogetthejQuerylibrary,ratherthanaccessthelocalserver,whichisgenerallycloserandfasterandhaslowerbandwidthcosts.
So,let’sjumprightinandusejQuerytohidetheexistinginputelementandaddsomethingelsein
itsplace.Listing2-2showshowthisisdone.
CHAPTER2GETTINGSTARTED
17
Listing2-2.HidingtheinputElementandAddingAnotherElement
<!DOCTYPE html> <html> <head> <title>CheeseLux</title> <link rel="stylesheet" type="text/css" href="styles.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script> $(document).ready(function() { $('#buttonDiv input:submit').hide(); $('<a href=#>Submit Order</a>').appendTo("#buttonDiv"); }) </script> </head> <body> <div id="logobar"> <img src="cheeselux.png"> <span id="tagline">Gourmet European Cheese</span> </div> <form action="/basket" method="post"> <div class="cheesegroup"> <div class="grouptitle">French Cheese</div> <div class="groupcontent"> <label for="camembert" class="cheesename">Camembert ($18)</label> <input name="camembert" value="0"/> </div> <div class="groupcontent"> <label for="tomme" class="cheesename">Tomme de Savoie ($19)</label> <input name="tomme" value="0"/> </div> <div class="groupcontent"> <label for="morbier" class="cheesename">Morbier ($9)</label> <input name="morbier" value="0"/> </div> </div> <div id="buttonDiv"> <input type="submit" /> </div> </form> </body> </html>
Ihaveaddedanotherscriptelementtothedocument.Thiselementcontainsinlinecode,ratherthanloadinganexternalJavaScriptfile.IhavedonethisbecauseitmakesiteasiertoshowyouthechangesIammaking.UsinginlinecodeisnotajQueryrequirement,andyoucanputyourjQuerycode
CHAPTER2GETTINGSTARTED
18
inexternalfilesifyouprefer.ThereisalotgoingoninthefourJavaScriptstatementsinthescriptelement,soI’llbreakthingsdownstep-by-stepinthefollowingsections.
UnderstandingtheReadyEventAttheheartofjQueryisthe$function,whichisaconvenientshorthandtobeginusingjQueryfeatures.ThemostcommonwaytousejQueryistotreatthe$asaJavaScriptfunctionandpassaCSSselectororoneormoreDOMobjectsasarguments.Usingthe$functionisverycommonwithjQuery.Ihaveuseditthreetimesinfourlinesofcode,forexample.
The$functionreturnsajQueryobjectonwhichyoucancalljQuerymethods.ThejQueryobjectisawrapperaroundtheelementsyouselected,andifyoupassaCSSselectorastheargument,thejQueryobjectwillcontainalloftheelementsinthedocumentthatmatchtheselectoryouspecify.
TipThisisoneofthemainadvantagesofjQueryoverthebuilt-inDOMAPI:youcanselectandmodifymultipleelementsmoreeasily.ThemostrecentversionsoftheDOMAPI(includingtheonethatispartofHTML5)providesupportforfindingelementsusingselectors,butjQuerydoesitmoreconciselyandelegantly.
ThefirsttimeIusethe$functioninthelisting,Ipassinthedocumentobjectastheargument.ThedocumentobjectistherootnodeoftheelementhierarchyintheDOM,andIhaveselecteditwiththe$functionsothatIcancallthereadymethod,ashighlightedinListing2-3.
Listing2-3.SelectingtheDocumentandCallingthereadyMethod
... <script> $(document).ready(function() { ...other JavaScript statements... }) </script> ...
BrowsersexecuteJavaScriptcodeassoonastheyfindthescriptelementsinthedocument.ThisgivesusaproblemwhenyouwanttomanipulatetheelementsintheDOM,becauseyourcodeisexecutedbeforethebrowserhasparsedtherestoftheHTMLdocument,discoveredtheelementsthatyouwanttoworkwith,andaddedobjectstotheDOMtorepresentthem.AtbestyourJavaScriptcodedoesn’twork,andatworstyoucauseanerrorwhenthishappens.Thereareanumberofwaystoworkaroundthis.Thesimplestsolutionistoplacethescriptelementattheendofthedocumentsothatthebrowserdoesn’tdiscoverandexecuteyourJavaScriptcodeuntiltherestoftheHTMLhasbeenprocessed.AmoreelegantapproachistousethejQueryreadymethod,whichishighlightedinthelistingjustshown.
YoupassaJavaScriptfunctionastheargumenttothereadymethod,andjQuerywillexecutethisfunctiononcethebrowserhasprocessedalloftheelementsinthedocument.Usingthereadymethodallowsyoutoplaceyourscriptelementsanywhereinthedocument,safeintheknowledgethatyourcodewon’tbeexecuteduntiltherightmoment.
www.allitebooks.com
CHAPTER2GETTINGSTARTED
19
CautionAcommonmistakeistoforgettowraptheJavaScriptstatementstobeexecutedinafunction,whichcausesanoddeffect.Ifyoupassasinglestatementtothereadymethod,thenitwillbeexecutedassoonasthebrowserprocessesthescriptelement.Ifyoupassmultiplestatements,thenthebrowserwillusuallyreportaJavaScripterror.
Thereadymethodcreatesahandlerforthereadyevent.I’llshowyoumoreofthewaythatjQuerysupportseventslaterinthischapter.Thereadyeventisavailableonlyforthedocumentobject,whichiswhyyouwillseethestatementshighlightedinthelistinginalmosteverywebappthatusesjQuery.
SelectingandHidingtheInputElementNowthatIhavedelayedtheexecutionoftheJavaScriptcodeuntiltheDOMisready,Icanturntothenextstepinmytask,whichistohidetheinputelementthatsubmitstheform.Listing2-4highlightsthestatementfromtheexamplethatdoesjustthis.
Listing2-4.SelectingandHidingtheinputElement
... <script> $(document).ready(function() { $('#buttonDiv input:submit').hide(); $('<a href=#>Submit Order</a>').appendTo("#buttonDiv"); }) </script> ...
Thisisaclassictwo-partjQuerystatement:firstIselecttheelementsIwanttoworkwith,andthenIapplyajQuerymethodtomodifytheselectedelements.YoumaynotrecognizetheselectorIhaveusedbecausethe:submitpartisoneoftheselectorsthatjQuerydefinesinadditiontothoseintheCSSspecification.Table2-1containsthemostusefuljQuerycustomselectors.
CHAPTER2GETTINGSTARTED
20
CautionThejQuerycustomselectorscanbeextremelyuseful,buttheyhaveaperformanceimpact.Whereverpossible,jQueryusesthenativebrowsersupportforfindingelementsinthedocument,andthisisusuallyprettyquick.However,jQueryhastoprocessthecustomselectorsdifferently,sincethebrowserdoesn’tknowanythingaboutthem,andthistakeslongerthanthenativeapproach.Thisperformancedifferencedoesn’tmatterformostwebapps,butifperformanceiscritical,youmaywanttostickwiththestandardCSSselectors.
Table2-1.jQueryCustomSelectors
Selector Description
:button Selectsallbuttons
:checkbox Selectsallcheckboxes
:contains(text) Selectselementsthatcontainthespecifiedtext
:eq(n) Selectstheelementatthenthindex(zero-based)
:even Selectsalltheevent-numberedelements(one-based)
:first Selectsthefirstmatchedelement
:has(selector) Selectselementsthatcontainatleastoneelementthatmatchestheselector
:hidden Selectsallhiddenelements
:input Selectsallinputelements
:last Selectsthelastmatchedelement
:odd Selectsalltheodd-numberedelements(one-based)
:password Selectsallpasswordelements
:radio Selectsallradioelement
:submit Selectsallformsubmissionelements
:visible Selectsallvisibleelements
CHAPTER2GETTINGSTARTED
21
InListing2-4,myselectormatchesanyinputelementwhosetypeissubmitandthatisadescendantoftheelementwhoseidattributeisbuttonDiv.Ididn’tneedtobequitesoprecisewiththeselector,giventhatitistheonlysubmitelementinthedocument,butIwantedtodemonstratethejQuerysupportforselectors.The$functionreturnsajQueryobjectthatcontainstheselectedelements,althoughthereisonlyoneelementthatmatchestheselectorinthiscase.
Havingselectedtheelement,Ithencallthehidemethod,whichchangesthevisibilityoftheselectedelementsbysettingtheCSSdisplaypropertytonone.Theinputelementislikethisbeforethemethodcall:
<input type="submit">
andistransformedlikethisafterthemethodcall:
<input type="submit" style="display: none; ">
Thebrowserwon’tshowelementswhosedisplaypropertyisnoneandsotheinputelementbecomesinvisible.
TipThecounterparttothehidemethodisshow,whichremovesthedisplaysettingandreturnstheelementtoitsvisiblestate.Idemonstratetheshowmethodlaterinthischapter.
InsertingtheNewElementNext,Iwanttoinsertanewelementintothedocument.Listing2-5highlightsthestatementintheexamplethatdoesthis.
Listing2-5.AddingaNewelementtotheDocument
... <script> $(document).ready(function() { $('#buttonDiv input:submit').hide(); $('<a href=#>Submit Order</a>').appendTo("#buttonDiv"); }) </script> ...
Inthisstatement,IhavepassedanHTMLfragmentstringtothejQuery$function.ThiscausesjQuerytoparsethefragmentandcreateasetofobjectstorepresenttheelementsitcontains.TheseelementobjectsarethenreturnedtomeinajQueryobject,justasifIhadselectedelementsfromthedocumentitself,exceptthatthebrowserdoesn’tyetknowabouttheseelementsandtheyarenotyetpartoftheDOM.
ThereisonlyoneelementintheHTMLfragmentinthislisting,sothejQueryobjectcontainsanaelement.ToaddthiselementtotheDOM,IcalltheappendTomethodonthejQueryobject,passinginaCSSselector,whichtellsjQuerywhereinthedocumentIwanttheelementtobeinserted.
TheappendTomethodinsertsmynewelementasthelastchildoftheelementsmatchedbytheselector.Inthiscase,IspecifiedthebuttonDivelement,whichmeansthattheelementsinmyHTMLfragmentareinsertedalongsidethehiddeninputelement,likethis:
CHAPTER2GETTINGSTARTED
22
... <div id="buttonDiv"> <input type="submit" style="display: none; "> <a href="#">Submit Order</a> </div> ...
TipIftheselectorthatIpassedtotheappendTomethodhadmatchedmultipleelements,thenjQuerywouldduplicatetheelementsfromtheHTMLfragmentandinsertacopyasthelastchildofeverymatchedelement.
jQuerydefinesanumberofmethodsthatyoucanusetoinsertchildelementsintothedocument,andthemostusefulofthesearedescribedinTable2-2.Whenyouappendelements,theybecomethelastchildrenoftheirparentelement.Whenyouprependelements,theybecomethefirstchildrenoftheirparents.(I’llexplainwhytherearetwoappendandtwoprependmethodslaterinthischapter.)
Table2-2.jQueryMethodsforInsertingElementsintheDocument
Method Description
append(HTML) append(jQuery)
InsertsthespecifiedelementsasthelastchildrenofalltheelementsintheDOM
prepend(HTML) prepend(jQuery)
InsertsthespecifiedelementsasthefirstchildrenofalltheelementsintheDOM
appendTo(HTML) appendTo(jQuery)
InsertstheelementsinthejQueryobjectasthelastchildrenoftheelementsspecifiedbytheargument
prependTo(HTML) prependTo(jQuery)
InsertstheelementsinthejQueryobjectasthefirstchildrenoftheelementsspecifiedbytheargument
ApplyingaCSSClassInthepreviousexample,Iinsertedanaelement,butIdidnotassignittoaCSSclass.Listing2-6showshowIcancorrectthisomissionbymakingacalltotheaddClassmethod.
CHAPTER2GETTINGSTARTED
23
Listing2-6.ChainingjQueryMethodCalls
... <script> $(document).ready(function() { $('#buttonDiv input:submit').hide(); $('<a href=#>Submit Order</a>').appendTo("#buttonDiv").addClass("button"); }) </script> ...
NoticehowIhavesimplyaddedthecalltotheaddClassmethodtotheendofthestatement.Thisisknownasmethodchaining,andalibrarythatsupportsmethodchainingissaidtohaveafluentAPI.
MostjQuerymethodsreturnthesamejQueryobjectonwhichthemethodwascalled.Intheexample,IcreatethejQueryobjectbypassinganHTMLfragmenttothe$function.ThisproducesajQueryobjectthatcontainsanaelement.TheappendTomethodinsertstheelementintothedocumentandreturnsajQueryobjectthatcontainsthesameaelementasitsresult.Thisallowsmetomakefurthermethodcalls,suchastheonetoaddClass.FluentAPIscantakeawhiletogetusedto,buttheyenableconciseandexpressivecodeandreduceduplication.
TheaddClassmethodaddstheclassspecifiedbytheargumenttotheselectedelements,likethis:
... <div id="buttonDiv"> <input type="submit" style="display: none; "> <a href="#" class="button">Submit Order</a> </div> ...
Thea.buttonclassisdefinedinstyles.cssandbringstheappearanceoftheaelementintolinewiththerestofthedocument.
UNDERSTANDING METHOD PAIRS AND METHOD CHAINING
IfyoulookatthemethodsdescribedinTable2-2,youwillseethatyoucanappendorprependelementsintwoways.TheelementsyouareinsertingeithercanbecontainedinthejQueryobjectonwhichyoucallamethodorcanbeinthemethodargument.jQueryprovidesdifferentmethodssoyoucanselectwhichelementsarecontainedinthejQueryobjectformethodchaining.Inmyexample,IusedtheappendTomethod,whichmeansIcanarrangethingssothatthejQueryobjectcontainstheelementparsedfromtheHTMLfragment,allowingmetochainthecalltotheaddClassmethodandhavetheclassappliedtotheaelement.
Theappendmethodreversestherelationshipbetweentheparentandchildelements,likethis:
$('#buttonDiv').append('<a href=#>Submit Order</a>').addClass("button");
Inthisstatement,IselecttheparentelementandprovidetheHTMLfragmentasthemethodargument.TheappendmethodreturnsajQueryobjectthatcontainsthebuttonDivelement,sotheaddClasstakeseffectontheparentdivelementratherthanthenewaelement.
CHAPTER2GETTINGSTARTED
24
Torecap,Ihavehiddentheoriginalinputelement,addedanaelement,and,finally,assignedtheaelementtothebuttonclass.YoucanseetheresultinFigure2-1.
Figure2-1.Replacingthestandardformsubmitbutton
Withfourlinesofcode(onlytwoofwhichmanipulatetheDOM),Ihaveupgradedthestandardsubmitbuttontosomethingconsistentwiththerestofthewebapp.AsIsaidatthestartofthischapter,alittlecodecanleadtosignificantenhancements.
RespondingtoEventsIamnotquitedonewiththenewaelement.ThebrowserknowsthataninputelementwhosetypeattributeissubmitshouldsubmittheHTMLformtotheserver,anditperformsthisactionautomaticallywhenthebuttonisclicked.
TheaelementthatIaddedtotheDOMlookslikeabutton,butthebrowserdoesn’tknowwhattheelementisforandsodoesn’tapplythesameautomaticaction.IhavetoaddsomeJavaScriptcodethatwillcompletetheeffectandmaketheaelementbehavelikeabuttonandnotjustlooklikeone.
Youdothisbyrespondingtoevents.Aneventisamessagethatissentbythebrowserwhenthestateofanelementchanges,forexample,whentheuserclickstheelementormovesthemouseoverit.YoutellthebrowserwhicheventsyouareinterestinginandprovideJavaScriptcallbackfunctionsthatareexecutedwheneventoccurs.Aneventissaidtohavebeentriggeredwhenitissentbythebrowser,andthecallbackfunctionsareresponsibleforhandlingtheevent.Inthefollowingsections,I’llshowyouhowtohandleeventstocompletethefunctionalityofthesubstitutebutton.
4
CHAPTER2GETTINGSTARTED
25
HandlingtheClickEventThemostimportantforthisexampleisclick,whichistriggeredwhentheuserpressesandreleasesthemousebutton(inotherwords,whentheuserclicks)anelement.Forthisexample,IwanttohandletheclickeventbysubmittingtheHTMLformtotheserver.TheDOMAPIprovidessupportfordealingwithevents,butjQueryprovidesamoreelegantalternative,whichyoucanseeinListing2-7.
Listing2-7.HandlingtheclickEvent
<!DOCTYPE html> <html> <head> <title>CheeseLux</title> <link rel="stylesheet" type="text/css" href="styles.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script> $(document).ready(function() { $('#buttonDiv input:submit').hide(); $('<a href=#>Submit Order</a>').appendTo("#buttonDiv") .addClass("button").click(function() { $('form').submit(); }) }) </script> </head> <body> <div id="logobar"> <img src="cheeselux.png"> <span id="tagline">Gourmet European Cheese</span> </div> <form action="/basket" method="post"> <div class="cheesegroup"> <div class="grouptitle">French Cheese</div> <div class="groupcontent"> <label for="camembert" class="cheesename">Camembert ($18)</label> <input name="camembert" value="0"/> </div> <div class="groupcontent"> <label for="tomme" class="cheesename">Tomme de Savoie ($19)</label> <input name="tomme" value="0"/> </div> <div class="groupcontent"> <label for="morbier" class="cheesename">Morbier ($9)</label> <input name="morbier" value="0"/> </div> </div>
CHAPTER2GETTINGSTARTED
26
<div id="buttonDiv"> <input type="submit" /> </div> </form> </body> </html>
jQueryprovidessomehelpfulmethodsthatmakehandlingcommoneventssimple.Theseeventsarenamedaftertheevent;so,theclickmethodregistersthecallbackfunctionpassedasthemethodargumentasahandlerfortheclickevent.Ihavechainedthecalltotheclickeventtotheothermethodsthatcreateandformattheaelement.Tosubmittheform,Iselecttheformelementbytypeandcallthesubmitmethod.That’sallthereistoit.Inowhavethebasicfunctionalityofthebuttoninplace.Notonlydoesithavethesamevisualstyleastherestofthewebapp,butclickingthebuttonwillsubmittheformtotheserver,justastheoriginalbuttondid.
HandlingMouseHoverEventsTherearetwoothereventsthatIwanttohandletocompletethebuttonfunctionality;theyaremouseenterandmouseleave.Themouseentereventistriggeredwhenthemousepointerismovedovertheelement,andthemouseleaveeventistriggeredthemouseleavestheelement.
Iwanttohandletheseeventstogivetheuseravisualcuethatthebuttoncanbeclicked,andIdothisbychangingthestyleofthebuttonwhenthemouseisovertheelement.TheeasiestwaytohandletheseeventsistousethejQueryhovermethod,asshowninListing2-8.
Listing2-8.UsingthejQueryhoverMethod
... <script> $(document).ready(function() { $('#buttonDiv input:submit').hide(); $('<a href=#>Submit Order</a>').appendTo("#buttonDiv") .addClass("button").click(function() { $('form').submit(); }) .hover( function(){ $('#buttonDiv a').addClass("buttonHover"); }, function() { $('#buttonDiv a').removeClass("buttonHover"); }) }) </script> ...
Thehovermethodtakestwofunctionsasarguments.Thefirstfunctionisexecutedwhenthemouseentereventistriggered,andthesecondfunctionistriggeredinresponsetothemouseleaveevent.Inthisexample,IhaveusedthesefunctionstoaddandremovethebuttonHoverclassfromtheaelement.ThisclasschangesthevalueoftheCSSbackground-colorpropertytohighlightthebuttonwhenthemouseispositionedabovetheelement.YoucanseetheeffectinFigure2-2.
CHAPTER2GETTINGSTARTED
27
Figure2-2.Usingeventstoapplyaclasstoanelement
UsingtheEventObjectThetwofunctionsthatIpassedasargumentstothehovermethodinthepreviousexamplearelargelythesame.Icancollapsethesetwofunctionsintoasinglehandlerthatcanprocessbothevents,asshowninListing2-9.
Listing2-9.HandlingMultipleEventsinaSingleHandlerFunction
... <script> $(document).ready(function() { $('#buttonDiv input:submit').hide(); $('<a href=#>Submit Order</a>').appendTo("#buttonDiv") .addClass("button").click(function() { $('form').submit(); }).hover(function(e){ var elem = $('#buttonDiv a') if (e.type == "mouseenter") { elem.addClass("buttonHover"); } else { elem.removeClass("buttonHover"); } }) }) </script> ...
Thecallbackfunctioninthisexampletakesanargument,e.ThisargumentisanEventobjectprovidedbythebrowsertogiveyouinformationabouttheeventyouarehandling.IhaveusedtheEvent.typepropertytodifferentiatebetweenthetypesofeventsthatmyfunctionexpects.Thetypepropertyreturnsastringthatcontainstheeventname.Iftheeventnameismouseenter,thenIcalltheaddClassmethod.Ifnot,IcalltheremoveClassmethodthathastheeffectofremovingthespecifiedclassfromtheclassattributeoftheelementsinthejQueryobject,theoppositeeffectoftheaddClassmethod.
CHAPTER2GETTINGSTARTED
28
DealingwithDefaultActionsTomakelifeeasierfortheprogrammer,thebrowserperformssomeactionsautomaticallywhencertaineventsaretriggeredforspecificelementtypes.Theseareknownasdefaultactions,andtheymeanyoudon’thavetocreateeventhandlersforeverysingleeventandelementinanHTMLdocument.Forexample,thebrowserwillnavigatetotheURLspecifiedbythehrefattributeofanaelementinresponsetotheclickevent.Thisisthebasisfornavigationinawebpage.
Icheatedalittlebysettingthehrefattributeto#.ThisisacommontechniquewhendefiningelementswhoseactionsaregoingtobemanagedbyJavaScriptbecausethebrowserwon’tnavigateawayfromthecurrentdocumentwhenthedefaultactionisperformed.Inotherwords,Idon’thavetoworryaboutthedefaultactionbecauseitdoesn’treallydoanythingthattheuserwillnotice.
Defaultactionscanbemoreimportantwhenyouneedtochangethebehavioroftheelementandyoucan’tdolittletrickslikeusing#asaURL.Listing2-10providesademonstration,whereIhavechangedthehrefattributefortheaelementtoarealwebpage.Ihaveusedtheattrmethodtosetthehrefattributeoftheaelementtohttp://apress.com.Withthismodification,clickingtheelementdoesn’tsubmittheformanymore;itnavigatestotheApresswebsite.
Listing2-10.ManagingDefaultActions
... <script> $(document).ready(function() { $('#buttonDiv input:submit').hide(); $('<a href=#>Submit Order</a>') .appendTo("#buttonDiv") .attr("href", "http://apress.com") .addClass("button").click(function() { $('form').submit(); }).hover(function(e){ var elem = $('#buttonDiv a') if (e.type == "mouseenter") { elem.addClass("buttonHover"); } else { elem.removeClass("buttonHover"); } }) }) </script> ...
Tofixthis,acalltothepreventDefaultmethodontheEventobjectpassedtotheeventhandlerfunctionisrequired.Thisdisablesthedefaultactionfortheevent,meaningthatonlythecodeintheeventhandlerfunctionwillbeused.YoucanseetheuseofthismethodinListing2-11.
www.allitebooks.com
CHAPTER2GETTINGSTARTED
29
Listing2-11.PreventingtheDefaultAction
... <script> $(document).ready(function() { $('#buttonDiv input:submit').hide(); $('<a href=#>Submit Order</a>') .appendTo("#buttonDiv") .attr("href", "http://apress.com") .addClass("button").click(function(e) { $('form').submit(); e.preventDefault(); }).hover(function(e){ var elem = $('#buttonDiv a') if (e.type == "mouseenter") { elem.addClass("buttonHover"); } else { elem.removeClass("buttonHover"); } }) }) </script> ...
Thereisnodefaultactionforthemouseenterandmouseleaveeventsonanaelement,sointhislisting,IneedonlytocallthepreventDefaultmethodwhenhandlingtheclickevent.WhenIclicktheelementnow,theformissubmitted,andthehrefattributevaluedoesn’thaveanyeffect.
AddingDynamicBasketDataYouhaveseenhowyoucanimproveawebapplicationsimplybyaddingandmodifyingelementsandhandlingevents.Inthissection,Igoonestepfurthertodemonstratehowyoucanusethesesimpletechniquestocreateamoreresponsiveversionofthecheeseshopbyincorporatingtheinformationdisplayedinthebasketphasealongsidetheproductselection.IhavecalledthisadynamicbasketbecauseIwillbeupdatingtheinformationshowntouserswhentheychangethequantitiesofindividualcheeseproducts,ratherthanthestaticbasket,whichisshownwhenuserssubmittheirselectionsusingtheunenhancedversionofthiswebapp.
AddingtheBasketElementsThefirststepistoaddtheadditionalelementsIneedtothedocument.IcouldaddtheelementsusingHTMLfragmentsandtheappendTomethod,butforvarietyIamgoingtouseanothertechnique,knownaslatentcontent.LatentcontentreferstoHTMLelementsthatareinthedocumentbutarehiddenusingCSSandarerevealedandmanagedusingJavaScript.Thoseuserswhodon’thaveJavaScriptenabledwon’tseetheelementsandwillgetthebasicfunctionality,butonceIrevealtheelementsandsetupmyeventhandling,thoseuserswithJavaScriptwillgetaricherandmorepolishedexperience.Listing2-12showstheadditionofthelatentcontenttotheHTMLdocument.
CHAPTER2GETTINGSTARTED
30
Listing2-12.AddingHiddenElementstotheHTMLDocument
<!DOCTYPE html> <html> <head> <title>CheeseLux</title> <link rel="stylesheet" type="text/css" href="styles.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script> $(document).ready(function() { $('#buttonDiv input:submit').hide(); $('<a href=#>Submit Order</a>') .appendTo("#buttonDiv").addClass("button").click(function(e) { $('form').submit(); e.preventDefault(); }).hover(function(e){ var elem = $('#buttonDiv a') if (e.type == "mouseenter") { elem.addClass("buttonHover"); } else { elem.removeClass("buttonHover"); } }) }) </script> </head> <body> <div id="logobar"> <img src="cheeselux.png"> <span id="tagline">Gourmet European Cheese</span> </div> <form action="/basket" method="post"> <div class="cheesegroup"> <div class="grouptitle">French Cheese</div> <div class="groupcontent"> <label for="camembert" class="cheesename">Camembert ($18)</label> <input name="camembert" value="0"/> <span class="subtotal latent">($<span>0</span>)</span> </div> <div class="groupcontent"> <label for="tomme" class="cheesename">Tomme de Savoie ($19)</label> <input name="tomme" value="0"/> <span class="subtotal latent">($<span>0</span>)</span> </div> <div class="groupcontent"> <label for="morbier" class="cheesename">Morbier ($9)</label>
CHAPTER2GETTINGSTARTED
31
<input name="morbier" value="0"/> <span class="subtotal latent">($<span>0</span>)</span> </div> <div class="sumline latent"></div> <div class="groupcontent latent"> <label class="cheesename">Total:</label> <input class="placeholder" name="spacer" value="0"/> <span class="subtotal latent" id="total">$0</span> </div> </div> <div id="buttonDiv"> <input type="submit" /> </div> </form> </body> </html>
Ihavehighlightedtheadditionalelementsinthelisting.Theyareallassignedtothelatentclass,whichhasthefollowingdefinitioninthestyles.cssfile:
...
.latent { display: none; } ...
IshowedyouearlierinthechapterthatthejQueryhidemethodsetstheCSSdisplaypropertytononetohideelementsfromtheuser,andIhavefollowedthesameapproachwhensettingupthisclass.Theelementsareinthedocumentbutnotvisibletotheuser.
ShowingtheLatentContentNowthatthelatentelementsareinplace,IcanworkwiththemusingjQuery.Thefirststepistorevealthemtotheuser.SinceIammanipulatingtheelementsusingJavaScript,theywillberevealedonlytouserswhohaveJavaScriptenabled.Listing2-13showstheadditiontothescriptelement.
Listing2-13.RevealingtheLatentContent
... <script> $(document).ready(function() { $('#buttonDiv input:submit').hide(); $('<a href=#>Submit Order</a>') .appendTo("#buttonDiv").addClass("button").click(function(e) { $('form').submit(); e.preventDefault(); }).hover(function(e){ var elem = $('#buttonDiv a') if (e.type == "mouseenter") { elem.addClass("buttonHover");
CHAPTER2GETTINGSTARTED
32
} else { elem.removeClass("buttonHover"); } }); $('.latent').show(); }) </script> ...
Thehighlightedstatementselectsalloftheelementsthataremembersofthelatentclassandthencallstheshowmethod.Theshowmethodaddsastyleattributetoeachselectedelementthatsetsthedisplaypropertytoinline,whichhastheeffectofrevealingtheelements.Theelementsarestillmembersofthelatentclass,butvaluesdefinedinastyleattributeoverridethosethataredefinedinastyleelement,andsotheelementsbecomevisible.
RespondingtoUserInputTocreateadynamicbasket,Iwanttobeabletodisplaysubtotalsforeachitemandanoveralltotalwhenevertheuserchangesaquantityforaproduct.IamgoingtohandletwoeventstogettheeffectIwant.Thefirsteventischange,whichistriggeredwhentheuserentersanewvalueandthenmovesthefocustoanotherelement.Thesecondeventiskeyup,whichistriggeredwhentheuserreleasesakey,havingpreviouslypressedit.ThecombinationofthesetwoeventsmeansIcanbeconfidentthatIwillbeabletorespondsmoothlytonewvalues.jQuerydefineschangeandkeyupmethodsthatIcoulduseinthesamewayIusedtheclickmethodearlier,butsinceIwanttohandlebotheventsinthesameway,Iamgoingtousethebindmethodinstead,asshowninListing2-14.
Listing2-14.BindingtothechangeandkeyupEvents
... <script> var priceData = { camembert: 18, tomme: 19, morbier: 9 } $(document).ready(function() { $('#buttonDiv input:submit').hide(); $('<a href=#>Submit Order</a>') .appendTo("#buttonDiv").addClass("button").click(function(e) { $('form').submit(); e.preventDefault(); }).hover(function(e){ var elem = $('#buttonDiv a') if (e.type == "mouseenter") { elem.addClass("buttonHover"); } else { elem.removeClass("buttonHover");
CHAPTER2GETTINGSTARTED
33
} }) $('.latent').show(); $('input').bind("change keyup", function() { var subtotal = $(this).val() * priceData[this.name]; $(this).siblings("span").children("span").text(subtotal) }) }) </script> ...
TheadvantageofthebindmethodisthatitletsmehandlemultipleeventsusingthesameanonymousJavaScriptfunction.Todothis,IhaveselectedtheinputelementsinthedocumenttogetajQueryobjectandcalledthebindmethodonit.Thefirstargumenttothebindmethodisastringcontainingthenamesoftheeventstohandle,whereeventnamesareseparatedbythespacecharacter.Thesecondargumentisthefunctionthatwillhandletheeventswhentheyaretriggered.Thereareonlytwostatementsintheeventhandlerfunction,buttheyareworthunpackingbecausetheycontainaninterestingmixofjQuery,theDOMAPI,andpureJavaScript.
TipHandlingtwoeventslikethismeansthatmycallbackfunctionmayendupbeinginvokedwhenitdoesn’treallyneedtobe.Forexample,iftheuserpressestheTabkey,thefocuswillchangetothenextelement,andboththechangeandkeyupeventswillbetriggered,eventhoughthevalueintheinputelementhasn’tchanged.Itendtowardacceptingthisduplicationasthecostofensuringafluiduserexperience.I’drathermyfunctionwasexecutedmoreoftenthanreallyneededandnotmissanyuserinteraction.
CalculatingtheSubtotalThefirststatementinthefunctionisresponsibleforcalculatingthesubtotalforthecheeseproductwhoseinputvaluehaschanged.Hereisthestatement:
var subtotal = $(this).val() * priceData[this.name];
WhenhandlinganeventwithjQuery,youcanusethevariablecalledthistorefertotheelementthattriggeredtheevent.ThethisvariableisanHTMLElementobject,whichiswhattheDOMAPIusestorepresentelementsinthedocument.ThereareacoresetofpropertiesdefinedbytheHTMLElement,themostimportantofwhicharedescribedinTable2-3.
CHAPTER2GETTINGSTARTED
34
Table2-3.BasicHTMLElementProperties
Property Description
className Getsorsetsthelistofclassesthattheelementbelongsto
id Getsorsetsthevalueoftheidattribute
tagName Returnsthetagname(indicatingtheelementtype)
Thecorepropertiesaresupplementedtoaccommodatetheuniquecharacteristicsofdifferent
elementtypes.Anexampleofthisisthenameproperty,whichreturnsthevalueofthenameattributeonthoseelementsthatsupportit,includingtheinputelement.IhaveusedthispropertyonthethisvariabletogetthenameoftheinputelementsothatIcan,inturn,useittogetavaluefromthepriceDataobjectthatIaddedtothescript:
var subtotal = $(this).val() * priceData[this.name];
ThepriceDataobjectisasimpleJavaScriptobjectthathasonepropertycorrespondingtoeachkindofcheeseandwherethevalueofeachpropertyisthepriceforthecheese.
ThethisvariablecanalsobeusedtocreatejQueryobjects,likethis:
var subtotal = $(this).val() * priceData[this.name];
BypassinganHTMLElementobjectastheargumenttothejQuery$function,IhavecreatedajQueryobjectthatactsjustasthoughIhadselectedtheelementusingaCSSselector.ThisallowsmetoeasilyapplyjQuerymethodstoobjectsfromtheDOMAPI.Inthisstatement,Icallthevalmethod,whichreturnsthevalueofthevalueattributeofthefirstelementinthejQueryobject.
TipThereisonlyoneelementinmyjQueryobject,butjQuerymethodsaredesignedtoworkwithmultipleelements.Whenyouuseamethodlikevaltoreadsomevaluefromtheelement,yougetthevaluefromthefirstelementintheselection,butwhenyouusethesamemethodtosetthevalue(bypassingthevalueasanargument),alloftheselectedelementsaremodified.
Usingthethisvariable,Ihavebeenabletogetthevalueoftheinputelementthattriggeredtheeventandthepricefortheproductassociatedwithit.Ithenmultiplythepriceandthequantitytogethertodeterminethesubtotal,whichIassigntoalocalvariablecalled,simplyenough,subtotal.
DisplayingtheSubtotalThesecondstatementinthehandlerfunctionisresponsiblefordisplayingthesubtotaltotheuser.Thisstatementalsooperatesintwoparts.Thefirstpartselectstheelementthatwillbeusedtodisplaythevalue:
$(this).siblings("span").children("span").text(subtotal)
CHAPTER2GETTINGSTARTED
35
Onceagain,IcreateajQueryobjectusingthethisvariable.Imakeacalltothesiblingsmethod,whichreturnsajQueryobjectthatcontainsanysiblingtotheelementsintheoriginaljQueryobjectthatmatchesthespecifiedCSSselector.ThismethodreturnsajQueryobjectthatcontainsthelatentspanelementnexttotheinputelementthattriggeredtheevent.
Ichainacalltothechildrenmethod,whichreturnsajQueryobjectthatcontainsanychildrenoftheelementinthepreviousjQueryobjectthatmatchthespecifiedselector.IendupwithajQueryobjectthatcontainsthenestedspanelement.Icouldhavesimplifiedtheselectorsinthisexample,butIwantedtodemonstratehowjQuerysupportsnavigationthroughtheelementsinadocumentandhowthecontentsofthejQueryobjectinachainofmethodcallschanges.ThesechangesaredescribedinTable2-4.
Table2-4.BasicHTMLElementProperties
Method Call Contents of jQuery Object
$(this) Theinputelementthattriggeredtheevent
.siblings("span") Thespanelementthatisasiblingtotheinputelementthattriggeredtheevent
.children("span") Thespanelementthatisachildofthespanelementthatisasiblingtotheinputelementthattriggeredtheevent
Bycombiningmethodcallslikethis,Iamabletonavigatethroughtheelementhierarchytocreatea
jQueryobjectthatcontainspreciselytheelementorelementsIwanttoworkwith,inthiscase,thechildofasiblingtowhicheverelementtriggeredanevent.
Thesecondpartofthestatementisacalltothetextmethod,whichsetsthetextcontentoftheelementsinajQueryobject.Inthiscase,thetextisthevalueofthesubtotalvariable:
$(this).siblings("span").children("span").text(subtotal)
Thenetresultisthatthesubtotalforacheeseisupdatedassoonasauserchangesthequantityrequired.
CalculatingtheOverallTotalTocompletethebasket,Ineedtogenerateanoveralltotaleachtimeasubtotalchanges.Ihavedefinedanewfunctioninthescriptelementandaddedacalltoitintheeventhandlerfunctionfortheinputelements.Listing2-15showstheadditions.
CHAPTER2GETTINGSTARTED
36
Listing2-15.CalculatingtheOverallTotal
... <script> var priceData = { camembert: 18, tomme: 19, morbier: 9 } $(document).ready(function() { $('#buttonDiv input:submit').hide(); $('<a href=#>Submit Order</a>') .appendTo("#buttonDiv").addClass("button").click(function(e) { $('form').submit(); e.preventDefault(); }).hover(function(e){ var elem = $('#buttonDiv a') if (e.type == "mouseenter") { elem.addClass("buttonHover"); } else { elem.removeClass("buttonHover"); } }) $('.latent').show(); $('input').bind("change keyup", function() { var subtotal = $(this).val() * priceData[this.name]; $(this).siblings("span").children("span").text(subtotal) calculateTotal(); }) }) function calculateTotal() { var total = 0; $('span.subtotal span').not('#total').each(function(index, elem) { total += Number($(elem).text()); }) $('#total').text("$" + total); } </script> ...
ThefirststatementinthecalculateTotalfunctiondefinesalocalvariableandinitializestozero.Iusethisvariabletosumtheindividualsubtotals.Thenextstatementisthemostinterestingoneinthisfunction.Thefirstpartofthestatementselectsasetofelements:
CHAPTER2GETTINGSTARTED
37
... $('span.subtotal span').not('#total').each(function(index, elem) { ...
Istartbyselectingallspanelementsthataredescendantsofspanelementsthatarepartofthesubtotalclass.Thisisanotherwayofselectingthesubtotalelements.Ithenusethenotmethodtoremoveelementsfromtheselection.Inthiscase,Iremovetheelementwhoseidistotal.IdothisbecauseIdefinedthesubtotalandtotalelementsusingthesameclassesandstyles,andIdon’twantthecurrenttotaltobeincludedwhencalculatinganewtotal.
Havingselectedtheitems,Ithenusetheeachmethod.ThismethodcallsafunctiononceforeachelementinajQueryobject.TheargumentstothefunctionaretheindexofthecurrentelementintheselectionandtheHTMLElementobjectthatrepresentstheelementintheDOM.
Igetthecontentofeachsubtotalelementusingthetextmethod.IcreateajQueryobjectbypassingtheHTMLElementobjectasanargumenttothe$function,justasIdidwiththethisvariableearlierinthischapter.
Thetextmethodreturnsastring,soIusetheJavaScriptNumberfunctiontocreateanumericvaluethatIcanaddtotherunningtotal:
total += Number($(elem).text());
Finally,Iselectthetotalelementandusethetextmethodtodisplaytheoveralltotal:
$('#total').text("$" + total);
Theeffectofaddingthisfunctionisthatachangeinthequantityforacheeseisimmediatelyreflectedinthetotal,aswellasintheindividualsubtotals.
ChangingtheFormTargetByaddingadynamicbasket,Ihavepulledthefunctionalityofthebasketwebpageintothemainpageoftheapplication.Itdoesn’tmakesensetosendJavaScript-enableduserstothebasketwebpagewhentheysubmittheform,becauseitjustduplicatedinformationtheyhavealreadyseen.Iamgoingtochangethetargetoftheformelementsothatsubmittingtheformgoesstraighttotheshippingpage,skippingoverthebasketpageentirely.Listing2-16showsthestatementthatchangesthetarget.
Listing2-16.ChangingtheTargetfortheformElement
... <script> var priceData = { camembert: 18, tomme: 19, morbier: 9 } $(document).ready(function() { $('#buttonDiv input:submit').hide(); $('<a href=#>Submit Order</a>') .appendTo("#buttonDiv").addClass("button").click(function(e) { $('form').submit(); e.preventDefault();
CHAPTER2GETTINGSTARTED
38
}).hover(function(e){ var elem = $('#buttonDiv a') if (e.type == "mouseenter") { elem.addClass("buttonHover"); } else { elem.removeClass("buttonHover"); } }) $('.latent').show(); $('input').bind("change keyup", function() { var subtotal = $(this).val() * priceData[this.name]; $(this).siblings("span").children("span").text(subtotal) calculateTotal(); }) $('form').attr("action", "/shipping"); }) function calculateTotal() { var total = 0; $('span.subtotal span').not('#total').each(function(index, elem) { total += Number($(elem).text()); }) $('#total').text("$" + total); } </script> ...
Bythispoint,itshouldbeobvioushowthenewstatementworks.Iselecttheformelementbytype(sincethereisonlyonesuchelementinthedocument)andcalltheattrmethodtosetanewvaluefortheactionattribute.Theuseristakentotheshippingdetailspagewhentheformissubmitted,skippingthebasketpageentirely.YoucanseetheeffectinFigure2-3.
www.allitebooks.com
CHAPTER2GETTINGSTARTED
39
Figure2-3.Changingtheflowoftheapplication
Asthisexampledemonstrates,youcanchangetheflowofawebapplicationaswellastheappearanceandinteractivityofindividualpages.Ofcourse,theback-endservicesneedtounderstandthevariouspathsthatdifferentkindsofusercanfollowthroughawebapp,butthisiseasytoachievewithalittleforethoughtandplanning.
UnderstandingProgressiveEnhancementThetechniquesIhavedemonstratedinthischapterarebasicbutveryeffective.ByusingJavaScripttomanagetheelementsintheDOMandrespondtoevents,Ihavebeenabletomaketheexamplewebappmoreresponsivefortheuser,provideusefulandtimelyinformationaboutthecostoftheuser’sproductselections,andstreamlinetheflowoftheappitself.
But—andthisisimportant—becausethesechangesaredonethroughJavaScript,thebasicnatureandstructureofthewebappremainunchangedfornon-JavaScriptusers.Figure2-4showsthemainwebapppagewhenJavaScriptisenabledanddisabled.
CHAPTER2GETTINGSTARTED
40
Figure2-4.ThewebappasshownwhenJavaScriptisdisabledandenabled
Theversionthatnon-JavaScriptusersexperienceremainsfullyfunctionalbutisclunkiertouseandrequiresmorestepstoplaceanorder.
Creatingabaseleveloffunctionalityandthenselectivelyenrichingitisanexampleofprogressiveenhancement.Progressiveenhancementisn’tjustabouttheavailabilityofJavaScript;itencompassesselectiveenrichmentbasedonanyfactor,suchastheamountofbandwidth,thetypeofbrowser,oreventhelevelofexperienceoftheuser.However,whencreatingwebapps,themostcommonformofprogressiveenhancementisdrivenbywhethertheuserhasJavaScriptenabled.
TipAsimilartermtoprogressiveenhancementisgracefuldegradation.Formypurposesinthisbook,progressiveenhancementandgracefuldegradationarethesame—thenotionthatthecorecontentandfeaturesofawebapplicationareavailabletoallusers,irrespectiveofthecapabilitiesofauser’sbrowser.
Ifyoudon’twanttosupportnon-JavaScriptbrowsers,thenyoushouldmakeitobvioustonon-JavaScriptvisitorsthatthereisaproblem.Theeasiestwaytodothisisbyusingthenoscriptandmetaelementstoredirectthebrowsertoapagethatexplainsthesituation,asshowninListing2-17.
Listing2-17.DealingwithNon-JavaScriptUsers
... <head> <title>CheeseLux</title> <link rel="stylesheet" type="text/css" href="styles.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script> ... JavaScript code goes here...
CHAPTER2GETTINGSTARTED
41
</script> <noscript> <meta http-equiv="refresh" content="0; noscript.html"/> </noscript> </head> ...
Thiscombinationofelementsredirectstheusertoapagecallednoscript.html,whichisanHTMLdocumentthattellstheuserthatIrequireJavaScript(and,obviously,doesn’trelyonJavaScriptitself).YoucanfindthispageinthesourcecodedownloadthataccompaniesthisbookandseetheresultinFigure2-5.
Figure2-5.EnforcingaJavaScript-onlypolicyinawebapp
ItistemptingtorequireJavaScript,butIrecommendcaution;youmightbesurprisedbyhowmanyusersdon’tenableJavaScriptorsimplycan’t.Thisisespeciallytrueforusersinlargecorporations,wherecomputersareusuallylockeddownandwherefeaturesthatarecommoninthegeneralpopulationaredisabledinthenameofsecurity,including,sadly,JavaScriptinbrowsers.Somewebappsjustdon’tmakesensewithoutJavaScript,butgivecarefulthoughttothepotentialusers/customersyouwillbeexcludingbeforedecidingthatyouarebuildingoneofthem.
NoteThisisabookaboutbuildingwebappswithJavaScript,soIamnotgoingtomaintainprogressiveenhancementinthechaptersthatfollow.Don’ttakethatasanendorsementofaJavaScript-onlypolicy.Inmyownprojects,Itrytosupportnon-JavaScriptuserswheneverpossible,evenwhenitrequiresalotofadditionaleffort.
CHAPTER2GETTINGSTARTED
42
RevisitingtheButton:UsingaUIToolkitIwanttofinishthischapterbyshowingyouadifferentapproachtoobtainingoneoftheresultsinthischapter:creatingavisuallyconsistentbutton.ThetechniquesIusedpreviouslydemonstratedhowyoucanmanipulatetheDOMandrespondtoeventstotailortheappearanceandbehaviorofelements,whichisthemainpremiseinthischapter.
Thatsaid,forprofessionaldevelopment,itagoodprincipletoneverwritewhatyoucanobtainfromagoodJavaScriptlibrary,andwhenIwanttocreatevisuallyrichelements,IuseaUItoolkit.Inthissection,I’llshowyouhoweasyitistocreateacustombuttonwithjQueryUI,whichisproducedbythejQueryteamandisoneofthemostwidelyusedJavaScriptUItoolkitsavailable.
SettingUpjQueryUISettingupjQueryUIisamultistageprocess.Thefirststageistocreateatheme,whichdefinestheCSSstylesthatareusedbythejQueryUIwidgets(whichisthenamegiventothestyledelementsthataUItoolkitcreates).Tocreateatheme,gotohttp://jqueryui.com,clicktheThemesbutton,expandeachsectionontheleftsideofthescreen,andspecifythestylesyouwant.Asyoumakechanges,thesamplewidgetsontherightsideofthescreenwillupdatetoreflectthenewsettings.Ittookmeaboutfiveminutes(andabitoftrialanderror)tocreateathemethatmatchestheappearanceoftheexamplewebapp.IhaveincludedthethemeIcreatedinthesourcecodedownloadforthisbookifyoudon’twanttocreateyourown.
TipIfyoudon’twanttocreateacustomtheme,youcanselectapredefinedstylefromthegallery.Thiscanbeusefulifyouarenottryingtomatchanexistingappdesign,althoughthecolorsusedinsomeofgallerystylesarequitealarming.
Whenyouaredone,clicktheDownloadThemebutton.YouwillseeascreenthatallowsyoutoselectwhichcomponentsofjQueryUIareincludedinthedownload.YoucancreateasmallerdownloadifyougetintothedetailofjQueryUI,butforthisbookensurethatallofthecomponentsareselectedandclicktheDownloadbutton.Yourbrowserwilldownloada.zipfilethatcontainsthejQueryUIlibrary,theCSSthemeyoucreated,andsomesupportingimages.
Thesecondpartofthesetupistocopythefollowingfilesfromthe.zipfileintothecontentdirectoryoftheNode.jsserver:
• Thedevelopment-bundle\ui\jquery-ui-1.8.16.custom.jsfile
• Thedevelopment-bundle\themes\custom-theme\jquery-ui-1.8.16.custom.cssfile
• Thedevelopment-bundle\themes\custom-theme\imagesfolder
ThenamesofthefilesincludethejQueryUIversionnumbers.AsIwritethis,thecurrentversionis1.8.16,butyouwillprobablyhavealaterversionbythetimethisbookgoesintoprint.
CHAPTER2GETTINGSTARTED
43
TipOnceagain,IamusingtheuncompressedversionsoftheJavaScriptfiletomakedebuggingeasier.Youwillfindtheminimizedversioninthejsfolderofthe.zipfile.
CreatingajQueryUIButtonNowthatjQueryUIissetup,IcanuseitinmyHTMLdocumenttocreateabuttonwidgetandsimplifymycode.Listing2-18showstheadditionsrequiredtoimportjQueryUIintothedocumentandtocreateabutton.
ImportingjQueryUIissimplyamatterofaddingascriptelementtoimporttheJavaScriptfileandalinkelementtoimporttheCSSfile.Youdon’tneedtoexplicitlyreferencetheimagesdirectory.
TipNoticethatthescriptelementthatimportsthejQueryUIJavaScriptfilecomesaftertheonethatimportsjQuery.ThisorderingisimportantsincejQueryUIdependsonjQuery.
Listing2-18.UsingjQueryUItoCreateaButton
<!DOCTYPE html> <html> <head> <title>CheeseLux</title> <link rel="stylesheet" type="text/css" href="styles.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script src="jquery-ui-1.8.16.custom.js" type="text/javascript"></script> <link rel="stylesheet" type="text/css" href="jquery-ui-1.8.16.custom.css"/> <script> var priceData = { camembert: 18, tomme: 19, morbier: 9 } $(document).ready(function() { $('#buttonDiv input:submit').button().css("font-family", "Yanone, sans-serif"); $('.latent').show(); $('input').bind("change keyup", function() { var subtotal = $(this).val() * priceData[this.name]; $(this).siblings("span").children("span").text(subtotal) calculateTotal(); })
CHAPTER2GETTINGSTARTED
44
$('form').attr("action", "/shipping"); }) function calculateTotal() { var total = 0; $('span.subtotal span').not('#total').each(function(index, elem) { total += Number($(elem).text()); }) $('#total').text("$" + total); } </script> <noscript> <meta http-equiv="refresh" content="0; noscript.html"/> </noscript> </head> ...
WhenusingjQueryUI,Idon’thavetohidetheinputelementandinsertasubstitute.Instead,IusejQuerytoselecttheelementIwanttomodifyandcallthebuttonmethod,asfollows:
$('#buttonDiv input:submit').button()
Withasinglemethodcall,jQueryUIchangestheappearanceofthelabelsandhandlesthehighlightingwhenthemousehoversoverthebutton.Idon’tneedtoworryabouthandlingtheclickeventinthiscase,becausethedefaultactionforasubmitinputelementistosubmittheform,whichisexactlywhatIwanttohappen.
Ihavemadeoneadditionalmethodcall,usingthecssmethod.ThismethodappliesaCSSpropertydirectlytotheselectedelementsusingthestyleattribute,andIhaveusedittosetthefont-familypropertyontheinputelement.ThejQueryUIthemesystemdoesn’thavemuchsupportfordealingwithfontsandgeneratesitswidgetsusingasinglefontfamily.IhavesetupwebfontsfromtheGoogleFonts(www.google.com/webfontsandtheexcellentLeagueofMovableType(www.theleagueofmoveabletype.com),soImustoverridethejQueryUICSSstylestoapplymypreferredfonttothebuttonelement.YoucanseetheresultofusingjQueryUItocreateabuttoninFigure2-6.Theresultis,asyoucansee,consistentwiththerestofthewebappbutmuchsimplertocreateinJavaScript.
Figure2-6.CreatingabuttonwithjQueryUI
ToolkitslikejQueryUIarejustaconvenientwrapperaroundthesameDOM,CSS,andeventtechniquesIdescribedearlier.Itisimportanttounderstandwhat’shappeningunderthecovers,butIrecommendusingjQueryUIoranothergoodUIlibrary.Theselibrariesarecomprehensivelytested,andtheysaveyoufromhavingtowriteanddebugcustomcode,allowingyoutospendmoretimeonthefeaturesthatsetyourwebappapartfromthecompetition.
CHAPTER2GETTINGSTARTED
45
SummaryAsImentionedatthestartofthischapter,thetechniquesIusedintheseexamplesaresimple,reliable,andentirelysuitedtosmallwebapps.Thereisnothingintrinsicallywrongwithusingtheseapproachesiftheappissosmallthattherecanneverbeanyissueaboutmaintainingitbecauseeveryaspectofitsbehaviorisimmediatelyobvioustoaprogrammer.
However,ifyouarereadingthisbook,youwanttogofurtherandcreatewebappsthatarelarge,arecomplex,andhavemanymovingparts.Andwhenappliedtosuchwebapps,thesetechniquescreatesomefundamentalproblems.Theunderlyingissueisthatthedifferentaspectsofthewebappareallmixedtogether.Theapplicationdata(theproductsandthebasket),thepresentationofthatdata(theHTMLelements),andtheinteractionsbetweenthem(theJavaScripteventsandhandlerfunctions)aredistributedthroughoutthedocument.Thismakesithardtoaddadditionaldata,extendthefunctionality,orfixbugswithoutintroducingerrors.
Inthechaptersthatfollow,Ishowyouhowtoapplyheavy-dutytechniquesfromtheworldofserver-sidedevelopmenttothewebapp.Client-sidedevelopmenthasbeenthepoorcousinofserver-sideworkformanyyears,butasbrowsersbecomemorecapable(andaswebappprogrammersbecomemoreambitious),wecannolongerpretendthattheclientsideisanythingotherthanafull-fledgedplatforminitsownright.Itistimetotakewebappdevelopmentseriously,andinthechaptersthatfollow,Ishowyouhowtocreateasolid,robust,andscalablefoundationforyourwebapp.
C H A P T E R 3
47
Adding a View Model
Ifyouhavedoneanyseriousdesktoporserver-sidedevelopment,youwillhaveencounteredeithertheModel-View-Controller(MVC)designpatternoritsderivativeModel-View-View-Model(MVVM).Iamnotgoingtodescribeeitherpatterninanydetail,otherthantosaythatthecoreconceptinbothisseparatingthedata,operations,andpresentationofanapplicationintoseparatecomponents.
Thereisalotofbenefitinapplyingthesamebasicprinciplestoawebapplication.Iamnotgoingtogetboggeddowninthedesignpatternsandterminology.Instead,Iamgoingtofocusondemonstratingtheprocessforstructuringawebappandexplainingthebenefitsthataregainedfromdoingso.
ResettingtheExampleThebestwaytounderstandhowtoapplyaviewmodelandthebenefitsthatdoingsoconfersistosimplydoit.ThefirstthingtodoiscuteverythingbutthebasicsoutoftheapplicationsothatIhaveacleanslatetostartfrom.AsyoucanseeinListing3-1,Ihaveremovedeverythingbutthebasicstructureofthedocument.
Listing3-1.WipingtheSlate
<!DOCTYPE html> <html> <head> <title>CheeseLux</title> <link rel="stylesheet" type="text/css" href="styles.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script src="jquery-ui-1.8.16.custom.js" type="text/javascript"></script> <link rel="stylesheet" type="text/css" href="jquery-ui-1.8.16.custom.css"/> <noscript> <meta http-equiv="refresh" content="0; noscript.html"/> </noscript> <script> $(document).ready(function() { $('#buttonDiv input:submit').button().css("font-family", "Yanone"); }) </script> </head> <body> <div id="logobar"> <img src="cheeselux.png"> <span id="tagline">Gourmet European Cheese</span>
CHAPTER3ADDINGAVIEWMODEL
48
</div> <form action="/shipping" method="post"> <div id="buttonDiv"> <input type="submit" /> </div> </form> </body> </html>
CreatingaViewModelThenextstepistodefinesomedata,whichwillbethefoundationoftheviewmodel.Togetstarted,Ihaveaddedanobjectthatdescribestheproductsinthecheeseshop,asshowninListing3-2.
Listing3-2.AddingDatatotheDocument
<script> var cheeseModel = { category: "French Cheese", items: [ {id: "camembert", name: "Camembert", price: 18}, {id: "tomme", name: "Tomme de Savoie", price: 19}, {id: "morbier", name: "Morbier", price: 9}] }; $(document).ready(function() { $('#buttonDiv input:submit').button().css("font-family", "Yanone"); }); </script>
IhavecreatedanobjectthatcontainsdetailsofthecheeseproductsandassignedittoavariablecalledcheeseModel.TheobjectdescribesthesameproductsthatIusedChapter2andisthefoundationofmyviewmodel,whichIwillbuildthroughoutthechapter;itisasimpledataobjectnow,butI’llbedoingalotmorewithitsoon.
TipIfyoufindyourselfstaringattheblinkingcursorwithnorealideahowtodefineyourapplicationdata,thenmyadviceissimple:juststarttyping.Oneofthebiggestbenefitsofembracingaviewmodelisthatitmakeschangeseasier,andthatincludeschangestothestructureoftheunderlyingdata.Don’tworryifyoudon’tgetitright,becauseyoucanalwayscorrectitlater.
CHAPTER3ADDINGAVIEWMODEL
49
AdoptingaViewModelLibraryFollowingtheprincipleofnotwritingwhatisavailableinagoodJavaScriptlibrary,Iwillintroduceaviewmodelintothewebappusingaviewmodellibrary.TheoneI’llbeusingiscalledKnockout(KO).IliketheKOapproachtoapplicationstructure,andthemainprogrammerforKOisSteveSanderson,whoismycoauthorfortheProASP.NETMVCbookfromApressandanall-aroundniceguy.TogetKO,gotohttp://knockoutjs.comandclicktheDownloadlink.Selectthemostrecentversion(whichis2.0.0asIwritethis)fromthelistoffilesandcopyittotheNode.jscontentdirectory.
TipDon’tworryifyoudon’tgetonwithKO.Otherstructurelibrariesareavailable.ThemaincompetitioncomesfromBackbone(http://documentcloud.github.com/backbone)andAngularJS(http://angularjs.org).Theimplementationdetailsinthesealternativelibrariesmaydiffer,buttheunderlyingprinciplesremainthesame.
Inthesectionsthatfollow,Iwillbringmyviewmodelandtheviewmodellibrarytogethertodecouplepartsoftheexampleapplication.
GeneratingContentfromtheViewModelTobegin,IamgoingtousethedatatogenerateelementsinthedocumentsothatIcandisplaytheproductstotheuser.Thisisasimpleuseoftheviewmodel,butitreproducesthebasicfunctionalityoftheimplementationinChapter2andgivesmeagoodfoundationfortherestofthechapter.Listing3-3showstheadditionoftheKOlibrarytothedocumentandthegenerationoftheelementsfromthedata.
Listing3-3.GeneratingElementsfromtheViewModel
<!DOCTYPE html> <html> <head> <title>CheeseLux</title> <link rel="stylesheet" type="text/css" href="styles.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script src="jquery-ui-1.8.16.custom.js" type="text/javascript"></script> <script src='knockout-2.0.0.js' type='text/javascript'></script> <link rel="stylesheet" type="text/css" href="jquery-ui-1.8.16.custom.css"/> <noscript> <meta http-equiv="refresh" content="0; noscript.html"/> </noscript> <script> var cheeseModel = { category: "French Cheese", items: [ {id: "camembert", name: "Camembert", price: 18}, {id: "tomme", name: "Tomme de Savoie", price: 19}, {id: "morbier", name: "Morbier", price: 9}] }; $(document).ready(function() {
www.allitebooks.com
CHAPTER3ADDINGAVIEWMODEL
50
$('#buttonDiv input:submit').button().css("font-family", "Yanone"); ko.applyBindings(cheeseModel); }); </script> </head> <body> <div id="logobar"> <img src="cheeselux.png"> <span id="tagline">Gourmet European Cheese</span> </div> <form action="/shipping" method="post"> <div class="cheesegroup"> <div class="grouptitle" data-bind="text: category"></div> <div data-bind="foreach: items"> <div class="groupcontent"> <label data-bind="attr: {for: id}" class="cheesename"> <span data-bind="text: name"> </span> $(<span data-bind="text:price"></span>)</label> <input data-bind="attr: {name: id}" value="0"/> </div> </div> </div> <div id="buttonDiv"> <input type="submit" /> </div> </form> </body> </html>
Therearethreesetsofadditionsinthislisting.ThefirstisimportingtheKOJavaScriptlibraryintothedocumentwithascriptelement.ThesecondadditiontellsKOtousemyviewmodelobject:
ko.applyBindings(cheeseModel);
ThekoobjectisthegatewaytotheKOlibraryfunctionality,andtheapplyBindingsmethodtakestheviewmodelobjectasanargumentandusesit,asthenamesuggests,tofulfillthebindingsdefinedinthedocument;thesearethethirdsetofadditions.YoucanseetheresultofthesebindingsinFigure3-1,andIexplainhowtheyworkinthesectionsthatfollow.
CHAPTER3ADDINGAVIEWMODEL
51
Figure3-1.Creatingcontentfromtheviewmodel
UnderstandingValueBindingsAvaluebindingisarelationshipbetweenapropertyintheviewmodelandanHTMLelement.Thisisthesimplestkindofbindingavailable.HereisanexampleofanHTMLelementthathasavaluebinding:
<div class="grouptitle" data-bind="text: category"></div>
AllKObindingsaredefinedusingthedata-bindattribute.Thisisanexampleofatextbinding,whichhastheeffectofsettingthetextcontentoftheHTMLelementtothevalueofthespecifiedviewmodelproperty,inthiscase,thecategoryproperty.
WhentheapplyBindingsmethodiscalled,KOsearchesforbindingsandinsertstheappropriatedatavalueintothedocument,transformingtheelementlikethis:
<div class="grouptitle" data-bind="text: category">French Cheese</div>
TipIlikehavingtheKOdatabindingsdefinedintheelementswheretheywillbeapplied,butsomepeopledon’tlikethisapproach.ThereisasimplelibraryavailablethatsupportsunobtrusiveKOdatabindings,meaningthatthebindingsaresetupusingjQueryinthescriptelement.Youcangetthecodeandseeanexampleathttps://gist.github.com/1006808.
CHAPTER3ADDINGAVIEWMODEL
52
TheotherbindingIusedinthisexamplewasattr,whichsetsthevalueofanelementattributetoapropertyfromthemodel.Hereisanexampleofanattrbindingfromthelisting:
<input data-bind="attr: {name: id}" value="0"/>
ThisbindingspecifiesthatKOshouldinsertthevalueoftheidpropertyforthenameattribute,whichproducesthefollowingresultwhenthebindingsareapplied:
<input data-bind="attr: {name: id}" value="0" name="camembert">
KOvaluebindingsdon’tsupportanyformattingorcombiningofvalues.Infact,valuebindingsjustinsertasinglevalueintothedocument,andthatmeansthatextraelementsareoftenneededastargetsforvaluebindings.Youcanseethisinthelabelelementinthelisting,whereIaddedacoupleofspanelements:
<label data-bind="attr: {for: id}" class="cheesename"> <span data-bind="text: name"></span> $(<span data-bind="text:price"></span>) </label>
Iwantedtoinserttwodatavaluesasthecontentforthelabelelementwithsomesurroundingcharacterstoindicatecurrency.Thewaytogetthedesiredeffectissimpleenough,albeititaddssomecomplexitytotheHTMLstructure.Analternativeistocreatecustombindings,whichIexplaininChapter4.
TipThetextandattrbindingsarethemostuseful,butKOsupportsotherkindsofvaluebindingsaswell:visible,html,css,andstyle.IusethevisiblebindinglaterinthechapterandthecssbindinginChapter4,butyoushouldconsulttheKOdocumentationatknockoutjs.comfordetailsoftheothers.
UnderstandingFlowControlBindingsFlowcontrolbindingsprovidethemeanstousetheviewmodeltocontrolwhichelementsareincludedinthedocument.Inthelisting,Iusedtheforeachbindingtoenumeratetheitemsviewmodelproperty.Theforeachbindingisusedonviewmodelpropertiesthatarearraysandduplicatesthesetofchildelementsforeachiteminthearray:
<div data-bind="foreach: items"> ... </div>
Valuebindingsonthechildelementscanrefertothepropertiesoftheindividualarrayitems,whichishowIamabletospecifytheidpropertyfortheattrbindingontheinputelement:KOknowswhicharrayitemisbeingprocessedandinsertstheappropriatevaluefromthatitem.
CHAPTER3ADDINGAVIEWMODEL
53
TipInadditiontotheforeachbinding,KOalsosupportstheif,ifnot,andwithbindings,whichallowcontenttobeselectivelyincludedinorexcludedfromadocument.Idescribetheifandifnotbindingslaterinthischapter,butyoushouldconsulttheKOdocumentationatknockoutjs.comforfulldetails.
TakingAdvantageoftheViewModelNowthatIhavethebasicstructureoftheapplicationinplace,IcanusetheviewmodelandKOtodomore.Iwillstartwithsomebasicfeatureandthenstepthingsuptoshowyousomemoreadvancedtechniques.
AddingMoreProductstotheViewModelThefirstbenefitthataviewmodelbringsistheabilitytomakechangesmorequicklyandwithfewererrorsthanwouldotherwisebepossible.Thesimplestdemonstrationofthisistoaddmoreproductstothecheeseshopcatalog.Listing3-4showsthechangesrequiredtoaddcheesesfromothercountries.
Listing3-4.AddingtotheViewModel
<!DOCTYPE html> <html> <head> <title>CheeseLux</title> <link rel="stylesheet" type="text/css" href="styles.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script src="jquery-ui-1.8.16.custom.js" type="text/javascript"></script> <script src='knockout-2.0.0.js' type='text/javascript'></script> <link rel="stylesheet" type="text/css" href="jquery-ui-1.8.16.custom.css"/> <noscript> <meta http-equiv="refresh" content="0; noscript.html"/> </noscript> <script> var cheeseModel = { products: [ {category: "British Cheese", items : [ {id: "stilton", name: "Stilton", price: 9}, {id: "stinkingbishop", name: "Stinking Bishop", price: 17}, {id: "cheddar", name: "Cheddar", price: 17}]}, {category: "French Cheese", items: [ {id: "camembert", name: "Camembert", price: 18}, {id: "tomme", name: "Tomme de Savoie", price: 19}, {id: "morbier", name: "Morbier", price: 9}]}, {category: "Italian Cheese", items: [ {id: "gorgonzola", name: "Gorgonzola", price: 8}, {id: "fontina", name: "Fontina", price: 11},
CHAPTER3ADDINGAVIEWMODEL
54
{id: "parmesan", name: "Parmesan", price: 16}]}] }; $(document).ready(function() { $('#buttonDiv input:submit').button().css("font-family", "Yanone"); ko.applyBindings(cheeseModel); }); </script> </head> <body> <div id="logobar"> <img src="cheeselux.png"> <span id="tagline">Gourmet European Cheese</span> </div> <form action="/shipping" method="post"> <div data-bind="foreach: products"> <div class="cheesegroup"> <div class="grouptitle" data-bind="text: category"></div> <div data-bind="foreach: items"> <div class="groupcontent"> <label data-bind="attr: {for: id}" class="cheesename"> <span data-bind="text: name"> </span> $(<span data-bind="text:price"></span>)</label> <input data-bind="attr: {name: id}" value="0"/> </div> </div> </div> </div> <div id="buttonDiv"> <input type="submit" /> </div> </form> </body> </html>
Thebiggestchangewastotheviewmodelitself.Ichangedthestructureofthedataobjectsothateachcategoryofproductsisanelementinanarrayassignedtotheproductsproperty(and,ofcourse,Iaddedtwonewcategories).IntermsoftheHTMLcontent,Ijusthadtoaddaforeachflowcontrolbindingsothattheelementscontainedwithinareduplicatedforeachcategory.
CHAPTER3ADDINGAVIEWMODEL
55
TipTheresultoftheseadditionsisalong,thinHTMLdocument.Thisisnotanidealwayofdisplayingdata,butasIsaidinChapter1,thisisabookaboutadvancedprogrammingandnotabookaboutdesign.Therearelotsofwaystopresentthisdatamoreusefully,andIsuggeststartingbylookingatthetabswidgetsofferedbyUItoolkitssuchasjQueryUIorjQueryTools.
CreatingObservableDataItemsInthepreviousexample,IusedKOlikeasimpletemplateengine;Itookthevaluesfromtheviewmodelandusedthemtogenerateasetofelements.Ilikeusingtemplateenginesbecausetheysimplifymarkupandreduceerrors.Butabiggerbenefitofviewmodelscomeswhenyoucreateobservabledataitems.Putsimply,anobservabledataitemisapropertyintheviewmodelthat,whenupdated,causesalloftheHTMLelementsthathavevaluebindingstothatpropertytoupdateaswell.Listing3-5showshowtocreateanduseanobservabledataitem.
Listing3-5.CreatingObservableDataItems
<!DOCTYPE html> <html> <head> <title>CheeseLux</title> <link rel="stylesheet" type="text/css" href="styles.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script src="jquery-ui-1.8.16.custom.js" type="text/javascript"></script> <script src='knockout-2.0.0.js' type='text/javascript'></script> <link rel="stylesheet" type="text/css" href="jquery-ui-1.8.16.custom.css"/> <noscript> <meta http-equiv="refresh" content="0; noscript.html"/> </noscript> <script> var cheeseModel = { products: [ {category: "British Cheese", items : [ {id: "stilton", name: "Stilton", price: 9}, {id: "stinkingbishop", name: "Stinking Bishop", price: 17}, {id: "cheddar", name: "Cheddar", price: 17}]}, {category: "French Cheese", items: [ {id: "camembert", name: "Camembert", price: 18}, {id: "tomme", name: "Tomme de Savoie", price: 19}, {id: "morbier", name: "Morbier", price: 9}]}, {category: "Italian Cheese", items: [ {id: "gorgonzola", name: "Gorgonzola", price: 8}, {id: "fontina", name: "Fontina", price: 11}, {id: "parmesan", name: "Parmesan", price: 16}]}] };
CHAPTER3ADDINGAVIEWMODEL
56
function mapProducts(func) { $.each(cheeseModel.products, function(catIndex, outerItem) { $.each(outerItem.items, function(itemIndex, innerItem) { func(innerItem); }); }); } $(document).ready(function() { $('#buttonDiv input').button().css("font-family", "Yanone"); mapProducts(function(item) { item.price = ko.observable(item.price); }); ko.applyBindings(cheeseModel); $('#discount').click(function() { mapProducts(function(item) { item.price(item.price() - 2); }); }); }); </script> </head> <body> <div id="logobar"> <img src="cheeselux.png"> <span id="tagline">Gourmet European Cheese</span> </div> <form action="/shipping" method="post"> <div id="buttonDiv"> <input id="discount" type="button" value="Apply Discount" /> </div> <div data-bind="foreach: products"> <div class="cheesegroup"> <div class="grouptitle" data-bind="text: category"></div> <div data-bind="foreach: items"> <div class="groupcontent"> <label data-bind="attr: {for: id}" class="cheesename"> <span data-bind="text: name"> </span> $(<span data-bind="text:price"></span>)</label> <input data-bind="attr: {name: id}" value="0"/> </div> </div> </div> </div>
CHAPTER3ADDINGAVIEWMODEL
57
<div id="buttonDiv"> <input type="submit" /> </div> </form> </body> </html>
ThemapProductsfunctionisasimpleutilitythatallowsmetoapplyafunctiontoeachindividualcheeseproduct.ThisfunctionusesthejQueryeachmethod,whichexecutesafunctionforeveryiteminanarray.Byusingtheeachfunctiontwice,Icanreachtheinnerarrayofcheeseproductsineachcategory.
Inthisexample,Ihavetransformedthepricepropertyforeachcheeseproductintoanobservabledataitem,asfollows:
mapProducts(function(item) { item.price = ko.observable(item.price); });
Theko.observablemethodtakestheinitialvalueforthedataitemasitsargumentandsetsuptheplumbingthatisrequiredtodisseminateupdatestothebindingsinthedocument.Idon’thavetomakeanychangestothebindingsthemselves;KOtakescareofallthedetailsforme.
Allthatremainsistosetupasituationthatwillcauseachangetooccur.Ihavedonethisbyaddinganewbuttontothedocumentanddefiningahandlerfortheclickeventasfollows:
$('#discount').click(function() { mapProducts(function(item) { item.price(item.price() - 2); }); });
Whenthebuttonisclicked,IusethemapProductsfunctiontochangethevalueofthepricepropertyforeachcheeseobjectintheviewmodel.Sincethisisanobservabledataitem,thenewvaluewillbepushedouttothevaluebindingsandcausethedocumenttobeupdated.
NoticetheslightlyoddsyntaxIusewhenalteringthevalue.TheoriginalpricepropertywasaJavaScriptNumber,whichmeantIcouldchangethevaluelikethis:
item.price -= 2;
Buttheko.observablemethodtransformsthepropertyintoaJavaScriptfunctioninordertoworkwithsomeolderversionsofInternetExplorer.Thismeansyoureadthevalueofanobservabledataitembycallingthefunction(inotherwords,bycallingitem.price())andupdatethevaluebypassinganargumenttothefunction(inotherwords,bycallingitem.price(newValue)).Thiscantakealittlewhiletogetusedto,andIstillforgettodothis.
Figure3-2showstheeffectoftheobservabledataitem.WhentheApplyDiscountbuttonisclicked,allofthepricesdisplayedtotheuserareupdated,asFigure3-2shows.
CHAPTER3ADDINGAVIEWMODEL
58
Figure3-2.Usinganobservabledataitem
Thepowerandflexibilityofanobservabledataitemissignificant;itcreatesanapplicationwherechangesfromtheviewmode,irrespectiveofhowtheyarise,causethedatabindingsinthedocumenttobeupdatedimmediately.Asyou’llseeintherestofthechapter,ImakealotofuseofobservabledataitemsasIaddmorecomplexfeaturestotheexamplewebapp.
CreatingBidirectionalBindingsAbidirectionalbindingisatwo-wayrelationshipbetweenaformelementandanobservabledataitem.Whentheviewmodelisupdated,soisthevalueshownintheelement,justasforaregularobservable.Inaddition,changingtheelementvaluecausesanupdatetogointheotherdirection:thepropertyintheviewmodelisupdated.So,forexample,ifIuseabidirectionalbindingforaninputelement,KOensuresthatthemodelisupdatedwhentheuserentersanewvalue.Byusingbidirectionalrelationshipsbetweenmultipleelementsandthesamemodelproperty,youcaneasilykeepacomplexwebappsynchronizedandconsistent.
Todemonstrateabidirectionalbinding,IwilladdaSpecialOfferssectiontothecheeseshop.Thisallowsmetopicksomeproductsfromthefullsection,applyadiscount,and,ideally,drawthecustomer’sattentiontoaproductthattheymightnototherwiseconsider.
Listing3-6containsthechangestothewebapptosupportthespecialoffers.Tosetupabidirectionalbinding,Iamgoingtodotwootherinterestingthings:extendtheviewmodelanduseKOtemplatestogenerateelements.I’llexplainallthreechangesinthesectionsthatfollowthelisting.
CHAPTER3ADDINGAVIEWMODEL
59
Listing3-6.UsingLiveBindingstoCreateSpecialOffers
<!DOCTYPE html> <html> <head> <title>CheeseLux</title> <link rel="stylesheet" type="text/css" href="styles.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script src="jquery-ui-1.8.16.custom.js" type="text/javascript"></script> <script src='knockout-2.0.0.js' type='text/javascript'></script> <link rel="stylesheet" type="text/css" href="jquery-ui-1.8.16.custom.css"/> <noscript> <meta http-equiv="refresh" content="0; noscript.html"/> </noscript> <script> var cheeseModel = { products: [ {category: "British Cheese", items : [ {id: "stilton", name: "Stilton", price: 9}, {id: "stinkingbishop", name: "Stinking Bishop", price: 17}, {id: "cheddar", name: "Cheddar", price: 17}]}, {category: "French Cheese", items: [ {id: "camembert", name: "Camembert", price: 18}, {id: "tomme", name: "Tomme de Savoie", price: 19}, {id: "morbier", name: "Morbier", price: 9}]}, {category: "Italian Cheese", items: [ {id: "gorgonzola", name: "Gorgonzola", price: 8}, {id: "fontina", name: "Fontina", price: 11}, {id: "parmesan", name: "Parmesan", price: 16}]}] }; function mapProducts(func) { $.each(cheeseModel.products, function(catIndex, outerItem) { $.each(outerItem.items, function(itemIndex, innerItem) { func(innerItem); }); }); } $(document).ready(function() { $('#buttonDiv input:submit').button().css("font-family", "Yanone"); cheeseModel.specials = { category: "Special Offers", discount: 3, ids: ["stilton", "tomme"], items: [] }; mapProducts(function(item) { if ($.inArray(item.id, cheeseModel.specials.ids) > -1) {
www.allitebooks.com
CHAPTER3ADDINGAVIEWMODEL
60
item.price -= cheeseModel.specials.discount; cheeseModel.specials.items.push(item); } item.quantity = ko.observable(0); }); ko.applyBindings(cheeseModel); }); </script> <script id="categoryTmpl" type="text/html"> <div class="cheesegroup"> <div class="grouptitle" data-bind="text: category"></div> <div data-bind="foreach: items"> <div class="groupcontent"> <label data-bind="attr: {for: id}" class="cheesename"> <span data-bind="text: name"> </span> $(<span data-bind="text:price"></span>)</label> <input data-bind="attr: {name: id}, value: quantity"/> </div> </div> </div> </script> </head> <body> <div id="logobar"> <img src="cheeselux.png"> <span id="tagline">Gourmet European Cheese</span> </div> <div data-bind="template: {name: 'categoryTmpl', data: specials}"></div> <form action="/shipping" method="post"> <div data-bind="template: {name: 'categoryTmpl', foreach: products}"></div> <div id="buttonDiv"> <input type="submit" /> </div> </form> </body> </html>
ExtendingtheViewModelJavaScript’sloosetypinganddynamicnaturemakesitidealforcreatingflexibleandadaptableviewmodels.Ilikebeingabletotaketheinitialdataandreshapeittocreatesomethingthatismorecloselytailoredtotheneedsofthewebapp,inthiscase,toaddsupportforspecialoffers.Tostartwith,Iaddapropertycalledspecialstotheviewmodel,definingitasanobjectthathascategoryanditemspropertiesliketherestofthemodelbutwithsomeusefuladditions:
CHAPTER3ADDINGAVIEWMODEL
61
cheeseModel.specials = { category: "Special Offers", discount: 3, ids: ["stilton", "tomme"], items: [] };
ThediscountpropertyspecifiesthedollardiscountIwanttoapplytothespecialoffers,andtheidspropertycontainsanarrayoftheIDsofproductsthatwillbespecialoffers.
Thespecials.itemsarrayisemptywhenIfirstdefineit.Topopulatethearray,Ienumeratetheproductsarraytofindthoseproductsthatareinthespecials.idsarray,likethis:
mapProducts(function(item) { if ($.inArray(item.id, cheeseModel.specials.ids) > -1) { item.price -= cheeseModel.specials.discount; cheeseModel.specials.items.push(item); } item.quantity = ko.observable(0); });
IusetheinArraymethodtodeterminewhetherthecurrentitemintheiterationisoneofthosethatwillbeincludedasaspecialoffer.TheinArraymethodisanotherjQueryutility,anditreturnstheindexofanitemifitiscontainedwithinanarrayand-1ifitisnot.ThisisaquickandeasywayformetochecktoseewhetherthecurrentitemisonethatIaminterestedinasaspecialoffer.
Ifanitemisonthespecialslist,thenIreducethevalueofthepricepropertybythediscountamountandusethepushmethodtoinserttheitemintothespecials.itemsarray.
item.price -= cheeseModel.specials.discount; cheeseModel.specials.items.push(item);
AfterIhaveiteratedthroughtheitemsintheviewmodel,thespecials.itemarraycontainsacompletesetoftheproductsthataretobediscounted,and,alongtheway,Ihavereducedeachoftheirprices.
Inthisexample,Ihavemadethequantitypropertyintoanobservabledataitem:
item.quantity = ko.observable(0);
ThisisimportantbecauseIamgoingtodisplaymultipleinputelementsforthespecialoffers:oneelementintheoriginalcheesecategoryandanotherinanewSpecial OfferscategorythatIexplaininthenextsection.Byusinganobservabledataitemandbidirectionalbindingsontheinputelements,Icaneasilymakesurethatthequantitiesenteredforacheeseareconsistentlydisplayed,irrespectiveofwhichinputelementisused.
GeneratingtheContentAllthatremainsnowistogeneratethecontentfromtheviewmodel.Iwanttogeneratethesamesetofelementsforthespecialoffersasfortheregularcategories,soIhaveusedtheKOtemplatefeature,whichallowsmetogeneratethesamesetofelementsatmultiplepointsinthedocument.Hereisthetemplatefromthelisting:
<script id="categoryTmpl" type="text/html"> <div class="cheesegroup"> <div class="grouptitle" data-bind="text: category"></div>
CHAPTER3ADDINGAVIEWMODEL
62
<div data-bind="foreach: items"> <div class="groupcontent"> <label data-bind="attr: {for: id}" class="cheesename"> <span data-bind="text: name"> </span> $(<span data-bind="text:price"></span>)</label> <input data-bind="attr: {name: id}, value: quantity"/> </div> </div> </div> </script>
Thetemplateiscontainedinascriptelement.Thetypeattributeissettotext/html,whichpreventsthebrowserfromexecutingthecontentasJavaScript.MostofthebindingsinthetemplatearethesametextandattrbindingsIusedinthepreviousexample.Theimportantadditionistotheinputelement,asfollows:
<input data-bind="attr: {name: id}, value: quantity"/>
Thedata-bindattributeforthiselementdefinestwobindings,separatedbyacomma.Thefirstisaregularattrbinding,butthesecondisavaluebinding,whichisoneofthebidirectionalbindingsthatKOdefines.Idon’thavetotakeanyactiontomakethevaluebindingbidirectional;KOtakescareofitautomatically.Inthislisting,Icreateatwo-waybindingtothequantityobservabledataitem.
Igeneratecontentfromthetemplateusingthetemplatebinding.Whenusingatemplate,KOduplicatestheelementsthatitcontainsandinsertsthemaschildrenoftheelementthathasthetemplatebinding.TherearetwopointsinthedocumentwhereIusethetemplate,andtheyareslightlydifferent:
<div data-bind="template: {name: 'categoryTmpl', data: specials}"></div> <form action="/shipping" method="post"> <div data-bind="template: {name: 'categoryTmpl', foreach: products}"></div> <div id="buttonDiv"> <input type="submit" /> </div> </form>
Whenusingthetemplatebinding,thenamepropertyspecifiestheidattributevalueofthetemplateelement.Ifyouwanttogenerateonlyonesetofelements,thenyoucanusethedatapropertytospecifywhichviewmodelpropertywillbeused.Iuseddatatospecifythespecialspropertyinthelisting,whichcreatesasectionofcontentformyspecial-offerproducts.
TipYoumustremembertoenclosetheidofthetemplateelementinquotes.Ifyoudon’t,KOwillfailquietlywithoutgeneratingelementsfromthetemplate.
Youcanusetheforeachpropertyifyouwanttogenerateasetofelementsforeachiteminanarray.Ihavedonethisfortheregularproductcategoriesbyspecifyingtheproductsarray.Inthisway,Icanapplythetemplatetoeachelementinanarraytogeneratecontentconsistently.
CHAPTER3ADDINGAVIEWMODEL
63
TipNoticethatthespecial-offerelementsareinsertedoutsidetheformelement.Theinputelementsforthespecial-offerproductswillhavethesamenameattributevalueasthecorrespondinginputelementintheregularproductcategory.Byinsertingthespecial-offerelementsoutsidetheform,Ipreventduplicateentriesfrombeingsenttotheserverwhentheformissubmitted.
ReviewingtheResultNowthatIhaveexplainedeachofthechangesImadetosetupthebidirectionalbindings,itistimetolookattheresults,whichyoucanseeinFigure3-3.
Figure3-3.Theresultofextendingtheviewmodel,creatingalivebinding,andusingtemplates
Thisisgooddemonstrationofhowusingaviewmodelcansavetimeandreduceerrors.Ihaveapplieda$3discounttotheSpecialOfferproducts,whichIdidbyalteringthevalueofthepricepropertyintheviewmodel.Eventhoughthepricepropertyisnotobservable,thecombinationoftheviewmodelandthetemplateensuresthatthecorrectpricesaredisplayedthroughoutthedocumentwhentheelementsareinitiallygenerated.(YoucanseethatbothStiltonlistingsarepricedat$6,ratherthanthe$9originallyspecifiedbytheviewmodel.)
Thebidirectionalbindingisthemostinterestingandusefulfeatureinthisexample.Alloftheinputelementshavebidirectionalbindingswiththeircorrespondingquantityproperty,andsincetherearetwoinputelementsinthedocumentforeachoftheSpecialOffercheeses,enteringavalueintoonewill
CHAPTER3ADDINGAVIEWMODEL
64
immediatelycausethatvaluetobedisplayedintheother;youcanseethishashappenedfortheStiltonproductinthefigure(butitisaneffectthatisbestexperiencedbyloadingtheexampleinthebrowser).
So,withverylittleeffort,Ihavebeenabletoenhancetheviewmodelandusethoseenhancementstokeepaformconsistentandresponsive,whileaddingnewfeaturestotheapplication.Inthenextsection,I’llbuildontheseenhancementstocreateadynamicbasket,showingyousomeoftheotherbenefitsthatcanarisefromaviewmodel.
TipIfyousubmitthisformtotheserver,theordersummarywillshowtheoriginal,undiscountedprice.Thisis,ofcourse,becauseIappliedthediscountonlyinthebrowser.Inarealapplication,theserverwouldalsoneedtoknowaboutthespecialoffers,butIamgoingtoskipoverthis,sincethisbookfocusesonclient-sidedevelopment.
AddingaDynamicBasketNowthatIhaveexplainedanddemonstratedhowchangesaredetectedandpropagatedwithvalueandbidirectionalbindings,IcancompletetheexamplesothatallofthefunctionalitypresentinChapter2isavailabletotheuser.ThismeansIneedtoimplementadynamicshoppingbasket,whichIdointhesectionsthatfollow.
AddingSubtotalsWithaviewmodel,newfeaturescanbeaddedquickly.Thechangestoaddper-itemsubtotalsaresurprisinglysimple,althoughIneedtousesomeadditionalKOfeatures.First,Ineedtoenhancetheviewmodel.Listing3-7highlightsthechangesinthescriptelementwithinthecalltothemapProductfunction.
Listing3-7.ExtendingtheViewModeltoSupportSubtotals
... mapProducts(function(item) { if ($.inArray(item.id, cheeseModel.specials.ids) > -1) { item.price -= cheeseModel.specials.discount; cheeseModel.specials.items.push(item); } item.quantity = ko.observable(0); item.subtotal = ko.computed(function() { return this.quantity() * this.price; }, item); }); ...
Ihavecreatedwhatisknownasacomputedobservabledataitemforthesubtotalproperty.Thisislikearegularobservableitem,exceptthatthevalueisproducedbyafunction,whichispassedasthefirstargumenttotheko.computedmethod.Thesecondmethodisusedasthevalueofthethisvariablewhenthefunctionisexecuted;Ihavesetthistotheitemloopvariable.
CHAPTER3ADDINGAVIEWMODEL
65
ThenicethingaboutthisfeatureisthatKOmanagesallofthedependencies,suchthatwhenmycomputedobservablefunctionreliesonaregularobservabledataitem,achangetotheregularitemautomaticallytriggersanupdateinthecomputedvalue.I’llusethisbehaviortomanagetheoveralltotallaterinthischapter.
Next,Ineedtoaddsomeelementswithbindingstothetemplate,asshowninListing3-8.
Listing3-8.AddingElementstotheTemplatetoSupportSubtotals
<script id="categoryTmpl" type="text/html"> <div class="cheesegroup"> <div class="grouptitle" data-bind="text: category"></div> <div data-bind="foreach: items"> <div class="groupcontent"> <label data-bind="attr: {for: id}" class="cheesename"> <span data-bind="text: name"> </span> $(<span data-bind="text:price"></span>)</label> <input data-bind="attr: {name: id}, value: quantity"/> <span data-bind="visible: subtotal" class="subtotal"> ($<span data-bind="text: subtotal"></span>) </span> </div> </div> </div> </script>
TheinnerspanelementusesatextdatabindingtodisplaythevalueofthesubtotalpropertyIcreatedamomentago.Tomakethingsmoreinteresting,theouterspanelementusesanotherKObinding;thisoneisvisible.Forthisbinding,thechildelementsarehiddenwhenthespecifiedpropertyisfalse-like(zero,null,undefined,orfalse).Fortruth-likevalues(1,true,oranon-nullobjectorarray),thechildelementsaredisplayed.Ihavespecifiedthesubtotalvalueforthevisiblebinding,andthislittletrickmeansthatIwilldisplayasubtotalonlywhentheuserentersanonzerovalueintotheinputelement.YoucanseetheresultinFigure3-4.
Figure3-4.Selectivelydisplayingsubtotals
CHAPTER3ADDINGAVIEWMODEL
66
Youcanseehoweasyandquickitistocreatenewfeaturesoncethebasicstructurehasbeenaddedtotheapplication.Somenewmarkupandalittlescriptgoalongway.And,asabonus,thesubtotalfeatureworksseamlesslywiththespecialoffers;sincebothoperateontheviewmodel,thediscountsappliedforthespecialoffersareseamlessly(andeffortlessly)incorporatedintothesubtotals.
AddingtheBasketLineItemsandTotalIdon’twanttousetheinlinebasketapproachthatItookinChapter2becausesomeoftheproductsareshowntwiceandthedocumentistoolongtomaketheuserscrolldowntoseethetotalcostoftheirselection.Instead,Iamgoingtocreateaseparatesetofbasketelementsthatwillbedisplayedalongsidetheproducts.YoucanwhatIhavedoneinFigure3-5.
Figure3-5.Addingaseparatebasket
Listing3-9showsthechangesrequiredtosupportthebasket.
Listing3-9.AddingtheBasketElementsandLineItems
<!DOCTYPE html> <html> <head> <title>CheeseLux</title> <link rel="stylesheet" type="text/css" href="styles.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script src="jquery-ui-1.8.16.custom.js" type="text/javascript"></script> <script src='knockout-2.0.0.js' type='text/javascript'></script>
CHAPTER3ADDINGAVIEWMODEL
67
<link rel="stylesheet" type="text/css" href="jquery-ui-1.8.16.custom.css"/> <noscript> <meta http-equiv="refresh" content="0; noscript.html"/> </noscript> <script> var cheeseModel = { products: [ {category: "British Cheese", items : [ {id: "stilton", name: "Stilton", price: 9}, {id: "stinkingbishop", name: "Stinking Bishop", price: 17}, {id: "cheddar", name: "Cheddar", price: 17}]}, {category: "French Cheese", items: [ {id: "camembert", name: "Camembert", price: 18}, {id: "tomme", name: "Tomme de Savoie", price: 19}, {id: "morbier", name: "Morbier", price: 9}]}, {category: "Italian Cheese", items: [ {id: "gorgonzola", name: "Gorgonzola", price: 8}, {id: "fontina", name: "Fontina", price: 11}, {id: "parmesan", name: "Parmesan", price: 16}]}] }; function mapProducts(func) { $.each(cheeseModel.products, function(catIndex, outerItem) { $.each(outerItem.items, function(itemIndex, innerItem) { func(innerItem); }); }); } $(document).ready(function() { $('#buttonDiv input:submit').button().css("font-family", "Yanone"); cheeseModel.specials = { category: "Special Offers", discount: 3, ids: ["stilton", "tomme"], items: [] }; mapProducts(function(item) { if ($.inArray(item.id, cheeseModel.specials.ids) > -1) { item.price -= cheeseModel.specials.discount; cheeseModel.specials.items.push(item); } item.quantity = ko.observable(0); item.subtotal = ko.computed(function() { return this.quantity() * this.price; }, item); }); cheeseModel.total = ko.computed(function() { var total = 0;
CHAPTER3ADDINGAVIEWMODEL
68
mapProducts(function(elem) { total += elem.subtotal(); }); return total; }); ko.applyBindings(cheeseModel); $('div.cheesegroup').not("#basket").css("width", "50%"); $('#basketTable a') .button({icons: {primary: "ui-icon-closethick"}, text: false}) .click(function() { var targetId = $(this).closest('tr').attr("data-prodId"); mapProducts(function(item) { if (item.id == targetId) { item.quantity(0); } }); }) }); </script> <script id="categoryTmpl" type="text/html"> <div class="cheesegroup"> <div class="grouptitle" data-bind="text: category"></div> <div data-bind="foreach: items"> <div class="groupcontent"> <label data-bind="attr: {for: id}" class="cheesename"> <span data-bind="text: name"> </span> $(<span data-bind="text:price"></span>)</label> <input data-bind="attr: {name: id}, value: quantity"/> <span data-bind="visible: subtotal" class="subtotal"> ($<span data-bind="text: subtotal"></span>) </span> </div> </div> </div> </script> <script id="basketRowTmpl" type="text/html"> <tr data-bind="visible: quantity, attr: {'data-prodId': id}"> <td data-bind="text: name"></td> <td>$<span data-bind="text: subtotal"></span></td> <td><a href="#"></a></td> </tr> </script> </head> <body> <div id="logobar"> <img src="cheeselux.png"> <span id="tagline">Gourmet European Cheese</span>
CHAPTER3ADDINGAVIEWMODEL
69
</div> <div id="basket" class="cheesegroup basket"> <div class="grouptitle">Basket</div> <div class="groupcontent"> <table id="basketTable"> <thead><tr><th>Cheese</th><th>Subtotal</th><th></th></tr></thead> <tbody data-bind="foreach: products"> <!-- ko template: {name: 'basketRowTmpl', foreach: items} --> <!-- /ko --> </tbody> <tfoot> <tr><td class="sumline" colspan=2></td></tr> <tr> <th>Total:</th><td>$<span data-bind="text: total"></span></td> </tr> </tfoot> </table> </div> <div class="cornerplaceholder"></div> <div id="buttonDiv"> <input type="submit" value="Submit Order"/> </div> </div> <div data-bind="template: {name: 'categoryTmpl', data: specials}"></div> <form action="/shipping" method="post"> <div data-bind="template: {name: 'categoryTmpl', foreach: products}"></div> </form> </body> </html>
I’llstepthrougheachcategoryofchangethatImadeandexplaintheeffectithas.AsIdothis,pleasereflectonhowlittlehastochangetoaddthisfeature.Onceagain,aviewmodelandsomebasicapplicationstructurecreateafoundationtowhichnewfeaturescanbequicklyandeasilyadded.
ExtendingtheViewModelThechangetotheviewmodelinthislistingistheadditionofthetotalproperty,whichisacomputedobservablethatsumstheindividualsubtotalvalues:
cheeseModel.total = ko.computed(function() { var total = 0; mapProducts(function(elem) { total += elem.subtotal(); }); return total; });
www.allitebooks.com
CHAPTER3ADDINGAVIEWMODEL
70
AsImentionedpreviously,KOtracksdependenciesbetweenobservabledataitemsautomatically.Anychangetoasubtotalvaluewillcausetotaltoberecalculatedandthenewvaluetobedisplayedinelementsthatareboundtoit.
AddingtheBasketStructureandTemplateTheouterstructureoftheHTMLelementsIaddedtothedocumentisjustaduplicateofacheesecategorytomaintainvisualconsistency.Theheartofthebasketisthetableelement,whichcontainsseveraldatabindings:
<table id="basketTable"> <thead><tr><th>Cheese</th><th>Subtotal</th><th></th></tr></thead> <tbody data-bind="foreach: products"> <!-- ko template: {name: 'basketRowTmpl', foreach: items} --> <!-- /ko --> </tbody> <tfoot> <tr><td class="sumline" colspan=2></td></tr> <tr> <th>Total:</th><td>$<span data-bind="text: total"></span></td> </tr> </tfoot> </table>
ThemostimportantadditionhereistheoddlyformattedHTMLcomments.Thisisknownasacontainerlessbinding,anditallowsmetoapplythetemplate bindingwithoutneedingacontainerelementforthecontentthatwillbeduplicated.AddingrowstoatablefromanestedarrayisaperfectsituationforthistechniquebecauseaddinganelementjustsoIcanapplythebindingwouldcauselayoutproblems.Thecontainerlessbindingiscontainedwithinaregularforeachbinding,butyoucannestthebindingcommentsmuchasyouwouldregularelements.
Theotherbindingisasimpletextvaluebinding,whichdisplaystheoveralltotalforthebasket,usingthecalculatedtotalobservableIcreatedamomentago.Idon’thavetotakeanyactiontomakesurethatthetotalisup-to-date;KOmanagesthechainofdependenciesbetweenthetotal,subtotal,andquantitypropertiesintheviewmodel.
ThetemplatethatIaddedtoproducethetablerowshasfourdatabindings:
<script id="basketRowTmpl" type="text/html"> <tr data-bind="visible: quantity, attr: {'data-prodId': id}"> <td data-bind="text: name"></td> <td>$<span data-bind="text: subtotal"></span></td> <td><a href="#"></a></td> </tr> </script>
Youhaveseenthesetypesofbindingpreviously.Thevisiblebindingonthetrelementensuresthattablerowsarevisibleonlyforthosecheesesforwhichthequantityisn’tzero;thispreventsthebasketfrombeingfilledupwithrowsforproductsthattheuserisn’tinterestedin.
Notetheattrbindingonthetrelement.IhavedefinedacustomattributeusingtheHTML5dataattributefeaturethatembedstheidvalueoftheproductthattherowrepresentsintothetrelement.I’llexplainwhyIdidthisshortly.
CHAPTER3ADDINGAVIEWMODEL
71
Ialsomovedthesubmitbuttonsothatitisunderthebasket,makingiteasierfortheusertosubmittheirorder.ThestylethatIassignedtothebasketelementsusesthefixedvaluefortheCSSpositionproperty,meaningthatthebasketwillalwaysbevisible,evenastheuserscrollsdownthepage.Toaccommodatethebasket,IusedjQuerytoapplyanewvaluefortheCSSwidthpropertydirectlytothecheesecategoryelements(butnotthebasketitself):
$('div.cheesegroup').not("#basket").css("width", "50%");
RemovingItemsfromtheBasketThelastsetofchangesbuildsontheaelementsthatareaddedtoeachtablerowinthebasketRowTmpltemplate:
$('#basketTable a') .button({icons: {primary: "ui-icon-closethick"}, text: false}) .click(function() { var targetId = $(this).closest('tr').attr("data-prodId"); mapProducts(function(item) { if (item.id == targetId) { item.quantity(0); } }); })
IusejQuerytoselectalltheaelementsandusejQueryUItocreatebuttonsfromthem.jQueryUIthemesincludeasetoficons,andtheobjectthatIpasstothejQueryUIbuttonmethodcreatesabuttonthatusesoneoftheseimagesanddisplaysnotext.Thisgivesmeanicesmallbuttonwithacross.
Intheclickfunction,IusejQuerytonavigatefromtheaelementthattriggeredtheclickeventtothefirstancestortrelementusingtheclosestmethod.ThisselectsthetrelementthatcontainsthecustomdataattributeIinsertedinthetemplateearlierandthatIreadusingtheattrmethod:
var targetId = $(this).closest('tr').attr("data-prodId");
Thisstatementletsmedeterminetheidoftheproducttheuserwantstoremovefromthebasket.IthenusethemapProductsfunctiontofindthematchingcheeseobjectandsetthequantitytozero.Sincequantityisanobservabledataitem,KOdisseminatesthenewvalue,whichcausesthesubtotalvaluetoberecalculatedandthevisiblebindingonthecorrespondingtrelementtobereevaluated.Sincethequantityiszero,thetablerowwillbehiddenautomatically.And,sincesubtotalisobservable,thetotalwillalsoberecalculated,andthenewvalueisdisplayedtotheuser.Asyoucansee,itisusefultohaveaviewmodelwherethedependenciesbetweendatavaluesaremanagedseamlessly.Thenetresultisadynamicbasketthatisalwaysconsistentwiththevaluesintheviewmodelandsoalwayspresentsthecorrectinformationtotheuser.
FinishingtheExampleBeforeIfinishthistopic,Ijustwanttotweakacoupleofthings.First,thebasketlooksprettypoorwhennoitemshavebeenselectedbytheuser,asshowninFigure3-6.Toaddressthis,Iwilldisplaysomeplaceholdertextwhenthebasketisempty.
CHAPTER3ADDINGAVIEWMODEL
72
Figure3-6.Theemptybasket
Second,theuserhasnowaytoclearthebasketwithasingleaction,soIwilladdabuttonthatwillresetthequantitiesofalloftheproductstozero.Finally,bymovingthesubmitbuttonoutsidetheformelement,Ihavelosttheabilitytorelyonthedefaultaction.Imustaddaneventhandlersothattheusercansubmittheform.Listing3-10showstheHTMLelementsthatIhaveaddedtosupportthesefeatures.
Listing3-10.AddingElementstoFinishtheExample
... <body> <div id="logobar"> <img src="cheeselux.png"> <span id="tagline">Gourmet European Cheese</span> </div> <div id="basket" class="cheesegroup basket"> <div class="grouptitle">Basket</div> <div class="groupcontent"> <div class="description" data-bind="ifnot: total"> No products selected </div> <table id="basketTable" data-bind="visible: total"> <thead> <tr><th>Cheese</th><th>Subtotal</th><th></th></tr> </thead> <tbody data-bind="template: {name:'basketRowTmpl', foreach: items}"> </tbody> <tfoot> <tr><td class="sumline" colspan=2></td></tr> <tr> <th>Total:</th><td>$<span data-bind="text: total"></span></td> </tr> </tfoot> </table>
CHAPTER3ADDINGAVIEWMODEL
73
</div> <div class="cornerplaceholder"></div> <div id="buttonDiv"> <input type="submit" value="Submit Order"/> <input type="reset" value="Reset"/> </div> </div> <div data-bind="template: {name: 'categoryTmpl', data: specials}"></div> <form action="/shipping" method="post"> <div data-bind="template: {name: 'categoryTmpl', foreach: products}"></div> </form> </body> ...
Ihaveusedtheifnotbindingonthedivelementthatcontainstheplaceholdertext.KOdefinesapairofbindings,ifandifnot,thataresimilartothevisiblebindingbutthataddandremoveelementstotheDOM,ratherthansimplyhidingthemfromview.Theifbindingshowsitselementswhenthespecifiedviewmodelpropertyistrue-likeandhidesthemifitisfalse-like.Theifnotbindingisinverted;itshowsitselementswhenthepropertyistrue-like.
Byspecifyingtheifnotbindingwiththetotalproperty,Iensurethatmyplaceholderelementisshownonlywhentotaliszero,whichhappenswhenallofthesubtotalvaluesarezero,whichhappenswhenallofthequantityvaluesarezero.Onceagain,IamrelyingonKO’sabilitytomanagethedependenciesbetweenobservabledataitemstogettheeffectIrequire.
Iwantthetableelementtobeinvisiblewhentheplaceholderisshowing,soIhaveusedthevisiblebinding.
Icouldhaveusedtheifbinding,butdoingsowouldhavecausedaproblem.Thebindingtothetotalpropertymeansthatthetablewillnotbeshowninitially,andwiththeifbinding,theelementwouldhavebeenremovedfromtheDOM.ThismeansthattheaelementswouldalsonotbepresentwhenItrytoselectthemtosetuptheremovebuttons.ThevisiblebindingleavestheelementsinthedocumentforjQuerytofindbuthidesthemfromtheuser.
YoumightwonderwhyIdon’tmovethejQueryselectionsothatitisperformedbeforethecalltoko.applyBindings.ThereasonisthattheaelementsIwanttoselectwithjQueryarecontainedintheKOtemplate,whichisn’tusedtocreateelementsuntiltheapplyBindingsmethodiscalled.Thereisnogoodwayaroundthis,andsothevisiblebindingisrequired.
TheonlyotherchangetotheHTMLelementsistheadditionofaninputelementwhosetypeisreset.Thiselementisoutsideoftheformelement,soIwillhavetohandletheclickeventtoremoveitemsfromthebasket.Listing3-11showsthecorrespondingchangestothescriptelement.
Listing3-11.EnhancingtheScripttoFinishtheExample
... <script> // ...code removed for brevity... // $(document).ready(function() { $('#buttonDiv input').button().css("font-family", "Yanone") .click(function() {
CHAPTER3ADDINGAVIEWMODEL
74
if (this.type == "submit") { $('form').submit(); } else if (this.type == "reset") { mapProducts(function(item) { item.quantity(0); }) } }); // ...code removed for brevity... // }); </script>
Ihaveshownonlypartofthescriptinthelistingbecausethechangesarequiteminor.NoticehowIamabletousejQueryandplainJavaScripttomanipulatetheviewmodel.Idon’tneedtoaddanycodeforthebasketplaceholder,sinceitwillbemanagedbyKO.Infact,allIneeddoiswidenthejQueryselectionsothatIcreatejQueryUIbuttonwidgetsforboththesubmitandresetinputelementsandaddaclickhandlerfunction.InthefunctionIsubmittheformorchangethequantityvaluestozerodependingonwhichbuttontheuserclicks.YoucanseetheplaceholderforthebasketinFigure3-7.
Figure3-7.Usingaplaceholderwhenthebasketisempty
Youwillhavetoloadtheexamplesinabrowserifyouwanttoseehowthebuttonswork.TheeasiestwaytodothisistousethesourcecodedownloadthataccompaniesthisbookandthatisavailablewithoutchargeatApress.com.
SummaryInthischapter,Ishowedyouhowtoembracethekindofdesignphilosophythatyoumayhavepreviouslyusedindesktoporserver-sidedevelopment,oratleastasmuchofthatphilosophyasmakessenseforyourproject.
Byaddingaviewmodeltomywebapp,Iwasabletocreateamuchmoredynamicversionoftheexampleapplication;it’sonethatismorescalable,easiertotestandmaintain,andmakeschangesandenhancementabreeze.
YoumayhavenoticedthattheshapeofastructuredwebapplicationchangessothatthereisalotmorecoderelativetotheamountofHTMLmarkup.Thisisagoodthing,becauseitputsthecomplexity
CHAPTER3ADDINGAVIEWMODEL
75
oftheapplicationwhereyoucanbetterunderstand,test,andmodifyit.TheHTMLbecomesaseriesofviewsortemplatesforyourdata,drivenfromtheviewmodelviathestructurelibrary.Icannotemphasizethebenefitsofembracingthisapproachenough;itreallydoessetthefoundationforprofessional-levelwebappsandwillmakecreating,enhancing,andmaintainingyourprojectssimpler,easier,andmoreenjoyable.
C H A P T E R 4
77
Using URL Routing
Inthischapter,Iwillshowyouhowtoaddanotherserver-sideconcepttoyourwebapp:URLrouting.TheideabehindURLroutingisverysimple:weassociateJavaScriptfunctionswithinternalURLs.AninternalURLisonethatisrelativetothecurrentdocumentandcontainsahashfragment.Infact,theyareusuallyexpressedasjustthehashfragmentonitsown,suchas#summary.
Undernormalcircumstances,whentheuserclicksalinkthatpointstoaninternalURL,thebrowserwillseewhetherthereisanelementinthedocumentthathasanidattributevaluethatmatchesthefragmentand,ifthereis,scrolltomakethatelementvisible.
WhenweuseURLrouting,werespondtothesenavigationchangesbyexecutingJavaScriptfunctions.Thesefunctionscanshowandhideelements,changetheviewmodel,orperformothertasksyoumightneedinyourapplication.Usingthisapproach,wecanprovidetheuserwithamechanismtonavigatethroughourapplication.
Wecould,ofcourse,useevents.Theproblemis,onceagain,scale.Handlingeventstriggeredbyelementsisaperfectlyworkableandacceptableapproachforsmallandsimplewebapplications.Forlargerandmorecomplexapps,weneedsomethingbetter,andURLroutingprovidesaniceapproachthatissimple,iselegant,andscaleswell.Addingnewfunctionalareastothewebapp,andprovidinguserswiththemeanstousethem,becomesincrediblysimpleandrobustwhenweuseURLsasthenavigationmechanism.
BuildingaSimpleRoutedWebApplicationThebestwaytoexplainURLroutingiswithasimpleexample.Listing4-1showsabasicwebapplicationthatreliesonrouting.
Listing4-1.ASimpleRoutedWebApplication
<!DOCTYPE html> <html> <head> <title>Routing Example</title> <link rel="stylesheet" type="text/css" href="jquery-ui-1.8.16.custom.css"/> <link rel="stylesheet" type="text/css" href="styles.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script src="jquery-ui-1.8.16.custom.js" type="text/javascript"></script> <script src='knockout-2.0.0.js' type='text/javascript'></script> <script src='utils.js' type='text/javascript'></script> <script src='signals.js' type='text/javascript'></script> <script src='crossroads.js' type='text/javascript'></script> <script src='hasher.js' type='text/javascript'></script>
CHAPTER4USINGURLROUTING
78
<script> var viewModel = { items: ["Apple", "Orange", "Banana"], selectedItem: ko.observable("Apple") }; $(document).ready(function() { ko.applyBindings(viewModel); $('div.catSelectors').buttonset(); hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); crossroads.addRoute("select/Apple", function() { viewModel.selectedItem("Apple"); }); crossroads.addRoute("select/Orange", function() { viewModel.selectedItem("Orange"); }); crossroads.addRoute("select/Banana", function() { viewModel.selectedItem("Banana"); }); }); </script> </head> <body> <div class="catSelectors" data-bind="foreach: items"> <a data-bind="formatAttr: {attr: 'href', prefix: '#select/', value: $data}, css: {selectedItem: ($data == viewModel.selectedItem())}"> <span data-bind="text: $data"></span> </a> </div> <div data-bind="foreach: items"> <div class="item" data-bind="fadeVisible: $data == viewModel.selectedItem()"> The selected item is: <span data-bind="text: $data"></span> </div> </div> </body> </html>
Thisisarelativelyshortlisting,butthereisalotgoingon,soI’llbreakthingsdownandexplainthemovingpartsinthesectionsthatfollow.
AddingtheRoutingLibraryOnceagain,IamgoingtouseapublicallyavailablelibrarytogettheeffectIrequire.ThereareafewURLroutinglibrariesaround,buttheonethatIlikeiscalledCrossroads.Itissimple,reliable,andeasytouse.Ithasonedrawback,whichisthatitdependsontwootherlibrariesbythesameauthor.Iliketoseedependenciesrolledintoasinglelibrary,butthisisnotauniversallyheldpreference,anditjustmeansthatwehavetodownloadacoupleofextrafiles.Table4-1liststheprojectsandtheJavaScriptfilesthat
CHAPTER4USINGURLROUTING
79
werequirefromthedownloadarchives,whichshouldbecopiedintotheNode.jsservercontentdirectory.(Allthreefilesarepartofthesourcecodedownloadforthisbookifyoudon’twanttodownloadthesefilesindividually.ThedownloadisfreelyavailableatApress.com.)
Table4-1.CrossroadsJavaScriptLibraries
Library Name URL Required File
Crossroads http://millermedeiros.github.com/crossroads.js/ crossroads.js
Signals http://millermedeiros.github.com/js-signals/ signals.js
Hasher https://github.com/millermedeiros/hasher/ hasher.js
IaddedCrossroads,itssupportinglibraries,andmynewcheeseutils.jsfileintotheHTML
documentusingscriptelements:
... <script src="jquery-1.7.1.js" type="text/javascript"></script> <script src="jquery-ui-1.8.16.custom.js" type="text/javascript"></script> <script src='knockout-2.0.0.js' type='text/javascript'></script> <script src='utils.js' type='text/javascript'></script> <script src='signals.js' type='text/javascript'></script> <script src='crossroads.js' type='text/javascript'></script> <script src='hasher.js' type='text/javascript'></script> <script> ...
AddingtheViewModelandContentMarkupURLroutingworksextremelywellwhencombinedwithaviewmodelinawebapplication.Forthisinitialapplication,Ihavecreatedaverysimpleviewmodel,asfollows:
var viewModel = { items: ["Apple", "Orange", "Banana"], selectedItem: ko.observable("Apple") };
Therearetwopropertiesintheviewmodel.Theitemspropertyreferstoanarrayofthreestrings.TheselectedItempropertyisanobservabledataitemthatkeepstrackofwhichitemispresentlyselected.Iusethesevalueswithdatabindingstogeneratethecontentinthedocument,likethis:
... <div data-bind="foreach: items"> <div class="item" data-bind="fadeVisible: $data == viewModel.selectedItem()"> The selected item is: <span data-bind="text: $data"></span> </div> </div> ...
ThebindingsthatKOsupportsbydefaultareprettybasic,butitiseasytocreatecustomones,whichisexactlywhatIhavedoneforthefadeVisiblebindingreferredtointhelisting.Listing4-2showsthe
CHAPTER4USINGURLROUTING
80
definitionofthisbinding,whichIhaveplacedinafilecalledutils.js(whichyoucanseeimportedinascriptelementinListing4-1).Thereisnorequirementtouseanexternalfile;IhaveusedonebecauseIintendtoemploythisbindingagainwhenIaddroutingtotheCheeseLuxexamplelaterinthechapter.
Listing4-2.DefiningaCustomBinding
ko.bindingHandlers.fadeVisible = { init: function(element, accessor) { $(element)[accessor() ? "show" : "hide"](); }, update: function(element, accessor) { if (accessor() && $(element).is(":hidden")) { var siblings = $(element).siblings(":visible"); if (siblings.length) { siblings.fadeOut("fast", function() { $(element).fadeIn("fast"); }) } else { $(element).fadeIn("fast"); } } } }
Creatingacustombindingisassimpleasaddinganewpropertytotheko.bindinghandlersobject;thenameofthepropertywillbethenameofthenewbinding.Thevalueofthepropertyisanobjectwithtwomethods:initandupdate.Theinitmethodiscalledwhenko.applyBindingsiscalled,andtheupdatemethodiscalledwhenobservabledataitemsthatthebindingdependsonchange.
Theargumentstobothmethodsaretheelementtowhichthebindinghasbeenappliedtoandanaccessorobjectthatprovidesaccesstothebindingargument.Thebindingargumentiswhateverfollowsthebindingname:
data-bind="fadeVisible: $data == viewModel.selectedItem()"
Ihaveused$datainmybindingargument.Whenusingaforeachbinding,$datareferstothecurrentiteminthearray.IcheckthisvalueagainsttheselectedItemobservabledataitemintheviewmodel.Ihavetorefertotheobservablethroughtheglobalvariablebecauseitisnotwithinthecontextoftheforeachbinding,andthismeansIneedtotreattheobservablelikeafunctiontogetthevalue.WhenKOcallstheinitorupdatemethodofmycustombinding,theexpressioninthebindingargumentisresolved,andtheresultofcallingaccessor()istrue.
Inmycustombinding,theinitmethodusesjQuerytoshoworhidetheelementtowhichthebindinghasbeenappliedbasedontheaccessorvalue.ThismeansthatonlytheelementsthatcorrespondtotheselectedItemobservablearedisplayed.
Theupdatemethodworksdifferently.IusejQueryeffectstoanimatethetransitionfromonesetofelementstoanother.Iftheupdatemethodisbeingcalledfortheelementsthatshouldbedisplayed,IselecttheelementsthatarepresentlyvisibleandcallthefadeOutmethod.Thiscausestheelementstograduallybecometransparentandtheninvisible;oncethishashappened,IthenusefadeIntomaketherequiredelementsvisible.Theresultisasmoothtransitionfromonesetofelementstoanother.
CHAPTER4USINGURLROUTING
81
AddingtheNavigationMarkupIgenerateasetofaelementstoprovidetheuserwiththemeanstoselectdifferentitems;inmysimpleapplication,theseformthenavigationmarkup.Hereisthemarkup:
<div class="catSelectors" data-bind="foreach: items"> <a data-bind="formatAttr: {attr: 'href', prefix: '#select/', value: $data}, css: {selectedItem: ($data == viewModel.selectedItem())}"> <span data-bind="text: $data"> </a> </div>
AsImentionedinChapter3,thebuilt-inKObindingssimplyinsertvaluesintothemarkup.Mostofthetime,thiscanbeworkedaroundbyaddingspanordivelementstoprovidestructuretowhichbindingscanbeattached.Thisapproachdoesn’tworkwhenitcomestoattributevalues,whichisaproblemwhenusingURLrouting.WhatIwantisaseriesofaelementswhosehrefattributecontainsavaluefromtheviewmodel,likethis:
<a href="#/select/Apple">Apple</a>
Ican’tgettheresultIwantfromthestandardattrbinding,soIhavecreatedanothercustomone.Listing4-3showsthedefinitionoftheformatAttrbinding.I’llbeusingthisbindinglater,soIhavedefineditintheutil.jsfile,alongsidethefadeVisiblebinding.
Listing4-3.DefiningtheformatAttrCustomBinding
function composeString(bindingConfig ) { var result = bindingConfig.value; if (bindingConfig.prefix) { result = bindingConfig.prefix + result; } if (bindingConfig.suffix) { result += bindingConfig.suffix;} return result; } ko.bindingHandlers.formatAttr = { init: function(element, accessor) { $(element).attr(accessor().attr, composeString(accessor())); }, update: function(element, accessor) { $(element).attr(accessor().attr, composeString(accessor())); } }
Thefunctionalityofthisbindingcomesthroughtheaccessor.ThebindingargumentIhaveusedontheelementisaJavaScriptobject,whichbecomesobviouswithsomejudiciousreformatting:
formatAttr: {attr: 'href', prefix: '#select/', value: $data }, css: {selectedItem: ($data == viewModel.selectedItem())}
KOresolvesthedatavaluesbeforepassingthisobjecttomyinitorupdatemethods,givingmesomethinglikethis:
CHAPTER4USINGURLROUTING
82
{attr: 'href', prefix: '#select/', value: Apple}
Iusethepropertiesofthisobjecttocreatetheformattedstring(usingthecomposeStringfunctionIdefinedalongsidethecustombinding)tocombinethecontentofvaluepropertywiththevalueoftheprefixandsuffixpropertiesiftheyaredefined.
Therearetwootherbindings.ThecssbindingappliesandremovesaCSSclass;IusethisbindingtoapplytheselectedItemclass.Thiscreatesasimpletogglebutton,showingtheuserwhichbuttonisclicked.Thetextbindingisappliedtoachildspanelement.ThisistoworkaroundaproblemwherejQueryUIandKObothassumecontroloverthecontentsoftheaelement;applyingthetextattributetoanestedelementavoidsthisconflict.IneedthisworkaroundbecauseIusejQueryUItocreatebuttonwidgetsfromthenavigationelements,likethis:
<script> var viewModel = { items: ["Apple", "Orange", "Banana"], selectedItem: ko.observable("Apple") }; $(document).ready(function() { ko.applyBindings(viewModel); $('div.catSelectors').buttonset(); ... other statements removed for brevity... }); </script>
Byapplyingthebuttonsetmethodtoacontainerelement,Iamabletocreateasetofbuttonsfromthechildaelements.Ihaveusedbuttonset,ratherthanbutton,sothatjQueryUIwillstyletheelementsinacontiguousblock.YoucanseetheeffectthatthiscreatesinFigure4-1.
Figure4-1.Thebasicapplicationtowhichroutingisapplied
Thereisnospacebetweenbuttonscreatedbythebuttonsetmethod,andtheouteredgesofthesetarenicelyrounded.Youcanalsoseeoneofthecontentelementsinthefigure.Theideaisthatclickingoneofthebuttonswillallowtheusertodisplaythecorrespondingcontentitem.
f
CHAPTER4USINGURLROUTING
83
ApplyingURLRoutingIhavealmosteverythinginplace:asetofnavigationalcontrolsandasetofcontentelements.Inowneedtotiethemtogether,whichIdobyapplyingtheURLrouting:
<script> var viewModel = { items: ["Apple", "Orange", "Banana"], selectedItem: ko.observable("Apple") }; $(document).ready(function() { ko.applyBindings(viewModel); $('div.catSelectors').buttonset(); hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); crossroads.addRoute("select/Apple", function() { viewModel.selectedItem("Apple"); }); crossroads.addRoute("select/Orange", function() { viewModel.selectedItem("Orange"); }); crossroads.addRoute("select/Banana", function() { viewModel.selectedItem("Banana"); }); }); </script>
ThefirstthreeofthehighlightedstatementssetuptheHasherlibrarysothatitworkswithCrossroads.HasherrespondstotheinternalURLchangethroughthelocation.hashbrowserobjectandnotifiesCrossroadswhenthereisachange.
CrossroadsexaminesthenewURLandcomparesittoeachoftheroutesithasbeengiven.RoutesaredefinedusingtheaddRoutemethod.ThefirstargumenttothismethodistheURLweareinterestedin,andthesecondargumentisafunctiontoexecuteiftheuserhasnavigatedtothatURL.So,forexample,iftheusernavigatesto#select/Apple,thenthefunctionthatsetstheselectedItemobservableintheviewmodeltoApplewillbeexecuted.
TipWedon’thavetospecifythe#characterwhenusingtheaddRoutemethodbecauseHasherremovesitbeforenotifyingCrossroadsofachange.
Intheexample,Ihavedefinedthreeroutes,eachofwhichcorrespondstooneoftheURLsthatIcreatedusingtheformatAttrbindingontheaelements.
CHAPTER4USINGURLROUTING
84
ThisisattheheartofURLrouting.YoucreateasetofURLroutesthatdrivethebehaviorofthewebappandthencreateelementsinthedocumentthatnavigatetothoseURLs.Figure4-2showstheeffectofsuchnavigationintheexample.
Figure4-2.Navigatingthroughtheexamplewebapp
Whentheuserclicksabutton,thebrowsernavigatestotheURLspecifiedbythehrefattributeoftheunderlyingaelement.Thisnavigationchangeisdetectedbytheroutingsystem,whichtriggersthefunctionthatcorrespondstotheURL.Thefunctionchangesthevalueofanobservableitemintheviewmodel,andthatcausestheelementsthatrepresenttheselecteditemtobedisplayedbytheuser.
Theimportantpointtounderstandisthatweareworkingwiththebrowser’snavigationmechanism.Whentheuserclicksoneofthenavigationelements,thebrowsermovestothetargetURL;althoughtheURLiswithinthesamedocument,thebrowser’shistoryandURLbarareupdated,asyoucanseeinthefigure.
Thisconferstwobenefitsonawebapplication.ThefirstisthattheBackbuttonworksthewaythatmostusersexpectittowork.ThesecondisthattheusercanenteraURLmanuallyandnavigatetoaspecificpartoftheapplication.Toseebothofthesebehaviorsinaction,followthesesteps:
1. Loadthelistinginthebrowser.
2. ClicktheOrangebutton.
3. Entercheeselux.com/#select/Bananaintothebrowser’sURLbar.
4. Clickthebrowser’sBackbutton.
WhenyouclickedtheOrangebutton,theOrangeitemwasselected,andthebuttonwashighlighted.SomethingsimilarhappensfortheBananaitemwhenyouenteredtheURL.Thisisbecausethenavigationmechanismfortheapplicationisnowmediatedbythebrowser,andthisishowweareabletouseURLroutingtodecoupleanotheraspectoftheapplication.
Thefirstbenefitis,tomymind,themostuseful.WhentheuserclickstheBackbutton,thebrowsernavigatesbacktothelastvisitedURL.Thisisanavigationchange,andifthepreviousURLiswithinourdocument,thenewURLismatchedagainstthesetofroutesdefinedbytheapplication.Thisisanopportunitytounwindtheapplicationstatetothepreviousstep,whichinthecaseofthesampleapplicationdisplaystheOrangebutton.Thisisamuchmorenaturalwayofworkingforauser,especiallycomparedtousingregularevents,whereclickingtheBackbuttontendstonavigatetothesitetheuservisitedbeforeourapplication.
CHAPTER4USINGURLROUTING
85
ConsolidatingRoutesInthepreviousexample,Idefinedeachrouteandthefunctionitexecutedseparately.Ifthisweretheonlywaytodefineroutes,acomplexwebappwouldendupwithamorassofroutesandfunctions,andtherewouldbenoadvantageoverregulareventhandling.Fortunately,URLsroutingisveryflexible,andwecanconsolidateourrouteswithease.Idescribethetechniquesavailableforthisinthesectionsthatfollow.
UsingVariableSegmentsListing4-4showshoweasyitistoconsolidatethethreeroutesfromtheearlierdemonstrationintoasingleroute.
Listing4-4.ConsolidatingRoutes
<script> var viewModel = { items: ["Apple", "Orange", "Banana"], selectedItem: ko.observable("Apple") }; $(document).ready(function() { ko.applyBindings(viewModel); $('div.catSelectors').buttonset(); hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); crossroads.addRoute("select/{item}", function(item) { viewModel.selectedItem(item); }); }); </script>
ThepathsectionofaURLismadeupofsegments.Forexample,theURLpathselect/Applehastwosegments,whichareselectandApple.WhenIspecifyaroute,likethis:
/select/Apple
theroutewillmatchaURLonlyifbothsegmentsmatchexactly.Inthelisting,Ihavebeenabletoconsolidatemyroutesbyaddingavariablesegment.AvariablesegmentallowsaroutetomatchaURLthathasanyvalueforthecorrespondingsegment.So,tobeclear,allofthenavigationURLsinthesimplewebappwillmatchmynewroute:
select/Apple select/Orange select/Banana
Thefirstsegmentisstillstatic,meaningthatonlyURLswhosefirstsegmentisselectwillmatch,butIhaveessentiallyaddedawildcardforthesecondsegment.
CHAPTER4USINGURLROUTING
86
SothatIcanrespondappropriatelytotheURL,thecontentofthevariablesegmentispassedtomyfunctionasanargument.IusethisargumenttochangethevalueoftheselectedItemobservableintheviewmodel,meaningthataURLof/select/Appleresultsinacalllikethis:
viewModel.selectedItem('Apple');
andaURLofselect/Cherrywillresultinacalllikethis:
viewModel.selectedItem('Cherry');
DealingwithUnexpectedSegmentValuesThatlastURLisaproblem.Thereisn’tanitemcalledCherryinmywebapp,andsettingtheviewmodelobservabletothisvaluewillcreateanoddeffectfortheuser,asshowninFigure4-3.
Figure4-3.Theresultofanunexpectedvariablesegmentvalue
TheflexibilitythatcomeswithURLroutingcanalsobeaproblem.Beingabletonavigatetoaspecificpartoftheapplicationisausefultoolfortheuser,but,aswithallopportunitiesfortheusertoprovideinput,wehavetoguardagainstunexpectedvalues.Formyexampleapplication,thesimplestwaytovalidatevariablesegmentvaluesistocheckthecontentsofthearrayintheviewmodel,asshowninListing4-5.
Listing4-5.IgnoringUnexpectedSegmentValues
... crossroads.addRoute("select/{item}", function(item) { if (viewModel.items.indexOf(item) > -1) { viewModel.selectedItem(item); } }); ...
Inthislisting,Ihavetakenthepathofleastresistance,whichistosimplyignoreunexpectedvalues.Therearelotsofalternativeapproaches.Icouldhavedisplayedanerrormessageor,asListing4-6shows,embracedtheunexpectedvalueandaddedittotheviewmodel.
CHAPTER4USINGURLROUTING
87
Listing4-6.DealingwithUnexpectedValuesbyAddingThemtotheViewModel
<script> var viewModel = { items: ko.observableArray(["Apple", "Orange", "Banana"]), selectedItem: ko.observable("Apple") }; $(document).ready(function() { ko.applyBindings(viewModel); $('div.catSelectors').buttonset(); hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); crossroads.addRoute("select/{item}", function(item) { if (viewModel.items.indexOf(item)== -1) { viewModel.items.push(item); $('div.catSelectors').buttonset(); } viewModel.selectedItem(item); }); }); </script>
Ifthevalueofthevariablesegmentisn’toneofthevaluesintheitemsarrayintheviewmodel,thenIusethepushmethodtoaddthenewvalue.Ichangedtheviewmodelsothattheitemsarrayisanobservableitemusingtheko.observableArraymethod.Anobservablearrayislikearegularobservabledataitem,exceptthatbindingssuchasforeachareupdatedwhenthecontentofthearraychanges.UsinganobservablearraymeansthataddinganitemcausesKnockouttogeneratecontentandnavigationelementsinthedocument.
ThelaststepinthisprocessistocallthejQueryUIbuttonsetmethodagain.KOhasnoknowledgeofthejQueryUIstylesthatareappliedtoanaelementtocreateabutton,andthismethodhastobereappliedtogettherighteffect.Youcanseetheresultofnavigatingto#select/CherryinFigure4-4.
Figure4-4.Incorporatingunexpectedsegmentvaluesintotheapplicationstate
CHAPTER4USINGURLROUTING
88
UsingOptionalSegmentsThelimitationofvariablesegmentsisthattheURLmustcontainasegmentvaluetomatcharoute.Forexample,therouteselect/{item}willmatchanytwo-segmentURLwherethefirstsegmentisselect,butitwon’tmatchselect/Apple/Red(becausetherearetoomanysegments)orselect(becausetherearetoofewsegments).
Wecanuseoptionalsegmentstoincreasetheflexibilityofourroutes.Listing4-7showstheapplicationonanoptionalsegmenttotheexample.
Listing4-7.UsinganOptionalSegmentinaRoute
... crossroads.addRoute("select/:item:", function(item) { if (!item) { item = "Apple"; } else if (viewModel.items.indexOf(item)== -1) { viewModel.items.push(item); $('div.catSelectors').buttonset(); } viewModel.selectedItem(item); }); ...
Tocreateanoptionalsegment,Isimplyreplacethebracecharacterswithcolonssothat{item}becomes:item:.Withthischange,theroutewillmatchURLsthathaveoneortwosegmentsandwherethefirstsegmentisselect.Ifthereisnosecondsegment,thentheargumentpassedtothefunctionwillbenull.Inmylisting,IdefaulttotheApplevalueifthisisthecase.Aroutecancontainasmanystatic,variable,andoptionalsegmentsasyourequire.Iwillkeepmyroutessimpleinthisexample,butyoucancreateprettymuchanycombinationyourequire.
AddingaDefaultRouteWiththeintroductionoftheoptionalsegment,myroutewillmatchone-andtwo-segmentURLs.ThefinalrouteIwanttoaddisadefaultroute,whichisonethatwillbeinvokedwhentherearenosegmentsintheURLatall.ThisisrequiredtocompletethesupportfortheBackbutton.ToseetheproblemIamsolving,loadthelistingintothebrowser,clickoneofthenavigationelements,andthenhittheBackbutton.Youcanseetheeffect—or,rather,thelackofaneffect—inFigure4-5.
Figure4-5.Navigatingbacktotheapplicationstartingpoint
CHAPTER4USINGURLROUTING
89
Theapplicationdoesn’tresettoitsoriginalstatewhentheBackbuttonisclicked.ThishappensonlywhenclickingtheBackbuttontakesthebrowserbacktothebaseURLforthewebapp(whichishttp://cheeselux.cominmycase).NothinghappensbecausethebaseURLdoesn’tmatchtheroutesthattheapplicationdefines.Listing4-8showstheadditionofanewroutetofixthisproblem.
Listing4-8.AddingaRoutefortheBaseURL
... <script> var viewModel = { items: ko.observableArray(["Apple", "Orange", "Banana"]), selectedItem: ko.observable("Apple") }; $(document).ready(function() { ko.applyBindings(viewModel); $('div.catSelectors').buttonset(); hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); crossroads.addRoute("select/:item:", function(item) { if (!item) { item = "Apple"; } else if (viewModel.items.indexOf(item)== -1) { viewModel.items.push(item); $('div.catSelectors').buttonset(); } viewModel.selectedItem(item); }); crossroads.addRoute("", function() { viewModel.selectedItem("Apple"); }) }); </script> ...
ThisroutecontainsnosegmentsofanykindandwillmatchonlythebaseURL.ClickingtheBackbuttonuntilthebaseURLisreachednowcausestheapplicationtoreturntoitsinitialstate.(Well,itreturnssortofbacktoitsoriginalstate;laterinthischapterI’llexplainawrinkleinthisapproachandshowyouhowtoimproveuponit.)
AdaptingEvent-DrivenControlstoNavigationItisnotalwayspossibletolimittheelementsinadocumentsothatallnavigationcanbehandledthroughaelements.WhenaddingJavaScripteventstoaroutedapplication,IfollowasimplepatternthatbridgesbetweenURLroutingandconventionaleventsandthatgivesmealotofthebenefitsof
CHAPTER4USINGURLROUTING
90
routingandletsmeuseotherkindsofelementsaswell.Listing4-9showsthispatternappliedtosomeotherelementtypes.
Listing4-9.BridgingBetweenURLRoutingandJavaScriptEvents
... <script> var viewModel = { items: ko.observableArray(["Apple", "Orange", "Banana"]), selectedItem: ko.observable("Apple") }; $(document).ready(function() { ko.applyBindings(viewModel); $('div.catSelectors').buttonset(); hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); crossroads.addRoute("select/:item:", function(item) { if (!item) { item = "Apple"; } else if (viewModel.items.indexOf(item)== -1) { viewModel.items.push(item); $('div.catSelectors').buttonset(); } if (viewModel.selectedItem() != item) { viewModel.selectedItem(item); } }); crossroads.addRoute("", function() { viewModel.selectedItem("Apple"); }) $('[data-url]').live("change click", function(e) { var target = $(e.target).attr("data-url"); if (e.target.tagName == 'SELECT') { target += $(e.target).children("[selected]").val(); } if (location.hash != target) { location.replace(target); } }) }); </script> ...
Thetechniquehereistoaddadata-urlattributetotheelementswhoseeventsshouldresultinanavigationchange.IusejQuerytohandlethechangeandclickeventsforelementsthathavethedata-
CHAPTER4USINGURLROUTING
91
urlattribute.Handlingbotheventsallowsmetocaterforthedifferentkindsofinputelements.Iusethelivemethod,whichisaneatjQueryfeaturethatreliesoneventpropagationtoensurethateventsarehandledforelementsthatareaddedtothedocumentafterthescripthasexecuted;thisisessentialwhenthesetofelementsinthedocumentcanbealteredinresponsetoviewmodelchanges.Thisapproachallowsmetouseelementslikethis:
... <div class="eventElemContainer" data-bind="foreach: items"> <label data-bind="attr: {for: $data}"> <span data-bind="text: $data"></span> <input type="radio" name="item" data-bind="attr: {id: $data}, formatAttr: {attr: 'data-url', prefix: '#select/', value: $data}"> </label> </div> ...
Thismarkupgeneratesasetofradiobuttonsforeachelementintheviewmodelitemsarray.Icreatethevalueforthedata-urlattributewithmycustomformatAttrdatabinding,whichIdescribedearlier.Theselectelementrequiressomespecialhandlingbecausewhiletheselectelementtriggersthechangeevent,theinformationaboutwhichvaluehasbeenselectedisderivedfromthechildoptionelements.Hereissomemarkupthatcreatesaselectelementthatworkswiththispattern:
... <div class="eventElemContainer"> <select name="eventItemSelect" data-bind="foreach: items, attr: {'data-url': '#select/'}"> <option data-bind="value: $data, text: $data, selected: $data == viewModel.selectedItem()"> </option> </select> </div> ...
PartofthetargetURLisinthedata-urlattributeoftheselectelement,andtherestistakenfromthevalueattributeoftheoptionelements.Someelements,includingselect,triggerboththeclickandchangeevents,soIchecktoseethatthetargetURLdiffersfromthecurrentURLbeforeusinglocation.replacetotriggeranavigationchange.Listing4-10showshowthistechniquecanbeappliedtoselectelements,buttons,radiobuttons,andcheckboxes.
Listing4-10.BridgingBetweenEventsandRoutingforDifferentKindsofElements
<!DOCTYPE html> <html> <head> <title>Routing Example</title> <link rel="stylesheet" type="text/css" href="jquery-ui-1.8.16.custom.css"/> <link rel="stylesheet" type="text/css" href="styles.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script src="jquery-ui-1.8.16.custom.js" type="text/javascript"></script> <script src='knockout-2.0.0.js' type='text/javascript'></script> <script src='utils.js' type='text/javascript'></script> <script src='signals.js' type='text/javascript'></script> <script src='crossroads.js' type='text/javascript'></script>
CHAPTER4USINGURLROUTING
92
<script src='hasher.js' type='text/javascript'></script> <script> var viewModel = { items: ko.observableArray(["Apple", "Orange", "Banana"]), selectedItem: ko.observable("Apple") }; $(document).ready(function() { ko.applyBindings(viewModel); $('div.catSelectors').buttonset(); hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); crossroads.addRoute("select/:item:", function(item) { if (!item) { item = "Apple"; } else if (viewModel.items.indexOf(item)== -1) { viewModel.items.push(item); $('div.catSelectors').buttonset(); } if (viewModel.selectedItem() != item) { viewModel.selectedItem(item); } }); crossroads.addRoute("", function() { viewModel.selectedItem("Apple"); }) $('[data-url]').live("change click", function(e) { var target = $(e.target).attr("data-url"); if (e.target.tagName == 'SELECT') { target += $(e.target).children("[selected]").val(); } if (location.hash != target) { location.replace(target); } }) }); </script> </head> <body> <div class="catSelectors" data-bind="foreach: items"> <a data-bind="formatAttr: {attr: 'href', prefix: '#select/', value: $data}, css: {selectedItem: ($data == viewModel.selectedItem())}"> <span data-bind="text: $data"></span> </a> </div> <div data-bind="foreach: items">
CHAPTER4USINGURLROUTING
93
<div class="item" data-bind="fadeVisible: $data == viewModel.selectedItem()"> The selected item is: <span data-bind="text: $data"></span> </div> </div> <div class="eventElemContainer"> <select name="eventItemSelect" data-bind="foreach: items, attr: {'data-url': '#select/'}"> <option data-bind="value: $data, text: $data, selected: $data == viewModel.selectedItem()"> </option> </select> </div> <div class="eventElemContainer" data-bind="foreach: items"> <input type="button" data-bind="value: $data, formatAttr: {attr: 'data-url', prefix: '#select/', value: $data}" /> </div> <div class="eventElemContainer" data-bind="foreach: items"> <label data-bind="attr: {for: $data}"> <span data-bind="text: $data"></span> <input type="checkbox" data-bind="attr: {id: $data}, formatAttr: {attr: 'data-url', prefix: '#select/', value: $data}"> </label> </div> <div class="eventElemContainer" data-bind="foreach: items"> <label data-bind="attr: {for: $data}"> <span data-bind="text: $data"></span> <input type="radio" name="item" data-bind="attr: {id: $data}, formatAttr: {attr: 'data-url', prefix: '#select/', value: $data}"> </label> </div> </body> </html>
Ihavedefinedanothercustombindingtocorrectlysettheselectedattributeontheappropriateoptionelement.Icalledthisbindingselected(obviouslyenough),anditisdefined,asshowninListing4-11,intheutils.jsfile.
Listing4-11.TheSelectedDataBinding
ko.bindingHandlers.selected = { init: function(element, accessor) { if (accessor()) { $(element).siblings("[selected]").removeAttr("selected"); $(element).attr("selected", "selected"); } }, update: function(element, accessor) {
CHAPTER4USINGURLROUTING
94
if (accessor()) { $(element).siblings("[selected]").removeAttr("selected"); $(element).attr("selected", "selected"); } } }
Youmightbetemptedtosimplyhandleeventsandtriggertheapplicationchangesdirectly.Thisworks,butyouwillhavejustaddedtothecomplexityofyourapplicationbytakingontheoverheadorcreatingandmanagingroutesandkeepingtrackofwhicheventsfromwhichelementstriggerdifferencestatechanges.MyrecommendationistofocusonURLroutingandusebridging,asdescribedhere,tofunneleventsfromelementsintotheroutingsystem.
UsingtheHTML5HistoryAPITheCrossroadslibraryIhavebeenusingsofarinthischapterdependsontheHasherlibraryfromthesameauthortoreceivenotificationswhentheURLchanges.TheHasherlibrarymonitorstheURLandtellsCrossroadswhenitchanges,triggeringtheroutingbehavior.
Thereisaweaknessinthisapproach,whichisthatthestateoftheapplicationisn’tpreservedaspartofthebrowserhistory.Herearesomestepstodemonstratetheissue:
1. Loadthelistingintothebrowser.
2. ClicktheOrangebutton.
3. Navigatedirectlyto#select/Cherry.
4. ClicktheBananabutton.
5. ClicktheBackbuttontwice.
Everythingstartsoffwellenough.Whenyounavigatedtothe#select/CherryURL,thenewitemwasaddedtotheviewmodelandselectedproperly.WhenyouclickedtheBackbuttonthefirsttime,theCherryitemwascorrectlyselectedagain.TheproblemariseswhenyouclickedtheBackbuttonforthesecondtime.TheselecteditemwascorrectlywoundbacktoOrange,buttheCherryitemremainedonthelist.TheapplicationisabletousetheURLtoselectthecorrectitem,butwhentheOrangeitemwasselectedoriginally,therewasnoCherryitemintheviewmodel,andyetitisstilldisplayedtotheuser.
Forsomewebapplications,thiswon’tbeabigdeal,anditisn’tforthissimpleexample,either.Afterall,itdoesn’treallymatteriftheusercanselectanitemthattheyexplicitlyaddedinthefirstplace.Butforotherwebapps,thisisacriticalissue,andmakingsurethattheviewmodeliscorrectlypreservedinthebrowserhistoryisessential.WecanaddressthisusingtheHTML5HistoryAPI,whichgivesusmoreaccesstothebrowserhistorythanwebprogrammershavepreviouslyenjoyed.WeaccesstheHistoryAPIthroughthewindows.historyorglobalhistoryobject.TherearetwoaspectsoftheHistoryAPIthatIaminterestedinforthissituation.
CHAPTER4USINGURLROUTING
95
NoteIamnotgoingtocovertheHTML5APIbeyondwhatisneededtomaintainapplicationstate.IprovidefulldetailsinTheDefinitiveGuidetoHTML5,alsopublishedbyApress.YoucanreadtheW3Cspecificationathttp://dev.w3.org/html5/spec (theinformationontheHistoryAPIisinsection5.4,butthismaychangesincetheHTML5specificationisstillindraft).
Thehistory.replaceStatemethodletsyouassociateastateobjectwiththeentryinthebrowser’shistoryforthecurrentdocument.Therearethreeargumentstothismethod;thefirstisthestateobject,thesecondargumentisthetitletouseinthehistory,andthethirdistheURLforthedocument.Thesecondargumentisn’tusedbythecurrentgenerationofbrowsers,buttheURLargumentallowsyoutoeffectivelyreplacetheURLinthehistorythatisassociatedwiththecurrentdocument.ThepartIaminterestedinforthischapteristhefirstargument,whichIwillusetostorethecontentsoftheviewModel.itemsarrayinthehistorysothatIcanproperlymaintainthestatewhentheuserclickstheBackandForwardbuttons.
TipYoucanalsoinsertnewitemsintothehistoryusingthehistory.pushStatemethod.ThismethodtakesthesameargumentsasreplaceStateandcanbeusefulforinsertingadditionalstateinformation.
Thewindowbrowserobjecttriggersapopstateeventwhenevertheactivehistoryentrychanges.Iftheentryhasstateinformationassociatedwithit(becausethereplaceStateorpushStatemethodwasused),thenyoucanretrievethestateobjectthroughthehistory.stateproperty.
AddingHistoryStatetotheExampleApplicationThingsaren’tquiteassimpleasyoumightlikewhenitcomestousingtheHistoryAPI;itsuffersfromtwoproblemsthatarecommontomostoftheHTML5APIs.ThefirstproblemisthatnotallbrowserssupporttheHistoryAPI.Obviously,pre-HTML5browsersdon’tknowabouttheHistoryAPI,butevensomebrowserversionsthatsupportotherHTML5featuresdonotimplementtheHistoryAPI.
ThesecondproblemisthatthosebrowsersthatdoimplementtheHTML5APIintroduceinconsistencies,whichrequiressomecarefultesting.So,evenastheHistoryAPIhelpsussolveoneproblem,wearefacedwithothers.Evenso,theHistoryAPIisworthusing,aslongasyouacceptthatitisn’tuniversallysupportedandthatafallbackisrequired.Listing4-12showstheadditionoftheHistoryAPItothesimpleexamplewebapp.
Listing4-12.UsingtheHTML5HistoryAPItoPreserveViewModelState
<!DOCTYPE html> <html> <head> <title>Routing Example</title> <link rel="stylesheet" type="text/css" href="jquery-ui-1.8.16.custom.css"/>
CHAPTER4USINGURLROUTING
96
<link rel="stylesheet" type="text/css" href="styles.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script src="jquery-ui-1.8.16.custom.js" type="text/javascript"></script> <script src="modernizr-2.0.6.js" type="text/javascript"></script> <script src='knockout-2.0.0.js' type='text/javascript'></script> <script src='utils.js' type='text/javascript'></script> <script src='signals.js' type='text/javascript'></script> <script src='crossroads.js' type='text/javascript'></script> <script src='hasher.js' type='text/javascript'></script> <script> var viewModel = { items: ko.observableArray(["Apple", "Orange", "Banana"]), selectedItem: ko.observable("Apple") }; $(document).ready(function() { ko.applyBindings(viewModel); $('div.catSelectors').buttonset(); crossroads.addRoute("select/:item:", function(item) { if (!item) { item = "Apple"; } else if (viewModel.items.indexOf(item)== -1) { viewModel.items.push(item); } if (viewModel.selectedItem() != item) { viewModel.selectedItem(item); } $('div.catSelectors').buttonset(); if (Modernizr.history) { history.replaceState(viewModel.items(), document.title, location); } }); crossroads.addRoute("", function() { viewModel.selectedItem("Apple"); }) if (Modernizr.history) { $(window).bind("popstate", function(event) { var state = history.state ? history.state : event.originalEvent.state; if (state) { viewModel.items.removeAll(); $.each(state, function(index, item) { viewModel.items.push(item); }); }
CHAPTER4USINGURLROUTING
97
crossroads.parse(location.hash.slice(1)); }); } else { hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); } }); </script> </head> <body> <div class="catSelectors" data-bind="foreach: items"> <a data-bind="formatAttr: {attr: 'href', prefix: '#select/', value: $data}, css: {selectedItem: ($data == viewModel.selectedItem())}"> <span data-bind="text: $data"></span> </a> </div> <div data-bind="foreach: items"> <div class="item" data-bind="fadeVisible: $data == viewModel.selectedItem()"> The selected item is: <span data-bind="text: $data"></span> </div> </div> </body> </html>
StoringtheApplicationStateThefirstsetofchangesinthelistingstorestheapplicationstatewhenthemainapplicationroutematchesaURL.ByrespondingtotheURLchange,IamabletopreservethestatewhenevertheuserclicksoneofthenavigationelementsorentersaURLdirectly.Hereisthecodethatstoresthestate:
... <script src="modernizr-2.0.6.js" type="text/javascript"></script> ... crossroads.addRoute("select/:item:", function(item) { if (!item) { item = "Apple"; } else if (viewModel.items.indexOf(item)== -1) { viewModel.items.push(item); } if (viewModel.selectedItem() != item) { viewModel.selectedItem(item); } $('div.catSelectors').buttonset(); if (Modernizr.history) { history.replaceState(viewModel.items(), document.title, location); } }); ...
CHAPTER4USINGURLROUTING
98
ThenewscriptelementinthelistingaddstheModernizrlibrarytothewebapp.Modernizrisafeature-detectionlibrarythatcontainscheckstodeterminewhethernumerousHTML5andCSS3featuresaresupportedbythebrowser.YoucandownloadModernizrandgetfulldetailsofthefeaturesitcandetectathttp://modernizr.com.
Idon’twanttocallthemethodsoftheHistoryAPIunlessIamsurethatthebrowserimplementsit,soIcheckthevalueoftheModernizr.historyproperty.AvalueoftruemeansthattheHistoryAPIhasbeendetected,andavalueoffalsemeanstheAPIisn’tpresent.
Youcouldwriteyourownfeature-detectiontestsifyouprefer.Asanexample,hereisthecodebehindtheModernizr.historytest:
tests['history'] = function() { return !!(window.history && history.pushState); };
Modernizrsimplycheckstoseewhetherhistory.pushStateisdefinedbythebrowser.IprefertousealibrarylikeModernizrbecausethetestsitperformsarewell-validatedandupdatedasneededand,further,becausenotallofthetestsarequitesosimple.
TipFeature-detectionlibrariessuchasModernizrdon’tmakeanyassessmentofhowwellafeaturehasbeenimplemented.Thepresenceofthehistory.pushStatemethodindicatesthattheHistoryAPIispresent,butitdoesn’tprovideanyinsightsintoquirksorodditiesthatmayhavetobereckonedwith.Inshort,afeature-detectionlibraryisnosubstituteforthoroughlytestingyourcodeonarangeofbrowsers.
IftheHistoryAPIispresent,thenIcallthereplaceStatemethodtoassociatethevalueoftheviewmodelitemsarraywiththecurrentURL.IcanperformnoactioniftheHistoryAPIisn’tavailablebecausethereisn’tanalternativemechanismforstoringstateinthebrowser(althoughIcouldhaveusedapolyfill;seethesidebarfordetails).
USING A HISTORY POLYFILL
ApolyfillisaJavaScriptlibrarythatprovidessupportforanAPIforolderbrowsers.Pollyfilla,fromwhichthenameoriginates,istheU.K.equivalentoftheSpacklehome-repairproduct,andtheideaisthatapolyfilllibrarysmoothesoutthedevelopmentlandscape.Polyfilllibrariescanalsoworkarounddifferencesbetweenbrowserimplementationfeatures.TheHistoryAPImayseemlikeanidealcandidateforapolyfill,buttheproblemisthatthebrowserdoesn’tprovideanyalternativemeansofstoringstateobjects.ThemostcommonworkaroundistoexpressthestateaspartoftheURLsothatwemightendupwithsomethinglikethis:
http://cheeselux.com/#select/Banana?items=Apple,Orange,Banana,Cherry
Idon’tlikethisapproachbecauseIdon’tliketoseecomplexdatatypesexpressedinthisway,andIthinkitproducesconfusingURLs.Butyoumightfeeldifferently,orastatefulhistoryfeaturemaybecriticaltoyourproject.Ifthat’sthecase,thenthebestHistoryAPIpolyfillthatIhavefoundiscalledHistory.jsandisathttp://github.com/balupton/history.js.
CHAPTER4USINGURLROUTING
99
RestoringtheApplicationStateOfcourse,storingtheapplicationstateisn’tenough.Ialsohavetobeabletorestoreit,andthatmeansrespondingtothepopstateeventwhenitistriggeredbyaURLchange.Hereisthecode:
... crossroads.addRoute("select/:item:", function(item) { ...other statements removed for brevity... if (Modernizr.history) { $(window).bind("popstate", function(event) { var state = history.state ? history.state : event.originalEvent.state; if (state) { viewModel.items.removeAll(); $.each(state, function(index, item) { viewModel.items.push(item); }); } crossroads.parse(location.hash.slice(1)); }); } else { hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); } }); ...
IhaveusedModernizr.historytocheckfortheAPIbeforeIusethebindmethodtoregisterahandlerfunctionforthepopstateevent.Thisisn’tstrictlynecessarysincetheeventsimplywon’tbetriggerediftheAPIisn’tpresent,butIliketomakeitobviousthatthisblockofcodeisrelatedtotheHistoryAPI.
Youcanseeanexampleofcateringtoabrowseroddityinthefunctionthathandlesthepopstateevent.Thehistory.statepropertyshouldreturnthestateobjectassociatedwiththecurrentURL,butGoogleChromedoesn’tsupportthis,andthevaluemustbeobtainedfromthestatepropertyoftheEventobjectinstead.jQuerynormalizesEventobjects,whichmeansthatIhavetousetheoriginalEventpropertytogettotheunderlyingeventobjectthatthebrowsergenerated,likethis:
var state = history.state ? history.state: event.originalEvent.state;
WiththisapproachIcangetthestatedatafromhistory.stateifitisavailableandtheeventifitisnot.Sadly,usingtheHTML5APIsoftenrequiresthiskindofworkaround,althoughIexpecttheconsistencyofthevariousimplementationswillimproveovertime.
Ican’trelyontherebeingastateobjecteverytimethepopstateeventistriggeredbecausenotallentriesinthebrowserhistorywillhavestateassociatedwiththem.
Whenthereisstatedata,IusetheremoveAllmethodtocleartheitemsarrayintheviewmodelandthenpopulateitwiththeitemsobtainedfromthestatedatausingthejQueryeachfunction:
CHAPTER4USINGURLROUTING
100
if (state) { viewModel.items.removeAll(); $.each(state, function(index, item) { viewModel.items.push(item); }); }
Oncethecontentoftheviewmodelhasbeenset,InotifyCrossroadsthattherehasbeenachangeinURLbycallingtheparsemethod.ThiswasthefunctionpreviouslyhandledbytheHasherlibrary,whichremovedtheleading#characterfromURLsbeforepassingthemtoCrossroads.IdothesametomaintaincompatibilitywiththeroutesIdefinedearlier:
crossroads.parse(location.hash.slice(1));
IwanttopreservecompatibilitybecauseIdon’twanttoassumethattheuserhasanHTML5browserthatsupportstheHistoryAPI.Tothatend,iftheModernizr.historypropertyisfalse,IfallbacktousingHashersothatthebasicfunctionalityofthewebappstillworks,evenifIcan’tprovidethestatemanagementfeature:
if (Modernizr.history) { ...History API code... } else { hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); }
Withthesechanges,IamabletousetheHistoryAPIwhenitisavailabletomanagethestateoftheapplicationandunwinditwhentheuserusestheBackbutton.Figure4-6showsthekeystepfromthesequenceoftasksIhadyouperformatthestartofthissection.Astheusermovesbackthroughthehistory,theCherryitemdisappears.
Figure4-6.UsingtheHistoryAPItomanagechangesinapplicationstate
Asanaside,IchosetostoretheapplicationstateeverytimetheURLchangedbecauseitallowsmetosupporttheForwardbuttonaswellastheBackbutton.Fromthestateshowninthefigure,clickingtheForwardbuttonrestorestheCherryitemtotheviewmodel,demonstratingthattheapplicationstateisproperlypreservedandrestoredinbothdirections.
CHAPTER4USINGURLROUTING
101
AddingURLRoutingtotheCheeseLuxWebAppIswitchedtoasimpleexampleinthischapterbecauseIdidn’twanttooverwhelmtheroutingcode(whichisprettysparse)withthemarkupanddatabindings(whichcanbeverbose).ButnowthatIhaveexplainedhowURLroutingworks,itistimetointroduceittotheCheeseLuxdemo,asshowninListing4-13.
Listing4-13.AddingRoutingtotheCheeseLuxExample
<!DOCTYPE html> <html> <head> <title>CheeseLux</title> <link rel="stylesheet" type="text/css" href="styles.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script src="jquery-ui-1.8.16.custom.js" type="text/javascript"></script> <script src='knockout-2.0.0.js' type='text/javascript'></script> <script src='utils.js' type='text/javascript'></script> <script src='signals.js' type='text/javascript'></script> <script src='crossroads.js' type='text/javascript'></script> <link rel="stylesheet" type="text/css" href="jquery-ui-1.8.16.custom.css"/> <noscript> <meta http-equiv="refresh" content="0; noscript.html"/> </noscript> <script> var cheeseModel = { products: [ {category: "British Cheese", items : [ {id: "stilton", name: "Stilton", price: 9}, {id: "stinkingbishop", name: "Stinking Bishop", price: 17}, {id: "cheddar", name: "Cheddar", price: 17}]}, {category: "French Cheese", items: [ {id: "camembert", name: "Camembert", price: 18}, {id: "tomme", name: "Tomme de Savoie", price: 19}, {id: "morbier", name: "Morbier", price: 9}]}, {category: "Italian Cheese", items: [ {id: "gorgonzola", name: "Gorgonzola", price: 8}, {id: "fontina", name: "Fontina", price: 11}, {id: "parmesan", name: "Parmesan", price: 16}]}] }; $(document).ready(function() { $('#buttonDiv input:submit').button().css("font-family", "Yanone"); cheeseModel.selectedCategory = ko.observable(cheeseModel.products[0].category); mapProducts(function(item) { item.quantity = ko.observable(0); item.subtotal = ko.computed(function() { return this.quantity() * this.price;
CHAPTER4USINGURLROUTING
102
}, item); item.quantity.subscribe(function() { updateState(); }); }, cheeseModel.products, "items"); cheeseModel.total = ko.computed(function() { var total = 0; mapProducts(function(elem) { total += elem.subtotal(); }, cheeseModel.products, "items"); return total; }); $('div.cheesegroup').not("#basket").css("width", "50%"); $('div.navSelectors').buttonset(); ko.applyBindings(cheeseModel); $(window).bind("popstate", function(event) { var state = history.state ? history.state : event.originalEvent.state; restoreState(state); crossroads.parse(location.hash.slice(1)); }); crossroads.addRoute("category/:newCat:", function(newCat) { cheeseModel.selectedCategory(newCat ? newCat : cheeseModel.products[0].category); updateState(); }); crossroads.addRoute("remove/{id}", function(id) { mapProducts(function(item) { if (item.id == id) { item.quantity(0); } }, cheeseModel.products, "items"); }); $('#basketTable a') .button({icons: {primary: "ui-icon-closethick"},text: false}); function updateState() { var state = { category: cheeseModel.selectedCategory() }; mapProducts(function(item) { if (item.quantity() > 0) { state[item.id] = item.quantity(); } }, cheeseModel.products, "items"); history.replaceState(state, "",
CHAPTER4USINGURLROUTING
103
"#select/" + cheeseModel.selectedCategory()); } function restoreState(state) { if (state) { mapProducts(function(item) { item.quantity(state[item.id] ? state[item.id] : 0); }, cheeseModel.products, "items"); cheeseModel.selectedCategory(state.category); } } }); </script> </head> <body> <div id="logobar"> <img src="cheeselux.png"> <span id="tagline">Gourmet European Cheese</span> </div> <div class="cheesegroup"> <div class="navSelectors" data-bind="foreach: products"> <a data-bind="formatAttr: {attr: 'href', prefix: '#category/', value: category}, css: {selectedItem: (category == cheeseModel.selectedCategory())}"> <span data-bind="text: category"> </a> </div> </div> <div id="basket" class="cheesegroup basket"> <div class="grouptitle">Basket</div> <div class="groupcontent"> <div class="description" data-bind="ifnot: total"> No products selected </div> <table id="basketTable" data-bind="visible: total"> <thead><tr><th>Cheese</th><th>Subtotal</th><th></th></tr></thead> <tbody data-bind="foreach: products"> <!-- ko foreach: items --> <tr data-bind="visible: quantity, attr: {'data-prodId': id}"> <td data-bind="text: name"></td> <td>$<span data-bind="text: subtotal"></span></td> <td> <a data-bind="formatAttr: {attr: 'href', prefix: '#remove/', value: id}"></a> </td> </tr> <!-- /ko --> </tbody>
CHAPTER4USINGURLROUTING
104
<tfoot> <tr><td class="sumline" colspan=2></td></tr> <tr> <th>Total:</th><td>$<span data-bind="text: total"></span></td> </tr> </tfoot> </table> </div> <div class="cornerplaceholder"></div> <div id="buttonDiv"> <input type="submit" value="Submit Order"/> </div> </div> <form action="/shipping" method="post"> <!-- ko foreach: products --> <div class="cheesegroup" data-bind="fadeVisible: category == cheeseModel.selectedCategory()"> <div class="grouptitle" data-bind="text: category"></div> <div data-bind="foreach: items"> <div class="groupcontent"> <label data-bind="attr: {for: id}" class="cheesename"> <span data-bind="text: name"> </span> $(<span data-bind="text:price"></span>)</label> <input data-bind="attr: {name: id}, value: quantity"/> <span data-bind="visible: subtotal" class="subtotal"> ($<span data-bind="text: subtotal"></span>) </span> </div> </div> </div> <!-- /ko --> </form> </body> </html>
Iamnotgoingtobreakthislistingdownlinebylinebecausemuchoffunctionalityissimilartopreviousexamples.Thereare,however,acoupleoftechniquesthatareworthlearningandsomechangesthatIneedtoexplain,allofwhichI’llcoverinthesectionsthatfollow.Figure4-7showshowthewebappappearsinthebrowser.
CHAPTER4USINGURLROUTING
105
Figure4-7.AddingroutingtotheCheeseLuxexample
MovingthemapProductsFunctionThefirstchange,andthemostbasic,isthatIhavemovedthemapProductsfunctionintotheutil.jsfile.InChapter9,Iamgoingtoshowyouhowtopackageupthiskindoffunctionmoreusefully,andIdon’twanttokeeprecyclingthesamecodeinthelistings.AsImovedthefunction,Irewroteitsothatitcanworkonanysetofnestedarrays.Listing4-14showsthenewversionofthisfunction.
Listing4-14.TheRevisedmapProductsFunction
function mapProducts(func, data, indexer) { $.each(data, function(outerIndex, outerItem) { $.each(outerItem[indexer], function(itemIndex, innerItem) { func(innerItem, outerItem); }); }); }
Thetwonewargumentstothefunctionaretheouternestedarrayandthepropertynameoftheinnerarray.YoucanseehowIhaveusedthisinthemainlistingsothattheargumentsarecheeseModel.productsanditems,respectively.
EnhancingtheViewModelImadetwochangestotheviewmodel.Thefirstwastodefineanobservabledataitemtocapturetheselectedcheesecategory:
cheeseModel.selectedCategory = ko.observable(cheeseModel.products[0].category);
CHAPTER4USINGURLROUTING
106
Thesecondismuchmoreinteresting.Databindingsarenotthemeansbywhichviewmodelchangesarepropagatedintothewebapp.Youcanalsosubscribetoanobservabledataitemandspecifyafunctionthatwillbeexecutedwhenthevaluechanges.HereisthesubscriptionIcreated:
mapProducts(function(item) { item.quantity = ko.observable(0); item.subtotal = ko.computed(function() { return this.quantity() * this.price; }, item); item.quantity.subscribe(function() { updateState(); }); }, cheeseModel.products, "items");
Isubscribedtothequantityobservableoneachcheeseproduct.Whenthevaluechanges,theupdateStatefunctionwillbeexecuted.I’lldescribethisfunctionshortly.Subscriptionsareratherlikeeventsfortheviewmodel;theycanbeusefulinanynumberofsituations,andIoftenfindmyselfusingthemwhenIwantsometaskperformedautomatically.
ManagingApplicationStateIwanttopreservetwokindsofstateinthiswebapp.Thefirstistheselectedproductcategory,andthesecondisthecontentsofthebasket.Istorestateinformationinthebrowser’shistoryintheupdateStatefunction,whichisexecutedwhenevermyquantitysubscriptionistriggeredortheselectedcategorychanges.
TipThetechniquethatIdemonstratehereisalittleoddwhenappliedtoashoppingbasket,becausewebsiteswillusuallygotogreatlengthstopreserveyourproductselections.Ignorethis,ifyouwill,andfocusonthestatemanagementtechnique,whichistherealpurposeofthissection.
function updateState() { var state = { category: cheeseModel.selectedCategory() }; mapProducts(function(item) { if (item.quantity() > 0) { state[item.id] = item.quantity(); } }, cheeseModel.products, "items"); history.replaceState(state, "", "#select/" + cheeseModel.selectedCategory()); }
CHAPTER4USINGURLROUTING
107
TipThislistingrequirestheHTML5HistoryAPI,andunliketheearlierexamplesinthischapter,thereisnofallbacktotheHTML4-compatibleapproachtakenbytheHasherlibrary.
Icreateanobjectthathasacategorypropertythatcontainsthenameoftheselectedcategoryandonepropertyforeachindividualcheesethathasanonzeroquantityvalue.IwritethistothebrowserhistoryusingthereplaceStatemethod,whichIhavehighlightedinthelisting.
Somethingcleverishappeninghere.ToexplainwhatIamdoing—andwhy—wehavetostartwiththemarkupforthenavigationelementsthatremoveproductsfromthebasket.HereistherelevantHTML:
<a data-bind="formatAttr: {attr: 'href', prefix: '#remove/', value: id}"></a>
Whenthedatabindingsareapplied,Iendupwithanelementlikethis:
<a href="#/remove/stilton"></a>
InChapter3,Iremoveditemsfromthebasketbyhandlingtheclickeventfromtheseelements.NowthatIamusingURLrouting,Ihavetodefinearoute,whichIdolikethis:
crossroads.addRoute("remove/{id}", function(id) { mapProducts(function(item) { if (item.id == id) { item.quantity(0); } }, cheeseModel.products, "items"); });
Myroutematchesanytwo-segmentURLwherethefirstsegmentisremove.Iusethesecondsegmenttofindtherightitemintheviewmodelandchangethevalueofthequantitypropertytozero.
Atthispoint,Ihaveaproblem.IhavenavigatedtoaURLthatIdon’twanttheusertobeabletonavigatebacktobecauseitwillmatchtheroutethatjustremovesitemsfromthebasket,andthatdoesn’thelpme.
Thesolutionisinthecalltothehistory.replaceStatemethod.Whenthequantityvalueischanged,mysubscriptioncausestheupdateStatefunctiontobecalled,whichinturncallshistory.replaceState.Thethirdargumentistheimportantone:
history.replaceState(state, "", "#select/" + cheeseModel.selectedCategory());
TheURLspecifiedbythisargumentisusedtoreplacetheURLthattheusernavigatedto.Thebrowserdoesn’tnavigatetotheURLwhenitischanged,butwhentheusermovesbackthroughthebrowserhistory,itisthereplacementURLthatwillbeusedbythebrowser.IrrespectiveofwhichroutematchestheURL,thehistorywillalwayscontainonethatstartswith#select/.Inthisway,IcanuseURLroutingwithoutexposingtheinnerworkingsofmywebapptotheuser.
CHAPTER4USINGURLROUTING
108
SummaryInthischapter,IhaveshownyouhowtoaddURLroutingtoyourwebapplications.ThisisapowerfulandflexibletechniquethatseparatesapplicationnavigationfromHTMLelements,allowingforamoreconciseandexpressivewayofhandlingnavigationandamoretestableandmaintainablecodebase.Itcantakeawhiletogetusedtousingroutingattheclient,butitiswellworththeinvestmentoftimeandenergy,especiallyforlargeandcomplexprojects.
C H A P T E R 5
109
Creating Offline Web Apps
TheHTML5specificationincludessupportfortheApplicationCache,whichisusedtocreatewebapplicationsthatareavailabletousersevenwhennonetworkconnectionisavailable.Thisisidealifyourusersneedtoworkofflineorinenvironmentswhereconnectivityisconstrained(suchasonanairplane,forexample).
AswithallofthemorecomplexHTML5features,usingtheapplicationcacheisn’tentirelysmoothsailing.Therearesomedifferencesinimplementationsbetweenbrowsersandsomeodditiesthatyouneedtobeawareof.Inthischapter,I’llshowyouhowtocreateaneffectiveofflinewebapplicationandhowtoavoidvariouspitfalls.
CautionThebrowsersupportforofflinestorageisatanearlystage,andtherearealotofinconsistencies.Ihavetriedtopointoutpotentialproblems,butbecauseeachbrowserreleasetendstorefinetheimplementationofHTML5features,youshouldexpecttoseesomevariationswhenyouruntheexamplesinthischapter.
ResettingtheExampleOnceagain,IamgoingtosimplifytheCheeseLuxexamplesothatIamnotlistingreamsofcodethatrelatetootherchapters.Listing5-1showsthereviseddocument.
Listing5-1.TheResetCheeseLuxExample
<!DOCTYPE html> <html> <head> <title>CheeseLux</title> <link rel="stylesheet" type="text/css" href="styles.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script src="jquery-ui-1.8.16.custom.js" type="text/javascript"></script> <script src='knockout-2.0.0.js' type='text/javascript'></script> <script src='utils.js' type='text/javascript'></script> <script src='signals.js' type='text/javascript'></script> <script src='hasher.js' type='text/javascript'></script> <script src='crossroads.js' type='text/javascript'></script> <link rel="stylesheet" type="text/css" href="jquery-ui-1.8.16.custom.css"/>
CHAPTER5CREATINGOFFLINEWEBAPPS
110
<noscript> <meta http-equiv="refresh" content="0; noscript.html"/> </noscript> <script> var cheeseModel = { products: [ {category: "British Cheese", items : [ {id: "stilton", name: "Stilton", price: 9}, {id: "stinkingbishop", name: "Stinking Bishop", price: 17}, {id: "cheddar", name: "Cheddar", price: 17}]}, {category: "French Cheese", items: [ {id: "camembert", name: "Camembert", price: 18}, {id: "tomme", name: "Tomme de Savoie", price: 19}, {id: "morbier", name: "Morbier", price: 9}]}, {category: "Italian Cheese", items: [ {id: "gorgonzola", name: "Gorgonzola", price: 8}, {id: "fontina", name: "Fontina", price: 11}, {id: "parmesan", name: "Parmesan", price: 16}]}] }; $(document).ready(function() { $('#buttonDiv input:submit').button(); $('div.navSelectors').buttonset(); enhanceViewModel(); ko.applyBindings(cheeseModel); hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); crossroads.addRoute("category/:cat:", function(cat) { cheeseModel.selectedCategory(cat || cheeseModel.products[0].category); }); }); </script> </head> <body> <div id="logobar"> <img src="cheeselux.png"> <span id="tagline">Gourmet European Cheese</span> </div> <div class="cheesegroup"> <div class="navSelectors" data-bind="foreach: products"> <a data-bind="formatAttr: {attr: 'href', prefix: '#category/', value: category}, css: {selectedItem: (category == cheeseModel.selectedCategory())}"> <span data-bind="text: category"> </a> </div>
CHAPTER5CREATINGOFFLINEWEBAPPS
111
</div> <form action="/shipping" method="post"> <div data-bind="foreach: products"> <div class="cheesegroup" data-bind="fadeVisible: category == cheeseModel.selectedCategory()"> <div class="grouptitle" data-bind="text: category"></div> <!-- ko foreach: items --> <div class="groupcontent"> <label data-bind="attr: {for: id}" class="cheesename"> <span data-bind="text: name"> </span> $(<span data-bind="text:price"></span>)</label> <input data-bind="attr: {name: id}, value: quantity"/> <span data-bind="visible: subtotal" class="subtotal"> ($<span data-bind="text: subtotal"></span>) </span> </div> <!-- /ko --> <div class="groupcontent"> <label class="cheesename">Total:</label> <span class="subtotal" id="total"> $<span data-bind="text: cheeseModel.total()"></span> </span> </div> </div> </div> <div id="buttonDiv"> <input type="submit" value="Submit Order"/> </div> </form> </body> </html>
Thisexamplebuildsontheviewmodelandroutingconceptsfrompreviouschapters,butIhavesimplifiedsomeofthefunctionality.Insteadofabasket,Ihaveaddedatotaldisplaytothebottomofeachcategoryofcheese.IhavemovedthecodethatcreatestheobservableviewmodelitemsintoafunctioncalledenhanceViewModelintheutils.jsfile.Everythingelseinthislistingshouldbeself-evident.
UsingtheHTML5ApplicationCacheThestartingpointforusingtheapplicationcacheistocreateamanifest.Thistellsthebrowserwhichfilesarerequiredtoruntheapplicationofflinesothatthebrowsercanensurethattheyareallpresentinthecache.Manifestfileshavetheappcachefilesuffix,soIhavecalledmymanifestfilecheeselux.appcache.YoucanseethecontentsofthisfileinListing5-2.
CHAPTER5CREATINGOFFLINEWEBAPPS
112
Listing5-2.ASimpleManifestFile
CACHE MANIFEST # HTML document example.html offline.html # script files jquery-1.7.1.js jquery-ui-1.8.16.custom.js knockout-2.0.0.js signals.js crossroads.js hasher.js utils.js # CSS files styles.css jquery-ui-1.8.16.custom.css # images #blackwave.png cheeselux.png images/ui-bg_flat_75_eb8f00_40x100.png images/ui-bg_flat_75_fbbe03_40x100.png images/ui-icons_ffffff_256x240.png images/ui-bg_flat_75_595959_40x100.png images/ui-bg_flat_65_fbbe03_40x100.png # fonts fonts/YanoneKaffeesatz-Regular.ttf fonts/fanwood_italic-webfont.ttf fonts/ostrich-rounded-webfont.woff
AbasicmanifestfilestartswiththeCACHE MANIFESTheaderandthenlistsallthefilesthattheapplicationrequires,includingtheHTMLfilewhosehtmlelementcontainsthemanifestattribute(discussedinamoment).Inthelisting,Ihavebrokenthefilesdownbytypeandusedcomments(whicharelinesstartingwiththe#character)tomakeiteasiertofigureoutwhat’shappening.
TipYouwillnoticethatIhavecommentedouttheentryfortheblackwave.pngfile.Iusethisfiletodemonstratethebehaviorofacachedapplicationinamoment.
ThemanifestisaddedtotheHTMLdocumentthroughthemanifestattributeofthehtmlelement,asListing5-3shows.
CHAPTER5CREATINGOFFLINEWEBAPPS
113
Listing5-3.AddingtheManifesttotheHTMLDocument
<!DOCTYPE html> <html manifest="cheeselux.appcache"> <head> ... </head> <body> ... </body> </html>
WhentheHTMLdocumentisloaded,thebrowserdetectsthemanifestattribute,requeststhespecifiedappcachefilefromthewebserver,andbeginsloadingandcachingeachfilelistedinthemanifestfile.Thefilesthataredownloadedwhenthebrowserprocessesthemanifestarecalledtheofflinecontent.Somebrowserswillprompttheuserforpermissiontostoreofflinecontent.
CautionBecarefulwhenyoucreatethemanifest.Ifanyoftheitemslistedcannotbeobtainedfromtheserver,thenthebrowserwillnotcachetheapplicationatall.
UnderstandingWhenCachedContentIsUsedTheofflinecontentisn’tusedwhenitisfirstloadedbythebrowser.Itiscachedforthenexttimethattheuserloadsorreloadsthepage.Thenameofflinecontentismisleading.Oncethebrowserhasofflinecontentforawebapp,itwillbeusedwhenevertheuservisitsthewebapp’sURL,evenwhenthereisanetworkconnectionavailable.Thebrowsertakesresponsibilityforensuringthatthelatestversionoftheofflinecontentisbeingused,butasyou’lllearn,thisisacomplicatedprocessandrequiressomeprogrammerintervention.
Icommentedouttheblackwave.pngfileinthemanifesttodemonstratehowthebrowserhandlesofflinecontent.Iuseblackwave.pngasthebackgroundimagefortheCheeseLuxwebapp,andthisgivesmeanicewaytodemonstratethebasicbehaviorofacachedwebapplication.
Tostartwith,addthemanifestattributetotheexampleasshowninListing5-3,andloadthedocumentintoyourbrowser.Differentbrowsersdealwithcachedapplicationsindifferentways.Forexample,GoogleChromewillquietlyprocessthemanifestandstartdownloadingthecontentitspecified.MozillaFirefoxwillusuallyprompttheusertoallowofflinecontent,asshowninFigure5-1.IfyouareusingFirefox,clicktheAllowbuttontostartthebrowserprocessingthemanifest.
CHAPTER5CREATINGOFFLINEWEBAPPS
114
Figure5-1.Firefoxpromptingtheusertoallowthewebapptostoredatalocally
TipAllofthemainstreambrowsersallowtheusertodisablecachedapplications,whichmeansyoucannotrelyonbeingabletostoredataevenifthebrowserimplementsthefeature.Insuchcases,theapplicationmanifestwillsimplybeignored.Youmayneedtochangetheconfigurationofyourbrowsertocachetheexamplecontent.
YoushouldseetheCheeseLuxwebappwiththeblackbackground.Atthispoint,thebrowserhastwocopiesofthewebapp.Thefirstcopyisintheregularbrowsercache,andthisistheversionthatiscurrentlyrunning.Thesecondcopyisintheapplicationcacheandcontainstheitemsspecifiedinthemanifest.Simplyreloadthepagetoswitchtotheapplicationcacheversion.Whenyoudoreload,thebackgroundwillbewhite,asshowninFigure5-2.
Figure5-2.Switchingtotheapplicationcache
Thedifferenceiscausedbythefactthattheblackwave.pngfileiscommentedoutinthemanifest.Thebrowserkeepstheapplicationcacheandtheregularcacheseparate,whichmeansthateventhoughithasablackwave.pngfileintheregularcache,itwon’tuseitforacachedapplication.
TipNoticethatyouhavenotdoneanythingtothenetworkconnection.Thebrowserisstillonline,buttheapplicationhasbeenloadedusingsolelyofflinecontent.ThisissomethingthatI’llreturntosoon.
CHAPTER5CREATINGOFFLINEWEBAPPS
115
AcceptingChangestotheManifestThemostsignificantchangeinbehaviorforacachedapplicationisthatrefreshingthewebpagedoesn’tcausetheapplicationcontenttobecached.Theideaisthatupdatestoacachedapplicationneedtobemanagedtoavoidinconsistentchanges.Uncommentingtheblackwave.pnglineinthemanifestandreloading,forexample,wouldn’tchangethebackgroundtoblack.
Listing5-4showstheminimumamountofcodethatisneededinawebapptosupportupdates.I’llshowyouhowtousemoreoftheApplicationCacheAPIlaterinthechapter,butweneedthesechangesbeforewecangoanyfurther.
Listing5-4.AcceptingChangesintheManifest
... <script> var cheeseModel = { products: [ {category: "British Cheese", items : [ {id: "stilton", name: "Stilton", price: 9}, {id: "stinkingbishop", name: "Stinking Bishop", price: 17}, {id: "cheddar", name: "Cheddar", price: 17}]}, {category: "French Cheese", items: [ {id: "camembert", name: "Camembert", price: 18}, {id: "tomme", name: "Tomme de Savoie", price: 19}, {id: "morbier", name: "Morbier", price: 9}]}, {category: "Italian Cheese", items: [ {id: "gorgonzola", name: "Gorgonzola", price: 8}, {id: "fontina", name: "Fontina", price: 11}, {id: "parmesan", name: "Parmesan", price: 16}]}] }; $(document).ready(function() { $('#buttonDiv input:submit').button(); $('div.navSelectors').buttonset(); enhanceViewModel(); ko.applyBindings(cheeseModel); hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); crossroads.addRoute("category/:cat:", function(cat) { cheeseModel.selectedCategory(cat || cheeseModel.products[0].category); }); $(window.applicationCache).bind("updateready", function() { window.applicationCache.swapCache(); }); }); </script>
CHAPTER5CREATINGOFFLINEWEBAPPS
116
...
TheHTML5ApplicationCacheAPIisexpressedthroughthewindow.applicationCachebrowserobject.Thisobjecttriggerseventstoinformthewebappofchangesinthecachestatus.Themostimportantforusatthemomentistheupdatereadyevent,whichmeansthatthereisupdatedcachedataavailable.Inadditiontotheevents,theapplicationCacheobjectdefinessomeusefulmethodsandproperties.Onceagain,I’llreturntotheselaterinthechapter,butthemethodIcareaboutnowisswapCache,whichappliestheupdatedmanifestanditscontentstotheapplicationcache.
Iamnowreadytodemonstrateupdatingacachedwebapplication.ButbeforeIdo,Imustremovetheexistingcacheddata.IhavecreatedazombiewebappbyapplyingamanifestwithoutaddingthecalltotheswapCachemethod,andthereisnowayIcangetupdatestotakeeffect.Ineedtoclearthecacheandstartagain.ThereisnowaytoclearthecacheusingJavaScript,andthebrowserhasadifferentmechanismformanuallyclearingapplicationcachedata.ForGoogleChrome,youdeletetheregularbrowsinghistory.ForMozillaFirefox,youmustselecttheAdvanced➤Networkoptionstab,selectthewebsitefromthelist,andclicktheRemovebutton.
Onceyouhaveclearedtheapplicationcache,reloadthelistingtoloadthemanifestandcachethedata.Reloadthepageagaintoswitchtothecachedversionoftheapplication(whichwillhavethewhitebackground).
Finally,youcanuncommenttheblackwave.pngentryinthecheeselux.appcachefile.Atthispoint,youwillneedtoreloadthewebpagetwice.Thefirsttimecausesthebrowsertocheckforanupdatedmanifest,findthatthereisanewversion,anddownloadtheupdatedresourcesintothecache.Atthispoint,theupdatereadyeventistriggered,andmyscriptcallstheswapCachemethod,applyingtheupdatestothecache.Thosechangesdon’ttakeeffectuntilthenexttimethatthewebappisloaded,whichiswhythesecondreloadisrequired.Thisisanawkwardapproach,butI’llshowyouhowtoimproveuponitshortly.Atthispoint,thecachewillhavebeenupdatedwithamanifestthatdoesincludetheblackwave.pngfile,andthewebappbackgroundwillhaveturnedblack.
TipThebrowsercheckstoseeonlyifthemanifestfilehaschanged.Changestoindividualresources,includingHTMLandscriptfiles,areignoredunlessthemanifestalsochanges.Ifthemanifesthaschanged,thenthebrowserwillchecktoseewhethertheindividualresourceshavebeenupdatedsincetheywerelastdownloaded(and,ofcourse,willdownloadanyresourcesthathavebeenaddedtothemanifest).
TakingControloftheCacheUpdateProcessItookyouthelongwayaroundtheupdatesbecauseIwantedtoemphasizethewayinwhichthebrowsertriestoisolateusfromhavingtodealwithaninconsistentcache.ThereisnostandardwayforaJavaScriptwebapptorespondtoacachechangewhileitisrunning,sotheHTML5ApplicationCachestandarderrsonthesideofcaution,andcacheupdatesareappliedonlywhentheapplicationisloaded.
CHAPTER5CREATINGOFFLINEWEBAPPS
117
CautionThecurrentimplementationsoftheapplicationcachearefineforusebynormalusers,buttheytendtostruggleduringthedevelopmentphasewhentherearelotsofchangestothemanifestandlotsofupdatesappliedtothecache.Therewillcomeapointwhereyoustartgettingoddbehavior,andnochangesyoumaketoyourmanifestoryourapplicationwillsortmattersout.Whenthishappens,thesimplestthingtodoistoclearthebrowserhistoryandapplicationcachecontentsandseewhethertheproblemspersist.Mostofthetime,Ifindthatsuddenchangesinbehaviorarecausedbythebrowserandthatstartingoverfixesthings(althoughthissometimesrequiresclearingthefilesdirectlyfromthediskusingthefileexplorer,becausethebrowser’sabilitytomanagetheapplicationcachealsogoesawry).
WecanusetheapplicationCachebrowserobjecttomanageacachedapplicationinamoreelegantway.Thefirstthingwecandoistomonitorthestatusofthecacheandpresenttheuserwithsomeoptions.Listing5-5showshowthiscanbedone.
Listing5-5.TakingActiveControloftheApplicationCache
<!DOCTYPE html> <html manifest="cheeselux.appcache"> <head> <title>CheeseLux</title> <link rel="stylesheet" type="text/css" href="styles.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script src="jquery-ui-1.8.16.custom.js" type="text/javascript"></script> <script src='knockout-2.0.0.js' type='text/javascript'></script> <script src='utils.js' type='text/javascript'></script> <script src='signals.js' type='text/javascript'></script> <script src='hasher.js' type='text/javascript'></script> <script src='crossroads.js' type='text/javascript'></script> <link rel="stylesheet" type="text/css" href="jquery-ui-1.8.16.custom.css"/> <noscript> <meta http-equiv="refresh" content="0; noscript.html"/> </noscript> <script> var cheeseModel = { products: [ {category: "British Cheese", items : [ {id: "stilton", name: "Stilton", price: 9}, {id: "stinkingbishop", name: "Stinking Bishop", price: 17}, {id: "cheddar", name: "Cheddar", price: 17}]}, {category: "French Cheese", items: [ {id: "camembert", name: "Camembert", price: 18}, {id: "tomme", name: "Tomme de Savoie", price: 19}, {id: "morbier", name: "Morbier", price: 9}]}, {category: "Italian Cheese", items: [ {id: "gorgonzola", name: "Gorgonzola", price: 8},
CHAPTER5CREATINGOFFLINEWEBAPPS
118
{id: "fontina", name: "Fontina", price: 11}, {id: "parmesan", name: "Parmesan", price: 16}]}], cache: { status: ko.observable(window.applicationCache.status) } }; $(document).ready(function() { $('#buttonDiv input:submit').button(); $('div.navSelectors').buttonset(); enhanceViewModel(); ko.applyBindings(cheeseModel); hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); crossroads.addRoute("category/:cat:", function(cat) { cheeseModel.selectedCategory(cat || cheeseModel.products[0].category); }); $(window.applicationCache).bind("checking noupdate downloading " + "progress cached updateready", function(e) { cheeseModel.cache.status(window.applicationCache.status); }); $('div.tagcontainer a').button().click(function(e) { e.preventDefault(); if ($(this).attr("data-action") == "update") { window.applicationCache.update(); } else { window.applicationCache.swapCache(); window.location.reload(false); } }); }); </script> </head> <body> <div id="logobar"> <img src="cheeselux.png"> <div class="tagcontainer"> <span id="tagline">Gourmet European Cheese</span> <div> <a data-bind="visible: cheeseModel.cache.status() != 4" data-action="update" class="cachelink">Check for Updates</a> <a data-bind="visible: cheeseModel.cache.status() == 4" data-action="swapCache" class="cachelink">Apply Update</a> </div> </div>
CHAPTER5CREATINGOFFLINEWEBAPPS
119
</div> <div class="cheesegroup"> <div class="navSelectors" data-bind="foreach: products"> <a data-bind="formatAttr: {attr: 'href', prefix: '#category/', value: category}, css: {selectedItem: (category == cheeseModel.selectedCategory())}"> <span data-bind="text: category"> </a> </div> </div> <form action="/shipping" method="post"> <div data-bind="foreach: products"> <div class="cheesegroup" data-bind="fadeVisible: category == cheeseModel.selectedCategory()"> <div class="grouptitle" data-bind="text: category"></div> <!-- ko foreach: items --> <div class="groupcontent"> <label data-bind="attr: {for: id}" class="cheesename"> <span data-bind="text: name"> </span> $(<span data-bind="text:price"></span>)</label> <input data-bind="attr: {name: id}, value: quantity"/> <span data-bind="visible: subtotal" class="subtotal"> ($<span data-bind="text: subtotal"></span>) </span> </div> <!-- /ko --> <div class="groupcontent"> <label class="cheesename">Total:</label> <span class="subtotal" id="total"> $<span data-bind="text: cheeseModel.total()"></span> </span> </div> </div> </div> <div id="buttonDiv"> <input type="submit" value="Submit Order"/> </div> </form> </body> </html>
Tostartwith,Ihaveaddedanewobservabledataitemtotheviewmodel,whichrepresentsthestateoftheapplicationcache:
cache: { status: ko.observable(window.applicationCache.status) }
IamusingtheviewmodelbecauseIwanttodisseminatethestatusintotheHTMLmarkupusingdatabindings.Tokeepthevalueup-to-date,Isubscribetoasetofeventstriggeredbythewindow.applicationCacheobject,likethis:
CHAPTER5CREATINGOFFLINEWEBAPPS
120
$(window.applicationCache).bind("checking noupdate downloading " + "progress cached updateready", function(e) { cheeseModel.cache.status(window.applicationCache.status); });
Sevencacheeventsareavailable.IhavelistedtheminTable5-1.Ihaveusedthebindmethodtohandlesixofthem,becausetheseventh,obsolete,arisesonlywhenthemanifestfileisn’tavailablefromthewebserver.
Table5-1.HTML5ApplicationCacheEvents
Event Name Description
cached Theinitialmanifestandcontentfortheapplicationhavebeendownloaded.
checking Thebrowserischeckingforanupdatetothemanifestfile.
noupdate Thebrowserhasfinishedcheckingthemanifest,andtherewerenoupdates.
downloading Thebrowserisdownloadingupdatedofflinecontent.
progress Usedbythebrowsertoindicatedownloadprogress.
updateready Thecontentdownloadiscomplete,andthereisacacheupdateready.
obsolete Themanifestisinvalid.
Iupdatethecache.statusdataitemintheviewmodelwhenIreceivedanapplicationcacheevent.
Thecurrentstatusisavailablefromthewindow.applicationCache.statusproperty,andIhavedescribedtherangeofvaluesthatarereturnedinTable5-2.
Table5-2.ValuesReturnedbytheapplicationCache.statusProperty
Value Name Description
0 UNCACHED Returnedforwebappsthatdonotspecifyamanifestorwhenthereisamanifestbuttheofflinecontenthasnotbeendownloaded.
1 IDLE Thecacheisnotperforminganyaction.Thisisthedefaultvalueoncetheofflinecontenthasbeendownloadedandcached.
2 CHECKING Thebrowserischeckingforanupdatedmanifest.
3 DOWNLOADING Thebrowserisdownloadingupdatedofflinecontent.
4 UPDATEREADY Thereisupdatedofflinecontentwaitingtobeappliedtothecache.
5 OBSOLETE Thecacheddataisobsolete.
CHAPTER5CREATINGOFFLINEWEBAPPS
121
Asyoucansee,thestatusvaluescorrespondwithsomeoftheapplicationcacheevents.Forthisexample,IcareonlyabouttheUPDATEREADYstatusvalue,whichIusetocontrolthevisibilityofsomeaelementsIaddedtothelogoareaofthepage:
<div> <a data-bind="visible: cheeseModel.cache.status() != 4" data-action="update" class="cachelink">Check for Updates</a> <a data-bind="visible: cheeseModel.cache.status() == 4" data-action="swapCache" class="cachelink">Apply Update</a> </div>
Whenthecacheisidle,Idisplaytheelementthatpromptstheusertocheckforanupdate,andwhenthereisanupdateavailable,Iprompttheusertoinstallit.Figure5-3showsbothofthesebuttonsinsitu.
Figure5-3.Addingbuttonstocontrolthecache
Asyoucanseeinthefigure,IhaveusedjQueryUItocreatebuttonsfromtheaelements.IhavealsousedthejQueryclickmethodtoregisterahandlerfortheclickevent,asfollows:
$('div.tagcontainer a').button().click(function(e) { e.preventDefault(); if ($(this).attr("data-action") == "update") { window.applicationCache.update(); } else { window.applicationCache.swapCache(); window.location.reload(false); } });
IhaveusedregularJavaScripteventstocontrolthecachebecauseIwanttheusertobeabletocheckforupdatesrepeatedly.BrowsersignorerequeststonavigatetothesameinternalURLthatisbeingdisplayed.Youcanseethishappeningifyouclickoneofthecheesecategorybuttons.Clickingthesamebuttonrepeatedlydoesn’tdoanything,andthebuttoniseffectivelydisableduntilanothercategoryisselected.IfIhadusedURLroutingtodealwiththecachebuttons,thentheuserwouldbeabletocheckforanupdateonceandthennotbeabletodosoagainuntiltheynavigatedtoanotherinternalURL(whichforthisexamplewouldrequireselectingacheesecategory).So,instead,IusedJavaScripteventsthataretriggeredeverytimethebuttonisclicked,irrespectiveoftherestoftheapplicationstate.
CHAPTER5CREATINGOFFLINEWEBAPPS
122
Wheneithercachebuttonisclicked,Ireadthevalueofthedata-actionattribute.Iftheattributevalueisupdate,thenIcallthecacheupdatemethod.Thiscausesthebrowsertocheckwiththeservertoseewhetherthemanifesthaschanged.Ifithas,thenthestatusofthecachewillchangetoUPDATEREADY,andtheApplyUpdatebuttonwillbeshowntotheuser.
WhentheApplyUpdatebuttonisclicked,IcalltheswapCachemethodtopushtheupdatesintotheapplicationcache.Theseupdateswon’ttakeeffectuntiltheapplicationisreloaded,whichIforcebycallingthewindow.location.reloadmethod.Thismeanstheupdatesareappliedtothecacheandimmediatelyusedinresponsetoasingleactionbytheuser.Thesimplestwaytotesttheseadditionsistotogglethestatusoftheblackwave.pngimageinthemanifestandapplytheresultingupdate.Seetheinformationonthecachecontrolheaderifyouwanttotestmoresubstantialchanges.
APPLICATION CACHE ENTRIES AND THE CACHE-CONTROL HEADER
CallingtheapplicationCachemethoddoesn’talwayscausethebrowsertocontacttheservertoseewhetherthemanifesthaschanged.AllofthemainstreambrowsershonortheHTTPCache-Controlheaderandwillcheckforupdatesonlywhenthelifeofthemanifesthasexpired.
Further,evenifthemanifesthaschanged,thebrowserhonorstheCache-Controlvalueforindividualmanifestitems.ThiscanleadtoasituationwhereanupdatetoanHTMLorscriptfileisignoredifthemanifestchangeswithintheCache-Controllifetimeoftheaffectedresource.
Inproduction,thisbehaviorisperfectlyreasonable.Butduringdevelopmentandtesting,it’sahugepainsincechangesmadetothecontentsofHTMLandscriptfileswon’tbeimmediatelyreflectedinanupdate.Togetaroundthis,IhavesetaveryshortcachelifeonthecontentservedbytheNode.jsserver.You’llneedtodosomethingsimilartoyourdevelopmentserverstogetthesameeffect.
AddingNetworkandFallbackEntriestotheManifestRegularmanifestentriestellthebrowsertoproactivelyobtainandcacheresourcesthatthewebapprequires.Inaddition,theapplicationcachesupportstwoothermanifestentrytypes:networkandfallbackentries.Networkentries,alsoknownaswhitelistentries,specifyaresourcethatthebrowsershouldnotcache.Requestsfortheseresourceswillalwaysresultinarequesttotheserverwhilethebrowserisonline.Thisisusefultoensurethattheuseralwaysreceivesthelatestversionofafile,eventhoughtherestoftheapplicationiscached.
Thefallbackentriestellthebrowserwhattodowhenthebrowserisofflineandtheuserrequestsanetworkentry.Fallbackentriesallowyoutosubstituteanalternativefileratherthandisplayinganerrortotheuser.Listing5-6showstheuseofbothkindsofentryinthecheeselux.appcachefile.
Listing5-6.UsingaNetworkEntryintheApplicationManifest
CACHE MANIFEST # HTML document example.html # script files jquery-1.7.1.js jquery-ui-1.8.16.custom.js
CHAPTER5CREATINGOFFLINEWEBAPPS
123
knockout-2.0.0.js signals.js crossroads.js hasher.js utils.js # CSS files styles.css jquery-ui-1.8.16.custom.css # images blackwave.png cheeselux.png images/ui-bg_flat_75_eb8f00_40x100.png images/ui-bg_flat_75_fbbe03_40x100.png images/ui-icons_ffffff_256x240.png images/ui-bg_flat_75_595959_40x100.png images/ui-bg_flat_65_fbbe03_40x100.png # fonts fonts/YanoneKaffeesatz-Regular.ttf fonts/fanwood_italic-webfont.ttf fonts/ostrich-rounded-webfont.woff NETWORK: news.html
ThenetworkentriesareprefixedwiththewordNETWORKandacolon(:).Aswiththeregularentries,eachresourceoccupiesasingleline.Inthislisting,Ihavecreatedanetworkentryforthefilenews.html.Ihavecreatedabuttonthatlinkstothisfileintheexample.htmlfile,likethis:
<div id="logobar"> <img src="cheeselux.png"> <div class="tagcontainer"> <span id="tagline">Gourmet European Cheese</span> <div> <a data-bind="visible: cheeseModel.cache.status() != 4" data-action="update" class="cachelink">Check for Updates</a> <a data-bind="visible: cheeseModel.cache.status() == 4" data-action="swapCache" class="cachelink">Apply Update</a> <a class="cachelink" href="news.html">News</a> </div> </div> </div>
Whenthebrowserisonline,clickingthislinkdisplaysthenews.htmlfile.YoucanseetheeffectinFigure5-4.
CHAPTER5CREATINGOFFLINEWEBAPPS
124
Figure5-4.Linkingtothenews.htmlpage
BecauseitisintheNETWORKsection,thenews.htmlfileisneveraddedtotheapplicationcache.WhenIclicktheNewsbutton,thebrowseractsasitwouldforregularcontent.Itcontactstheserver,getstheresources,andaddsthemtotheregular(nonapplication)cache,beforeshowingthemtotheuser.Icanmakechangestothenews.htmlfile,andtheywillbedisplayedtotheuserevenwhentheapplicationcachehasn’tbeenupdated.
Whenthebrowsergoesoffline,thereisnowaytogetholdofthecontentthatisnotintheapplicationcache.ThisiswheretheFALLBACKentriescomein.Theformatoftheseentriesisdifferentfromtheothers.
CautionBrowserstakedifferentviewsaboutwhatbeingofflinemeans.Iexplainmoreaboutthisinthe“MonitoringOfflineStatus”sectionlaterinthischapter.
Thefirstpartspecifiesaprefixforresources,andthesecondpartspecifiesafiletousewhenaresourcethatmatchestheprefixisrequestedwhilethebrowserisoffline.So,inListing5-7,IhavesetthemanifestsothatanyrequesttoanyURL(representedby/)shouldbegiventhefileoffline.htmlinstead.
CHAPTER5CREATINGOFFLINEWEBAPPS
125
Listing5-7.UsingaFallbackEntryintheApplicationManifest
... # fonts fonts/YanoneKaffeesatz-Regular.ttf fonts/fanwood_italic-webfont.ttf fonts/ostrich-rounded-webfont.woff FALLBACK: / offline.html
TipBrowsershandlefallbackforresourcesinthenetworkinconsistently.YoushouldnotrelyonthefallbacksectiontoprovidesubstitutecontentforURLsthatarelistedinthenetworksection,onlythosethatareinthemainpartofthemanifest.Supportforprovidingfallbacksforindividualfilesisalsoinconsistent,whichiswhyIhaveusedthebroadestpossiblefallbackintheexamplesforthischapter.IexpectthereliabilityandconsistencyofthesefeaturestoimproveastheHTML5implementationsstabilize.
Whenthebrowserisoffline,clickingtheNewsbuttontriggersarequestforaURLthatthebrowsercannotservicefromtheapplicationcache,andthefallbackentryisusedinstead.YoucanseetheresultinFigure5-5.TheURLinthebrowseraddressbarshowstheURLthatwasrequested,butthecontentthatisshownisfromthefallbackresource.
Figure5-5.Usingthefallbackentry
CHAPTER5CREATINGOFFLINEWEBAPPS
126
TheHTML5ApplicationCachespecificationprovidessupportformorecomplexfallbackentries,includingper-URLfallbacksandtheuseofwildcards.However,asIwritethis,GoogleChromedoesn’tsupporttheseentries,andageneralfallback,suchasIhaveshowninthelisting,isallthatcanbereliablyused.
ThespecificationfortheHTML5ApplicationCachefeatureisambiguousaboutwhetherthebrowsershouldusetheregularcontentcachetosatisfyrequestsfornetworkentryresources.And,ofcourse,differentapproacheshavebeenadopted.GoogleChrometakesthemostliteralinterpretationofthestandard.Whenthebrowserisoffline,networkentryresourcesarenotavailabletothewebapp.MozillaFirefoxandOperatakeamoreforgivingapproach:iftheresourceisinthemainbrowsercachewhenthebrowsergoesoffline,itwillbeavailabletothewebapp.Ofcourse,thebrowsersareupdatedfrequently,sotheremightbeadifferentsetofbehaviorsbythetimeyoureadthis.
CautionTheimplementationofthenetworkandfallbackfeaturescanbeinconsistent.Therearesomeodditiesintheimplementationsofthemainstreambrowsers,andasaconsequence,Itendtoavoidusingthesekindsofentriesforcachedapplications.Theregularcacheentriesworkwell,however,andcanberelieduponinthosebrowsersthatsupporttheapplicationcachefeature.
MonitoringOfflineStatusHTML5definestheabilitytodeterminewhetherthebrowserisonline.Whatbeingofflinemeansdependsontheplatformandthebrowser.Formobiledevices,beingofflineusuallyrequirestheusertoswitchtoairplanemodeortoexplicitlyswitchoffnetworkinginsomeotherway.Simplybeingoutofcoveragedoesn’tusuallychangethebrowserstatus.
Explicituseractionisrequiredformostdesktopbrowsersaswell.Forexample,FirefoxandOperabothhavemenuitemsthattogglethebrowserbetweenonlineandofflinemodes.TheexceptionisGoogleChrome,whichmonitorstheunderlyingnetworkconnectionsandswitchestoofflineifnonetworkdevicesareenabled.
NoteChromewillgointoofflinemodeonlywhenthereisnoenablednetworkconnection.Tocreatethescreenshotinthissection,Ihadtodisablemymain(wireless)connection,manuallydisableanEthernetportthatwasenabledbutnotpluggedintoanything,anddisableaconnectioncreatedbyavirtualmachinepackage.OnlythendidChromedecideitwastimetogooffline.Mostuserswon’thavethisproblem,butitissomethingtobearinmind,especiallyifyouarenotgettingtheofflinebehavioryouexpect.
RecentversionsofthemainstreambrowsersimplementanHTML5featurethatreportsonwhetherthebrowserisonlineoroffline.Thisisusefulbothintermsofpresentingtheuserwithausefulandcontextualinterfaceandintermsofmanagingtheinternaloperationsofthewebapp.Todemonstratethisfeature,IamgoingtochangetheexamplewebappsothatthecachecontrolandNewsbuttonsaredisplayedonlywhenthebrowserisonline.Listing5-8showsthechangestothescriptelement.
CHAPTER5CREATINGOFFLINEWEBAPPS
127
Listing5-8.DetectingtheStateoftheNetwork
<script> var cheeseModel = { products: [ {category: "British Cheese", items : [ {id: "stilton", name: "Stilton", price: 9}, {id: "stinkingbishop", name: "Stinking Bishop", price: 17}, {id: "cheddar", name: "Cheddar", price: 17}]}, {category: "French Cheese", items: [ {id: "camembert", name: "Camembert", price: 18}, {id: "tomme", name: "Tomme de Savoie", price: 19}, {id: "morbier", name: "Morbier", price: 9}]}, {category: "Italian Cheese", items: [ {id: "gorgonzola", name: "Gorgonzola", price: 8}, {id: "fontina", name: "Fontina", price: 11}, {id: "parmesan", name: "Parmesan", price: 16}]}], cache: { status: ko.observable(window.applicationCache.status), online: ko.observable(window.navigator.onLine) } }; $(document).ready(function() { $('#buttonDiv input:submit').button(); $('div.navSelectors').buttonset(); enhanceViewModel(); ko.applyBindings(cheeseModel); hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); crossroads.addRoute("category/:cat:", function(cat) { cheeseModel.selectedCategory(cat || cheeseModel.products[0].category); }); $(window.applicationCache).bind("checking noupdate downloading " + "progress cached updateready", function(e) { cheeseModel.cache.status(window.applicationCache.status); }); $(window).bind("online offline", function() { cheeseModel.cache.online(window.navigator.onLine); }); $('div.tagcontainer a').button().filter(':not([href])').click(function(e) { e.preventDefault(); if ($(this).attr("data-action") == "update") {
CHAPTER5CREATINGOFFLINEWEBAPPS
128
window.applicationCache.update(); } else { window.applicationCache.swapCache(); window.location.reload(false); } }); }); </script>
Thewindowbrowserobjectsupportstheonlineandofflineeventsthataretriggeredwhenthebrowserstatuschanges.Youcangetthecurrentstatusthroughthewindow.navigator.onLineproperty,whichreturnstrueifthebrowserisonlineandfalseifitisoffline.NotethattheLinonLineisuppercase.Ihaveaddedanonlineobservabledataitemtotheviewmodel,whichIupdateinresponsetotheonlineandofflineevents.ThisisthesametechniquethatIusedfortheapplicationcachestatus,anditallowsmetousetheviewmodeltopropagatechangesthroughtomymarkup.Listing5-9showsthechangestotheHTMLelementsthatdisplaytheNewsandapplicationcachecontrolbuttons.
Listing5-9.AddingElementsandBindingstoRespondtotheBrowserOnlineStatus
<div id="logobar"> <img src="cheeselux.png"> <div class="tagcontainer"> <span id="tagline">Gourmet European Cheese</span> <div> <span data-bind="visible: cheeseModel.cache.online()"> <a data-bind="visible: cheeseModel.cache.status() != 4" data-action="update" class="cachelink">Check for Updates</a> <a data-bind="visible: cheeseModel.cache.status() == 4" data-action="swapCache" class="cachelink">Apply Update</a> <a class="cachelink" href="/news.html">News</a> </span> <span data-bind="visible: !cheeseModel.cache.online()"> (Offline) </span> </div> </div> </div>
Whenthebrowserisonline,thecachecontrolandtheNewsbuttonsaredisplayed.Whenthebrowserisoffline,Ireplacethebuttonswithasimpleplaceholder.YoucanseetheeffectinFigure5-6.
TipYouneedtoensurethatyouhavetherightversionoftheofflinecontentbeforetakingthebrowseroffline.Beforerunningthisexample,youshouldeitherchangethemanifestorclearthebrowser’shistory.
CHAPTER5CREATINGOFFLINEWEBAPPS
129
Figure5-6.Respondingtothebrowseronlinestatus
USING RECURRING AJAX REQUESTS POLYFILLS
ThereareJavaScriptpolyfilllibrariesavailablethatuseperiodicAjaxrequestsasasubstituteforthenavigator.onLineproperty.Arequestforasmallfileismadetotheservereveryfewminutes,andiftherequestfails,thebrowserisassumedtobeoffline.
Istronglyrecommendavoidingthisapproach.First,itisn’tresponsiveenoughtobeuseful.Ifyouaretryingtoworkoutwhenthebrowserisoffline,findingoutseveralminutesafterithappensisn’tmuchuse.Duringtheperiodsbetweentests,thestatusofthebrowserisunknownandcannotbereliedon.
Second,repeatedlyrequestingafileconsumesbandwidththatyouandtheuserhavetopayfor.Ifyouhaveapopularwebapp,thebandwidthcostsofperiodiccheckscanbesignificant.Moreimportantly,asunlimiteddataplansformobiledevicesbecomelesscommon,assumingthatyoucanmakefreeuseofyourusers’bandwidthisextremelypresumptuous.Myadviceistonotrelyonthissortofpolyfill.Justdowithoutthenotificationsifthebrowserdoesn’tsupportthem.
UnderstandingwithAjaxandPOSTRequestsTheapplicationcachemakesitdifficulttoworkwithAjaxand,morebroadly,postingformsingeneral.Andthingsgetworsewhenthebrowserisoffline,althoughperhapsnotinthewayyoumightexpect.Inthissection,I’llshowyoutheproblemsandthelimitedoptionsthatareavailabletodealwiththem.First,however,IneedtoupdatetheCheeseLuxwebappsothatitdependsonanAjaxGETrequesttooperate.Listing5-10showstherequiredchangedtothescriptelement(nochangesareneededtothemarkupforthisexample).
CHAPTER5CREATINGOFFLINEWEBAPPS
130
Listing5-10.AddinganAjaxGETRequestRequest
... <script> var cheeseModel = { cache: { status: ko.observable(window.applicationCache.status), online: ko.observable(window.navigator.onLine) } }; $.getJSON("products.json", function(data) { cheeseModel.products = data; }).success(function() { $(document).ready(function() { $('#buttonDiv input:submit').button(); $('div.navSelectors').buttonset(); enhanceViewModel(); ko.applyBindings(cheeseModel); hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); crossroads.addRoute("category/:cat:", function(cat) { cheeseModel.selectedCategory(cat || cheeseModel.products[0].category); }); $(window.applicationCache).bind("checking noupdate downloading " + "progress cached updateready", function(e) { cheeseModel.cache.status(window.applicationCache.status); }); $(window).bind("online offline", function() { cheeseModel.cache.online(window.navigator.onLine); }); $('div.tagcontainer a').button().filter(':not([href])').click(function(e) { e.preventDefault(); if ($(this).attr("data-action") == "update") { window.applicationCache.update(); } else { window.applicationCache.swapCache(); window.location.reload(false); } }); }); }); </script> ...
CHAPTER5CREATINGOFFLINEWEBAPPS
131
Inthislisting,IhaveusedthejQuerygetJSONmethod.ThisisaconveniencemethodthatmakesanAjaxGETrequestfortheJSONfilespecifiedbythefirstmethodargument,whichisproducts.jsoninthiscase.WhentheAjaxrequestshascompleted,jQueryparsestheJSONdatatocreateaJavaScriptobject,whichispassedtothefunctionspecifiedbythesecondmethodargument.Inmylisting,thefunctionsimplytakestheJavaScriptobjectandassignsittotheproductspropertyoftheviewmodel.Theproducts.jsonfilecontainsasupersetofthedataIhavebeendefininginline.Thesamecategories,products,andpricesaredefined,alongwithanadditionaldescriptionofeachcheese.Listing5-11showsanextractfromproducts.json.
Listing5-11.AnExtractfromtheproducts.jsonFile
... {"id": "stilton", "name": "Stilton", "price": 9, "description": "A semi-soft blue cow's milk cheese produced in the Nottinghamshire region. A strong cheese with a distinctive smell and taste and crumbly texture."}, ...
InthelistingIchainthegetJSONmethodwithacalltosuccess.ThesuccessmethodispartofthejQuerysupportforJavaScriptPromises,whichmakeiteasytouseandmanageasynchronousoperationslikeAjaxrequests.Thefunctionpassedtothesuccessmethodwon’tbeexecuteduntilthegetJSONmethodhascompleted,ensuringthatmyviewmodeliscompletebeforetherestofmyscriptisrun.
ThisapproachtogettingcoredatafromJSONisacommonone,especiallywherethedataissourcedfromadifferentsetofsystemstotherestofthewebapp.And,ifusedcarefully,itcanensurethattheuserhasthemostrecentdatabutstillhasthebenefitofacachedapplication.
UnderstandingtheDefaultAjaxGETBehaviorThebrowsertreatsanAjaxGETrequestinaverysimpleway.TherequestwillfailiftheAjaxrequestisforaresourcethatisnotinthemanifest,evenwhenthebrowserisonline.
Formyexampleapplication,thismeansthatdataisreturnedfromtherequestanditdiesahorribledeath.ThefunctionIpassedasanargumenttothegetJSONmethodisexecutedonlyiftheAjaxrequestsucceeds,andthesameistrueforthefunctionpassedtothesuccessmethod.Becauseneitherfunctionisexecuted,themainpartofmyscriptcodeisn’tperformed,andIleavetheuserstranded.Worse,sincetheapplicationcachecontrolbuttonsareneversetup,Idon’tgivetheuserameanstoupdatetheapplicationtofixtheproblem.
Ihaveshownthisscenariobecauseitisverycommonlyencounteredwhenprogrammersfirststartusingtheapplicationcache.I’llshowyouhowtomaketheAjaxconnectionworkshortly,butfirst,thereareacoupleofimportantchangestobemade.
RestructuringtheApplicationThefirstchangeistostructuretheapplicationsothatthecorebehaviorthatwillgettheuserbackoutoftroublewillalwaysbeexecuted.Myinitiallistingisjusttoooptimistic,andIneedtoseparatethosepartsofthecodethatshouldalwaysberun.Therearelotsofdifferenttechniquesfordoingthis,butIfindthesimplestisjusttocreateanotherfunctionthatiscontingentonthejQueryreadyevent.Listing5-12showsthechangesIrequiretothescriptelement.
CHAPTER5CREATINGOFFLINEWEBAPPS
132
Listing5-12.RestructuringthescriptElement
... <script> var cheeseModel = { cache: { status: ko.observable(window.applicationCache.status), online: ko.observable(window.navigator.onLine) } }; $.getJSON("products.json", function(data) { cheeseModel.products = data; }).success(function() { $(document).ready(function() { enhanceViewModel(); ko.applyBindings(cheeseModel); hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); crossroads.addRoute("category/:cat:", function(cat) { cheeseModel.selectedCategory(cat || cheeseModel.products[0].category); }); }); }).complete(function() { $(document).ready(function() { $('#buttonDiv input:submit').button(); $('div.navSelectors').buttonset(); $(window).bind("online offline", function() { cheeseModel.cache.online(window.navigator.onLine); }); $(window.applicationCache).bind("checking noupdate downloading " + "progress cached updateready", function(e) { cheeseModel.cache.status(window.applicationCache.status); }); $('div.tagcontainer a').button().filter(':not([href])').click(function(e) { e.preventDefault(); if ($(this).attr("data-action") == "update") { window.applicationCache.update(); } else { window.applicationCache.swapCache(); window.location.reload(false); } }); }); });
CHAPTER5CREATINGOFFLINEWEBAPPS
133
</script> ...
Ihavepulledallofthecodethatisn’tcontingentonasuccessfulAjaxrequesttogetherandplaceditinafunctionpassedtothecompletemethod,whichIaddtothechainofmethodcalls.ThisfunctionwillbeexecutedwhentheAjaxrequestfinishes,irrespectiveofwhetheritsucceededorfailed.
Now,evenwhentheAjaxrequestfails,thecontrolsforupdatingthecacheandapplyingchangesarealwaysavailable.GiventhatAjaxproblemsarethemostlikelyreasonforerrorsattheclient,givingtheuserawaytoapplyanupdateisessential.Otherwise,youaregoingtohavetoprovideper-browserinstructionsforclearingthecache.Itisnotaperfectsolution,becauseIamunabletoapplymydatabindings,soelementsthatIwouldratherwerehiddenarevisible.IcouldusetheCSSdisplaypropertytohidesomeoftheseitems,butIthinkjustgivingtheusertheabilitytodownloadandapplyanupdateiswhatisessential.YoucanseetheeffectbeforeandaftertherestructuringinFigure5-7.
Figure5-7.Theeffectofrestructuringtheapplication
HandlingtheAjaxErrorTheotherchangeIneedtomakeistoaddsomekindoferrorhandlerforwhentheAjaxrequestfails.Thismayseemlikeabasictechnique,butmanywebapplicationsarecodedonlyforsuccess,andwhentheconnectionfails,everythingfallsapart.TherearelotsofwaysofhandlingAjaxerrors,buttheoneshowninListing5-13usessomejQueryfeatures.
Listing5-13.AddingSupportforHandlingAjaxErrors
<script> var cheeseModel = { cache: { status: ko.observable(window.applicationCache.status), online: ko.observable(window.navigator.onLine) } }; $.getJSON("products.json", function(data) { cheeseModel.products = data; }).success(function() { $(document).ready(function() { enhanceViewModel(); ko.applyBindings(cheeseModel);
CHAPTER5CREATINGOFFLINEWEBAPPS
134
hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); crossroads.addRoute("category/:cat:", function(cat) { cheeseModel.selectedCategory(cat || cheeseModel.products[0].category); }); }); }).error(function() { var dialogHTML = '<div>Try again later</div>'; $(dialogHTML).dialog({ modal: true, title: "Ajax Error", buttons: [{text: "OK", click: function() {$(this).dialog("close")}}] }); }).complete(function() { $(document).ready(function() { $('#buttonDiv input:submit').button(); $('div.navSelectors').buttonset(); $(window).bind("online offline", function() { cheeseModel.cache.online(window.navigator.onLine); }); $(window.applicationCache).bind("checking noupdate downloading " + "progress cached updateready", function(e) { cheeseModel.cache.status(window.applicationCache.status); }); $('div.tagcontainer a').button().filter(':not([href])').click(function(e) { e.preventDefault(); if ($(this).attr("data-action") == "update") { window.applicationCache.update(); } else { window.applicationCache.swapCache(); window.location.reload(false); } }); }); }); </script>
jQuerymakesiteasytohandleerrorswiththeerrormethod.ThisisanotherpartofthePromisesfeature,andthefunctionpassedtotheerrormethodwillbeexecutedifthereisaproblemwiththerequest.Inthisexample,IcreatedasimplejQueryUIdialogboxthattellstheuserthatthereisaproblem.
CHAPTER5CREATINGOFFLINEWEBAPPS
135
AddingtheAjaxURLtotheMainManifestorFALLBACKSectionsTheworstthingyoucandoatthispointisaddtheAjaxURLtothemainsectionofthemanifest.ThebrowserwilltreattheURLlikeanyotherresource,downloadingandcachingthecontentwhenthemanifestisprocessed.WhentheclientmakestheAjaxrequest,thebrowserwillreturnthecontentfromtheapplicationcache,andthedatawon’tbeupdateduntilamanifestchangetriggersacacheupdate.Theresultofthisisthatyouruserswillbeworkingwithstaledata,whichisgenerallycontrarytothereasoningbehindmakingtheAjaxrequestinthefirstplace.
YougetprettymuchthesameresultifyouaddtheURLtotheFALLBACKsection.Everyrequest,evenwhenthebrowserisonline,willbesatisfiedbywhateveryousetasthefallback,andnorequestwilleverbemadetotheserver.
AddingtheAjaxURLtotheManifestNETWORKSectionThebestapproach(albeitfarfromideal)istoaddtheAjaxURLtotheNETWORKsectionofthemanifest.Whenthebrowserisonline,theAjaxrequestswillbepassedtotheserver,andthelatestdatawillbepresentedtotheuser.
Theproblemsstartwhenthebrowserisoffline.TherearetwodifferentapproachestohandlingAjaxrequestsinanofflinebrowser.Thefirstapproach,whichyoucanseeinGoogleChrome,isthattheAjaxrequestwillfail.YourAjaxerrorhandlerwillbeinvoked,andthereisacleanfailure.
TheotherapproachcanbeseeninFirefox.Whenthebrowserisoffline,Ajaxrequestswillbeservicedusingthemainbrowsercacheifpossible.ThiscreatestheoddsituationwheretheuserwillgetstaledataifarequestforthesameURLwasmadebeforethebrowserwentofflineandwillgetanerrorifthisisthefirsttimethattheURLhasbeenaskedfor.
UnderstandingthePOSTRequestBehaviorThewaythatPOSTrequestsarehandledisalotmoreconsistentthanforGETrequests.Ifthebrowserisonline,thenthePOSTrequestwillbemadetotheserver.Ifthebrowserisoffline,thentherequestwillfail.ThisistrueforPOSTrequeststhataremadeusingregularHTMLandforPOSTrequestsmadeusingAjax.
ThisleadstoannoyedusersbecausePOSTingaformusuallycomesaftersomeperiodofactivityontheirpart.InthecaseoftheCheeseLuxexample,theuserwillhavepagedthroughthecategoriesandenteredtheamountsofeachproducttheyrequire.Whentheycometosubmittheirorder,thebrowserwillshowanerrorpage.Youcan’tevenusetheFALLBACKsectionofthemanifesttonominateapagetobeshowninsteadoftheerror.
Theonlysensiblethingtodoistointercepttheformsubmissionandusethenavigator.onLinepropertyandeventstomonitorthebrowserstatusandpreventtheuserfromtryingtopostcontentwhenthebrowserisoffline.InChapter6,I’llshowyousometechniquesforpreservingtheresultoftheuser’seffort,readyforwhenthebrowsercomesbackonline.
CHAPTER5CREATINGOFFLINEWEBAPPS
136
SummaryInthischapter,IshowedyouhowtousetheHTML5ApplicationCachetocreateofflineapplications.Byusingtheapplicationcache,youcancreateapplicationsthatareavailableevenwhentheuserdoesn’thaveanetworkconnection.Althoughthecoreoftheapplicationcacheiswell-supported,therearesomeanomalies,andcarefuldesignandtestingarerequiredtogetaresultthatisreliableandrobust.Inthenextchapter,I’llshowyouhowtousesomerelatedfunctionalitythathelpssmoothoutsomeoftheroughedgesofofflineappsandthatcanbeusedtocreateabetterexperiencefortheuser.
C H A P T E R 6
137
Storing Data in the Browser
Anaturalcomplementtoofflineapplicationsisclient-sidedatastorage.HTML5definessomeusefulJavaScriptAPIsforstoringdatainthebrowser,rangingfromsimplename/valuepairstousingaJavaScriptobjectdatabase.Inthischapter,Ishowyouhowtobuildapplicationsthatrelyonpersistentlystoreddata,includingdetailsofhowtousesuchdatainanofflinewebapplication.
CautionThebrowsersupportfordatastorageismixed.YoushouldruntheexamplesinthischapterusingGoogleChrome,withtheexceptionofthoseintheIndexedDBsection,whichwillrunonlyinMozillaFirefox.
UsingLocalStorageThesimplestwaytostoredatainthebrowseristousetheHTML5localstoragefeature.Thisallowsyoutostoresimplename/valuepairsandretrieveormodifythemlater.Thedataisstoredpersistentlybutisnotguaranteedtobestoredforever.Thebrowserisfreetodeleteyourdataifitneedsthespace(orifthedatahasn’tbeenaccessedforalongtime),and,ofcourse,theusercanclearthedatastoreatanytime,evenwhenyourwebappisrunning.Theresultisdatathatisbroadly,butnotindefinitely,persistent.UsinglocalstorageisverysimilartousingaregularJavaScriptarray,asListing6-1demonstrates.
Listing6-1.UsingLocalStorage
<!DOCTYPE html> <html> <head> <title>Local Storage Example</title> <link rel="stylesheet" type="text/css" href="jquery-ui-1.8.16.custom.css"/> <link rel="stylesheet" type="text/css" href="styles.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script src="jquery-ui-1.8.16.custom.js" type="text/javascript"></script> <script src='knockout-2.0.0.js' type='text/javascript'></script> <script src='utils.js' type='text/javascript'></script> <script src='signals.js' type='text/javascript'></script> <script src='crossroads.js' type='text/javascript'></script> <script src='hasher.js' type='text/javascript'></script> <script> var viewModel = {
CHAPTER6STORINGDATAINTHEBROWSER
138
items: ["Apple", "Orange", "Banana"], selectedItem: ko.observable("Apple") }; $(document).ready(function() { ko.applyBindings(viewModel); $('div.catSelectors').buttonset(); hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); crossroads.addRoute("select/{item}", function(item) { viewModel.selectedItem(item); localStorage["selection"] = item; }); viewModel.selectedItem(localStorage["selection"] || viewModel.items[0]); }); </script> </head> <body> <div class="catSelectors" data-bind="foreach: items"> <a data-bind="formatAttr: {attr: 'href', prefix: '#select/', value: $data}, css: {selectedItem: ($data == viewModel.selectedItem())}"> <span data-bind="text: $data"></span> </a> </div> <div data-bind="foreach: items"> <div class="item" data-bind="fadeVisible: $data == viewModel.selectedItem()"> The selected item is: <span data-bind="text: $data"></span> </div> </div> </body> </html>
Todemonstratelocalstorage,IhaveusedthesimpleexamplefromChapter4,whichallowsmetofocusonthestoragetechniqueswithoutthefeaturesfromotherchaptersgettingintheway.Asthelistingshows,gettingstartedwithlocalstorageisprettysimple.ThegloballocalStorageobjectactslikeanarray.Whentheusermakesaselectioninthissimplewebapp,Istoretheselecteditemusingarray-stylenotation,likethis:
localStorage["selection"] = item;
TipKeysarecase-sensitive(sothatselectionandSelectionwouldrepresentdifferentdataitems),andassigningavaluetoakeythatalreadyexistsoverwritesthepreviouslydefinedvalue.
CHAPTER6STORINGDATAINTHEBROWSER
139
Thisstatementcreatesanewlocalstorageitem,whichIcanreadbackusingthesamearray-stylenotation,likethis:
viewModel.selectedItem(localStorage["selection"] || viewModel.items[0]);
Theeffectofaddingthesetwostatementstotheexampleistocreatesimplepersistencefortheuser’sselection.Whenthewebappisloaded,Ichecktoseewhetherthereisdatastoredundertheselectionkeyand,ifthereis,setthecorrespondingdataitemintheviewmodel,whichrestorestheuser’sselectionfromanearliersession.
TipItisimportantnottouselocalstorageforsensitiveinformationortotrusttheintegrityofdataretrievedfromlocalstorageforcriticalfunctionsinyourwebapp.Userscanseeandeditthecontentsoflocalstorage,whichmeansthatnothingyoustoreissecretandeverythingcanbechanged.Don’tstoreanythingyoudon’twantpublicallydisseminated,anddon’trelyonlocalstoragetogiveprivilegedaccesstoyourwebapp.
Fromthatpointon,IupdatethevalueassociatedwiththeselectionkeyeachtimemyrouteismatchedbyaURLchange.Iincludedafallbacktoadefaultselectiontocopewiththepossibilitythatthelocalstoragedatahasbeendeleted(orthisisthefirsttimethattheuserhasloadedthewebapp).Totestthisfeature,loadtheexamplewebapp,selectoneoftheoptions,andthenreloadthewebpage.Thebrowserwillreloadthedocument,executetheJavaScriptcodeafresh,andrestoreyourselection.
StoringJSONDataThespecificationforlocalstoragerequiresthatkeysandvaluesarestrings,justlikeinthepreviousexample.Beingabletostorealistofname/valuepairsisn’talwaysthatuseful,butwecanbuildonthesupportforstringstouselocalstorageforJSONdata,asshowninListing6-2.
Listing6-2.UsingLocalStorageforJSONData
... <script> var viewModel = { selectedItem: ko.observable() }; function loadViewModelData() { var storedData = localStorage["viewModelData"]; if (storedData) { var storedDataObject = JSON.parse(storedData); viewModel.items = storedDataObject.items; viewModel.selectedItem(storedDataObject.selectedItem); } else { viewModel.items = ["Apple", "Orange", "Banana"]; viewModel.selectedItem("Apple"); } }
CHAPTER6STORINGDATAINTHEBROWSER
140
function storeViewModelData() { var viewModelData = { items: viewModel.items, selectedItem: viewModel.selectedItem() }; localStorage["viewModelData"] = JSON.stringify(viewModelData); } $(document).ready(function() { loadViewModelData(); ko.applyBindings(viewModel); $('div.catSelectors').buttonset(); hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); crossroads.addRoute("select/{item}", function(item) { viewModel.selectedItem(item); storeViewModelData(); }); }); </script> ...
IhavedefinedtwonewfunctionsinthescriptelementtosupportstoringJSON.ThestoreViewModelDatafunctioniscalledwhenevertheusermakesaselection.JSONisonlyabletostoredatavaluesandnotJavaScriptfunctions,soIextractthedatavaluesfromtheviewmodelandusethemtocreateanewobject.IpassthisobjecttotheJSON.stringifymethod,whichreturnsaJSONstring,likethis:
{"items":["Apple","Orange","Banana"], "selectedItem":"Banana"}
IstorethisstringbyassociatingitwiththeviewModelDatakeyinlocalstorage.ThecorrespondingfunctionisloadViewModelData.IcallthisfunctionwhenthejQueryreadyeventisfiredanduseittocompletetheviewmodel.
TipThepersistentnatureoflocalstoragemeansthatifyoureuseakeytostoreadifferentkindofdata,youruntheriskofencounteringtheoldformatthatwasstoredinaprevioussession.Thesimplestwaytohandlethisindevelopmentistoclearthebrowser’scache.Inproduction,youmustbeabletodetecttheolddataandeitherprocessitor,attheveryleast,beabletodiscarditwithoutgeneratinganyerrors.
CHAPTER6STORINGDATAINTHEBROWSER
141
IloadtheJSONstringandusetheJSON.parsemethodtocreateaJavaScriptobjectifthereislocalstoragedataassociatedwiththeviewModelDatakey.Icanthenreadthepropertiesoftheobjecttopopulatetheviewmodel.Ofcourse,Icannotrelyontherebeingdataavailable,soIfallbacktosomesensibledefaultvaluesifneeded.
STORING OBJECT DATA
Itwasn’thardtoseparatethedatafromtheobjectthatcontaineditinmysimpleexample,butitcanbesignificantlymoredifficultinacomplexwebapplication.Youmightbetemptedtoshortcutthisprocessbystoringobjectsdirectly,ratherthanmappingdatatostrings.Don’tdothis;itwillonlycauseyouproblems.Hereisacodesnippetthatshowslocalstoragebeingusedwithobjects:
... <script> var viewModel = {}; function loadViewModelData() { var storedData = localStorage["viewModelData"]; if (storedData) { viewModel = storedData; } else { viewModel.items = ["Apple", "Orange", "Banana"]; viewModel.selectedItem = ko.observable("Apple"); } } function storeViewModelData() { localStorage["viewModelData"] = viewModel; } $(document).ready(function() { loadViewModelData(); ko.applyBindings(viewModel); $('div.catSelectors').buttonset(); hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); crossroads.addRoute("select/{item}", function(item) { viewModel.selectedItem(item); storeViewModelData(); }); }); </script> ...
CHAPTER6STORINGDATAINTHEBROWSER
142
Thistechniquedoesn’twork.Thebrowserwon’tcomplainwhenyoustoreobjects,andifyoureadthevaluebackwithinthesamesession,everythinglooksfine.Butthebrowserserializestheobjectinordertostoreitforfuturesessions.FormostJavaScriptobjects,thestoredvaluewillbe[object Object],whichistheresultyougetifyoucallthetoStringmethod.Whentheuserrevisitsthewebapp,thevalueinlocalstorageisn’tavalidJavaScriptobjectandcan’tbeparsed.Thisisthekindofproblemthatshouldbedetectedduringtesting,butIseethisissuealot,notleastbecauseevenprojectsthattaketestingseriouslydon’tgenerallyrevisittheapplicationformultiplesessions.
StoringFormDataLocalstorageisideallysuitedformakingformdatapersistent.Thekey/valuemappingsuitsthenatureofformelementsverywell,andwithverylittleeffort,youcancreateformsthatarepersistentbetweensessions,asListing6-3shows.
Listing6-3.UsingLocalStoragetoCreatePersistentForms
<!DOCTYPE html> <html> <head> <title>Local Storage Example</title> <link rel="stylesheet" type="text/css" href="jquery-ui-1.8.16.custom.css"/> <link rel="stylesheet" type="text/css" href="styles.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script src="jquery-ui-1.8.16.custom.js" type="text/javascript"></script> <script src='knockout-2.0.0.js' type='text/javascript'></script> <script> var viewModel = { personalDetails: [ {name: "name", label: "Name", value: ko.observable()}, {name: "city", label: "City", value: ko.observable()}, {name: "country", label: "Country", value: ko.observable()} ] }; $(document).ready(function() { $.each(viewModel.personalDetails, function(index, item) { item.value(localStorage[item.name] || ""); item.value.subscribe(function(newValue) { localStorage[item.name] = newValue; }); }); ko.applyBindings(viewModel); $('#buttonDiv input').button().click(function(e) { localStorage.clear(); }); }); </script> </head>
CHAPTER6STORINGDATAINTHEBROWSER
143
<body> <form action="/formecho" method="POST"> <div class="cheesegroup"> <div class="grouptitle">Your Details</div> <div class="groupcontent centered"> <div data-bind="foreach: personalDetails"> <span data-bind="text: label"></span>: <input class="stwin" data-bind="attr: {name: name}, value: value"> </div> </div> </div> <div id="buttonDiv"> <input type="submit" value="Submit"> <input type="reset" value="Reset"> </div> </form> </body> </html>
Ihavedefinedasimplethree-fieldformelementinthisexample,whichyoucanseeinFigure6-1.Theformcapturestheuser’sname,city,andcountryandispostedtothe/formechoURLattheserver,whichsimplyrespondswithdetailsofthedatathatwassubmitted.
Figure6-1.Usinglocalstoragewithformelements
Ihaveusedaviewmodelasanintermediarybetweentheinputelementsandlocalstorage.Whentheuserentersavalueintooneoftheinputelements,thevaluedatabindingupdatesthecorrespondingobservabledataitemintheviewmodel.Iusethesubscribefunctiontoreceivenotificationsofthesechangesandwritetheupdatetolocalstorage,likethis:
$.each(viewModel.personalDetails, function(index, item) { item.value(localStorage[item.name] || ""); item.value.subscribe(function(newValue) { localStorage[item.name] = newValue; }); });
CHAPTER6STORINGDATAINTHEBROWSER
144
Isetupthesubscriptionbyenumeratingthroughtheitemsintheviewmodel.Iusethisopportunitytosettheinitialvaluesintheviewmodelfromlocalstorageifthereisdataavailable,likethis:
item.value(localStorage[item.name] || "");
WhenIsettheinitialvalue,thevaluesfromlocalstoragearepropagatedthroughtheviewmodeltotheinputelements,keepingeverythingup-to-date.
Itdoesn’tmakesensetocontinuetostoretheformdataoncetheformhasbeensubmittedorwhentheuserclickstheResetbutton.WheneithertheSubmitorResetbuttonisclicked,Iremovethedatafromlocalstorage,likethis:
$('#buttonDiv input').button().click(function(e) { localStorage.clear(); });
Theclearmethodremovesallofthedatainlocalstorageforthewebapp(butnotforotherwebapps;onlytheuserorthebrowseritselfcanaffectstorageacrosswebapps).Ididnotpreventthedefaultactionforeitherbutton,whichmeansthattheformwillbesubmittedbythesubmitbutton,andtheformwillberesetbytheresetbutton.
TipStrictlyspeaking,Ineednothavehandledtheclickeventfortheresetbuttonsincetheviewmodelwouldhaveledtoemptyvaluesbeingwrittentolocalstorage.Insituationslikethese,ItendtoprefercleansingthedatatwiceinordertogetsimplerJavaScriptcode.
Theeffectofthislittlewebappisthattheformdataispersistentuntiltheusersubmitstheform.Iftheusernavigatesawayfromtheformbeforesubmittingit,thedatatheyenteredbeforenavigatingawaywillberestoredthenexttimethewebappisloaded.
SynchronizingViewModelDataBetweenDocumentsThedatainlocalstorageisstoredonaper-originbasis,meaningthateachoriginhasitsownseparatelocalstoragearea.Thismeansyoudon’thavetoworryaboutkeycollisionwithotherpeople’swebapplications.Italsomeansthatwecanusewebstoragetosynchronizeviewmodelsbetweendifferentdocumentswithinthesamedomain.
Whenusinglocalstorageinthisway,Iwanttobenotifiedwhenanotherdocumentmodifiesastoreddatavalue.Icanreceivesuchnotificationsbyhandlingthestorageevent,whichisemittedbythewindowbrowserobject.Tomakethiseventeasiertouse,Ihavecreatedanewkindofobservabledataitemthatautomaticallypersistsitselftolocalstorageandthatloadschangedvaluesinresponsetothestorageevent.Iaddedthisnewfunctionalitytotheutils.jsfile,asshowninListing6-4.
Listing6-4.CreatingaPersistentObservableDataItem
... ko.persistentObservable = function(keyName, initialValue) { var obItem = ko.observable(localStorage[keyName] || initialValue); $(window).bind("storage", function(e) {
CHAPTER6STORINGDATAINTHEBROWSER
145
if (e.originalEvent.key == keyName) { obItem(e.originalEvent.newValue); } }); obItem.subscribe(function(newValue) { localStorage[keyName] = newValue; }); return obItem; } ...
Thiscodeisawrapperaroundthestandardobservabledataitem,thelocalstoragedataarray,andthestorageevent.Thefunctioniscalledwithakeynamethatreferstoadataiteminlocalstorage.Whenthefunctioniscalled,Iusethekeytocheckwhetherthereisalreadydatainlocalstorageforthespecifiedkeyand,ifthereis,settheinitialvalueoftheobservable.Ifthereisn’tadefaultvalue,IusetheinitialValuefunctionargument:
var obItem = ko.observable(localStorage[keyName] || initialValue);
IusejQuerytobindtothestorageeventonthewindowobject.jQuerynormalizesevents,wrappingtheeventobjectsemittedbyelementswithajQuery-specificsubstitute.Ineedtogettotheunderlyingeventobjectbecauseitcontainsinformationaboutthechangeinlocalstorage;IdothisthroughtheoriginalEventproperty.Whenhandlingthestorageevent,theoriginalEventpropertyreturnsaStorageEventobject,themostusefulpropertiesofwhicharedescribedinTable6-1.
Table6-1.PropertiesoftheStorageEventObject
Property Description
key Returnsthekeyfortheitemthathasbeenmodified
oldValue Returnstheoldvaluefortheitemthathasbeenmodified
newValue Returnsthenewvaluefortheitemthathasbeenmodified
url ReturnstheURLofthedocumentthatmadethechange
Intheexample,IusethekeypropertytodeterminewhetherthisisaneventforthedataitemthatI
ammonitoringand,ifitis,thenewValuepropertytoupdatetheregularobservabledataitem:
$(window).bind("storage", function(e) { if (e.originalEvent.key == keyName) { obItem(e.originalEvent.newValue); } });
Finally,IusetheKOsubscribemethodsothatIcanupdatethelocalstoragevalueinresponsetochangesintheviewmodel:
obItem.subscribe(function(newValue) { localStorage[keyName] = newValue; });
CHAPTER6STORINGDATAINTHEBROWSER
146
Withjustafewlinesofcode,Ihavebeenabletocreateapersistentobservabledataitemformyviewmodel.
Ihavenothadtotakeanyspecialprecautionstopreventaninfiniteloopofevent-update-subscription-eventoccurring.Therearetworeasonsforthis.First,theKOobservabledataitemthatmycodewrapsaroundissmartenoughtoissueupdatesonlywhenanupdatedvalueisdifferentfromtheexistingvalue.
Second,thebrowsertriggersthestorageeventonlyinotherdocumentsinthesameoriginandnotthedocumentinwhichthechangewasmade.Ihavealwaysthoughtthiswasslightlyodd,butitdoesmeanthatmycodeissimplerthanitwouldotherwisehavebeen.
Todemonstratemynewlypersistentdataitems,Ihavedefinedanewdocumentcalledembedded.html,thecontentofwhichisshowninListing6-5.
Listing6-5.ANewDocumentThatUsesPersistentObservableDataItems
<!DOCTYPE html> <html> <head> <title>Embedded Storage Example</title> <link rel="stylesheet" type="text/css" href="jquery-ui-1.8.16.custom.css"/> <link rel="stylesheet" type="text/css" href="styles.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script src="jquery-ui-1.8.16.custom.js" type="text/javascript"></script> <script src='knockout-2.0.0.js' type='text/javascript'></script> <script src='utils.js' type='text/javascript'></script> <script> var viewModel = { personalDetails: [ {name: "name", label: "Name", value: ko.persistentObservable("name")}, {name: "city", label: "City", value: ko.persistentObservable("city")}, {name: "country", label: "Country", value: ko.persistentObservable("country")} ] }; $(document).ready(function() { ko.applyBindings(viewModel); }); </script> </head> <body> <div class="cheesegroup"> <div class="grouptitle">Embedded Document</div> <div class="groupcontent centered"> <div data-bind="foreach: personalDetails"> <span data-bind="text: label"></span>: <input class="stwin" data-bind="attr: {name: name}, value: value"> </div> </div> </div> </body> </html>
CHAPTER6STORINGDATAINTHEBROWSER
147
Thisdocumentduplicatestheinputelementsfromthemainexample,butwithouttheformandbuttonelements.Itdoes,however,haveaviewmodelthatusesthepersistentObservabledataitem,meaningthatchangestotheinputelementvaluesinthisdocumentwillbereflectedinlocalstorageand,equally,thatchangesinlocalstoragewillbereflectedintheinputelements.Ihavenotsupplieddefaultvaluesforthepersistentobservableitems;ifthereisnolocalstoragevalue,thenIwanttheinitialvaluetodefaulttonull,whichIachievebynotsupplyingasecondargumenttothepersistentObservablefunction.
Allthatremainsistomodifythemaindocument.Forsimplicity,Iamembeddingonedocumentinsideanother,butlocalstorageissharedacrossanydocumentsfromthesameorigin,meaningthatthistechniquewillworkwhenthosedocumentsarewithindifferentbrowsertabsorwindows.Listing6-6showsthemodificationstoexample.html,includingembeddingtheembedded.htmldocument.
Listing6-6.ModifyingtheMainExampleDocument
<!DOCTYPE html> <html> <head> <title>Local Storage Example</title> <link rel="stylesheet" type="text/css" href="jquery-ui-1.8.16.custom.css"/> <link rel="stylesheet" type="text/css" href="styles.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script src="jquery-ui-1.8.16.custom.js" type="text/javascript"></script> <script src='knockout-2.0.0.js' type='text/javascript'></script> <script src='utils.js' type='text/javascript'></script> <script> var viewModel = { personalDetails: [ {name: "name", label: "Name", value: ko.persistentObservable("name")}, {name: "city", label: "City", value: ko.persistentObservable("city")}, {name: "country", label: "Country", value: ko.persistentObservable("country")} ] }; $(document).ready(function() { ko.applyBindings(viewModel); $('#buttonDiv input').button().click(function(e) { localStorage.clear(); }); }); </script> </head> <body> <form action="/formecho" method="POST"> <div class="cheesegroup"> <div class="grouptitle">Your Details</div> <div class="groupcontent centered"> <div data-bind="foreach: personalDetails"> <span data-bind="text: label"></span>:
CHAPTER6STORINGDATAINTHEBROWSER
148
<input class="stwin" data-bind="attr: {name: name}, value: value"> </div> </div> </div> <iframe src="embedded.html"></iframe> <div id="buttonDiv"> <input type="submit" value="Submit"> <input type="reset" value="Reset"> </div> </form> </body> </html>
IhaveusedthesamekeysforthepersistentObservablefunctionwhendefiningtheviewmodelandaddedaniframeelementthatembedstheotherHTMLdocument.Sincebothareloadedfromthesameorigin,thebrowsersharesthesamelocalstoragebetweenthem.Changingthevalueofaninputelementinonedocumentwilltriggeracorrespondingchangeintheotherdocument,vialocalstorageandthetwoviewmodels.
CautionThebrowsersdon’tprovideanyguaranteesabouttheintegrityofadataitemifupdatesarewrittentolocalstoragefromtwodocumentssimultaneously.Itishardtocaterforthiseventuality(andIhaveneverseenithappen),butitisprudenttoassumethatdatacorruptioncanoccurifyouaresharinglocalstorage.
UsingSessionStorageThecomplementtolocalstorageissessionstorage,whichisaccessedthroughthesessionStorageobject.ThesessionStorageandlocalStorageobjectsareusedinthesamewayandemitthesamestorageevent.Thedifferenceisthatthedataisdeletedwhenthedocumentisclosedinthebrowser(morespecifically,thedataisdeletedwhenthetop-levelbrowsingcontextisdestroyed,butthat’susuallythesamething).
Themostcommonuseforsessionstorageistopreservedatawhenadocumentisreloaded.Thisisausefultechnique,althoughIhavetoadmitthatItendtouselocalstoragetoachievethesameeffectinstead.Themainbenefitofsessionstorageisperformance,sincethedataisusuallyheldinmemoryanddoesn’tneedtobewrittentodisk.Thatsaid,ifyoucareaboutthemarginalperformancegainsthatthisoffers,thenyoumayneedtoconsiderwhetherthebrowseristhebestenvironmentforyourapp.Listing6-7showshowIhaveaddedsupportforsessionpersistencetomyobservabledataiteminutils.js.
Listing6-7.DefiningaSemi-persistentObservableDataItemUsingSessionStorage
ko.persistentObservable = function(keyName, initialValue, useSession) { var storageObject = useSession ? sessionStorage : localStorage var obItem = ko.observable(storageObject[keyName] || initialValue);
CHAPTER6STORINGDATAINTHEBROWSER
149
$(window).bind("storage", function(e) { if (e.originalEvent.key == keyName) { obItem(e.originalEvent.newValue); } }); obItem.subscribe(function(newValue) { storageObject[keyName] = newValue; }); return obItem; }
SincethesessionStorageandlocalStorageobjectsexposethesamefeaturesandusethesameevent,Iamabletoeasilymodifymylocalstorageobservableitemtoaddsupportforsessionstorage.Ihaveaddedanargumenttothefunctionthat,iftrue,switchestosessionstorage.Iuselocalstorageiftheargumentisnotprovidedorisfalse.Listing6-8showshowIhaveappliedsessionstoragetotwooftheobservabledataitemsintheexampleviewmodel.
Listing6-8.UsingSessionStorage
... var viewModel = { personalDetails: [ {name: "name", label: "Name", value: ko.persistentObservable("name")}, {name: "city", label: "City", value: ko.persistentObservable("city", null, true)}, {name: "country", label: "Country", value: ko.persistentObservable("country", null, true)} ] }; ...
ThevaluesoftheCityandCountryelementsarehandledusingsessionstoragewhiletheNameelementremainswithlocalstorage.Ifyouloadtheexampleintothebrowser,youwillfindthatreloadingthedocumentdoesn’tclearanyofthevaluesyouhaveentered.However,onlytheNamevalueremainsifyoucloseandreopenthedocument.
UsingLocalStoragewithOfflineWebApplicationsPartofthebenefitthatcomesfromusinglocalstorageisthatitisavailableoffline.ThismeansthatwecanuselocaldatatoaddresstheproblemsarisingfromAjaxGETrequestswhenthebrowserisoffline.Listing6-9showsthecachedCheeseLuxwebappfromthepreviouschapter,updatedtotakeadvantageoflocalstorage.
Listing6-9.UsingLocalStorageforOfflineWebAppsThatUseAjax
<!DOCTYPE html> <html manifest="cheeselux.appcache"> <head> <title>CheeseLux</title> <link rel="stylesheet" type="text/css" href="styles.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script>
CHAPTER6STORINGDATAINTHEBROWSER
150
<script src="jquery-ui-1.8.16.custom.js" type="text/javascript"></script> <script src='knockout-2.0.0.js' type='text/javascript'></script> <script src='utils.js' type='text/javascript'></script> <script src='signals.js' type='text/javascript'></script> <script src='hasher.js' type='text/javascript'></script> <script src='crossroads.js' type='text/javascript'></script> <link rel="stylesheet" type="text/css" href="jquery-ui-1.8.16.custom.css"/> <noscript> <meta http-equiv="refresh" content="0; noscript.html"/> </noscript> <script> var cheeseModel = { cache: { status: ko.observable(window.applicationCache.status), online: ko.observable(window.navigator.onLine) } }; $.getJSON("products.json", function(data) { cheeseModel.products = data; localStorage["jsondata"] = JSON.stringify(data); }).error(function() { if (localStorage["jsondata"]) { cheeseModel.products = JSON.parse(localStorage["jsondata"]); } }).complete(function() { $(document).ready(function() { if (cheeseModel.products) { enhanceViewModel(); ko.applyBindings(cheeseModel); hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); crossroads.addRoute("category/:cat:", function(cat) { cheeseModel.selectedCategory(cat || cheeseModel.products[0].category); }); $('#buttonDiv input:submit').button(); $('div.navSelectors').buttonset(); $(window).bind("online offline", function() { cheeseModel.cache.online(window.navigator.onLine); }); $(window.applicationCache).bind("checking noupdate downloading " + "progress cached updateready", function(e) { cheeseModel.cache.status(window.applicationCache.status); }); $('div.tagcontainer a').button().filter(':not([href])')
CHAPTER6STORINGDATAINTHEBROWSER
151
.click(function(e) { e.preventDefault(); if ($(this).attr("data-action") == "update") { window.applicationCache.update(); } else { window.applicationCache.swapCache(); window.location.reload(false); } }); } else { var dialogHTML = '<div>Try again later</div>'; $(dialogHTML).dialog({ modal: true, title: "Ajax Error", buttons: [{text: "OK", click: function() {$(this).dialog("close")}}] }); } }); }); </script> </head> <body> <div id="logobar"> <img src="cheeselux.png"> <div class="tagcontainer"> <span id="tagline">Gourmet European Cheese</span> <div> <span data-bind="visible: cheeseModel.cache.online()"> <a data-bind="visible: cheeseModel.cache.status() != 4" data-action="update" class="cachelink">Check for Updates</a> <a data-bind="visible: cheeseModel.cache.status() == 4" data-action="swapCache" class="cachelink">Apply Update</a> <a class="cachelink" href="/news.html">News</a> </span> <span data-bind="visible: !cheeseModel.cache.online()"> (Offline) </span> </div> </div> </div> <div class="cheesegroup"> <div class="navSelectors" data-bind="foreach: products"> <a data-bind="formatAttr: {attr: 'href', prefix: '#category/', value: category}, css: {selectedItem: (category == cheeseModel.selectedCategory())}"> <span data-bind="text: category"> </a> </div> </div>
CHAPTER6STORINGDATAINTHEBROWSER
152
<form action="/shipping" method="post"> <div data-bind="foreach: products"> <div class="cheesegroup" data-bind="fadeVisible: category == cheeseModel.selectedCategory()"> <div class="grouptitle" data-bind="text: category"></div> <!-- ko foreach: items --> <div class="groupcontent"> <label data-bind="attr: {for: id}" class="cheesename"> <span data-bind="text: name"> </span> $(<span data-bind="text:price"></span>)</label> <input data-bind="attr: {name: id}, value: quantity"/> <span data-bind="visible: subtotal" class="subtotal"> ($<span data-bind="text: subtotal"></span>) </span> </div> <!-- /ko --> <div class="groupcontent"> <label class="cheesename">Total:</label> <span class="subtotal" id="total"> $<span data-bind="text: cheeseModel.total()"></span> </span> </div> </div> </div> <div id="buttonDiv"> <input type="submit" value="Submit Order"/> </div> </form> </body> </html>
Inthislisting,IusetheJSON.stringifymethodtostoreacopyoftheviewmodeldatawhentheAjaxrequestissuccessful:
$.getJSON("products.json", function(data) { cheeseModel.products = data; localStorage["jsondata"] = JSON.stringify(data); })
Iaddedtheproducts.jsonURLtotheNETWORKsectionofthemanifestforthiswebapp,soIhaveareasonableexpectationthatthedatawillbeavailableandthattheAjaxrequestwillsucceed.
If,however,therequestfails,whichwilldefinitelyhappenifthebrowserisoffline,thenItrytolocateandrestoretheserializeddatafromlocalstorage,likethis:
}).error(function() { if (localStorage["jsondata"]) { cheeseModel.products = JSON.parse(localStorage["jsondata"]); } })
Assumingtheinitialrequestworks,Iwillhaveagoodfallbackpositionifsubsequentrequestsfail.TheeffectthatthistechniquecreatesissimilartothewaythatFirefoxhandlesAjaxrequestswhenthebrowserisofflinebecauseIendupusingthelastversionofthedataIwasabletoobtainfromtheserver.
CHAPTER6STORINGDATAINTHEBROWSER
153
NoticethatIhaverestructuredthecodesothattherestofthewebappsetupoccursinthecompletehandlerfunction,whichistriggeredirrespectiveoftheoutcomeoftheAjaxrequest.ThesuccessorfailureofAjaxnolongerdetermineshowIprocessedit;nowitisallaboutwhetherornotIhavedata,eitherfreshfromtheserverorrestoredfromlocalstorage.
UsingLocalStoragewithOfflineFormsImentionedinChapter5thattheonlywayofdealingwithPOSTrequestsinacachedapplicationistopreventtheuserfrominitiatingtherequestwhenthebrowserisoffline.Thisremainstrue,butyoucanimprovetheexperiencethatyoudelivertotheuserbyusinglocalstoragetocreatepersistentvalues.Todemonstratethisapproach,IfirstneedtoupdatetheenhanceViewModelfunctionintheutils.jsfiletouselocalstoragetopersisttheformvalues,asshowninListing6-10.
Listing6-10.UpdatingtheenhanceViewModelFunctiontoUseLocalStorage
... function enhanceViewModel() { cheeseModel.selectedCategory = ko.persistentObservable("selectedCategory", cheeseModel.products[0].category); mapProducts(function(item) { item.quantity = ko.persistentObservable(item.id + "_quantity", 0); item.subtotal = ko.computed(function() { return this.quantity() * this.price; }, item); }, cheeseModel.products, "items"); cheeseModel.total = ko.computed(function() { var total = 0; mapProducts(function(elem) { total += elem.subtotal(); }, cheeseModel.products, "items"); return total; }); }; ...
Thisisaprettysimplechange,butthereareacoupleofpointstonote.Iwanttomaketheviewmodelquantitypropertypersistentforeachcheeseproduct,soIusethevalueoftheitemidpropertytoavoidkeycollisioninlocalstorage:
item.quantity = ko.persistentObservable(item.id + "_quantity", 0);
ThesecondpointtonoteisthatwhenIloadvaluesfromlocalstorage,Iwillbeputtingstrings,andnotnumbers,intheviewmodel.However,JavaScriptiscleverenoughtoconvertstringswhenperformingmultiplicationoperations,likethis:
return this.quantity() * this.price;
EverythingworksasIwouldlikeittowork.However,JavaScriptusesthesamesymboltodenotestringconcatenationandnumericaddition,soifIhadbeentryingtosumvaluesintheviewmodel,Iwouldhavehadtotaketheextrastepofparsingthevalue,likethis:
CHAPTER6STORINGDATAINTHEBROWSER
154
return Number(this.quantity()) + someOtherValue;
UsingPersistenceintheOfflineApplicationNowthatIhavemodifiedtheviewmodel,IcanchangethemaindocumenttoimprovethewaythatIhandletheformelementwhenthebrowserisoffline.Listing6-11showsthechangestotheHTMLmarkup.
Listing6-11.AddingButtonsThatHandletheFormWhentheBrowserIsOffline
... <form action="/shipping" method="post"> <div data-bind="foreach: products"> <div class="cheesegroup" data-bind="fadeVisible: category == cheeseModel.selectedCategory()"> <div class="grouptitle" data-bind="text: category"></div> <!-- ko foreach: items --> <div class="groupcontent"> <label data-bind="attr: {for: id}" class="cheesename"> <span data-bind="text: name"> </span> $(<span data-bind="text:price"></span>)</label> <input data-bind="attr: {name: id}, value: quantity"/> <span data-bind="visible: subtotal" class="subtotal"> ($<span data-bind="text: subtotal"></span>) </span> </div> <!-- /ko --> <div class="groupcontent"> <label class="cheesename">Total:</label> <span class="subtotal" id="total"> $<span data-bind="text: cheeseModel.total()"></span> </span> </div> </div> </div> <div id="buttonDiv"> <input type="submit" value="Submit Order" data-bind="visible: cheeseModel.cache.online()"/> <input type="button" value="Save for Later" data-bind="visible: !cheeseModel.cache.online()"/> </div> </form> ...
IhaveaddedaSaveforLaterbuttontothedocument,whichisvisiblewhenthebrowserisoffline.Ihavealsochangedthesubmitbuttonsothatitisvisibleonlywhenthebrowserisonline.Listing6-12showsthecorrespondingchangestothescriptelement.
Listing6-12.ChangestothescriptElementtoSupportOfflineForms
<script> var cheeseModel = {
CHAPTER6STORINGDATAINTHEBROWSER
155
cache: { status: ko.observable(window.applicationCache.status), online: ko.observable(window.navigator.onLine) } }; $.getJSON("products.json", function(data) { cheeseModel.products = data; localStorage["jsondata"] = JSON.stringify(data); }).error(function() { if (localStorage["jsondata"]) { cheeseModel.products = JSON.parse(localStorage["jsondata"]); } }).complete(function() { $(document).ready(function() { if (cheeseModel.products) { enhanceViewModel(); ko.applyBindings(cheeseModel); hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); crossroads.addRoute("category/:cat:", function(cat) { cheeseModel.selectedCategory(cat || cheeseModel.products[0].category); }); $('#buttonDiv input').button().click(function(e) { if (e.target.type == "button") { createDialog("Basket Saved for Later"); } else { localStorage.clear(); } }); $('div.navSelectors').buttonset(); $(window).bind("online offline", function() { cheeseModel.cache.online(window.navigator.onLine); }); $(window.applicationCache).bind("checking noupdate downloading " + "progress cached updateready", function(e) { cheeseModel.cache.status(window.applicationCache.status); }); $('div.tagcontainer a').button().filter(':not([href])') .click(function(e) { e.preventDefault(); if ($(this).attr("data-action") == "update") { window.applicationCache.update(); } else {
CHAPTER6STORINGDATAINTHEBROWSER
156
window.applicationCache.swapCache(); window.location.reload(false); } }); } else { createDialog("Try again later"); } }); }); </script>
Thisisasimplechange,andyou’llquicklyrealizethatIamdoingsomemildmisdirection.Whenthebrowserisonline,theusercansubmittheformasnormal,andanydatainlocalstorageiscleared.ThemisdirectioncomeswhenthebrowserisofflineandtheuserclickstheSaveforLaterbutton.AllIdoiscallthecreateDialogfunction,tellingtheuserthattheformdatahasbeensaved.However,Idon’tactuallyneedtosavethedatabecauseIamusingpersistentobservabledataitemsintheviewmodel.Theuserdoesn’tneedtoknowaboutthis;theyjustgetthebenefitofthepersistenceandaclearsignalfromthewebapplicationthattheformdatahasnotbeensubmitted.Whenthebrowserisonlineagain,theusercansubmitthedata.Usinglocalstorageallofthetimemeansthattheuserwon’tlosetheirdataiftheycloseandlaterreloadtheapplicationbeforebeingabletosubmittheformtotheserver.Forcompleteness,Listing6-13showsthecreateDialogfunction,whichIdefinedintheutils.jsfile.ThisisthesameapproachIusedtocreateanerrordialogintheoriginalexample,andImovedthecodeintoafunctionbecauseIneededtocreatethesamekindofdialogboxatmultiplepointsintheapplication.
Listing6-13.ThecreateDialogFunction
function createDialog(message) { $('<div>' + message + '</div>').dialog({ modal: true, title: "Message", buttons: [{text: "OK", click: function() {$(this).dialog("close")}}] }); };
Ihavetakenaverysimpleanddirectapproachtodealingwithformdatawhenthebrowserisoffline,butyoucaneasilyseehowamoresophisticatedapproachcouldbecreated.Youmight,forexample,respondtotheonlineeventbypromptingtheusertosubmitthedataorevensubmititautomaticallyusingAjax.Whateverapproachyoutake,youmustensurethattheuserunderstandsandapprovesofwhatyourwebappisdoing.
StoringComplexDataStoringname/valuepairsisperfectlysuitedtostoringformdata,butforanythingmoresophisticated,suchasimpleapproachstartstobreakdown.Thereisanotherbrowserfeature,calledIndexedDB,whichyoucanusetostoreandworkwithmorecomplexdata.
CHAPTER6STORINGDATAINTHEBROWSER
157
NoteIndexedDBisonlyoneoftwocompetingstandardsforstoringcomplexdatainthebrowser.TheotherisWebSQL.AsIwritethis,theW3CissupportingIndexedDB,butitisentirelypossiblethatWebSQLwillmakeacomebackor,atleast,becomeadefactostandard.IhavenotincludedWebSQLinthischapterbecausesupportforitislimitedatpresent,butthisisanareaoffunctionalitythatisfarfromsettled,andyoushouldreviewthesupportforbothstandardsbeforeadoptingoneofthemforyourprojects.
ItisstillearlydaysforIndexedDB,andasIwritethis,thefunctionalityisavailableonlythroughvendor-specifiedprefixes,signifyingthatthebrowserimplementationsarestillexperimentalandmaydeviatefromtheW3Cspecification.Currently,thebrowserthatadheresmostcloselytotheW3CspecificationisMozillaFirefox,sothisisthebrowserIhaveusedtodemonstrateIndexedDB.
CautionTheexamplesinthischaptermaynotworkwithbrowsersotherthanFirefox.Infact,theymaynotworkevenwithversionsofFirefoxotherthantheoneIusedinthischapter(version10).Thatsaid,youshouldstillbeabletogetasolidunderstandingofhowIndexedDBworks,evenifthespecificationorimplementationschange.
TheIndexedDBfeatureisorganizedarounddatabasesthat,likelocalandsessionstorage,areisolatedonaper-originbasissothattheycanbesharedbetweenapplicationsfromthesameorigin.IndexedDBdoesn’tfollowtheSQL-basedtablestructurethatiscommoninrelationaldatabases.AnIndexedDBdatabaseismadeupofobjectstores,whichcancontainJavaScriptobjects.YoucanaddJavaScriptobjectstoobjectstores,andyoucanquerythosestoresindifferentways,someofwhichIdemonstrateshortly.
TheresultofthisapproachisastoragemechanismthatismoreinkeepingwiththestyleoftheJavaScriptlanguagebutthatendsupbeingslightlyawkwardtouse.AlmostalloperationsinIndexedDBareperformedasasynchronousrequeststowhichfunctionscanbeattachedsothattheyareexecutedwhentheoperationcompletes.TodemonstratehowIndexedDBworks,IamgoingtocreateaCheeseFinderapplication.IwillputthecheeseproductdataintoanIndexedDBdatabaseandprovidetheuserwithsomedifferentwaysofsearchingthedataforcheesestheymightlike.Figure6-2showsthefinishedwebapptohelpprovidesomecontextforthecodethatfollows.
CHAPTER6STORINGDATAINTHEBROWSER
158
Figure6-2.UsingIndexedDBtoqueryproductdata
Thefigureshowstheoptiontosearchthedescriptionofeachproductinuse.Ihavesearchedforthetermcow,andthoseproductswhosedescriptionscontainthistermarelistedatthebottomofthepage.(Thereareseveralmatchesbecausemanyofthedescriptionsexplainthatthecheeseismadefromcows’milk.)
CreatingtheIndexedDBDatabaseandObjectStoreThecodeforthisexampleissplitbetweentheutils.jsfileandthemainexample.htmldocument.I’llbejumpingbetweenthesefilestodemonstratethecorefeaturesthatIndexedDBoffers.Tobegin,IhavedefinedaDBOobjectandthesetupDatabasefunctioninutils.js,asshowninListing6-14.
CHAPTER6STORINGDATAINTHEBROWSER
159
Listing6-14.SettingUptheIndexedDBDatabase
var DBO = { dbVersion: 31 } function setupDatabase(data, callback) { var indexDB = window.indexedDB || window.mozIndexedDB; var req = indexDB.open("CheeseDB", DBO.dbVersion); req.onupgradeneeded = function(e) { var db = req.result; var existingStores = db.objectStoreNames; for (var i = 0; i < existingStores.length; i++) { db.deleteObjectStore(existingStores[i]); } var objectStore = db.createObjectStore("products", {keyPath: "id"}); objectStore.createIndex("category", "category", {unique: false}); $.each(data, function(index, item) { var currentCategory = item.category; $.each(item.items, function(index, item) { item.category = currentCategory; objectStore.add(item); }); }); }; req.onsuccess = function(e) { DBO.db = this.result; callback(); }; };
IhavedefinedanobjectcalledDBOthatperformstwoimportanttasks.First,itdefinestheversionofthedatabasethatIamexpectingtoworkwith.EachtimeImakeachangetothedatabaseschema,IincrementthevalueofthedbVersionproperty,andasyoucansee,ittookme31changesuntilIgottheresultIwantedforthisexample.ThiswaslargelybecauseofthedifferencesbetweenthecurrentdraftofthespecificationandtheimplementationinFirefox.
TipTheversionnumberisanimportantmechanisminensuringIamworkingwiththerightversionoftheschemaformyapp.I’llshowyouhowtochecktheschemaversionand,ifneeded,upgradetheschema,shortly.
CHAPTER6STORINGDATAINTHEBROWSER
160
InthesetupDatabasefunction,IbeginbylocatingtheobjectthatactsasthegatewaytotheIndexedDBdatabases,likethis:
var indexDB = window.indexedDB || window.mozIndexedDB;
TheIndexedDBfeatureisavailableinFirefoxonlythroughthewindow.mozIndexedDBobjectatthemoment,butthatwillchangetowindow.indexedDBoncetheimplementationconvergesonthefinalspecification.Togiveyouthegreatestchanceofmakingtheexamplesinthispartofthechapterwork,Itrytousethe“official”IndexedDBobjectfirstandfallbacktothevendor-prefixedalternativeifitisn’tavailable.Thenextstepistoopenthedatabase:
var req = indexDB.open("CheeseDB", DBO.dbVersion);
Thetwoargumentsarethenameofthedatabaseandtheexpectedschemaversion.IndexedDBwillopenthespecifieddatabaseifitalreadyexistsandcreateitifitdoesn’t.Theresultfromtheopenmethodisanobjectthatrepresentstherequesttoopenthedatabase.TogetanythingdoneinIndexedDB,youmustsupplyhandlerfunctionsforoneormoreofthepossibleoutcomesfromarequest.
RespondingtotheUpgrade-NeededOutcomeIcareabouttwopossibleoutcomeswhenIopenthedatabase.First,Iwanttobenotifiedifthedatabasealreadyexistsandtheschemaversiondoesn’tmatchtheversionIamexpecting.Whenthishappens,Iwanttodeletetheobjectstoresinthedatabaseandstartover.Ireceivenotificationofaschemamismatchbyregisteringafunctionthroughtheonupgradeneededproperty:
req.onupgradeneeded = function(e) { var db = req.result; var existingStores = db.objectStoreNames; for (var i = 0; i < existingStores.length; i++) { db.deleteObjectStore(existingStores[i]); } var objectStore = db.createObjectStore("products", {keyPath: "id"}); objectStore.createIndex("category", "category", {unique: false}); $.each(data, function(index, item) { var currentCategory = item.category; $.each(item.items, function(index, item) { item.category = currentCategory; objectStore.add(item); }); }); };
Thedatabaseobjectisavailablethroughtheresultpropertyoftherequestreturnedbytheopen method.IgetalistoftheexistingobjectstoresthroughtheobjectStoreNamespropertyanddeleteeachinturnusingthedeleteObjectStoremethod.Indeletingtheobjectstores,Ialsodeletethedatatheycontain.Thisisfineforsuchasimplewebappwhereallofthedataiscomingfromtheserverandiseasilyreplaced,butyoumayneedtotakeamoresophisticatedapproachifyourdatabasescontaindatathathasbeengeneratedasaresultofuseractions.
CHAPTER6STORINGDATAINTHEBROWSER
161
CautionThefunctionassignedtotheonupgradeneededpropertyistheonlyopportunityyouhavetomodifytheschemaofthedatabase.Ifyoutrytoaddordeleteanobjectstoreelsewhere,thebrowserwillgenerateanerror.
Oncetheexistingobjectstoresareoutoftheway,IcancreatesomenewonesusingthecreateObjectstoremethod.Theargumentstothismethodarethenameofthenewstoreandanoptionalobjectcontainingconfigurationsettingstobeappliedtothenewstore.IhaveusedthekeyPathconfigurationoption,whichletsmesetadefaultkeyforobjectsthatareaddedtothestore.Ihavespecifiedtheidpropertyasthekey.IhavealsocreatedanindexusingthecreateIndexmethodonthenewlycreatedobjectstore.Anindexallowsmetoperformsearchesintheobjectstoreusingapropertyotherthanthekey,inthiscase,thecategoryproperty.I’llshowyouhowtouseanindexshortly.
Finally,Iaddobjectstothedatastore.WhenIusethisfunctioninthemaindocument,I’llbeusingthedataIgetfromanAjaxrequestfortheproducts.jsonfile.ThisisinthesameformatasthedataIhavebeenusingthroughoutthisbook.IusethejQueryeachfunctiontoenumerateeachcategoryandtheitemsitcontains.IhaveaddedacategorypropertytoeachitemsothatIcanfindalloftheproductsthatbelongtothesamecategorymoreeasily.
TipTheobjectsyouaddtoanobjectstoreareclonedusingtheHTML5structuredclonetechnique.ThisisamorecomprehensiveserializationtechniquethanJSON,andthebrowserwillgenerallymanagetodealwithcomplexobjects,justaslongasnoneofthepropertiesisafunctionorDOMAPIobject.
RespondingtotheSuccessOutcomeThesecondoutcomeIcareaboutissuccess,whichIhandlebyassigningafunctiontotheonsuccesspropertyoftherequesttoopenthedatabase,asfollows:
req.onsuccess = function(e) { DBO.db = this.result; callback(); };
ThefirststatementinthisfunctionassignstheopeneddatabasetothedbpropertyoftheDBOobject.ThisisjustaconvenientwaytokeepahandleonthedatabasesothatIcanuseitinotherfunctions,somethingthatI’lldemonstrateshortly.
ThesecondstatementinvokesthecallbackfunctionthatwaspassedasthesecondargumenttothesetupDatabasefunction.Itisn’tsafetoassumethatthedatabaseisopenuntiltheonsuccessfunctionisexecuted,whichmeansIneedtohavesomemechanismforsignalingthefunctioncallerthatthedatabasehasbeensuccessfullyopenedanddata-relatedoperationscanbestarted.
CHAPTER6STORINGDATAINTHEBROWSER
162
TipIndexedDBrequestshaveacounterpartoutcomepropertycalledonerror.Iwon’tbedoinganyerrorhandlingintheseexamplesbecause,asIwritethis,tryingtodealwithIndexedDBerrorscausesmoreproblemsthanitsolves.Ideally,thiswillhaveimprovedbythetimeyoureadthischapter,andyouwillbeabletowritemorerobustcode.
IncorporatingtheDatabaseintotheWebApplicationListing6-15showsthemarkupandinlineJavaScriptfortheexampleapplication.Withtheexceptionofthedatabase-specificfunctions,everythinginthisexamplereliesontopicscoveredinearlierchapters.
Listing6-15.TheDatabase-ConsumingWebApplication
<!DOCTYPE html> <html> <head> <title>CheeseLux Cheese Finder</title> <link rel="stylesheet" type="text/css" href="styles.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script src="jquery-ui-1.8.16.custom.js" type="text/javascript"></script> <script src='knockout-2.0.0.js' type='text/javascript'></script> <script src='utils.js' type='text/javascript'></script> <script src='signals.js' type='text/javascript'></script> <script src='hasher.js' type='text/javascript'></script> <script src='crossroads.js' type='text/javascript'></script> <link rel="stylesheet" type="text/css" href="jquery-ui-1.8.16.custom.css"/> <noscript> <meta http-equiv="refresh" content="0; noscript.html"/> </noscript> <script> var viewModel = { searchModes: ["ID", "Description", "Category"], selectedMode: ko.observable("ID"), selectedItems: ko.observableArray() }; function handleSearchResults(resultData) { if (resultData) { viewModel.selectedItems.removeAll(); if ($.isArray(resultData)) { for (var i = 0; i < resultData.length; i++) { viewModel.selectedItems.push(resultData[i]); } } else { viewModel.selectedItems.push(resultData); } }
CHAPTER6STORINGDATAINTHEBROWSER
163
} $.getJSON("products.json", function(data) { setupDatabase(data, function() { $(document).ready(function() { hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); crossroads.addRoute("mode/:mode:", function(mode) { viewModel.selectedMode(mode || viewModel.searchModes[0]); viewModel.selectedItems.removeAll(); $('#textsearch').val(""); }); crossroads.parse(location.hash.slice(1)); ko.applyBindings(viewModel); $('div.navSelectors').buttonset(); $('div.groupcontent a').button().click(function() { var sText = $('#textsearch').val(); switch (viewModel.selectedMode()) { case "ID": getProductByID(sText, handleSearchResults) break; case "Description": getProductsByDescription(sText, handleSearchResults); break; case "Category": getProductsByCategory(sText, handleSearchResults); break; }; }); }); }); }); </script> </head> <body> <div id="logobar"> <img src="cheeselux.png"> <div class="tagcontainer"> <span id="tagline">Cheese Finder</span> </div> </div> <div class="cheesegroup"> <div class="navSelectors" data-bind="foreach: searchModes"> <a data-bind="formatAttr: {attr: 'href', prefix: '#mode/', value: $data}, css: {selectedItem: $data == $root.selectedMode()}"> <span data-bind="text: $data"> </a>
CHAPTER6STORINGDATAINTHEBROWSER
164
</div> </div> <div class="cheesegroup"> <div class="grouptitle">Search Criteria</div> <div class="groupcontent centered"> <label class="cheesename">Search Text:</label> <input id="textsearch" class="stwin"/> <a id="textsearch" class="smallbutton">Search</a> </div> </div> <div class="cheesegroup"> <div class="grouptitle">Search Results</div> <div class="groupcontent centered"> <table id="resultTable" data-bind="visible: selectedItems().length > 0"> <thead> <tr><th>Name</th><th>Price</th><th>Description</th></tr> <tr><td colspan=3 class="sumline"></td></tr> </thead> <tbody> <!-- ko foreach: viewModel.selectedItems() --> <tr> <td data-bind="text: name"></td> <td data-bind="text: price"></td> <td data-bind="text: description"></td> </tr> <tr><td colspan=3 class="sumline"></td></tr> <!-- /ko --> </tbody> </table> <div data-bind="visible: selectedItems().length == 0"> No matches </div> </div> </div> </body> </html>
Asyoumightexpectbynow,IhaveusedaviewmodeltobindthestateoftheapplicationtotheHTMLmarkup.Mostofthedocumentistakenupdefiningandcontrollingtheviewgiventotheuserandsupportinguserinteractions.
Whentheuserclicksthesearchbutton,oneofthreefunctionsintheutils.jsfileiscalled,dependingontheselectedsearchmode.IftheuserhaselectedtosearchbyproductID,thenthegetProductByIDfunctioniscalled.ThegetProductsByDescriptionfunctionisusedwhentheuserwantstosearchtheproductdescriptions,andthegetProductsByCategoryfunctionisusedtofindalltheproductsinaspecificcategory.Eachofthesefunctionstakestwoarguments:thetexttosearchforandacallbackfunctiontowhichtheresultsshouldbedispatched(evensearchinganobjectstoreisanasynchronousoperationwithIndexedDB).Thecallbackfunctionisthesameforallthreesearchmodes:handleSearchResults.Theresultfromthesearchfunctionswillbeasingleproductobjectoranarrayofobjects.ThejobofthehandleSearchResultsfunctionistoclearthecontentsoftheselectedItems
CHAPTER6STORINGDATAINTHEBROWSER
165
observablearrayintheviewmodelandreplacethemwiththenewresults;thiscausestheelementstobeupdatedandtheresultstobedisplayedtotheuser.
NoticethatIplacemostofthecodestatementsinmyinlinescriptelementinsidethecallbackforthesetupDatabasefunction.Thisisthefunctionthatiscalledwhenthedatabasehassuccessfullybeenopened.
LocatinganObjectbyKeyThefirstofthesearchfunctionsisgetProductByID,whichlocatesanobjectbasedonthevalueoftheidproperty.YouwillrecallthatIspecifiedthispropertyasthekeyfortheobjectstorewhenIcreatedthedatabase:
var objectStore = db.createObjectStore("products", {keyPath: "id"});
Gettinganobjectusingitskeyisprettysimple.Listing6-16showsthegetProductByIDfunction,whichIdefinedintheutils.jsfile.
Listing6-16.LocatinganObjectUsingItsKey
function getProductByID(id, callback) { var transaction = DBO.db.transaction(["products"]); var objectStore = transaction.objectStore("products"); var req = objectStore.get(id); req.onsuccess = function(e) { callback(this.result); }; }
Thisfunctionshowsthebasicpatternforqueryinganobjectstoreinadatabase.First,youmustcreateatransaction,usingthetransactionmethod,declaringtheobjectsstoresthatyouwanttoworkwith.Onlythencanyouopenanobjectstore,usingtheobjectStoremethodonthetransactionyoujustcreated.
TipYoudon’tneedtoexplicitlycloseyourobjectstoreoryourtransactions;thebrowserclosesthemforyouwhentheyareoutofscope.Thereisnobenefitintryingtoexplicitlyforcethestoreortransactionstoclose.
Iobtaintheobjectwiththespecifiedkeyusingthegetmethod,whichmatchesatmostoneobject(iftherearemultipleobjectswiththesamekey,thenthefirstmatchingobjectismatched).Themethodreturnsarequest,andImustsupplyafunctionfortheonsuccesspropertytobenotifiedwhenthesearchhascompleted.Thematchedobjectisavailableintheresultpropertyoftherequest,whichIpassbacktothemainpartofthewebappbyinvokingthecallbackfunctionpassedtothegetProductByIDfunction(which,asyouwillrecall,isthehandleSearchResultsfunction).
The(eventual)resultfromthegetmethodisaJavaScriptobjector,ifthereisnomatch,null.Idon’thavetoworryaboutre-creatinganobjectfromtheserializeddatastoredbythedatabaseoruseanykindofobject-relationalmappinglayer.TheIndexedDBdatabaseworksonJavaScriptobjectsthroughout,whichisanicefeature.
CHAPTER6STORINGDATAINTHEBROWSER
166
Itisalittlefrustratingtohavetousecallbackseverytimeyouwanttoperformasimpleoperation,butitquicklybecomessecondnature.TheresultisastoragemechanismthatfitsnicelyintotheJavaScriptworldandthatdoesn’ttieupthemainthreadofexecutionwhenlongoperationsarebeingperformedbutthatrequirescarefulthoughtandapplicationdesigntobeproperlyused.
LocatingObjectsUsingaCursorIhavetotakeadifferentapproachwhentheuserwantstosearchforproductsbytheirdescription.Descriptionsarenotakeyinmyobjectstore,andIwanttobeabletolookforpartialmatches(otherwisetheuserwouldhavetoexactlytypeinallofthedescriptiontomakeamatch).Listing6-17showsthegetProductsByDescriptionfunction,whichisdefinedinutils.js.
Listing6-17.LocatingObjectsUsingaCursor
function getProductsByDescription(text, callback) { var searchTerm = text.toLowerCase(); var results = []; var transaction = DBO.db.transaction(["products"]); var objectStore = transaction.objectStore("products"); objectStore.openCursor().onsuccess = function(e) { var cursor = this.result; if (cursor) { if (cursor.value.description.toLowerCase().indexOf(searchTerm) > -1) { results.push(cursor.value); } cursor.continue(); } else { callback(results); } }; };
Mytechniquehereistouseacursortoenumeratealloftheobjectsintheobjectstoreandlookforthosewhoseproductspropertycontainsthesearchtermprovidedbytheuser.AcursorsimplykeepstrackofmyprogressasIenumeratethroughasequenceofdatabaseobjects.
IndexedDBdoesn’thaveatextsearchfacility,soIhavetohandlethismyself.CallingtheopenCursormethodonanobjectstorecreatesarequestwhoseonsuccesscallbackisexecutedwhenthecursorisopened.Thecursoritselfisavailablethroughtheresultpropertyofthethiscontextobject.(Itshouldalsobeavailablethroughtheresultpropertyoftheeventpassedtothefunction,butthecurrentimplementationdoesn’talwayssetthisreliably.)
Ifthecursorisn’tnull,thenthereisanobjectavailableinthevalueproperty.IchecktoseewhetherthedescriptionpropertyoftheobjectcontainsthetermIamlookingfor,andifitdoes,Ipushtheobjectintoalocalarray.Tomovethecursortothenextobject,Icallthecontinuemethod,whichexecutestheonsuccessfunctionagain.
ThecursorisnullwhenIhavereadalloftheobjectsintheobjectstore.Atthispoint,mylocalarraycontainsalloftheobjectsthatmatchmysearch,andIpassthembacktothemainpartofthewebapplicationusingthecallbacksuppliedasthesecondargumenttothegetProductsByDescriptionfunction.
CHAPTER6STORINGDATAINTHEBROWSER
167
LocatingObjectsUsinganIndexEnumeratingalloftheobjectsinanobjectstoreisn’tanefficientwayoffindingobjects,whichiswhyIcreatedanindexforthecategorypropertywhenIsetuptheobjectstore:
objectStore.createIndex("category", "category", {unique: false});
TheargumentstothecreateIndexmethodarethenameoftheindex,thepropertyintheobjectsthatwillbeindexed,andaconfigurationobject,whichIhaveusedtotellIndexedDBthatthevaluesforthecategorypropertyarenotunique.
ThegetProductsByCategoryfunction,whichisshowninListing6-18,usestheindextonarrowtheobjectsthatareenumeratedbythecursor.
Listing6-18.UsinganIndexedDBIndex
function getProductsByCategory(searchCat, callback) { var results = []; var transaction = DBO.db.transaction(["products"]); var objectStore = transaction.objectStore("products"); var keyRange = IDBKeyRange.only(searchCat); var index = objectStore.index("category"); index.openCursor(keyRange).onsuccess = function(e) { var cursor = this.result; if (cursor) { results.push(cursor.value); cursor.continue(); } else { callback(results); } }; };
TheIDBKeyRangeobjecthasanumberofmethodsforconstrainingthekeyvaluesthatwillmatchobjectsintheobjectstore.IhaveusedtheonlymethodtospecifythatIwantexactmatchesonly.
IopentheindexbycallingtheindexmethodontheobjectstoreandpassintheIDBKeyRangeobjectasanargumentwhenIopenthecursor.Thishastheeffectofnarrowingthesetofobjectsthatareavailablethroughthecursor,meaningthattheresultsIpassviathecallbackcontainonlythecheeseproductsinthespecifiedcategory.Thereisnopartialmatchinginthisexample;theusermustentertheentirecategoryname,suchasFrenchCheese.
SummaryInthischapter,Ishowedyouhowtouselocalstoragetopersistentlystorename/valuepairsinthebrowserandhowthisfeaturecanbeusedinanofflinewebapptodealwithHTMLforms.IalsoshowedyoutheIndexedDBfeatures,whichisfarlessmaturebutshowspromiseasafoundationforstoringandqueryingmorecomplexdatausingnaturalJavaScriptobjectsandlanguageidioms.
IndexedDBisn’tyetreadyforproductionuse,butIfindthatlocalstorageisveryrobustandhelpfulinawiderangeofsituations.Ifinditespeciallyusefulinmakingformsmoreusefulandlessannoying,muchasIdemonstratedinthischapter.Thelocalstoragefeatureisveryeasytouse,especiallywhenitisembeddedwithinyourapplicationviewmodel.
CHAPTER6STORINGDATAINTHEBROWSER
168
Inthenextchapter,Ishowyouhowtocreateresponsivewebappsthatadaptandrespondtothecapabilitiesofthedevicesonwhichtheyrun.
C H A P T E R 7
169
Creating Responsive Web Apps
Therearetwoapproachestotargetingmultipleplatformswithawebapp.Thefirstistocreateadifferentversionoftheappforeachkindofdeviceyouwanttotarget:desktop,smartphone,tablet,andsoon.I’llgiveyousomeexamplesofhowtodothisinChapter8.
Theotherapproach,andthetopicofthischapter,istocreatearesponsivewebapp,whichsimplymeansthatthewebappadaptstothecapabilitiesofthedeviceitisrunningon.Ilikethisapproachbecauseitdoesn’tdrawaharddistinctionbetweenmobileand“normal”devices.
Thisisimportantbecausethecapabilitiesofsmartphones,tablets,anddesktopsblurtogether.ManymobilebrowsersalreadyhavegoodHTML5support,anddesktopmachineswithtouchscreensarebecomingmorecommon.Inthischapter,I’llshowyoutechniquesthatyoucanusetocreatewebapplicationsthatareflexibleandfluid.
SettingtheViewportIneedtoaddressoneissuethatisspecifictothebrowsersrunningonsmartphonesandtablets(whichI’llstartreferringtoasmobilebrowsers).Mobilebrowserstypicallystartfromtheassumptionthatawebsitewillhavebeendesignedforalarge-screeneddesktopdeviceandthat,asaconsequence,theuserwillneedsomehelptobeabletoviewit.Thisisdonethroughtheviewport,whichscalesdownthewebpagesothattheusergetsasenseoftheoverallpagestructure.Theuserthenzoomsintoaparticularregionofthepageinordertoreadoruseit.YoucanseetheeffectinFigure7-1.
Figure7-1.Theeffectofthedefaultviewportinamobilebrowser
CHAPTER7CREATINGRESPONSIVEWEBAPPS
170
NoteThescreenshotsinFigure7-1areoftheOperaMobileemulator,whichyoucangetfromwww.opera.com/developer/tools/mobile.Althoughithassomequirks,thisemulatorisreasonablyfaithfultotherealOperaMobile,whichiswidelyusedinmobiledevices.Ilikeitbecauseitallowsmetocreateemulatorswithscreensizesrangingfromsmallsmartphonestolargetabletsandtoselectwhethertoucheventsaresupported.Asabonus,youcandebugandinspectyourwebappusingthestandardOperadevelopmenttools.Anemulatorisnosubstitutefortestingonarangeofrealhardwaredevicesbutcanbeveryconvenientduringtheearlystagesofdevelopment.
Thisisasensiblefeature,butyouneedtodisableitforwebapps;otherwise,contentandcontrolsaredisplayedatasizethatistoosmalltouse.Listing7-1showshowtodisablethisfeatureusingtheHTMLmetatag,whichIhaveappliedtoasimplifiedversionoftheCheeseLuxwebapp,whichwillbethefoundationexampleforthischapter.
Listing7-1.UsingthemetaTagtoControltheViewportintheCheeseLuxWebApp
<!DOCTYPE html> <html> <head> <title>CheeseLux</title> <link rel="stylesheet" type="text/css" href="styles.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script src="jquery-ui-1.8.16.custom.js" type="text/javascript"></script> <script src='knockout-2.0.0.js' type='text/javascript'></script> <script src='utils.js' type='text/javascript'></script> <script src='signals.js' type='text/javascript'></script> <script src='crossroads.js' type='text/javascript'></script> <script src='hasher.js' type='text/javascript'></script> <script src='modernizr-2.0.6.js' type='text/javascript'></script> <link rel="stylesheet" type="text/css" href="jquery-ui-1.8.16.custom.css"/> <meta name="viewport" content="width=device-width, initial-scale=1"> <script> var cheeseModel = {}; $.getJSON("products.json", function(data) { cheeseModel.products = data; }).success(function() { $(document).ready(function() { $('#buttonDiv input:submit').button().css("font-family", "Yanone"); $('div.cheesegroup').not("#basket").css("width", "50%"); $('div.navSelectors').buttonset(); enhanceViewModel(); ko.applyBindings(cheeseModel); hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads);
CHAPTER7CREATINGRESPONSIVEWEBAPPS
171
hasher.init(); crossroads.addRoute("category/:newCat:", function(newCat) { cheeseModel.selectedCategory(newCat ? newCat : cheeseModel.products[0].category); }); crossroads.parse(location.hash.slice(1)); }); }); </script> </head> <body> <div id="logobar"> <img src="cheeselux.png"> <span id="tagline">Gourmet European Cheese</span> </div> <div class="cheesegroup"> <div class="navSelectors" data-bind="foreach: products"> <a data-bind="formatAttr: {attr: 'href', prefix: '#category/', value: category}, css: {selectedItem: (category == cheeseModel.selectedCategory())}"> <span data-bind="text: category"> </a> </div> </div> <div id="basket" class="cheesegroup basket"> <div class="grouptitle">Basket</div> <div class="groupcontent"> <div class="description" data-bind="ifnot: total"> No products selected </div> <table id="basketTable" data-bind="visible: total"> <thead><tr><th>Cheese</th><th>Subtotal</th><th></th></tr></thead> <tbody data-bind="foreach: products"> <!-- ko foreach: items --> <tr data-bind="visible: quantity, attr: {'data-prodId': id}"> <td data-bind="text: name"></td> <td>$<span data-bind="text: subtotal"></span></td> </tr> <!-- /ko --> </tbody> <tfoot> <tr><td class="sumline" colspan=2></td></tr> <tr> <th>Total:</th><td>$<span data-bind="text: total"></span></td> </tr> </tfoot> </table>
CHAPTER7CREATINGRESPONSIVEWEBAPPS
172
</div> <div class="cornerplaceholder"></div> <div id="buttonDiv"> <input type="submit" value="Submit Order"/> </div> </div> <form action="/shipping" method="post"> <!-- ko foreach: products --> <div class="cheesegroup" data-bind="fadeVisible: category == cheeseModel.selectedCategory()"> <div class="grouptitle" data-bind="text: category"></div> <div data-bind="foreach: items"> <div class="groupcontent"> <label data-bind="attr: {for: id}" class="cheesename"> <span data-bind="text: name"> </span> $(<span data-bind="text:price"></span>)</label> <input data-bind="attr: {name: id}, value: quantity"/> <span data-bind="visible: subtotal" class="subtotal"> ($<span data-bind="text: subtotal"></span>) </span> </div> </div> </div> <!-- /ko --> </form> </body> </html>
Addingthehighlightedmetaelementtothedocumentdisablesthescalingfeature.YoucanseetheeffectinFigure7-2.ThisparticularmetatagtellsthebrowsertodisplaytheHTMLdocumentusingtheactualwidthofthedisplayandwithoutanymagnification.Ofcourse,thewebappisstillamess,butitisamessthatisbeingdisplayedatthecorrectsize,whichisthefirststeptowardaresponsiveapp.Intherestofthischapter,I’llshowyouhowtorespondtodifferentdevicecharacteristicsandcapabilities.
Figure7-2.Theeffectofdisablingtheviewportforawebapp
CHAPTER7CREATINGRESPONSIVEWEBAPPS
173
RespondingtoScreenSizeMediaqueriesareausefulwayoftailoringCSSstylestothecapabilitiesofthedevice.Perhapsthemostimportantcharacteristicofadevicefromtheperspectiveofaresponsivewebappisscreensize,whichCSSmediaqueriesaddressverywell.AsFigure7-2shows,theCheeseLuxlogotakesupalotofspaceonasmallscreen,andIcanuseaCSSmediaquerytoensurethatitisshownonlyonlargerdisplays.Listing7-2showsasimplemediaquerythatIaddedtothestyles.cssfile.
Listing7-2.ASimpleMediaQuery
@media screen AND (max-width:500px) { *.largeScreenOnly { display: none; } }
TipOperaMobileaggressivelycachesCSSandJavaScriptfiles.Whenexperimentingwithmediaqueries,thebesttechniqueistodefinetheCSSandscriptcodeinthemainHTMLdocumentandmoveittoexternalfileswhenyouarehappywiththeresult.Otherwise,youwillneedtoclearthecache(orrestarttheemulator)toensureyourchangesareapplied.
The@mediatagtellsthebrowserthatthisisamediaquery.IhavespecifiedthatthelargeScreenOnlystylecontainedinthisqueryshouldbeappliedonlyifthedeviceisascreen(asopposedtoaprojectororprintedmaterial)andthewidthisnogreaterthan500pixels.
TipInthischapter,Iamgoingtodividetheworldintotwocategoriesofdisplays.Smalldisplayswillbethosewhosewidthisnogreaterthan500pixels,andlargedisplayswillbeeverythingelse.Thisissimpleandarbitrary,andyoumayneedtodevisemorecategoriestogettheeffectyourequireforyourwebapp.Iamgoingtoignoretheheightofthedisplayentirely.Mysimplecategorieswillkeeptheexamplesinthischaptermanageable,albeitatthecostofgranularity.
Iftheseconditionsaremet,thenastyleisdefinedthatsetstheCSSdisplaypropertyforanyelementassignedtothelargeScreenOnlyclasstonone,whichhidestheelementfromview.Withtheadditiontothestylesheet,IcanensurethattheCheeseLuxlogoisshownonlyonlargedisplaysbyapplyingthelargeScreenOnlyclasstomymarkup,asshowninListing7-3.
CHAPTER7CREATINGRESPONSIVEWEBAPPS
174
Listing7-3.UsingCSSMediaQueriestoRespondtoScreenSizes
... <div id="logobar" class="largeScreenOnly"> <img src="cheeselux.png"> <span id="tagline">Gourmet European Cheese</span> </div> ...
CSSmediaqueriesarelive,whichmeansthecategoryofscreensizecanchangeifthebrowserwindowisresized.Thisisn’tmuchuseonmobiledevices,butitmeansthataresponsivewebappwilladapttothedisplaysizeevenonadesktopplatform.YoucanseehowthelayoutsalterinFigure7-3.
Figure7-3.Usingmediaqueriestomanagethevisibilityofelements
UsingMediaQuerieswithJavaScriptToproperlyintegratemediaqueriesintoawebapp,weneedtousetheViewmoduleoftheW3CCSSObjectModelspecification,whichbringsJavaScriptmediaqueriessupportintothebrowser.MediaqueriesareevaluatedinJavaScriptusingthewindow.matchMediamethod,asshowninListing7-4.IhavedefinedthedetectDeviceFeaturesfunctionintheutils.jsfile;atthemoment,itdetectsonlythescreensize,butI’lldetectsomeadditionalfeatureslater.Thereisalotgoingoninthelisting,soI’llbreakitdownandexplainthevariouspartsinthesectionsthatfollow.
Listing7-4.UsingaMediaQueryinJavaScript
function detectDeviceFeatures(callback) { var deviceConfig = {}; Modernizr.load({ test: window.matchMedia, nope: 'matchMedia.js', complete: function() {
CHAPTER7CREATINGRESPONSIVEWEBAPPS
175
var screenQuery = window.matchMedia('screen AND (max-width:500px)'); deviceConfig.smallScreen = ko.observable(screenQuery.matches); if (screenQuery.addListener) { screenQuery.addListener(function(mq) { deviceConfig.smallScreen(mq.matches); }); } deviceConfig.largeScreen = ko.computed(function() { return !deviceConfig.smallScreen(); }); setInterval(function() { deviceConfig.smallScreen(window.innerWidth <= 500); }, 500); callback(deviceConfig); } }); };
LoadingthePolyfillIneedtouseapolyfilltomakesureIcanusethematchMediamethod.Supportforthisfeatureisgoodindesktopbrowsersbutspottyinthemobileworld.ThepolyfillIuseiscalledmatchMedia.jsandisavailablefromhttp://github.com/paulirish/matchMedia.js.
Iwanttoloadthepolyfillonlyifthebrowserdoesn’tsupportthematchMediafeaturenatively.Toarrangethis,IhaveusedtheModernizr.loadmethod,whichisaflexibleresourceloader.IpasstheloadmethodanobjectwhosepropertiestellModernizrwhattodo.
TipTheModernizr.loadfeatureisavailableonlywhenyoucreateacustomModernizrbuild;itisnotincludedintheuncompresseddevelopmentversionoftheModernizrlibrary.TheModernizrloadmethodisawrapperaroundalibrarycalledYepNope,whichisavailableathttp://yepnopejs.com.YoucanuseYepNopedirectlyifyoudon’twanttouseacompressedModernizrbuildforanyreason.Thehttp://yepnopejs.comsitealsocontainsdetailsofalloftheloaderfeatures;thesyntaxdoesn’tchangewhenthelibraryisincludedwithModernizr.BecarefulwhenusingaresourceloaderinexternalJavaScriptfiles.Thereareseriousissuesthatcanarise,whichIdescribeinChapter9.YouwillseealinktocreateacustomdownloadontheModernizrwebpage.ForthecustombuildthatIusedinthischapter,IsimplycheckedalloftheoptionstoincludeasmuchModernizrfunctionalityaspossibleinthedownload.
Thetestproperty,asthenamesuggests,specifiestheexpressionthatIwantModernizrtoevaluate.Inthiscase,Iwanttoseewhetherthewindow.matchMediamethodisdefinedbythebrowser.YoucanuseanyJavaScriptexpressionwiththetestproperty,includingModernizrfeaturedetectionchecks.
CHAPTER7CREATINGRESPONSIVEWEBAPPS
176
ThenopepropertytellsModernizrwhatresourcesIwanttoloadiftestevaluatesfalse.Inthisexample,IhavespecifiedthematchMedia.jsfile,whichcontainsthepolyfillcode.Thereisacorrespondingproperty,yep,whichtellsModernizrwhatresourcesarerequirediftestistrue,butIdon’tneedtousethatinthisexamplebecauseIwillberelyingonthebuilt-insupportformatchMediaiftestistrue.Thecompletepropertyspecifiesafunctionthatwillbeexecutedwhentheresourcesspecifiedbytheyepornopepropertyhaveallbeenloadedandexecuted.
Modernizr.loadgetsandexecutesJavaScriptscriptsasynchronously,whichiswhythedetectDeviceFeaturesfunctiontakesacallbackfunctionasanargument.Iinvokethiscallbackattheendofthecompletefunction,passinginanobjectthatcontainsdetailsofthefeaturesthathavebeendetected.
DetectingtheScreenSizeIcannowturntoworkingoutwhetherthedevice’sscreenfallsintomylargeorsmallcategory.Todothis,Ipassamediaquery,justtheliketheoneIusedinCSS,tothematchMediamethod,likethis:
var screenQuery = window.matchMedia('screen AND (max-width:500px)');
IdeterminewhethermymediaqueryhasbeenmatchedbyreadingthematchespropertyoftheobjectIgetbackfrommatchMedia.Ifmatchesistrue,thenIamdealingwithascreenthatisinmysmallcategory(500pixelsandsmaller).Ifitisfalse,thenIhavealargescreen.IassigntheresulttoanobservabledataitemintheobjectthatIpasstothecallbackfunction:
var deviceConfig = { smallScreen: ko.observable(screenQuery.matches) };
IfthebrowserimplementsthematchMediafeature,thenIcanusetheaddListenermethodtobenotifiedwhenthestatusofthemediaquerychanges,likethis:
if (screenQuery.addListener) { screenQuery.addListener(function(mq) { deviceConfig.smallScreen(mq.matches); }); }
Thestatusofamediaquerychangeswhenoneoftheconditionsitcontainschanges.Thetwoconditionsinmyqueryarethatweareworkingonascreenandthatithasamaximumwidthof500pixels.Achangenotification,therefore,indicatesthatthewidthofthedisplayhaschanged.Thismeansthatthebrowserwindowhasbeenresizedorthatthescreenorientationhaschanged(seethe“RespondingtoScreenOrientation”sectionlaterinthischapterformoredetails).
ThematchMedia.jspolyfilldoesn’tsupportchangenotifications,soIhavetotestfortheexistenceoftheaddListenermethodbeforeIuseit.MyfunctionisexecutedwhenthestatusofthemediaquerychangesandIupdatethevalueoftheobservabledataitem.ThelastthingIdoiscreateacomputedobservabledataitem,likethis:
deviceConfig.largeScreen = ko.computed(function() { return !deviceConfig.smallScreen(); });
ThisisjusttohelptidyupmysyntaxwhenIwanttorefertothescreensizeintherestofmywebappsothatIcanrefertosmalllScreenandlargeScreentofigureoutwhatIamworkingwith,asopposedtosmallScreenand!smallScreen.Itisasmallthing,butIcreatefewertyposthisway.
CHAPTER7CREATINGRESPONSIVEWEBAPPS
177
Somebrowsersareinconsistentinthewaythatstatuschangesinmediaqueriesarehandled.Forexample,theversionofGoogleChromethatiscurrentasIwritethisdoesn’talwaysupdatemediaquerieswhenthescreensizechanges.Asabelt-and-bracesmeasure,Ihaveaddedasimplecheckonthescreensize,whichissetupusingthesetIntervalfunction:
setInterval(function() { deviceConfig.smallScreen(window.innerWidth <= 500); }, 500);
Thefunctionisexecutedevery500millisecondsandupdatesthescreensizeitemintheviewmodel.Thisisn’tideal,butitisimportantthataresponsivewebappisabletoadapttodevicechanges,andthiscanmeantakingsomeundesirableprecautions,includingpollingforstatuschanges.
TipNoticethatIusethewindow.innerWidthpropertytotrytofigureoutthesizeofthescreen.TheproblemIamworkingaroundisthatthemediaqueriesdon’tworkproperlyinallbrowsers,soIneedtofindasubstitutemechanismforassessingscreensize.
IntegratingCapabilityDetectionintotheWebAppIwanttodetectthecapabilitiesofthedevicebeforeIdoanythingelseinthewebapp,whichIwhyIaddedacallbacktothedetectDeviceFeaturesfunction.YoucanseehowIhaveintegratedtheuseofthisfunctiontothewebappscriptelementinListing7-5.
Listing7-5.CallingthedetectDeviceFeaturesFunctionfromtheInlinescriptElement
<script> var cheeseModel = {}; detectDeviceFeatures(function(deviceConfig) { cheeseModel.device = deviceConfig; $.getJSON("products.json", function(data) { cheeseModel.products = data; }).success(function() { $(document).ready(function() { $('#buttonDiv input:submit').button().css("font-family", "Yanone"); $('div.cheesegroup').not("#basket").css("width", "50%"); $('div.navSelectors').buttonset(); enhanceViewModel(); ko.applyBindings(cheeseModel); hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); crossroads.addRoute("category/:newCat:", function(newCat) { cheeseModel.selectedCategory(newCat ?
CHAPTER7CREATINGRESPONSIVEWEBAPPS
178
newCat : cheeseModel.products[0].category); }); }); }); }); </script>
IassigntheobjectthatthedetectDeviceFeaturesfunctionpassestothecallbacktothedevicepropertyintheviewmodel.Byusinganobservabledataitem,Idisseminatechangesintotheapplicationfromtheviewmodelwhenthemediaquerychanges.
Thelaststepistotakeadvantageoftheenhancementstotheviewmodelinthewebappmarkup.Listing7-6showshowIcancontrolthevisibilityoftheCheeseLuxlogothroughadatabinding.
Listing7-6.ControllingElementVisibilityBasedonScreenCapabilityExpressedThroughtheViewModel
... <div id="logobar" data-bind="visible: device.largeScreen()"> <img src="cheeselux.png"> <span id="tagline">Gourmet European Cheese</span> </div> ...
Theresultistore-createtheeffectofusingtheCSSmediaqueryinJavaScript.TheCheeseLuxlogoisvisibleonlyonlargescreens.YoumightbewonderingwhyIhavegonetoalltheeffortofre-creatingasimpleandelegantCSStechniqueinJavaScript.Thereasonissimple:pushinginformationaboutthecapabilitiesofthedevicethroughmywebappviewmodelgivesmeafoundationforcreatingresponsivewebappsthatarefarmorecapableandflexiblethanwouldbepossiblewithCSSalone.Thefollowingsectiongivesanexample.
DeferringImageLoadingTheproblemwithsimplyhidinganimgelementisthatthebrowserstillloadsit;itjustnevershowsittotheuser.Thisisaridiculoussituationbecauseitiscostingmeandtheuserbandwidthtodownloadaresourcethatwon’teverbeshownonadevicewithasmallscreen.Tofixthis,IhavedefinedanewdatabindingcalledifAttrintheutils.jsfile,asshowninListing7-7.Thisbindingaddsandremovesanattributebasedonevaluatingacondition.
Listing7-7.ADataBindingforConditionallySettinganelementAttribute
ko.bindingHandlers.ifAttr = { update: function(element, accessor) { if (accessor().test) { $(element).attr(accessor().attr, accessor().value); } else { $(element).removeAttr(accessor().attr); } } }
Thisbindingexpectsadataobjectthatcontainsthreeproperties:theattrpropertyspecifieswhichattributeIwanttoapply,thetestpropertydetermineswhethertheattributeisaddedtotheelement,
CHAPTER7CREATINGRESPONSIVEWEBAPPS
179
andthevalueattributespecifiesthevaluethatwillbeassignedtotheattributeiftestistrue.Listing7-8showshowIcanapplythisbindingtomyCheeseLuxlogomarkuptodeferloadingtheimageuntilitisrequired.
Listing7-8.UsingtheifAttrBindingtoPreventImageLoading
<div id="logobar" data-bind="visible: device.largeScreen()"> <img data-bind="ifAttr: {attr: 'src', value: 'cheeselux.png', test: device.largeScreen()}"> <span id="tagline">Gourmet European Cheese</span> </div>
Thebrowsercan’tloadanimagewhentheimgelementdoesn’thaveasrcattribute.Totakeadvantageofthis,IusetheifAttrattributewiththelargeScreenviewmodelitemsothatthesrcattributeissetonlywhentheimagewillbedisplayed.Inthisway,Iamabletopreventtheimagefromloadingunlessitwillbeshown.Thisisaprettysimpletrickbutdemonstratesthekindofflexibilitythatyoushouldlookforwhencreatingaresponsivewebapp.
TipItisimportanttodistinguishbetweenresourcesthatyoudon’twanttouseimmediatelyfromresourcesthatyouareunlikelytowantatall.Ifyouhaveareasonableexpectationthattheuserwillrequireanimageinthenormaluseofyourapplication,thenyoushouldletthebrowserdownloaditsothatitisimmediatelyavailablewhenrequired.UsetheifAttrtechniquetoavoidawasteddownloadifitisunlikelythattheuserwillrequirearesource.
AdaptingtheWebAppLayoutFromthispointon,IsimplyhavetoadapteachpartofthewebapptothetwocategoriesofscreenthatIaminterestedin.Listing7-9showsthechangesthatarerequired.
TipDon’ttrytoloadthislistinginthebrowseruntilyouhavealsoappliedthechangesinListing7-10.Ifyoudo,you’llgetanerrorbecausetheviewmodeldataandthedatabindingsareoutofsync.
Listing7-9.AdaptingtheWebApptoLargeandSmallScreens
<!DOCTYPE html> <html> <head> <title>CheeseLux</title> <link rel="stylesheet" type="text/css" href="styles.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script src="jquery-ui-1.8.16.custom.js" type="text/javascript"></script>
CHAPTER7CREATINGRESPONSIVEWEBAPPS
180
<script src='knockout-2.0.0.js' type='text/javascript'></script> <script src='utils.js' type='text/javascript'></script> <script src='signals.js' type='text/javascript'></script> <script src='crossroads.js' type='text/javascript'></script> <script src='hasher.js' type='text/javascript'></script> <script src='modernizr-2.0.6.js' type='text/javascript'></script> <link rel="stylesheet" type="text/css" href="jquery-ui-1.8.16.custom.css"/> <meta name="viewport" content="width=device-width, initial-scale=1"> <script> var cheeseModel = {}; detectDeviceFeatures(function(deviceConfig) { cheeseModel.device = deviceConfig; $.getJSON("products.json", function(data) { cheeseModel.products = data; }).success(function() { $(document).ready(function() { function performScreenSetup(smallScreen) { $('div.cheesegroup').not("#basket") .css("width", smallScreen ? "" : "50%"); }; cheeseModel.device.smallScreen.subscribe(performScreenSetup); performScreenSetup(cheeseModel.device.smallScreen()); $('div.buttonDiv input:submit').button(); $('div.navSelectors').buttonset(); enhanceViewModel(); ko.applyBindings(cheeseModel); hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); crossroads.addRoute("category/:newCat:", function(newCat) { cheeseModel.selectedCategory(newCat ? newCat : cheeseModel.products[0].category); }); crossroads.parse(location.hash.slice(1)); }); }); }); </script> </head> <body> <div id="logobar" data-bind="visible: device.largeScreen()"> <img data-bind="ifAttr: {attr: 'src', value: 'cheeselux.png', test: device.largeScreen()}"> <span id="tagline">Gourmet European Cheese</span> </div>
CHAPTER7CREATINGRESPONSIVEWEBAPPS
181
<div class="cheesegroup"> <div class="navSelectors" data-bind="foreach: products"> <a data-bind="formatAttr: {attr: 'href', prefix: '#category/', value: category}, css: {selectedItem: (category == cheeseModel.selectedCategory())}"> <span data-bind="text: cheeseModel.device.smallScreen() ? shortName : category"></span> </a> </div> </div> <div id="basket" class="cheesegroup basket" data-bind="visible: cheeseModel.device.largeScreen()"> <div class="grouptitle">Basket</div> <div class="groupcontent"> <div class="description" data-bind="ifnot: total"> No products selected </div> <table id="basketTable" data-bind="visible: total"> <thead><tr><th>Cheese</th><th>Subtotal</th><th></th></tr></thead> <tbody data-bind="foreach: products"> <!-- ko foreach: items --> <tr data-bind="visible: quantity, attr: {'data-prodId': id}"> <td data-bind="text: name"></td> <td>$<span data-bind="text: subtotal"></span></td> </tr> <!-- /ko --> </tbody> <tfoot> <tr><td class="sumline" colspan=2></td></tr> <tr> <th>Total:</th><td>$<span data-bind="text: total"></span></td> </tr> </tfoot> </table> </div> <div class="cornerplaceholder"></div> <div class="buttonDiv"> <input type="submit" value="Submit Order"/> </div> </div> <form action="/shipping" method="post"> <div data-bind="foreach: products"> <div class="cheesegroup" data-bind="fadeVisible: category == $root.selectedCategory()"> <div class="grouptitle" data-bind="text: category"></div> <!-- ko foreach: items --> <div class="groupcontent">
CHAPTER7CREATINGRESPONSIVEWEBAPPS
182
<label data-bind="attr: {for: id}" class="cheesename"> <span data-bind="text: name"> </span> $(<span data-bind="text:price"></span>)</label> <input data-bind="attr: {name: id}, value: quantity"/> <span data-bind="visible: subtotal" class="subtotal"> ($<span data-bind="text: subtotal"></span>) </span> </div> <!-- /ko --> <div class="groupcontent" data-bind="if: $root.device.smallScreen()"> <label class="cheesename">Total:</label> <span class="subtotal" id="total"> $<span data-bind="text: cheeseModel.total()"></span> </span> </div> </div> </div> <div class="buttonDiv" data-bind="visible: $root.device.smallScreen()"> <input type="submit" value="Submit Order"/> </div> </form> </body> </html>
Thejoyofthisapproachishowfewchangesarerequiredtomakeawebappresponsivetoscreensize(andhowsimplethosechangesare).Thatsaid,thereareasmallnumberofchangesthatrequireexplanation,whichIprovideinthefollowingsections.YoucanseehowmyresponsivewebappappearsonlargeandsmallscreensinFigure7-4.
Figure7-4.Thesamewebappdisplayedonalargeandsmallscreen
CHAPTER7CREATINGRESPONSIVEWEBAPPS
183
Thesesmallchangeshaveabigimpact,andforthemostpart,thechangesarecosmetic.Theunderlyingfeaturesandstructureofmywebappremainthesame.Idon’thavetoforgomyviewmodelorroutingjusttosupportadevicewithasmallerscreen.
AdaptingtheSourceDataThecategorybuttonsareaproblemonasmallscreen,soIwanttodisplaysomethingtotheuserthatismeaningfulbutrequireslessscreenspace.Todothis,Imadesomeadditionstotheproducts.jsonfilesothateachcategorycontainsanametobeusedwhenspaceislimited.Listing7-10showstheadditionforoneofthecategories.
Listing7-10.AddingScreen-SpecificInformationtotheProductData
... [{"category": "British Cheese", "shortName": "British", "items" : [ {"id": "stilton", "name": "Stilton", "price": 9, "description": "A semi-soft blue cow's milk cheese produced in the Nottinghamshire region. A strong cheese with a distinctive smell and taste and crumbly texture."}, ...
Ihaveappliedasimilarchangetoalloftheothercategoriesintheproducts.jsonfile.Icouldhavearrivedattheshortnamebysplittingthecategoryvaluestringonthespacecharacter,butIwanttomakethepointthatitisnotjustthescriptandmarkupinawebappthatcanberesponsive;youcanalsosupportthisconceptinthedatathatdrivesyourapplication.
InListing7-9,Imodifiedthedatabindingforthenavigationbuttonstotakeadvantageoftheshortercategoriesnames,likethis:
<div class="cheesegroup"> <div class="navSelectors" data-bind="foreach: products"> <a data-bind="formatAttr: {attr: 'href', prefix: '#category/', value: category}, css: {selectedItem: (category == cheeseModel.selectedCategory())}"> <span data-bind="text: cheeseModel.device.smallScreen() ? shortName : category"></span> </a> </div> </div>
IstillusethefullcategorynamefortheformatAttrbinding.Thisallowsmetousethesamesetofnavigationroutesirrespectiveofthescreensize(seeChapter4fordetailsofusingroutinginawebapp).
ApplyingConditionaljQueryUIStylingInthelargescreenlayout,Iresizetheproductlistelementstomakeroomforthebasket.Inthesmallscreenlayout,Ireplacethededicatedbasketwithaone-linetotalattheendofeachsection.IliketotakeadvantageofthematchMedia.addListenerfeatureifitisavailable,whichmeansImustbeabletotogglebetweenthesmallandlargescreenlayoutsasneeded.Toaccommodatethis,Itreatthosescript
CHAPTER7CREATINGRESPONSIVEWEBAPPS
184
statementsthatdrivetheindividuallayoutsintheirownfunctionandregisterthatfunctionasasubscribertochangesintheviewmodel:
function performScreenSetup(smallScreen) { $('div.cheesegroup').not("#basket").css("width", smallScreen ? "" : "50%"); }; cheeseModel.device.smallScreen.subscribe(performScreenSetup);
Thefunctionwillbecalledonlywhenthevaluechanges,soIcallthefunctionexplicitlytogettherightbehaviorwhenthedocumentisfirstloaded,likethis:
performScreenSetup(cheeseModel.device.smallScreen());
Ineffect,ItoggletheCSSwidthpropertyofthedivelementsinthecheesegroupclassbasedonthesizeofthescreen.Youcouldignorethisapproachandjustleavethelayoutinitsinitialstate,butIthinkthatisalostopportunitytoprovideaniceexperiencefordesktopusers.
RemovingElementsfromtheDocumentForthemostpart,Isimplyhideandshowelementsinthedocumentbasedonthesizeofthescreen.However,thereareoccasionswhentheifandifnotbindingsarerequiredtoensurethatelementsarecompletelyremovedfromthedocument.AsimpleexampleofthiscanbeseeninthelistingwhereIusetheifbindingfortheone-linetotalsummary:
<div class="groupcontent" data-bind="if: $root.device.smallScreen()"> <label class="cheesename">Total:</label> <span class="subtotal" id="total"> $<span data-bind="text: cheeseModel.total()"></span> </span> </div>
Ihaveusedtheifbindingherebecausetuckedawayinthestyles.cssfileisaCSSstylethatappliesroundedcorners:
div.groupcontent:last-child { border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; }
Thebrowserdoesn’ttakeintoaccountthevisibilityofelementswhenworkingoutwhichisthelastchildofitsparent.IfIhadusedthevisiblebinding,thenIdon’tgettheroundedcornersIwantinthelargescreenlayout.TheifbindingforcesthebehaviorIwantbyremovingtheelementsentirely,ensuringthattheroundedcornersareappliedcorrectly.
RespondingtoScreenOrientationManymobiledevicesrespondtothewaythattheuserisholdingthedevicebychangingthescreenorientationbetweenlandscapeandportraitmodes.Keepinginformedofthedisplaymodeturnsouttobequitetricky,butitisworthdoingtomakesurethatyourwebapprespondsappropriatelywhentheorientationchanges.Thereareseveralwaystoapproachthisissue.
Somedevicessupportawindow.orientationpropertyandanorientationchangeeventtomakeiteasiertokeeptrackofthescreenorientation,butthisfeatureisn’tuniversal,andevenwhenitisimplemented,theeventtendstobefiredwhenitshouldn’tbe(andisn’tfiredwhenitshouldbe).
CHAPTER7CREATINGRESPONSIVEWEBAPPS
185
Otherdevicessupportorientationaspartofamediaquery.ThisisusefuliftheaddListenerfeatureissupportedaspartofmatchMedia,butmostmobilebrowsersdon’tsupportthisfeature,andthesearethedeviceswhoseorientationismostlikelytochange.
Almostallbrowserssupportaresizeevent,whichistriggeredwhenthewindowisresizedortheorientationischanged.However,someimplementationsintroducedelaysbetweenorientationchangesandtheeventbeingtriggered,whichmakesforawebappthatisslowtorespondandthatmaychangeitslayoutorbehavioraftertheuserhasstartedinteractingintheneworientation.
Thefinalapproachistoperiodicallycheckscreendimensionsandworkouttheorientationmanually.Thisiscrudebuteffectiveandworksonlyifthefrequencyofthecheckishighenoughtomakeforarapidresponsebutlowenoughnottooverwhelmthedevice.
Theonlyreliablewaytomakesureyoudetectorientationchangesistoapplyallfourtechniques.Listing7-11showstherequiredadditionstothedetectDeviceFeaturesfunction.
Listing7-11.DetectingScreenOrientationChanges
function detectDeviceFeatures(callback) { var deviceConfig = {}; deviceConfig.landscape = ko.observable(); deviceConfig.portrait = ko.computed(function() { return !deviceConfig.landscape(); }); var setOrientation = function() { deviceConfig.landscape(window.innerWidth > window.innerHeight); } setOrientation(); $(window).bind("orientationchange resize", function() { setOrientation(); }); setInterval(setOrientation, 500); if (window.matchMedia) { var orientQuery = window.matchMedia('screen AND (orientation:landscape)') if (orientQuery.addListener) { orientQuery.addListener(setOrientation); } } Modernizr.load({ test: window.matchMedia, nope: 'matchMedia.js', complete: function() { var screenQuery = window.matchMedia('screen AND (max-width:500px)'); deviceConfig.smallScreen = ko.observable(screenQuery.matches); if (screenQuery.addListener) { screenQuery.addListener(function(mq) { deviceConfig.smallScreen(mq.matches); });
CHAPTER7CREATINGRESPONSIVEWEBAPPS
186
} deviceConfig.largeScreen = ko.computed(function() { return !deviceConfig.smallScreen(); }); setInterval(function() { deviceConfig.smallScreen(window.innerWidth <= 500); }, 500); callback(deviceConfig); } }); };
Ihavesetuptwoviewmodeldataitems,landscapeandportrait,followingthesamepatternthatIusedforsmallScreenandlargeScreen.Idon’twanttoduplicatemycodefortestingtheorientationofthedevice,soIhavecreatedasimpleinlinefunctioncalledsetOrientationthatsetsthevalueofthelandscapedataitem:
var setOrientation = function() { deviceConfig.landscape(window.innerWidth > window.innerHeight); }
IhavefoundcomparingtheinnerWidthandinnerHeightvaluesofthewindowobjecttobethemostreliablewayoffiguringoutthescreenorientation.Thescreen.widthandscreen.heightvaluesshouldwork,butsomebrowsersdon’tchangethesevalueswhenthedeviceisreoriented.Thewindow.orientationpropertyprovidesgoodinformation,butitisn’tuniversallyimplemented.Thisisanundoubtedcompromise,andIrecommendyoutesttheefficacyofthisapproachonyourtargetdevices.
TherestoftheadditionsimplementthevariousmeansbywhichthesetOrientationwillbecalled:viatheorientationchangeandresizeevents,viaamediaquery,andviapolling.Judgingtherightfrequencytopolltheorientationisdifficult,butIusuallyuse500milliseconds.Itisn’talwaysasresponsiveasIwouldlike,butitstrikesareasonablebalance.
TipIcouldhaveusedasinglesetIntervalcalltopollforboththescreensizeandtheorientation,butIprefertokeeptheregionsofcodefunctionalityasseparateaspossible.
IntegratingScreenOrientationintotheWebAppIcanmakethewebapprespondtothescreenorientationnowthattheviewmodelhastheportraitandlandscapeitems.Todemonstratethis,Iamgoingtofixaproblem:thewebappcurrentlyrequirestheusertoscrolldowntoseealloftheelementsinlandscapemodeonadevicethathasasmallscreen.Figure7-5showstheproblemandtheresultafterIhavemodifiedthewebapplayout.
CHAPTER7CREATINGRESPONSIVEWEBAPPS
187
Figure7-5.Respondingtothelandscapeorientationonsmallscreens
Torespondtothisorientationforsmallscreens,Ihaveremovedthecategorynavigationelementsandreplacedthemwithleftandrightbuttonsthatpagethroughthecategories.Thisisn’tthemostelegantapproach,butitmakesgooduseoflimitedscreenspacewhilepreservingthebasicnatureofthewebapp.Listing7-12showstheadditionofthedatabindingtocontrolvisibilityforthenavigationitems.
Listing7-12.BindingElementVisibilitytotheScreenSizeandOrientation
<div class="cheesegroup" data-bind="ifnot: cheeseModel.device.smallScreen() && cheeseModel.device.landscape()"> <div class="navSelectors" data-bind="foreach: products"> <a data-bind="formatAttr: {attr: 'href', prefix: '#category/', value: category}, css: {selectedItem: (category == cheeseModel.selectedCategory())}"> <span data-bind="text: cheeseModel.device.smallScreen()? shortName : category"></span> </a> </div> </div>
IremovetheelementsfromtheDOMifthedevicehasasmallscreenandisinthelandscapeorientation.ThebuttonsIaddareasfollows:
<div class="buttonDiv" data-bind="visible: $root.device.smallScreen()"> <button id="left">Prev</button> <input type="submit" value="Submit Order"/> <button id="right">Next</button> </div>
Theelementsthemselvesarenotinteresting,butthecodethathandlesthenavigationthatariseswhenclickedisworthlookingat:
5
CHAPTER7CREATINGRESPONSIVEWEBAPPS
188
... function performScreenSetup(smallScreen) { $('div.cheesegroup').not("#basket") .css("width", smallScreen ? "" : "50%"); $('button#left').button({icons: {primary: "ui-icon-circle-triangle-w"},text: false}); $('button#right').button({icons: {primary: "ui-icon-circle-triangle-e"},text: false}); $('button#left, button#right').click(function(e) { e.preventDefault(); advanceCategory(e, this.id); }); }; ...
Thisisanexampleofwhenusingroutingfornavigationdoesn’twork.Iwanttheusertobeabletorepeatedlyclickthesebuttons,andasImentionedalready,thebrowserwon’trespondtoanattempttonavigatetothesameURLthatisalreadybeingdisplayed.Withthisinmind,IhaveusedthejQueryclickmethodtohandletheregularJavaScripteventbycallingtheadvanceCategoryfunction.Idefinedthisfunctioninutils.js,anditisshowninListing7-13.
Listing7-13.TheadvanceCategoryFunction
function advanceCategory(e, dir) { var cIndex = -1; for (var i = 0; i < cheeseModel.products.length; i++) { if (cheeseModel.products[i].category == cheeseModel.selectedCategory()) { cIndex = i; break; } } cIndex = (dir == "left" ? cIndex - 1 : cIndex + 1) % (cheeseModel.products.length); if (cIndex < 0) { cIndex = cheeseModel.products.length -1; } cheeseModel.selectedCategory(cheeseModel.products[cIndex].category) }
Thereisnoneatorderingofcategoriesintheviewmodel,soIenumeratethroughthedatatofindtheindexofthecurrentlyselectedcategoryandincrementordecrementthevaluebasedonwhichbuttonhasbeenclicked.Theresultisamorecompactlayoutthatbettersuitsthesmall-screenlandscapeorientation.ThewayIhavecategorizeddevicesisprettycrude,andIrecommendyoutakeamoregranularapproachinrealprojects,butitservestodemonstratethetechniquesyouneedinordertorespondtoscreenorientation.
RespondingtoTouchThefinalfeaturethataresponsivewebappneedstodealwithistouchsupport.Theideaoftouch-basedinteractionisfirmlyestablishedinthesmartphoneandtabletmarkets,butitisalsomakingitswaytothedesktop,mostlythroughMicrosoftWindows8.
CHAPTER7CREATINGRESPONSIVEWEBAPPS
189
Tosupporttouchinteraction,weneedtwothings:atouchscreenandabrowserthatemitstouchevents.Thesetwodon’talwayscometogether;pluggingatouch-enabledmonitorintoadesktopmachinedoesn’tautomaticallyenabletouchinthebrowser,forexample.Equally,youshouldnotassumethatifadevicesupportstouchthatthiswillbetheonlymodelforinteractions.Manydeviceswillsupportmouseandkeyboardinteractionsalongsidetouch,andtheusershouldbeabletopickwhichevermodelsuitsthemwhenusingyourwebappandswitchfreelybetweenthem.
Devicesthatdon’thavearegularmouseandkeyboardsynthesizeeventssuchasclickinresponsetotouchevents.Thismeansyoudon’tneedtomakechangestoyourwebapptosupportbasictouchinteractions.However,tocreateatrulyresponsewebapp,youshouldconsidersupportingthenavigationgesturesthatarecommonontouchdevices,suchasswiping.Idemonstratehowtodothisshortly.
DetectingTouchSupportThereisaW3Cspecificationfortouchevents,butitislow-level,andalotofworkisrequiredtofigureoutwhatgesturestheuserismaking.AsIhavesaidbefore,partofthejoyofwebappdevelopmentistheavailabilityofhigh-qualityJavaScriptlibrariesthatmakedevelopmentsimpler.OnesuchexampleistouchSwipe,whichbuildsonjQueryandtransformsthelow-leveltoucheventsintoeventsthatrepresentgestures.IincludedthetouchSwipelibraryinthesourcecodedownloadthataccompaniesthisbookandthatisavailablefromApress.com.Thewebsiteforthelibraryishttp://labs.skinkers.com/touchSwipe.
ThesimplestandmostreliableapproachtodetectingtouchsupportistorelyontheModernizrtest.Listing7-14showstheadditionstothedetectDeviceFeaturesfunctionintheutils.jsfiletodetectandreportontouchsupportandshowstheuseoftouchSwipetorespondtotouchevents.
Listing7-14.DetectingSupportforTouchEvents
function detectDeviceFeatures(callback) { var deviceConfig = {}; deviceConfig.landscape = ko.observable(); deviceConfig.portrait = ko.computed(function() { return !deviceConfig.landscape(); }); var setOrientation = function() { deviceConfig.landscape(window.innerWidth > window.innerHeight); } setOrientation(); $(window).bind("orientationchange resize", function() { setOrientation(); }); setInterval(setOrientation, 500); if (window.matchMedia) { var orientQuery = window.matchMedia('screen AND (orientation:landscape)') if (orientQuery.addListener) { orientQuery.addListener(setOrientation); } }
CHAPTER7CREATINGRESPONSIVEWEBAPPS
190
Modernizr.load([{ test: window.matchMedia, nope: 'matchMedia.js', complete: function() { var screenQuery = window.matchMedia('screen AND (max-width:500px)'); deviceConfig.smallScreen = ko.observable(screenQuery.matches); if (screenQuery.addListener) { screenQuery.addListener(function(mq) { deviceConfig.smallScreen(mq.matches); }); } deviceConfig.largeScreen = ko.computed(function() { return !deviceConfig.smallScreen(); }); } }, { test: Modernizr.touch, yep: 'jquery.touchSwipe-1.2.5.js', callback: function() { $('html').swipe({ swipeLeft: advanceCategory, swipeRight: advanceCategory }); } },{ complete: function() { callback(deviceConfig); } }]); };
WhenyoupassanarrayofobjectstotheModernizr.loadmethod,eachtestisperformedinturn.IhaveaddedatestthatusestheModernizr.touchcheckandthatloadsthetouchSwipelibraryiftouchsupportispresent.
TipMakesureyouincludedthetouchtestsifyoudownloadedyourownversionofModernizr.TheversionIincludedinthesourcecodeforthischaptercontainsalloftheavailabletests.
NoticethatIusedthecallbackpropertytosetupsupportforhandlingswipes.Functionssetusingthecallbackpropertyareexecutedwhenthespecifiedresourcesareloaded,whereasfunctionsspecifiedusingcomplete areexecutedattheendofthetest,irrespectiveofthetestresult.IwanttohandleswipeeventsonlyiftouchSwipehasbeenloaded(whichitselfindicatesthattouchsupportispresent),soIhaveusedcallbacktogiveModernizrmyfunction.
ThetouchSwipelibraryisappliedusingtheswipemethod.Inthisexample,Ihaveselectedthehtmlelementasthetargetfordetectingswipegestures.Somebrowserslimitthebodyelementsizesothatitdoesn’tfilltheentirewindowwhenthecontentissmallerthantheavailablespace.Thisisn’tusuallya
CHAPTER7CREATINGRESPONSIVEWEBAPPS
191
problem,butitcreatesdeadspotsonthescreenwhendealingwithgestures,whichmaynotbetargetedatindividualelements.Thesimplestwaytogetaroundthisistoworkonthehtmlelement.
ThetouchSwipelibraryisabletodifferentiatebetweendifferentkindsoftoucheventsandswipesinarangeofdirections.Icareaboutswipesonlytotheleftandtherightinthisexample,whichiswhyIhavedefinedafunctionfortheswipeLeftandswipeRightpropertiesintheobjectIpassedtotheswipemethod.InbothcasesIhavespecifiedtheadvanceCategoryfunction,whichisthesamefunctionIusedtochangeselectedcategoriesearlier.Theresultisthatswipingleftmovestothepreviouscategoryandswipingrightgoestothenextcategory.ThelastpointtonoteaboutthislistingisthelastiteminthearraypassedtotheModernizr.loadmethod:
{ complete: function() { callback(deviceConfig); } }
Idon’twanttoinvokethecallbackfunctionuntilIhavesetupallofthedevicedetailsintheresultobjectthatwillbeaddedtotheviewmodel.Theeasiestwaytoensurethishappensistocreateanadditionaltestthatcontainsjustacompletefunction.Modernizrwon’texecutethisfunctionuntilalloftheothertestshavebeenperformed,therequiredresourceshavebeenloaded,andthecallbackandcompletefunctionsforalloftheprevioustestshavebeenperformed.
UsingTouchtoNavigatetheWebAppHistoryInthepreviousexample,Irespondtoswipegesturesbyloopingthroughtheavailableproductcategories.Inthissection,Ishowyouhowtorespondtothesegesturesinamoreusefulway.
Thetemptationistousethebrowser’shistorytorespondtoswipes.Theproblemisthatthereisnowaytopeekatthepreviousornextentryinthehistoryandseewhetheritisonethatbelongstothewebapp.Ifitisn’t,thenyouendupmakingtheusernavigateawayfromyourwebapp,potentiallytoaURLthattheyhadnointentionofvisiting.Listing7-15showsthechangesrequiredtotheenhanceViewModelfunctionintheutils.jsfiletosetupthebasicsupportfortrackingtheuser’scategoryselections.
TipYoucouldelecttouselocalstorageandmaketheswipe-relatedhistorypersistent.Iprefernottodothis,sinceIthinkitmakesmoresenseforthehistorytobelimitedtothecurrentlifeofthewebapp.
Listing7-15.AddingApplication-SpecificHistoryUsingSessionStorage
function enhanceViewModel() { cheeseModel.selectedCategory = ko.observable(cheeseModel.products[0].category); mapProducts(function(item) { item.quantity = ko.observable(0); item.subtotal = ko.computed(function() { return this.quantity() * this.price; }, item); }, cheeseModel.products, "items");
CHAPTER7CREATINGRESPONSIVEWEBAPPS
192
cheeseModel.total = ko.computed(function() { var total = 0; mapProducts(function(elem) { total += elem.subtotal(); }, cheeseModel.products, "items"); return total; }); var history = cheeseModel.history = {}; history.index = 0; history.categories = [cheeseModel.selectedCategory()]; cheeseModel.selectedCategory.subscribe(function(newValue) { if (newValue != history.categories[history.index]) { history.index++; history.categories.push(newValue); } }) };
Theadditionsaresimple.IhaveaddedanindexandanarraytotheviewmodelandsubscribedtotheselectedCategoryobservabledataitemsothatIcanbuilduptheuser’shistoryastheychangecategories.IhavenotworriedaboutmanagingthesizeofthearraysinceIthinkitisunlikelythatenoughcategorychangeswillbemadetocauseacapacityproblem.Listing7-16showsthechangestothead.
Listing7-16.TakingAdvantageoftheApp-SpecificHistory
function advanceCategory(e, dir) { if (cheeseModel.device.smallScreen() && cheeseModel.device.landscape()) { var cIndex = -1; for (var i = 0; i < cheeseModel.products.length; i++) { if (cheeseModel.products[i].category == cheeseModel.selectedCategory()) { cIndex = i; break; } } cIndex = (dir == "left" ? cIndex-1 : cIndex + 1) % (cheeseModel.products.length); if (cIndex < 0) { cIndex = cheeseModel.products.length -1; } cheeseModel.selectedCategory(cheeseModel.products[cIndex].category) } else { var history = cheeseModel.history; if (dir == "left" && history.index > 0) { cheeseModel.selectedCategory(history.categories[--history.index]); } else if (dir == "right" && history.index < history.categories.length -1) { cheeseModel.selectedCategory(history.categories[++history.index]); } } }
CHAPTER7CREATINGRESPONSIVEWEBAPPS
193
Ihavetobecarefulnottoapplytheswipehistorywhenthewebappisdisplayedonasmallscreeninthelandscapeorientation.Iremovedthecategorybuttonsinthisdeviceconfiguration,meaningthatthereisnowayfortheusertogenerateahistoryformetonavigatethrough.Inallotherdeviceconfigurations,Iamabletorespondtotheswipebychangingthevalueoftheindexandselectingthecorrespondinghistoriccategory.Theresultisthattheusercannavigatebetweencategoriesusingthenavigationbuttonsandswipingmovesbackwardorforwardthroughtherecentselections.
IntegratingwiththeApplicationRoutesThelasttweakIwanttomakeistorespondtotheswipeeventsthroughthewebapp’sURLroutes.Inthelastlisting,Itooktheshortcutofchangingtheobservabledataitemdirectly,butthismeansIwillbypassanycodethatisgeneratedasaresultofaURLchange,includingintegrationwiththeHTML5HistoryAPI(whichIdescribeinChapter4).ThechangesareshowninListing7-17.
Listing7-17.RespondingtoSwipeEventsThroughtheApplicationRoutes
function advanceCategory(e, dir) { if (cheeseModel.device.smallScreen() && cheeseModel.device.landscape()) { var cIndex = -1; for (var i = 0; i < cheeseModel.products.length; i++) { if (cheeseModel.products[i].category == cheeseModel.selectedCategory()) { cIndex = i; break; } } cIndex = (dir == "left" ? cIndex-1 : cIndex + 1) % (cheeseModel.products.length); if (cIndex < 0) { cIndex = cheeseModel.products.length -1; } cheeseModel.selectedCategory(cheeseModel.products[cIndex].category) } else { var history = cheeseModel.history; if (dir == "left" && history.index > 0) { location.href = "#category/" + history.categories[--history.index]; } else if (dir == "right" && history.index < history.categories.length -1) { location.href = "#category/" + history.categories[++history.index]; } } }
IhaveusedthebrowserlocationobjecttochangetheURLthatthebrowserdisplays.SinceIhavespecifiedrelativeURLs,thebrowserwillnotnavigateawayfromthewebapp,andmyrouteswillbeabletomatchtheURLs.Bydoingthis,Iensurethatmyresponsetoswipeeventsisconsistentwithotherformsofnavigation.
CHAPTER7CREATINGRESPONSIVEWEBAPPS
194
SummaryInthischapter,Ihaveshownyouthethreecharacteristicsthatyoumustadapttoinordertocreatearesponsivewebapp:screensize,screenorientation,andtouchinteraction.Bydetectingandadaptingtodifferentdeviceconfigurations,youcancreateonewebappthatcanseamlesslyandelegantlyadaptitslayoutandinteractionmodeltosuittheuser’sdevice.Theadvantagesofsuchanapproachareobviouswhenyouconsidertheproliferationofsmartphonesandtabletsandtheblurringofthedistinctionsbetweenthesedevicesanddesktops.Inthenextchapter,Ishowyouadifferentapproachtosupportingdifferenttypesofdevices:creatingaplatform-specificwebapp.
C H A P T E R 8
195
Creating Mobile Web Apps
Analternativetocreatingawebappthatadaptstothecapabilitiesofdifferentdevicesistocreateaversionthatisspecificallytargetedtomobiledevices.Choosingbetweenaresponsivewebappandamobile-specificimplementationcanbedifficult,butmyruleofthumbisthatamobileversionmakessensewhenIwanttoofferaradicallydifferentexperiencetomobileanddesktopusersorwhendealingwithdeviceconstraintsinaresponsiveimplementationbecomesunwieldyandoverlycomplex.Yourdecisionwill,ofcourse,dependonthespecificsofyourproject,butthischapterisforwhenyoudecidethatoneversionofyourwebapp,howeverresponsive,won’tcatertoyourmobileusers’needs.
DetectingMobileDevicesThefirststepistodecidehowyouaregoingtodirectusersofmobiledevicestothemobileversionofyourwebapp.Thedecisionyoumakeatthisstagewillshapealotoftheassumptionsyouwillhavewhenyoucometobuildthemobilewebapp.Thereareacoupleofbroadapproaches,whichIdescribeinthefollowingsections.
DetectingtheUserAgentThetraditionalapproachistolookattheuseragentstringthatthebrowserusestodescribeitself.Thisisavailablethroughthenavigator.userAgentproperty,andthevaluethatitreturnscanbeusedtoidentifythebrowserand,usually,theplatformthebrowserisrunningon.Asanexample,hereisthevalueofnavigator.userAgentthatChromereturnsonmyWindowssystem:
"Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/535.7 (KHTML, like Gecko) Chrome/16.0.912.77 Safari/535.7"
And,forcontrast,hereiswhatIgetfromtheOperaMobileemulator:
Opera/9.80 (Windows NT 6.1; Opera Mobi/23731; U; en) Presto/2.9.201 Version/11.50"
Youcanidentifymobiledevicesbybuildingalistofuseragentvaluesandkeepingtrackofwhichonesrepresentmobilebrowsers.Youdon’thavetocreateandmanagetheselistsyourself,however—therearesomegoodsourcesofinformationavailableonline.(AverycomprehensivedatabasecalledWURFLcanbefoundathttp://wurfl.sourceforge.net,butthisrequiresintegrationintoyourserver-sidecode,whichisnotidealforthisbook.)
Aless-comprehensiveclient-sidesolutioncanbefoundathttp://detectmobilebrowsers.com,whereyoucandownloadasmalljQuerylibrarythatmatchestheuseragentagainstaknownlistofmobilebrowsers.Thisapproachisn’tascompleteasWURFL,butitissimplertouse,anditdetectsthemostwidelyusedmobilebrowsers.Todemonstratethiskindofmobiledevicedetection,IdownloadedthejQuerycodetomyNode.jscontentdirectoryinafilecalleddetectmobilebrowser.js(youcanfindthis
CHAPTER8CREATINGMOBILEWEBAPPS
196
fileinthesourcecodedownloadforthisbook,availablefromApress.com).Listing8-1showshowtousethisplugintodetectmobiledevices.
Listing8-1.DetectingMobileDevicesattheClient
<!DOCTYPE html> <html> <head> <title>CheeseLux</title> <link rel="stylesheet" type="text/css" href="styles.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script src="jquery-ui-1.8.16.custom.js" type="text/javascript"></script> <script src='knockout-2.0.0.js' type='text/javascript'></script> <script src='utils.js' type='text/javascript'></script> <script src='signals.js' type='text/javascript'></script> <script src='crossroads.js' type='text/javascript'></script> <script src='hasher.js' type='text/javascript'></script> <script src='modernizr-2.0.6.js' type='text/javascript'></script> <script src='detectmobilebrowser.js' type='text/javascript'></script> <link rel="stylesheet" type="text/css" href="jquery-ui-1.8.16.custom.css"/> <meta name="viewport" content="width=device-width, initial-scale=1"> <script> if ($.browser.mobile) { location.href = "mobile.html"; } var cheeseModel = {}; ...
OnceIhaveaddedthelibrarytomydocumentwithascriptelement,Icanchecktoseewhethermywebappisrunningonamobilebrowserbyreadingthe$.browser.mobileproperty,whichreturnstrueiftheuseragentisrecognizedasbelongingtoamobilebrowser.Inthiscase,Iredirectmobileuserstothemobile.htmldocument,whichIwillusetobuildmymobilewebapplaterinthischapter.
Themainproblemwithusingtheuseragentisthatitisn’talwaysaccurate,andasImentionedinthepreviouschapter,thedistinctionsbetweenmobileanddesktopdevicesarebecomingblurred.Inessence,yourelyonsomeoneelse’sdecisionaboutwhatdefinesmobile,andthatwon’talwayslineupwiththewayyouwanttosegmentyouruserbase.And,althoughthelistsofbrowseraregenerallyaccurate,itcantakeawhilefornewmodelstobeproperlyidentifiedandcategorized,especiallyfromnichehardwareproviders.
Arelatedproblemisthatmanybrowsersallowtheusertochangetheuseragentsothatanotherbrowserisidentified.Notmanyusersmakethischange,butitdoesmeanyoucannotentirelyrelyontheuseragentreportedthroughthenavigator.userAgentproperty.
DetectingDeviceCapabilitiesIprefertoclassifyadeviceasmobilebydetectingitscapabilities,muchasIdidinChapter7.Thisallowsmetodecidewhatdefinesmobileinthecontextofthewaymywebappworks.FortheCheeseLuxwebapp,Ihavedecidedthatdevicesthataretouchenabledandthathavescreensthatarenarrowerthan500pixelswillbegiventhemobileversionofmywebapp.YoucanseehowIhaveimplementedthispolicyinListing8-2,whichshowsthechangestothedetectDeviceFeaturesfunctionfromtheutils.jsfile.
CHAPTER8CREATINGMOBILEWEBAPPS
197
Listing8-2.DetectingMobileDevicesBasedonTheirCapabilities
function detectDeviceFeatures(callback) { var deviceConfig = {}; ...code removed for brevity... Modernizr.load([{ test: window.matchMedia, nope: 'matchMedia.js', complete: function() { var screenQuery = window.matchMedia('screen AND (max-width: 500px)'); deviceConfig.smallScreen = ko.observable(screenQuery.matches); if (screenQuery.addListener) { screenQuery.addListener(function(mq) { deviceConfig.smallScreen(mq.matches); }); } deviceConfig.largeScreen = ko.computed(function() { return !deviceConfig.smallScreen(); }); } }, { test: Modernizr.touch, yep: 'jquery.touchSwipe-1.2.5.js', callback: function() { $('html').swipe({ swipeLeft: advanceCategory, swipeRight: advanceCategory }) } },{ complete: function() { deviceConfig.mobile = Modernizr.touch && deviceConfig.smallScreen(); callback(deviceConfig); } }]); };
Ihaveaddedamobilepropertytotheviewmodel;itreturnstrueifthedevicemeetsmycriteriaforgettingthemobileversionofmywebapp.Listing8-3showshowIhaveusedthisnewpropertyinexample.html.
Listing8-3.UsingMobileDeviceDetectionintheMainWebAppDocument
var cheeseModel = {}; detectDeviceFeatures(function(deviceConfig) { cheeseModel.device = deviceConfig; if (cheeseModel.device.mobile) { location.href = "mobile.html"; }
CHAPTER8CREATINGMOBILEWEBAPPS
198
$.getJSON("products.json", function(data) { cheeseModel.products = data; }).success(function() { $(document).ready(function() { ...
IaddthecapabilitiescheckbeforetheJSONdataisloadedsothatIcandirecttheusertomobile.htmlbeforeIstartmakingnetworkrequestsandprocessingtheelementsintheDOM.
TipInthisexampleandthepreviousone,IplacedthemobiledetectioncodeoutsideofthejQueryreadyeventsothatthebrowserwillexecutethecodeassoonasitreachesitinthedocument.AmorethoroughapproachwouldbetoplacethedetectioncoderightatthetopofthedocumentsothatitisexecutedbeforeanyoftheJavaScriptlibrariesareloaded.However,sinceIrelyonsomeoftheselibrariestoactuallyperformthedetection,carefulorderingofthescriptelementsisrequired.
CreatingaSimpleMobileWebAppBothoftheapproachesIshowedyouassumethattheuserwillwanttoviewthemobileversionofmywebapp—butthiswon’talwaysbethecase.Iprefertoidentifyamobiledeviceandthenasktheuserwhattheywanttodo.Thisapproachputscontrolintousers’hands(whichiswhereitshouldbe),butitdoesmeanthatIhavetoprovideamechanismforlettingthemchooseandrememberingthechoicetheymake.So,ratherthansimplydirectingmobiledevicestothemobileversionofthewebapp,Iuseaninterimdocumentcalledaskmobile.html.IplacedthisfileintheNode.jscontentdirectory,andyoucanseethefilecontentinListing8-4.ThisisaverysimplewebappthatusesjQueryandjQueryMobile.
Listing8-4.AskingtheUserIfTheyWanttoUsetheMobileVersionoftheWebApp
<!DOCTYPE html> <html> <head> <title>CheeseLux</title> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script src="jquery.mobile-1.0.1.js" type="text/javascript"></script> <link rel="stylesheet" type="text/css" href="jquery.mobile-1.0.1.css"/> <link rel="stylesheet" type="text/css" href="styles.mobile.css"/> <meta name="viewport" content="width=device-width, initial-scale=1"> <script> function setCookie(name, value, days) { var date = new Date(); date.setTime(date.getTime()+(days * 24 * 60 * 60 *1000)); document.cookie = name + "="+ value + "; expires=" + date.toGMTString() +"; path=/"; }
CHAPTER8CREATINGMOBILEWEBAPPS
199
$(document).bind("pageinit", function() { $('button').click(function(e) { var useMobile = e.target.id == "yes"; var useMobileValue = useMobile ? "mobile" : "desktop"; if (localStorage) { localStorage["cheeseLuxMode"] = useMobileValue; } else { setCookie("cheeseLuxMode", useMobileValue, 30); } location.href = useMobile ? "mobile.html" : "example.html"; }); }); </script> </head> <body> <div id="page1" data-role="page" data-theme="a"> <img class="logo" src="cheeselux.png"> <span class="para"> Would you like to use our mobile web app? </span> <div class="middle"> <button data-inline="true" data-theme="b" id="yes">Yes</button> <button data-inline="true" id="no">No</button> </div> </div> </body> </html>
TipIexplainhowtogettheCSSandJavaScriptfilesreferredtointhislistingshortly.
Thisdocumentpresentstheuserwithtwobuttonsthattheycanusetochoosetheversionofthewebapptheywanttouse.YoucanseehowthedocumentisdisplayedinthebrowserinFigure8-1.
CHAPTER8CREATINGMOBILEWEBAPPS
200
Figure8-1.Askingtheuserwhichversionofthewebapptheyrequire
ThistinywebappgivesmeagoodexamplewithwhichtointroducejQueryMobile,whichiswhatI’llbeusinginthischapter.jQueryMobileisatoolkitoptimizedformobiledevices,anditincludeswidgetsthatareeasytointeractwithusingtouchandbuilt-insupportforhandlingtoucheventsandgestures.
jQueryMobileisthe“official”mobiletoolkitfromthemainjQueryproject,andit’sprettygood,althoughtherearesomeroughedgeswithsomelayoutsthatneedtweakingwithminorCSS.ThereareotherjQuery-basedmobilewidgettoolkitsavailable—andsomeofthemareverygoodaswell.IhavechosenjQueryMobilebecauseitsharesabroadlycommonapproachwithjQueryUIandithassomedesigncharacteristicsthataretypicalofmostmobiletoolkitsandthatrequirespecialattentionwhenwritingcomplexwebapps.
AVOIDING PSEUDONATIVE MOBILE APPS
AnotherreasonthatIusejQueryMobileisthatitdoesn’ttrytore-createtheappearanceofanativesmartphoneapplication,whichisanapproachthatsomeoftheothertoolkitsadopt.Idon’tlikethatapproachbecauseitdoesn’tquitework.IfyougivetheusersomethingthatlookslikeanativeiOSorAndroidapp,thenyouneedtomakesureitbehavesexactlythewayanativeapplicationshould—and,atleastatthemoment,thatisn’tpossible.
Theworstpossibleapproachistotrytore-createanativeappforjustoneplatform.Youoftenseethis,anditisusuallyiOSthatwebappdevelopersaimfor.Thismightnotbesobadifthere-creationwasfaithfulandallmobiledevicesraniOS,butusersofAndroidandotheroperatingsystemsgetsomethingthatistotallyalien,andiOSusersgetsomethingthatinitiallyappearstobefamiliarbutthatturnsouttobeconfusingandinconsistent.
Tomymind,itisfarbettertodesignawebappthatisgenuinelyobviousandeasytouse.Theresultsarebetter,youuserswillbehappier,andyoudon’thavetocontortyourwebapptofitinsidetheconstraintsofplatformthatyoucan’tproperlyadheretoanyway.
CHAPTER8CREATINGMOBILEWEBAPPS
201
IamnotgoingtoprovidealengthytutorialonjQueryMobile,buttherearesomeimportantcharacteristicsthatIneedtoexplaininordertodemonstratehowtocreateasolidmobilewebapplication.Iexplainthecoreconceptsinthesectionsthatfollow.IfyouwantmoreinformationaboutjQueryMobile,thenseetheprojectwebsiteormyProjQuerybook,whichispublishedbyApressandcontainsacompletereferenceforusingjQueryMobile.
InstallingjQueryMobileYoucandownloadjQueryMobilefromhttp://jquerymobile.com.jQueryMobiledependsonjQuery,andthescriptelementthatimportsjQueryintothedocumentmustcomebeforetheonethatimportsthejQueryMobilelibrary,likethis:
<head> <title>CheeseLux</title> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script src="jquery.mobile-1.0.1.js" type="text/javascript"></script> <link rel="stylesheet" type="text/css" href="jquery.mobile-1.0.1.css"/>
jQueryMobilereliesonitsownCSSandimagesthataredifferentfromthoseusedbyjQueryUI.WhenyoudownloadjQueryMobile,copytheCSSfileintotheNode.jscontentdirectoryalongwiththeJavaScriptfile,andputtheimagesintotheimagesdirectoryalongwiththosefromjQueryUI.
UnderstandingthejQueryMobileDataAttributesjQueryMobilereliesondataattributestoconfigurethelayoutofthewebapp.Dataattributesallowcustomattributestobeappliedtoelements,justlikethedata-bindattributethatIhavebeenusingfordatabindings.Thereisnodata-bindattributedefinedintheHTMLspecification,butanyattributethatisprefixedbydata-isignoredbythebrowserandallowsyoutoembedusefulinformationinyourmarkupthatyoucanthenaccessviaJavaScript.DataattributeshavebeenusedunofficiallyforafewyearsandareanofficialpartofHTML5.
jQueryMobileusesdataattributesratherthanthecode-centricapproachthatjQueryUIrequires.Youusethedata-roleattributetotelljQueryMobilehowitshouldtreatanelement—themarkupisprocessedautomaticallywhenthedocumentisloadedandthewidgetsarecreated.
Youdon’talwaysneedtousethedata-roleattribute.Forsomeelements,jQueryMobilewillassumethatitneedstocreateawidgetbasedontheelementtype.Thishashappenedforthebuttonsinthedocument:jQueryMobilewillcreateabuttonwidgetwhenitfindsabuttonelementinthemarkup.So,thiselement:
<button data-inline="true" id="no">No</button>
doesn’tneedadata-roleattributebutcouldhavebeenwrittenlikethisifyouprefer:
<button data-role="button" data-inline="true" id="no">No</button>
DefiningPagesThemostimportantvalueforthedata-roleattributeispage.Whenbuildingmobilewebapps,itisgoodpracticetominimizethenumberofrequestsmadetotheserver.jQueryMobilehelpsinthisregardbysupportingsingle-pageapps,wherethemarkupandscriptformultiplelogicalpagesiscontainedwithinasingledocumentandshowntotheuserasrequired.Apageisdenotedbyadivelementwhosedata-roleattributeispage.Thecontentofthedivelementisthecontentofthatpage:
CHAPTER8CREATINGMOBILEWEBAPPS
202
... <body> <div id="page1" data-role="page" data-theme="a"> ...page content goes here... </div> </body> ...
Thereisjustonepageinmyaskmobile.htmldocument,butI’llreturntothetopicofpageswhenwebuildthefullmobileCheeseLuxapplaterinthechapter.
ConfiguringWidgetsjQueryMobilealsousesdataattributestoconfigurewidgets.Bydefault,jQueryMobilebuttonsspantheentirepage.Thisgivesalargetargettohitonasmallportraitscreenbutlooksprettyoddinotherlayouts.Todisablethisbehavior,IhavetoldjQueryMobilethatIwantinlinebuttons,wherethebuttonisjustlargeenoughtocontainitscontent.Ididthisbysettingthedata-inlineattributetotrue forthebuttonelements,likethis:
<button data-inline="true" id="no">No</button>
Anumberofelement-specificdataattributesareavailable,andyoushouldconsultthejQueryMobilewebsitefordetails.OneimportantconfigurationattributethatIwillmention,however,isdata-theme,whichappliesastyletothepageorwidgettowhichitisapplied.AjQueryMobilethemecontainsanumberofswatches,namedA,B,C,andsoon.Ihavesetthedata-themeattributetoaforthepageelementsoastosetthethemeforthesinglepageinthedocumentandallofitscontent:
<div id="page1" data-role="page" data-theme="a">
YoucancreateyourowncustomthemesusingthejQueryMobileThemeRoller,whichisavailableatjquerymobile.com.Iamusingthedefaultthemes,andswatchAprovidesthedarkstyleforthewebapp.Forcontrast,IhavesettheswatchontheYesbuttontob,likethis:
<button data-inline="true" data-theme="b" id="yes">Yes</button>
ButtonsinswatchBareblue,whichgivestheuserastrongsuggestionastotherecommendeddecision.
TipIhavedefinedanewCSSstylesheetforusewithjQueryMobile.Itiscalledhttp://styles.mobile.css,anditlivesintheNode.jscontentdirectoryalongwiththeotherexamplefiles.Thestylesinthisfilejusttweakthelayoutslightly,allowingmetocenterelementsinthepageandmakeotherminoradjustmentstothedefaultjQueryMobilelayout.Youcanfindthestylesheetinthesourcecodedownloadforthisbook,whichisavailablefromApress.com.
CHAPTER8CREATINGMOBILEWEBAPPS
203
DealingwithjQueryMobileEventsUsingawidgetlibrarythatisbasedonjQuerymeanswecanhandleeventsusingfamiliartechniques.Ifyoulookatthescriptelementintheaskmobile.htmldocument,youwillseethathandlingtheeventstriggeredwhenthebuttonsareclickedrequiresthesamebasicjQuerycodethatIhavebeenusingthroughoutthisbook:
<script> ...code removed for brevity... $(document).bind("pageinit", function() { $('button').click(function(e) { var useMobile = e.target.id == "yes"; var useMobileValue = useMobile ? "mobile" : "desktop"; if (localStorage) { localStorage["cheeseLuxMode"] = useMobileValue; } else { setCookie("cheeseLuxMode", useMobileValue, 30); } location.href = useMobile ? "mobile.html" : "example.html"; }); }); </script>
IusejQuerytoselectthebuttonelementsandthestandardclickmethodtohandletheclickevent.However,thereisoneveryimportantdifferenceinthewaythatjQueryMobiledealswithevents.Hereitis:
$(document).bind("pageinit", function() { ...code to handle button click events... }
jQueryMobileprocessesthemarkupfordataattributeswhenthestandardjQueryreadyeventfires.ThismeansIhavetobindtothepageiniteventifIwanttoexecutecodeafterjQueryMobilehasfinishedsettingupitswidgets.ThereisnoconvenientmethodforspecifyingafunctionforthiseventandsoIhaveusedthebindmethodinstead.ThecodeinthisexamplewouldhaveruninresponsetothejQueryreadyeventquitehappily,sinceIamnotinteractingdirectlywiththewidgetsthatjQueryMobilecreates.ThiswillchangewhenIcometothefulljQueryMobileCheeseLuxwebapp,anditisgoodpracticetousethepageiniteventinalljQueryMobileapps.
StoringtheUser’sDecisionNowthatIhavedescribedthejQueryMobilepartsofaskmobile.html,wecanreturntotheapplication’sfunction,whichistorecordandstoretheuser’spreferencefortheversionofthewebapptheuserwantstouse.Iuselocalstorageifitisavailableandfallbacktoaregularcookieifitisnot.ThereisnoconvenientjQuerysupportforworkingwithcookies,soIhavewrittenmyownfunctioncalledsetCookie:
CHAPTER8CREATINGMOBILEWEBAPPS
204
function setCookie(name, value, days) { var date = new Date(); date.setTime(date.getTime()+(days * 24 * 60 * 60 *1000)); document.cookie = name + "="+ value + "; expires=" + date.toGMTString() +"; path=/"; }
IfIhavetousethecookie,thenIsetthelifetobe30days,afterwhichthebrowserwilldeletethecookieandtheuserwillhavetoexpresstheirpreferenceagain.Forbrevity,Ihavenotsetanylifetimewhenusinglocalstorage,butdoingsowouldbegoodpractice.
TipItisalsogoodpracticetoasktheuseriftheywantyoutostoretheirchoiceatall.Ihaven’ttakenthisstepinmysimpleexample,butsomeusersaresensitivetotheseissues,especiallywhenitcomestocookies.
DetectingtheUser’sDecisionintheWebAppThelaststepistodetecttheuser’sdecisioninthedesktopversionoftheCheeseLuxwebapp.Listing8-5showsapairoffunctionsIhaveaddedtoutils.jstosupportthisprocess.
Listing8-5.CheckingforaPriorDecisionBeforePerformingaRedirect
function checkForVersionPreference() { var previousDecision; if (localStorage && localStorage["cheeseLuxMode"]) { previousDecision = localStorage["cheeseLuxMode"]; } else { previousDecision = getCookie("cheeseLuxMode"); } if (!previousDecision && cheeseModel.device.mobile) { location.href = "/askmobile.html"; } else if (location.pathname == "/mobile.html" && previousDecision == "desktop") { location.href = "/example.html"; } else if (location.pathname != "/mobile.html" && previousDecision == "mobile") { location.href = "/mobile.html"; } } function getCookie(name) { var val; $.each(document.cookie.split(';'), function(index, elem) { var cookie = $.trim(elem); if (cookie.indexOf(name) == 0) { val = cookie.slice(name.length + 1); } }) return val; }
CHAPTER8CREATINGMOBILEWEBAPPS
205
ThecheckForVersionPreferencefunctionusestheviewmodelvaluestoseewhethertheuserhasamobiledeviceand,ifso,triestorecovertheresultofapreviousdecisionfromlocalstorageoracookie.Cookiesareawkwardtoprocess,soIhaveaddedagetCookiefunctionthatfindsacookiebynameandreturnsitsvalue.Ifthereisnostoredvalue,thenIdirecttheusertotheaskmobile.htmldocumenttogettheirpreference.Ifthereisastoredvalue,thenIuseittoswitchtothemobileversionifthatwastheuser’spreference.AllthatremainsistoincorporateacalltothecheckForVersionPreferencefunctionintoexample.html,whichcontainsthedesktopversionofthewebapp,likethis:
... detectDeviceFeatures(function(deviceConfig) { cheeseModel.device = deviceConfig; checkForVersionPreference(); $.getJSON("products.json", function(data) { cheeseModel.products = data; }).success(function() { $(document).ready(function() { ... code removed for brevity... }); }); )}; ...
IhaveshownthechangesascodesnippetsbecauseIdon’twanttousepagesinachapteronmobiledevicestolistthedesktopwebappcode.YoucangetthecompletelistingaspartofthesourcecodedownloadavailablefreeofchargefromApress.com.
TipItmakessensetooffertheuserthechancetochangetheirmindswhentheeffectofthedecisionisstoredandappliedautomatically.IskippedthisstepbecauseIwanttofocusonthemobileappinthischapter,butyoushouldalwaysincludesomekindofUIcuethatallowstheusertoswitchtotheotherversionofthewebapp,especiallyifthedecisionisstoredandusedpersistently.
BuildingtheMobileWebAppIamgoingtostartwithabasicmobileversionoftheCheeseLuxwebappandthenbuildonittoshowyouhowtocreateabetterexperiencefortheuser.WhenIcreateamobileversionofawebappthathasadesktopcounterpart,Ihavetwogoalsinmind:
• Reuseasmuchdesktopcodeasispossible
• Ensurethatthemobilerespondselegantlytodifferentdevicecapabilities
Thefirstgoalisallaboutlong-termmaintainability.ThemorecommoncodeIhave,thefeweroccasionstherewillbewhereIhavetofindandfixabugintwodifferentplaces.Iliketodecideinadvancewhichversionofthewebapphasprimacyandwhichwillhavetoflextobeabletousethecode.
CHAPTER8CREATINGMOBILEWEBAPPS
206
Ingeneral,Itendtocreatethedesktopversionfirstandmakethemobilewebappadapt.Theexceptiontothisiswhenthemajorityofuserswillbeusingmobiledevices.
WHAT ABOUT MOBILE FIRST?
Thereisaview(oftenreferredtoasmobilefirst)thatfocusesonthedesignanddevelopmentofthemobileplatformfirst,largelybecauseitforcesyoutoworkwithinthemostconstrainedenvironmentyouwillbetargetingandbecausemobiledeviceshavecapabilities,likegeolocation,thatarenotondesktops.
Inmyprojects,Idon’twantinitialconstraints—Iwanttobuildtherichest,deepest,andmostimmersiveexperienceIcan,and,forthemomentatleast,thatisthedesktop.OnceIhaveahandleonwhatispossiblewithlargescreensandrichinteraction,Ibegintheprocessofdealingwithdeviceconstraints,paringdownandtailoringmyappuntilIgetsomethingthatworkswellonamobiledevice.Iamnotabelieverintheuniquecapabilitiesofmobiledevices,either.AsImentionedinChapter7,thehardandfastdistinctionsbetweencategoriesofdevicesarefadingfast.OneofmymomentsofwonderrecentlywaswhenGooglewasabletousetheWi-FidataitcollectsalongwithitsStreetViewproducttopinpointmylocationwithinafewfeet.Thiswasonamachinethatwouldrequireaforklifttrucktobemobile.
But,asImentionedpreviously,Iamnotapatternzealot,andyoushouldfollowwhateverapproachmakesthemostsenseforyouandyourprojects.Don’tletanyonedictateyourdevelopmentstyle,includingme.
Thesecondgoalisaboutensuringthatmymobilewebappisresponsiveandadaptstothewide
rangeofdevicetypesthatusersmayhave.Youcannotaffordtomakeassumptionsaboutscreensizeandinputmechanismsevenwhentargetingjustmobiledevices.
CautionYoumaybetemptedtotrytocreateawebappthatswitchesbetweenjQueryUIandjQueryMobile(orequivalentlibraries)basedonthekindofdevicethatisbeingused.Suchatrickispossiblebutincrediblyhardtopulloffwithoutcreatingalotofverycontortedcodeandmarkup.Themostsensibleapproachistocreateseparateversionsifyouwanttotakeadvantageoffeaturesthatarespecifictoonelibraryoranother.
Togetthingsgoing,Listing8-6showsafirstpassatcreatingthecorefunctionalityusingjQueryMobile.ThislistingdependsonsomechangesintheviewmodelthatI’llexplainshortly.
Listing8-6.TheInitialVersionoftheCheeseLuxMobileWebApp
<!DOCTYPE html> <html> <head> <title>CheeseLux</title> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script type="text/javascript"> $(document).bind("mobileinit", function() { $.mobile.autoInitializePage = false;
CHAPTER8CREATINGMOBILEWEBAPPS
207
}); </script> <script src="jquery.mobile-1.0.1.js" type="text/javascript"></script> <link rel="stylesheet" type="text/css" href="jquery.mobile-1.0.1.css"/> <link rel="stylesheet" type="text/css" href="styles.mobile.css"/> <script src='knockout-2.0.0.js' type='text/javascript'></script> <script src='utils.js' type='text/javascript'></script> <script src='signals.js' type='text/javascript'></script> <script src='crossroads.js' type='text/javascript'></script> <script src='hasher.js' type='text/javascript'></script> <script src='modernizr-2.0.6.js' type='text/javascript'></script> <meta name="viewport" content="width=device-width, initial-scale=1"> <script> var cheeseModel = {}; detectDeviceFeatures(function(deviceConfig) { cheeseModel.device = deviceConfig; checkForVersionPreference(); $.getJSON("products.json", function(data) { cheeseModel.products = data; enhanceViewModel(); $(document).ready(function() { ko.applyBindings(cheeseModel); $('button#left, button#right').live("click", function(e) { e.preventDefault(); advanceCategory(e, e.target.id); }) $.mobile.initializePage(); }); }); $(document).bind("pageinit", function() { function positionCategoryButtons() { setTimeout(function() { $('fieldset:visible').each(function(index, elem) { var fsWidth = 0; $(elem).children().each(function(index, child) { fsWidth+= $(child).width(); }); if (fsWidth > 0) { $(elem).width(fsWidth); } else { positionCategoryButtons(); } }); }, 10); }; positionCategoryButtons(); cheeseModel.device.smallAndPortrait.subscribe(positionCategoryButtons); });
CHAPTER8CREATINGMOBILEWEBAPPS
208
}); </script> </head> <body> <div id="page1" data-role="page" data-theme="a"> <div id="logobar" data-bind="visible: device.largeScreen()"> <img data-bind="ifAttr: {attr: 'src', value: 'cheeselux.png', test: device.largeScreen()}"> <span id="tagline">Gourmet European Cheese</span> </div> <fieldset class="middle" data-role="controlgroup" data-type="horizontal" data-bind="foreach:products, visible: device.largeScreen() || device.smallAndPortrait()"> <input type="radio" name="category" data-bind="attr: {id: category, value: category}, checked: $root.selectedCategory" /> <label data-bind="attr: {for: category}"> <span data-bind="text: $root.device.smallAndPortrait()? shortName : category"></span> </label> </fieldset> <form action="/basket" method="post"> <div data-bind="foreach: products"> <div data-bind="fadeVisible: category == $root.selectedCategory()"> <div data-role="header" > <h1 data-bind="text: category"></h1> </div> <!-- ko foreach: items --> <div class="itemContainer ui-grid-a"> <div class="ui-block-a"> <label data-bind="attr: {for: id}, formatText: {value: name, suffix:':'}"></label> </div> <div class="ui-block-b"> <input data-bind="attr: {name: id}, value: quantity"> </div> </div> <!-- /ko --> <div data-role="footer"> <h1> <label>Total:</label> <span data-bind="formatText: {prefix: '$', value: cheeseModel.total()}" </h1> </div> </div> </div> <div class="middle" data-role="controlgroup" data-type="horizontal" data-bind="visible: device.smallAndLandscape()"> <button id="left" data-icon="arrow-l"> </button>
CHAPTER8CREATINGMOBILEWEBAPPS
209
<input type="submit" value="Submit Order"/> <button id="right" data-icon="arrow-r" data-iconpos="right"> </button> </div> <div class="middle" data-role="controlgroup" data-type="horizontal" data-bind="visible: !device.smallAndLandscape()"> <input type="submit" value="Submit Order"/> </div> </form> </div> </body> </html>
Forthemostpart,thisisastraightforwardwebappthatreliesonthecorefunctionalityofjQueryMobile,butyouneedtobeawareofsomewrinklesandadditionsthatIdescribeinthefollowingsections.Youcanseethelandscapeandportraitlayoutsforasmall-screendeviceinFigure8-2.Thewebappalsosupportslayoutsformobiledeviceswithlargerscreens.Ihavenotshowntheselayouts,buttheyaresimilartothoseshowninthefigure,butwiththeCheeseLuxlogoandthefullcategorynamesdisplayedinthenavigationbuttons.
Figure8-2.ThebasicimplementationofthemobileCheeseLuxwebapp
Youwillnoticenewdatabindingsandviewmodelitemsinthislisting.TheformatTextdatabindingletsmeapplyaprefixandsuffixtothetextcontentofanelement,whichsimplifiesworkingwith
CHAPTER8CREATINGMOBILEWEBAPPS
210
composedstrings,especiallycurrencyamounts.ThisisoneofthesetofcustombindingsthatIgenerallyaddtoprojectsandthecode,whichisincludedintheutils.jsfile,asshowninListing8-7.ThecomposeStringfunctionusedbythisbindingisthesameoneIshowedyouinChapter4whenIintroducedthecustomformatAttrbinding.
Listing8-7.TheformatTextCustomDataBinding
ko.bindingHandlers.formatText = { update: function(element, accessor) { $(element).text(composeString(accessor())); } }
Theotheradditionsaresomehelpfulshortcutsaddedtothedevicecapabilitiesinformationintheviewmodel.AlthoughKOcandealwithexpressionsindatabindings,Idon’tlikedefiningcodeinthisway,andIgenerallycreatecomputeddataitemsthatallowmetodeterminethestateofthedevicethroughasingleviewmodelitem.Forthischapter,IdefinedapairofcomputedvaluesthatletmeeasilyreadthecombinationsofscreensizeandorientationthatIaminterestedinforthemobilewebapp.TheseshortcutsaredefinedinthedetectDeviceFeaturesfunctionintheutils.jsfile,asshowninListing8-8.
Listing8-8.CreatingShortcutsintheViewModeltoAvoidExpressionsinBindings
... function detectDeviceFeatures(callback) { var deviceConfig = {}; deviceConfig.landscape = ko.observable(); deviceConfig.portrait = ko.computed(function() { return !deviceConfig.landscape(); }); var setOrientation = function() { deviceConfig.landscape(window.innerWidth > window.innerHeight); } setOrientation(); $(window).bind("orientationchange resize", function() { setOrientation(); }); setInterval(setOrientation, 500); if (window.matchMedia) { var orientQuery = window.matchMedia('screen AND (orientation:landscape)') if (orientQuery.addListener) { orientQuery.addListener(setOrientation); } } Modernizr.load([{ test: window.matchMedia,
CHAPTER8CREATINGMOBILEWEBAPPS
211
nope: 'matchMedia.js', complete: function() { var screenQuery = window.matchMedia('screen AND (max-width: 500px)'); deviceConfig.smallScreen = ko.observable(screenQuery.matches); if (screenQuery.addListener) { screenQuery.addListener(function(mq) { deviceConfig.smallScreen(mq.matches); }); } deviceConfig.largeScreen = ko.computed(function() { return !deviceConfig.smallScreen(); }); setInterval(function() { deviceConfig.smallScreen(window.innerWidth <= 500); }, 500); } }, { test: Modernizr.touch, yep: 'jquery.touchSwipe-1.2.5.js', callback: function() { $('html').swipe({ swipeLeft: advanceCategory, swipeRight: advanceCategory }) } },{ complete: function() { deviceConfig.mobile = Modernizr.touch && deviceConfig.smallScreen(); deviceConfig.smallAndLandscape = ko.computed(function() { return deviceConfig.smallScreen() && deviceConfig.landscape(); }); deviceConfig.smallAndPortrait = ko.computed(function() { return deviceConfig.smallScreen() && deviceConfig.portrait(); }); callback(deviceConfig); } }]); }; ...
ManagingtheEventSequenceAsIdemonstratedintheaskmobile.htmldocument,jQueryMobilewillprocessadocumentautomaticallyandcreatewidgetsbasedonelementtypesandthevalueofthedata-roleattribute.Thisisanicefeature,anditsignificantlyreducestheamountofcoderequiredforsimplewebapps.Unfortunately,itgetsinthewaywhenyouareusingtheviewmodeltogenerateorformatelements,especiallyifthedataintheviewmodelisobtainedviaAjax.jQueryMobilewillprocessthedocument
w
CHAPTER8CREATINGMOBILEWEBAPPS
212
beforetheviewmodelispopulatedwiththedatabindings,whichmeansthatwidgetsarenotcreatedproperly.
ThisisthesameproblemIencounteredpreviouslywithjQueryUI,buttheissueisworsewithjQueryMobilebecauseitassumesthatithassolecontrolofelementsinapageandmakesitverydifficulttocreatebindingsthatcannegotiatetheextraelementsthatjQueryMobileuseswhenitsetsupawidget.(ThisisaproblemI’llreturntofordifferentreasonslaterinthischapter.)
DisablingAutomaticProcessingThebestapproachistopreventjQueryMobilefromautomaticallyprocessingthedocument.Todothis,Ineedtohandlethemobileinitevent,whichisemittedbyjQueryMobilewhenthelibraryisfirstloaded.IneedtoregistermyhandlerfunctionbeforejQueryMobileisloaded,whichmeansIhavetoinsertanewscriptelementaftertheonethatimportsjQueryandbeforetheonethatimportsjQueryMobile,likethis:
... <sript src="jquery-1.7.1.js" type="text/javascript"></script> <script type="text/javascript"> $(document).bind("mobileinit", function() { $.mobile.autoInitializePage = false; }); </script> <script src="jquery.mobile-1.0.1.js" type="text/javascript"></script> ...
Bysettingthe$.mobile.autoInitializePagepropertytofalse,IdisablethejQueryMobilefeaturethatprocessesthemarkupinthedocumentautomatically.
TipTobefair,IneedtoinsertmyscriptelementafterjQueryonlyifIwanttousethebindmethod,butIprefertodothisratherthanusetheclunkyDOMAPIforhandlingevents.
DisablingtheautomaticprocessingstopstheracebetweentheviewmodelandjQueryMobileandallowsmetomakemyAjaxrequest,populatetheviewmodel,anddoanyothertasksIneedwithoutworryingaboutprematurewidgetcreation.WhenIamdonesettingup,IexplicitlytelljQueryMobilethatitshouldprocessthepage,likethis:
$.getJSON("products.json", function(data) { cheeseModel.products = data; enhanceViewModel(); $(document).ready(function() { ko.applyBindings(cheeseModel); $('button#left, button#right').live("click", function(e) { e.preventDefault(); advanceCategory(e, e.target.id); }) $.mobile.initializePage();
CHAPTER8CREATINGMOBILEWEBAPPS
213
}); });
ThemobileobjectprovidesaccesstothejQueryMobileAPI,andtheinitializePagemethodstartspageprocessing.
RespondingtothepageinitEventNowthatIhavethemaineventsundercontrol,IcanusethepageinittoperformtasksafterjQueryMobilehasprocessedthepagesinthedocument.jQueryMobileisgenerallyverysolid,butithassomelayoutquirks.Oneinparticularisthatgroupsofbuttonsarenotcenteredinthepage.Forthebuttonsatthebottomofthepage,IhavebeenabletofixthisissuewithCSS(whichiswhatthecenteredstyleisforinthestyles.mobile.cssfile).Butthesizeofthenavigationbuttonschanges,andthatrequiresaJavaScriptsolution,whichisasfollows:
... $(document).bind("pageinit", function() { function positionCategoryButtons() { setTimeout(function() { $('fieldset:visible').each(function(index, elem) { var fsWidth = 0; $(elem).children().each(function(index, child) { fsWidth+= $(child).width(); }); if (fsWidth > 0) { $(elem).width(fsWidth); } else { positionCategoryButtons(); } }); }, 10); }; positionCategoryButtons(); cheeseModel.device.smallAndPortrait.subscribe(positionCategoryButtons); }); ...
IwanttocenterthebuttonsafterjQueryMobilehasfinishedcreatingthem,whichisanidealuseforthepageinitevent.Inthefunction,Iaddupthewidthofthechildrenofeachfieldsetelementandthenusethetotalvaluetosetthewidthofthefieldset.jQueryMobileleavesthefieldsettobethewidthofthewindow,andthesequenceofelementsrequiredtocreateasetofbuttonsmakesithardtocenterthebuttonsbyothermeans.
TipIusethejQueryeachmethodsothatIcanbesurethatthechildrenmethodreturnsonlythechildrenofonefieldsetelement.Thismeansmycodewon’tbreakifIaddanotherfieldsetelementlater.Elementselectorsaregreedy,andifIjustcall$('fieldset').children(),Iwillgetthechildrenofallfieldsetelementsinthedocument,whichwillthrowoutthewidthcalculations.
CHAPTER8CREATINGMOBILEWEBAPPS
214
IwrappedthecodethatsetsthewidthinsideacalltothesetTimeoutfunctionbecauseIwanttocorrectlyresizethefieldsetelementwhenthecontentofthenavigationbuttonschange,whichhappenswhenthesizeandorientationarealtered.
Thecontentoftheelementsischangedbydatabindings,whichareexecutedwhenobservabledataitemsintheviewmodelareupdated.SinceIamusingthesubscribemethodtoreceivethesamekindofnotifications,Ineedtomakesurethatmycodetoresizethefieldsetisn’texecutedbeforethebuttoncontentischanged,whichIachievebyintroducingasmalldelayusingthesetTimeoutfunction.
PreparingforContentChangesjQueryMobileassumesthatithascontroloftheelementsthatareusedasthefoundationforwidgets.Inthecaseofbuttons,jQueryMobilewrapsthebuttoncontents(orlabelcontentswhenusingradiobuttons)inaspanelementsothatstylingcanbeapplied.
ThisisthesameproblemthatjQueryUIcreates,andthesolutionisthesameforjQueryMobile:wrapthecontentinaspanelementyourselfsothatyouhaveatargetfordatabindings.Onceyouhaveanelementthatyoucanattachdatabindingsto,youdon’tneedtoworryabouthowjQueryMobiletransformstheelementintoawidget.YoucanseehowIhavedonethisforthenavigationbuttons:
<fieldset class="middle" data-role="controlgroup" data-type="horizontal" data-bind="foreach:products, visible: device.largeScreen() || device.smallAndPortrait()"> <input type="radio" name="category" data-bind="attr: {id: category, value: category}, checked: $root.selectedCategory" /> <label data-bind="attr: {for: category}"> <span data-bind="text: $root.device.smallAndPortrait()? shortName : category"></span> </label> </fieldset>
Thismayseemlikeasimpletrick,butalotofmobilewebappprogrammersgetcaughtbythisissueandenduptryingtoresolveitthroughsometorturedandunreliablealternative.Thissimpleapproachresolvestheproblemratherneatly.AllofthemobilewidgettoolkitsthatIhaveusedclashwithdatabindingsinasimilarway.InthecaseofjQueryMobile,youknowthattheproblemhasoccurredwhentheformattingofbuttonsislostwhenadatabindingchangesthebuttoncontent,asshowninFigure8-3.
Figure8-3.ProblemscausedbyjQueryMobileaddingelementsforstyling
CHAPTER8CREATINGMOBILEWEBAPPS
215
DuplicatingElementsandUsingTemplatesNotallconflictsbetweenwidgetlibrariesanddatabindingscanberesolvedsoeasily.InListing8-6,Icreatedduplicatesetsofthebuttonsthataredisplayedatthebottomofthepage,likethis:
<div class="middle" data-role="controlgroup" data-type="horizontal" data-bind="visible: device.smallAndLandscape()"> <button id="left" data-icon="arrow-l"> </button> <input type="submit" value="Submit Order"/> <button id="right" data-icon="arrow-r" data-iconpos="right"> </button> </div> <div class="middle" data-role="controlgroup" data-type="horizontal" data-bind="visible: !device.smallAndLandscape()"> <input type="submit" value="Submit Order"/> </div>
Onesethasadditionalbuttonsthattheusercanclicktonavigatethroughtheproductcategories.TheproblemthatIamworkingaroundisthatjQueryMobilecreatesasetofbuttonswithouttakingintoaccountthevisibilityoftheelementsitisworkingwith.Thatmeanstheouterbuttonsaregivenroundedcornerseveniftheyareinvisible,whichmeansthatusingthevisiblebindingdoesn’tcreatewell-formattedgroupsofbuttons.
TheifbindinghasitsownissuesbecausejQueryMobilewon’tautomaticallyupdatethestylingofbuttonswhennewelementsareaddedtothecontainer,andaskingjQueryMobiletorefreshthecontentdoesn’taddressthisissue.So,thesimplestapproachistocreateduplicatesetsofelements.
UsingTwo-PassDataBindingsDuplicatingelementsisOKforsimplesituations,butitbecomesproblematicwhenyouareworkingwithcomplexsetsofelementsthathavealotofbindingsandformatting.Atsomepoint,achangewillbeappliedtoonesetofelementsandnottheother.Trackingdownthiskindofissuewhenithappenscanbetime-consuming.Analternativeapproachistogenerateduplicatesetsofelementsfromasingletemplate.Thisisanelegant,butfiddly,technique—youcanseethechangesrequiredinListing8-9.
Listing8-9.UsingaTemplatetoCreateDuplicateSetsofElements
<!DOCTYPE html> <html> <head> <title>CheeseLux</title> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script type="text/javascript"> $(document).bind("mobileinit", function() { $.mobile.autoInitializePage = false; }); </script> <script src="jquery.mobile-1.0.1.js" type="text/javascript"></script> <link rel="stylesheet" type="text/css" href="jquery.mobile-1.0.1.css"/> <link rel="stylesheet" type="text/css" href="styles.mobile.css"/> <script src='knockout-2.0.0.js' type='text/javascript'></script>
CHAPTER8CREATINGMOBILEWEBAPPS
216
<script src='utils.js' type='text/javascript'></script> <script src='signals.js' type='text/javascript'></script> <script src='crossroads.js' type='text/javascript'></script> <script src='hasher.js' type='text/javascript'></script> <script src='modernizr-2.0.6.js' type='text/javascript'></script> <meta name="viewport" content="width=device-width, initial-scale=1"> <script> var cheeseModel = {}; detectDeviceFeatures(function(deviceConfig) { cheeseModel.device = deviceConfig; checkForVersionPreference(); $.getJSON("products.json", function(data) { cheeseModel.products = data; enhanceViewModel(); $(document).ready(function() { ko.applyBindings(cheeseModel); $('*.deferred').each(function(index, elem) { ko.applyBindings(cheeseModel, elem); }); $('button#left, button#right').live("click", function(e) { e.preventDefault(); advanceCategory(e, e.target.id); }) $.mobile.initializePage(); }); }); $(document).bind("pageinit", function() { function positionCategoryButtons() { setTimeout(function() { $('fieldset:visible').each(function(index, elem) { var fsWidth = 0; $(elem).children().each(function(index, child) { fsWidth+= $(child).width(); }); if (fsWidth > 0) { $(elem).width(fsWidth); } else { positionCategoryButtons(); } }); }, 10); }; positionCategoryButtons(); cheeseModel.device.smallAndPortrait.subscribe(positionCategoryButtons); }); }); </script> <script id="buttonsTemplate" type="text/html">
CHAPTER8CREATINGMOBILEWEBAPPS
217
<div class="deferred middle" data-role="controlgroup" data-type="horizontal" data-bind="attr: {'data-bind': 'visible: ' + ($data ? '' : '!') + 'device.smallAndLandscape()' }"> <!-- ko if: $data --> <button id="left" data-icon="arrow-l"> </button> <!-- /ko --> <input type="submit" value="Submit Order"/> <!-- ko if: $data --> <button id="right" data-icon="arrow-r" data-iconpos="right"> </button> <!-- /ko --> </div> </script> </head> <body> <div id="page1" data-role="page" data-theme="a"> <div id="logobar" data-bind="visible: device.largeScreen()"> <img data-bind="ifAttr: {attr: 'src', value: 'cheeselux.png', test: device.largeScreen()}"> <span id="tagline">Gourmet European Cheese</span> </div> <fieldset class="middle" data-role="controlgroup" data-type="horizontal" data-bind="foreach:products, visible: device.largeScreen() || device.smallAndPortrait()"> <input type="radio" name="category" data-bind="attr: {id: category, value: category}, checked: $root.selectedCategory" /> <label data-bind="attr: {for: category}"> <span data-bind="text: $root.device.smallAndPortrait()? shortName : category"></span> </label> </fieldset> <form action="/basket" method="post"> <div data-bind="foreach: products"> <div data-bind="fadeVisible: category == $root.selectedCategory()"> <div data-role="header" > <h1 data-bind="text: category"></h1> </div> <!-- ko foreach: items --> <div class="itemContainer ui-grid-a"> <div class="ui-block-a"> <label data-bind="attr: {for: id}, formatText: {value: name, suffix:':'}"></label> </div> <div class="ui-block-b"> <input data-bind="attr: {name: id}, value: quantity"> </div> </div> <!-- /ko --> <div data-role="footer"> <h1> <label>Total:</label>
CHAPTER8CREATINGMOBILEWEBAPPS
218
<span data-bind="formatText: {prefix: '$', value: cheeseModel.total()}" </h1> </div> </div> </div> <!-- ko template: {name: 'buttonsTemplate', foreach: [true, false] } --> <!-- /ko --> </form> </div> </body> </html>
Thistechniquehasthreeparts,andtoshowhowthepartsfittogether,Ineedtoexplaintheminreverseorderfromhowtheyappearinthedocument.
InvokingaTemplatewithCustomDataIhaveusedthetemplatebindingtogenerateelementsfromaKnockout.jstemplate,atechniquethatIdescribedinChapter3:
<!-- ko template: {name: 'buttonsTemplate', foreach: [true, false] } --> <!-- /ko -->
ThetwististhatIamnotusingtheviewmodeltodrivethetemplate.Instead,Ihavecreatedanarraythatcontainstrueorfalsevalues.Iamapplyingthistechniqueinaverysimplesituation,andIneedtoknowonlyifIamcreatingthesetofbuttonsthatallowforcategorynavigation(representedbythetruevalue)orthesetthatdoesn’t(representedbythefalsevalue).Thepointisthatyoucanusetheforeachbindingwithdatathatisnotpartoftheviewmodel.Youcanusemorecomplexdatastructuresformorecomplexsetsofelements.
UsingaTemplatetoGenerateBindingsThesecondstepisalittleodd.Iusetheattrdatabindingstosetthevalueofthedata-bindattributeontheelementsthataregeneratedbythetemplate,likethis:
<script id="buttonsTemplate" type="text/html"> <div class="deferred middle" data-role="controlgroup" data-type="horizontal" data-bind="attr: {'data-bind': 'visible: ' + ($data ? '' : '!') + 'device.smallAndLandscape()' }"> <!-- ko if: $data --> <button id="left" data-icon="arrow-l"> </button> <!-- /ko --> <input type="submit" value="Submit Order"/> <!-- ko if: $data --> <button id="right" data-icon="arrow-r" data-iconpos="right"> </button> <!-- /ko --> </div> </script>
CHAPTER8CREATINGMOBILEWEBAPPS
219
Thesimplestpartofthetemplateistheuseoftheifbindingtofigureoutwhenthecategorynavigationbuttonsshouldbegenerated.Mytemplatewillbeusedtwice:onceeachforthetrueandfalsevaluesthatIpassedtotheforeachbinding.Whenthevalueistrue,thebuttonelementsareincludedintheDOM,andtheyareomittedwhenthevalueisfalse.
ThemorecomplexpartiswhereIhaveusedtheattrbindingtospecifyavaluethatIwantforthedata-bindattributeintheelementsthataregeneratedbythetemplate.Hereisthevalueofthedata-bindattributeinthetemplate:
data-bind="attr: {'data-bind': 'visible: ' + ($data ? '' : '!') + 'device.smallAndLandscape()'}"
Thereisalotgoingoninthisbinding.ThemostimportantthingtounderstandisthatIamspecifyingthedata-bindvalueIwantthegeneratedelementstohaveasastring,andthisstringwon’tbeprocessedatthemoment.I’llreturntotheprocessingshortly.
Iuse$datatorefertothevaluesIpassedtotheforeachbindingwhenIcalledthetemplate.Thevalueof$datawillbeeithertrueorfalse.First,Knockoutwillresolvethispartofthebinding,sowhenIamdealingwiththetruevalue,thegenerateddivelementwillhaveabindinglikethis:
data-bind="attr: {'data-bind': 'visible: device.smallAndLandscape()'}"
andthefalsevaluewillcauseabindinglikethis:
data-bind="attr: {'data-bind': 'visible: !device.smallAndLandscape()'}"
Then,oncethedatavalueshavebeenresolved,Knockoutwillprocesstheentireattrbinding,whichhastheratherneateffectofreplacingitselfinthegeneratedelement,likethis:
data-bind="visible: device.smallAndLandscape()"
ReapplyingtheDataBindingsKnockoutprocessesthedata-bindattributeonlyonce,whichmeansthatmytemplategenerateselementswiththedatabindingsthatIwant,butthesebindingsarenotlive.Changesintheviewmodelwon’taffectthembecausethedata-bindattributeswerenotdefinedwhenIcalledtheko.applyBindingsmethod.
Tofixthis,IsimplycallapplyBindingsagain,butthistimeIusetheoptionalargumentthatallowsmetospecifywhichelementsareprocessed:
$(document).ready(function() { ko.applyBindings(cheeseModel); $('*.deferred').each(function(index, elem) { ko.applyBindings(cheeseModel, elem); }); $('button#left, button#right').live("click", function(e) { e.preventDefault(); advanceCategory(e, e.target.id); }) $.mobile.initializePage(); });
Iaddedmybuttoncontainerelementtothedeferredclass.InowselectallmembersofthisclassandusetheeachmethodtocalltheapplyBindingsmethodoneachelementinturn.Thismakes
CHAPTER8CREATINGMOBILEWEBAPPS
220
Knockout.jsprocessthebindingsthatIgeneratedfromthetemplateandmakethemlive.Thisfinalstepmeansthatmybindingswillrespondtochangesintheviewmodel.
Thereareacoupleofpointstonoteaboutthistechnique.First,IamnottryingtopreventduplicationofelementsintheDOM.ThereisnoeasywaytodealwiththejQueryMobileformattingissueswithoutduplicateelementsets.MygoalistogeneratetheduplicatesfromasinglesetofsourceelementssothatImakechangesinoneplaceandhavethemtakeeffectinalloftheduplicateswhentheyaregenerated.
Second,whenusingthistechnique,youmustensurethatyoudon’trefertoviewmodelitemsexceptwithinapairofquotecharacters(i.e.,withinastring).Ifyourefertoavariableoutsideofastring,thenKockout.jswilltrytofindavaluetoresolvethereference,andyouwillgetanerror.ViewmodelvaluesareresolvedinthesecondcalltotheapplyBindingsmethodandnotwhenthetemplateisusedtocreateelements.
CautionItcanbedifficulttogetthestringproperlysetup,buttheeffortisworthwhileforcomplexsetsofelements.Forsimplersituations,Isuggestyousimplyduplicatewhatyouneedinsidethedocumentandskipthetemplatesaltogether.Thesourcecodedownloadforthisbookcontainsthefulllistingsforthisexample.
AdoptingtheMultipageModelMymobilewebappisshapingup,butIamstillmissingURLrouting,whichmeansthereisasignificantdifferencebetweenthemobileanddesktopversions.Thefirststepinaddingsupportforroutingistoembracethemultipagemodel.AsIexplainedearlier,jQueryMobilesupportstheideaofhavingmultiplepagesinasingleHTMLdocument.Iwillusethisfeaturetoprovidetheuserwiththemeanstonavigatebetweencategories.Listing8-10showsthechangesthatarerequired.
Listing8-10.AddingSupportfortheMultipageModel
<!DOCTYPE html> <html> <head> <title>CheeseLux</title> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script type="text/javascript"> $(document).bind("mobileinit", function() { $.mobile.autoInitializePage = false; }); </script> <script src="jquery.mobile-1.0.1.js" type="text/javascript"></script> <link rel="stylesheet" type="text/css" href="jquery.mobile-1.0.1.css"/> <link rel="stylesheet" type="text/css" href="styles.mobile.css"/> <script src='knockout-2.0.0.js' type='text/javascript'></script> <script src='utils.js' type='text/javascript'></script> <script src='signals.js' type='text/javascript'></script> <script src='crossroads.js' type='text/javascript'></script> <script src='hasher.js' type='text/javascript'></script> <script src='modernizr-2.0.6.js' type='text/javascript'></script>
CHAPTER8CREATINGMOBILEWEBAPPS
221
<meta name="viewport" content="width=device-width, initial-scale=1"> <script> var cheeseModel = {}; detectDeviceFeatures(function(deviceConfig) { cheeseModel.device = deviceConfig; checkForVersionPreference(); $.getJSON("products.json", function(data) { cheeseModel.products = data; enhanceViewModel(); $(document).ready(function() { ko.applyBindings(cheeseModel); $('*.deferred').each(function(index, elem) { ko.applyBindings(cheeseModel, elem); }); $('button.left, button.right').live("click", function(e) { e.preventDefault(); advanceCategory(e, $(e.target).hasClass("left") ? "left" : "right"); $.mobile.changePage($('div[data-category="' + cheeseModel.selectedCategory() + '"]')); }) $.mobile.initializePage(); hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); crossroads.addRoute("category/:newCat:", function(newCat) { cheeseModel.selectedCategory(newCat || cheeseModel.products[0].category); }); crossroads.addRoute("{shortCat}", function(shortCat) { $.each(cheeseModel.products, function(index, item) { if (item.shortName == shortCat) { crossroads.parse("category/" + item.category); } }); }); crossroads.parse(location.hash.slice(1)); }); }); }); </script> <script id="buttonsTemplate" type="text/html"> <div class="deferred middle" data-role="controlgroup" data-type="horizontal" data-bind="attr: {'data-bind': 'visible: ' + ($data ? '' : '!')
CHAPTER8CREATINGMOBILEWEBAPPS
222
+ 'device.smallAndLandscape()'}"> <!-- ko if: $data --> <button class="left" data-icon="arrow-l"> </button> <!-- /ko --> <input type="submit" value="Submit Order"/> <!-- ko if: $data --> <button class="right" data-icon="arrow-r" data-iconpos="right"> </button> <!-- /ko --> </div> </script> </head> <body> <!-- ko foreach: products --> <div data-role="page" data-theme="a" data-bind="attr: {'id': shortName, 'data-category': category}"> <div id="logobar" data-bind="visible: $root.device.largeScreen()"> <img data-bind="ifAttr: {attr: 'src', value: 'cheeselux.png', test: $root.device.largeScreen()}"> <span id="tagline">Gourmet European Cheese</span> </div> <fieldset class="middle" data-role="controlgroup" data-type="horizontal" data-bind="foreach: $root.products, visible: $root.device.largeScreen() || $root.device.smallAndPortrait()"> <a data-role="button" data-bind="formatAttr: {attr: 'href', prefix: '#', value: shortName}, css: {'ui-btn-active': (category == $root.selectedCategory())}"> <span data-bind="text: $root.device.smallAndPortrait()? shortName : category"></span> </a> </fieldset> <form action="/basket" method="post"> <div> <div> <div data-role="header" > <h1 data-bind="text: category"></h1> </div> <!-- ko foreach: items --> <div class="itemContainer ui-grid-a"> <div class="ui-block-a"> <label data-bind="attr: {for: id}, formatText: {value: name, suffix:':'}"> </label> </div> <div class="ui-block-b"> <input data-bind="attr: {name: id}, value: quantity"> </div> </div>
CHAPTER8CREATINGMOBILEWEBAPPS
223
<!-- /ko --> <div data-role="footer"> <h1> <label>Total:</label> <span data-bind="formatText: {prefix: '$', value: cheeseModel.total()}" </h1> </div> </div> </div> <!-- ko template: {name: 'buttonsTemplate', foreach: [true, false] } --> <!-- /ko --> </form> </div> <!-- /ko --> </body> </html>
Ihavehighlightedthemostimportantchanges(andI’lldescribetheminamoment),butthebasicapproachistocreateonepagepercategory.Eachpagecontainsaduplicatesetofnavigationitems,andonlythedetailsofindividualproductsdiffer.Forthemostpart,thechangesaretothedatabindingstocreatethiseffect.Somechanges,however,requiremoreexplanation.
ReworkingCategoryNavigationjQueryMobileusesthesameURL-fragment-basedapproachIemployedinthedesktopversiontonavigatebetweenpages.Forexample,ifthereisadivelementwhosedata-roleattributeissettopageandwhoseidattributeissettomypage,IcangetjQueryMobiletodisplaythatpagebynavigatingtothe#mypagefragment.
ThedifferencefromthedesktopwebappisthatjQueryMobileplacessomeconstraintsonthenamesthatcanbeusedforpages.Iusedthefullcategorynamebefore(suchasBritish Cheese),butspacesareaproblemforjQueryMobile,soIhaveusedtheshortcategorynameinstead(British,forexample).HereisthebindingthatsetsthepageID:
<div data-role="page" data-theme="a" data-bind="attr: {'id': shortName, 'data-category': category}">
NoticethatIhaveaddedadata-categoryattributethatcontainsthefullcategoryname.I’llreturntothisattributeshortly.
ReplacingRadioButtonswithAnchorsThepagenavigationmodelmeansthatIcanreplacemyradiobuttonswithaelements.jQueryMobilewillcreatebuttonwidgetsfromanaelementifthedata-roleattributeissettobutton,andthevalueofthehrefattributecanbeusedfornavigationwithinthedocument:
<a data-role="button" data-bind="formatAttr: {attr: 'href', prefix: '#', value: shortName}, css: {'ui-btn-active': (category == $root.selectedCategory())}"> <span data-bind="text: $root.device.smallAndPortrait()? shortName : category"></span> </a>
CHAPTER8CREATINGMOBILEWEBAPPS
224
Whenthedatabindingsareresolved,Igetanavigationelementwhosepurposeisaloteasiertodivine:
<a data-role="button" href="#British" <span>British</span> </a>
ClickingoneofthebuttonsthatjQueryMobilecreatesfromthiskindofelementwillnavigatetotheappropriatecategorypage.Asanaddedbonus,jQueryMobileproperlycentersgroupsofbuttonscreatedfromaelements,soIdon’thavetoworryaboutexplicitlysettingthewidthofthecontainingfieldsetelement.
TipNoticethatIhaveusedthecssbindingtoapplytheui-btn-activeclasstothebuttonwhentheselectedcategorymatchesthecategorythatbuttonrepresents.ThisisthejQueryMobileCSSclassthatisusedwhenabuttonisactive,andapplyingthisclasscreatesthebluehighlightingthatIhadinthepreviousversionofthemobilewebapp.DiggingaroundinthetoolkitCSSisn’tideal,butsometimesthereisnoalternative.
MappingPageNamestoRoutesSothatIcanreusemyJavaScriptcodeforhandlingroutes,Iwanttousethesameroutenamesasinthedesktopversion.ThisisaproblembecauseoftherestrictionsonpagenamesthatjQueryMobileenforces.Togetaroundthis,IhaveaddedaroutethatmapsbetweentheroutesthatjQueryMobilerequiresandtheroutesIreallywant:
... hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); crossroads.addRoute("category/:newCat:", function(newCat) { cheeseModel.selectedCategory(newCat || cheeseModel.products[0].category); }); crossroads.addRoute("{shortCat}", function(shortCat) { $.each(cheeseModel.products, function(index, item) { if (item.shortName == shortCat) { crossroads.parse("category/" + item.category); } }); }); crossroads.parse(location.hash.slice(1)); ...
TheURLfragmentchangeswhentheuserclicksoneoftheaelementstonavigatetoanewcategory.Thehasherlibrarydetectsthischangeandpassesonthenewhashtothecrossroadsroutingengine.The
CHAPTER8CREATINGMOBILEWEBAPPS
225
jQueryMobileURLmatchesthehighlightedroute,andIenumeratetheproductsintheviewmodeltofindtheonethathasamatchingshortNamevalue.IusethecategorypropertyoftheproducttocreatethekindofURLthatthedesktopversionusesandcallthecrossroads.parsemethodtohaveitmatchedagainsttheapplicationroutes.ThistechniqueallowsmetobridgebetweenthejQueryMobileURLsandroutesIwant,allowingmetopreserverouteconsistencyacrossallversionsofmywebapp.Thisisn’tabigdealwithmysimpleexampleroutes,butthisbecomesausefultrickifyouhaveanexternalJavaScriptfilefullofJavaScriptcodethatisexecutedwhenURLsarematched.
ExplicitlyChangingPagesThelastchangerelatestothedata-categoryattributethatIaddedtothepagedivelements.Whentheuserswipesthescreenorusesoneofthelandscapenavigationbuttons,theadvanceCategoryfunctioniscalled,andthevalueoftheselectedCategoryitemintheviewmodelisupdated.However,updatingtheviewmodeldoesn’tautomaticallycausejQueryMobiletonavigatetothepagefortheselectedcategory.Toaddressthis,Ihaveaddedacalltothemobile.changePagemethod.ThismethodwillacceptaURLtonavigatetoorajQueryobjectastheelementtodisplay:
$('button.left, button.right').live("click", function(e) { e.preventDefault(); advanceCategory(e, $(e.target).hasClass("left") ? "left" : "right"); $.mobile.changePage($('div[data-category="' + cheeseModel.selectedCategory() + '"]')); })
Iusethedata-categoryitemtoselectthepageelementforthenewselectedCategoryvaluewithouthavingtoiteratethroughtheproducts.Withthissmalladdition,IcanrelyonthesameadvanceCategorycodethatIuseinthedesktopversionofthewebappbutgetthebenefitsofthejQueryMobilepagemodel.
AddingtheFinalChromeThereisjustonefinalchangethatIwanttomaketotheCheeseLuxmobileapp.Atonelevel,itisanentirelytrivialchange,butitdoesalsoallowmetodemonstrateanimportantbehavioralquirkthatjQueryMobiledisplays.
jQueryMobileplaysaslidinganimationwhenanewpageisdisplayed.Bydefault,thepageslidesinfromtheright.ThechangethatIwanttomakeistohavethenewpageslideinfromtheleftwhentheuserpressestheleftlandscapenavigationbuttonorpressesoneoftheportrait/largescreenbuttonsforacategorythatappearsintheviewmodelbeforethecurrentcategory.
ThejQueryMobilechangePagemethodacceptsanoptionalconfigurationobject.OneoftheobjectpropertiesthatjQueryMobilerecognizesisreverse.Whenthevalueofthispropertyistrue,thepageappearsfromtheleft.Thedefaultvalue,false,causesthenewpagetoappearfromtheright.
Fortheportraitnavigationbuttons,Ihaveaddedafunctiontoutils.jscalledgetIndexOfCategory.Thisfunction,whichisshowninListing8-11,enumeratesthroughtheviewmodeldatatofindtheindexofaspecifiedfullorshortcategoryname.
CHAPTER8CREATINGMOBILEWEBAPPS
226
Listing8-11.ThegetIndexOfCategoryFunction
function getIndexOfCategory(category) { var result = -1; for (var i = 0; i < cheeseModel.products.length; i++) { if (cheeseModel.products[i].category == category || cheeseModel.products[i].shortName == category) { result = i; break; } } return result; }
Listing8-12showsthechangesinmobile.htmltomakeuseofthisfunction.
Listing8-12.ManagingPageTransitionAnimationDirection
<script> var cheeseModel = {}; detectDeviceFeatures(function(deviceConfig) { cheeseModel.device = deviceConfig; checkForVersionPreference(); $.getJSON("products.json", function(data) { cheeseModel.products = data; enhanceViewModel(); $(document).ready(function() { ko.applyBindings(cheeseModel); $('*.deferred').each(function(index, elem) { ko.applyBindings(cheeseModel, elem); }); $('button.left, button.right').live("click", function(e) { e.preventDefault(); advanceCategory(e, $(e.target).hasClass("left") ? "left" : "right"); $.mobile.changePage($('div[data-category="' + cheeseModel.selectedCategory() + '"]'), {reverse: $(e.target).hasClass("left")}); }) $('a[data-role=button]').click(function(e) { e.preventDefault(); var cIndex = getIndexOfCategory(cheeseModel.selectedCategory()); var newIndex = getIndexOfCategory(this.hash.slice(1)); $.mobile.changePage(this.hash, {reverse: cIndex > newIndex}); }); $.mobile.initializePage();
CHAPTER8CREATINGMOBILEWEBAPPS
227
hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init(); crossroads.addRoute("category/:newCat:", function(newCat) { cheeseModel.selectedCategory(newCat || cheeseModel.products[0].category); }); crossroads.addRoute(":shortCat:", function(shortCat) { $.each(cheeseModel.products, function(index, item) { if (item.shortName == (shortCat || cheeseModel.products[0].shortName)) { crossroads.parse("category/" + item.category); } }); }); crossroads.parse(location.hash.slice(1)); }); }); }); </script>
IjustneededtoprovidetheoptionalargumenttothechangePagemethodtomakethehorizontalbuttonswork.Fortheaelements,Idecidedtohandletheclickevent,figureoutthetransitiondirection,andcallthechangePagemethoddirectly.ThereareotherwaysofdoingthisinjQueryMobile,butthisisthesimplestandmostdirect.
TheimportantjQueryMobilecharacteristicIwantedtodemonstraterelatestothewaythatinternalURLsaremanaged.jQueryMobilewillnavigatetotheURLfortheentiredocumentratherthanthespecificpageifyouusethechangePagemethodtonavigatetotheURLthatrepresentsthefirstpageinthedocument.Forexample,ifyoucallchangePage('#British'),jQueryMobilewillnavigatetocheeselux.com/mobile.htmlandnotcheeselux.com/mobile.html#British.
Tocaterforthis,IneedtochangetheroutethatmapsbetweenthejQueryMobile–friendlyfragmentURLsandtheroutessharedwiththedesktopversionofthewebapp,likethis:
crossroads.addRoute(":shortCat:", function(shortCat) { $.each(cheeseModel.products, function(index, item) { if (item.shortName == (shortCat || cheeseModel.products[0].shortName)) { crossroads.parse("category/" + item.category); } }); });
Imadethesegmentoptional,ratherthanvariable(IexplainthedifferenceinChapter4),andifthereisnocategorynameprovidedaspartoftheURL,Iassumethatthefirstcategoryintheviewmodelshouldbeused.Thisisasimplechangeformywebapp,butifyouaremappingcomplexsetsofroutes,youmustensurethatyousetdefaultsforalloftheroutesegmentsthatareexpectedandwouldusuallybeprovidedbythedesktopversion.
CHAPTER8CREATINGMOBILEWEBAPPS
228
SummaryInthischapter,IcreatedasolidmobileimplementationofmyCheeseLuxwebapp.Ishowedyoutheimportanceofadoptingthenavigationmodelprovidedbythemobiletoolkityouareusingandvariousapproachesforintegratingthecorefeaturesofaprofessional-levelwebapp,suchasrouting,viewmodels,anddatabindings.Mobilewidgettoolkitsusuallyrequiresometweaksandtrickstogetthemtoplaynicelywithprowebapps,buttheresultisworthfiguringoutsolutionstothewrinklesthatarise.Inthenextchapter,IshowyoudifferenttechniquesforimprovingthewayyouwriteandpackageyourJavaScriptcode.
C H A P T E R 9
229
Writing Better JavaScript
Inthischapter,IexplainsomeofthetechniquesIusetocreatebetterJavaScript.Thisisnotalanguageguide,andIwon’tbedemonstratinganycodehacksortweaks.Mycodingpreferencesareyourmaintenancenightmares,andviceversa.Ihaveseenotherwisemild-manneredpeopleendupinascreamingmatchoverthe“right”waytocode,andIdon’tseethepointinlecturingyouwhenIhaveafairfewbadhabitsmyself.
Instead,IamgoingtoshowyousomeofthetechniquesIusetomakemycodeeasierforotherprogrammersandprojectstouse.Mostlarge-scalewebappshaveateamofprogrammers,andsharingcodebecomesimportant.
Ihavebeendumpingusefulfunctionsintotheutils.jsfilethroughoutthisbook.ThisishowItendtowork,withageneralkitchen-sinkfilewhereIputfunctionsthatIexpecttorepeatedlyuse.Forthisbook,usingutils.jsletmespendmoretimeineachchapteronthetopicsathandwithouthavingtospendpageslistingcodethatIdefinedinapreviouschapter.Italsoletmedemonstratetheideaofusingacoresetofcommonfunctionswhencreatingdesktopandmobileversionsofthesamewebapp.
Theproblemwithjustdumpingfunctionsintoafileinthiswayisthattheybecomehardtomanageandmaintainand,asI’llexplainshortly,difficultforotherstointegrateintotheirprojects.Forthisreason,Irevisitmykitchen-sinkfilewhenIhavereachedapointinaprojectwherethebasicfunctionalityisstableandIhaveagoodfeelforthewaythatdifferentfeaturesfittogether.Atthispoint,andnotbefore,Istarttoreworkthecodeintomodulessothatitplaysnicelywithotherlibraries.Inthischapter,IshowyouthetechniquesIuseforthis.
OnceIhavetidiedupandmodularizedthecode,Ibeginunittesting.Testingisaverypersonalthing,andmanytestingproselytizerswillinsistthattestingmustbeginassoonasyoustartcoding,ifnotsooner.Iunderstandthatpointofview,butIalsoknowthatIdon’teventhinkabouttestinguntilIhavemadeacertainamountofprogresswithaproject.TherenaturallycomesapointwhereIhaveenoughprogressandmymindstartstoturntowardconsolidatingandimprovingwhatIhave.
TestingisanothertopiconwhichIamnotgoingtolecture.Myonlyadviceisthatyoushouldbehonestwithyourself.Testwhenitfeelsright,testuntilyouarehappywithyourcode,andusethetechniquesandtoolsthatworkforyou.Dowhatisrightforyourproject,andacceptthattestinglaterwillrequiremorecodingchangesandthatnottestingatallmeansyouruserswillhavetofindyourbugsforyou.
ManagingtheGlobalNamespaceOneofthebiggestproblemswithlargeJavaScriptprojectsisthelikelihoodofanamingcollision,wheretworegionsofcodeusethesameglobalvariablenamesfordifferentpurposes.Aglobalvariableisonethatexistsoutsideafunctionorobject.JavaScriptmakestheseavailablethroughoutyourwebapplicationsothataglobalfunctiondefinedinaninlinescriptelementorexternalJavaScriptfileis
CHAPTER9WRITINGBETTERJAVASCRIPT
230
availabletoeveryotherscriptelementandJavaScriptfileyouuse.Whenaglobalfunctionorvariableiscreated,itissaidtoresideintheglobalnamespace.
Forsmallapplications,thisisausefulfeature;itmeansthatyoucanjustpartitionyourcodeandrelyonthebrowsertomergeittogetherwhentheapplicationisloaded.Thisiswhatallowsmyutils.jsfiletowork:thebrowserloadsallofthefunctionsinmyfileandmakesthemavailableviaglobalvariables.Idon’tneedtoknowwherethemapProductsfunctionisdefinedtouseit;itisautomaticallyavailable.
Theproblemcomeswhenyouusecodethathasfunctionsandvariableswiththesamenamesthatyouhaveused.AllsortsofproblemswillariseifIuseaJavaScriptlibrarythatdefinesamapProductsfunction.ThemapProductscontainedinthefilethatisloadedlastistheonethatwillwin,andanycodethatwasexpectingtheotherversionisgoingtobesurprised.
Whatcanbeausefultrickinasmallwebappbecomesamaintenancenightmareasawebapplicationgrowsinsizeandcomplexity.Itsoonbecomeshardtothinkupmeaningfulnamesthatarenotalreadyinuse,andthelikelihoodofcollisionincreasessharply.Inthesectionsthatfollow,Idescribesomeusefultechniquesthatwillhelpyouavoidnamingcollisionsbystructuringyourcodeandreducingthenumberofglobalvariablesthatarecreatedasaconsequence.
AVOIDING IMPLIED GLOBAL VARIABLES
Acommoncauseofglobalvariablesistoassignvaluestovariablesthathavenotbeendefinedusingthevarkeyword.JavaScriptinterpretsthisasarequesttocreateaglobalvariable:
... (function() { var var1 = "my local variable"; var2 = "my global variable"; })(); ...
Inthislisting,thevariablevar1existsonlywithinthescopeofthefunctionthatdefinesit,butvar2isdefinedintheglobalnamespace.Thiscanbeausefulfeaturewhenusedcarefullyanddeliberately,allowingyoucontroloverwhichvariablesareexportedglobally,butusuallythissituationarisesthrougherrorratherthanintention.Ihaveshownthisinaself-executingfunction,butitcanhappeninanyfunctionthatdefinesvariableswithoutthevarkeyword.
DefiningaJavaScriptNamespaceThefirsttechniqueistoemploynamespaces,whichlimitthescopeofvariablesandfunctions.YouwillbefamiliarwithnamespacesifyouhaveusedalanguagelikeJavaorC#.JavaScriptdoesn’thaveanamespacelanguageconstructlikethoselanguages,butyoucancreatesomethingthatsolvestheproblembyrelyingonthewaythatJavaScriptscopesobjects.Listing9-1showshowthisisdone.
Listing9-1.DefiningaJavaScriptNamespace
var cheeseUtils = {}; cheeseUtils.mapProducts = function(func, data, indexer) { $.each(data, function(outerIndex, outerItem) { $.each(outerItem[indexer], function(itemIndex, innerItem) {
CHAPTER9WRITINGBETTERJAVASCRIPT
231
func(innerItem, outerItem); }); }); } cheeseUtils.composeString = function(bindingConfig ) { var result = bindingConfig.value; if (bindingConfig.prefix) { result = bindingConfig.prefix + result; } if (bindingConfig.suffix) { result += bindingConfig.suffix;} return result; }
Tocreatethenamespaceeffect,Icreateanobjectandthenassignmyfunctionsandvariablesaspropertieswithinit.Thismeansthattoaccessthesefunctionselsewhere,Ihavetousethenameoftheobjectasaprefix,likethis:
cheeseUtils.mapProducts(function(item) { if (item.id == id) { item.quantity(0); } }, cheeseModel.products, "items");
Tobeclear,thisisn’tarealnamespacebecauseJavaScriptdoesn’tsupportthem;itjustlooksandactsalittlebitlikeone.Butitisenoughtoreducepollutionoftheglobalnamespace,inthatIhavetakentwofunctionsoutofthesharedcontextandreplacedthemwithasingleobjectname,cheeseUtils.
Thereisstillariskofnamecollision,soitisimportanttoselectanamefortheobjectthatisspecifictoyourprojectorareaoffunctionality.Youcannestnamespacesbynestingobjects,creatingahierarchythatmustbenavigatedinordertouseyourcode.Listing9-2showsanexample.
TipTosavespace,Iwon’tlistallofthefunctionsthatareintheutils.jsfile.I’lljustpicksomerepresentativesamplestodemonstratethedifferenttechniques.
Listing9-2.CreatingNestedNamespaces
if (!com) { var com = {}; } com.cheeselux = {}; com.cheeselux.utils = {}; com.cheeselux.utils.mapProducts = function(func, data, indexer) { $.each(data, function(outerIndex, outerItem) { $.each(outerItem[indexer], function(itemIndex, innerItem) { func(innerItem, outerItem); }); }); } com.cheeselux.utils.composeString = function(bindingConfig ) { var result = bindingConfig.value;
CHAPTER9WRITINGBETTERJAVASCRIPT
232
if (bindingConfig.prefix) { result = bindingConfig.prefix + result; } if (bindingConfig.suffix) { result += bindingConfig.suffix;} return result; }
InthislistingIhaveusedaprettystandardapproachtonamespaces,whichistousethestructureofmydomainnamebutinreverse.However,sincecomislikelytobeusedbyotherlibrariesfollowingthesameapproach,thenIchecktoseewhetherithasbeendefinedalreadybeforedoingsomyself.Idon’thavetodothisforthecheeseluxpartbecauseIamtheownerofthecheeselux.comdomainandthereislittlechanceofcollision.
Referringdirectlytofunctionsinanestednamespacecanleadtoverbosecode.WhenIusethecodeinanestednamespaces,Itendtoaliastheinnermostobjecttoalocalvariable,likethis:
var utils = com.cheeselux.utils;
ThiscreatesalooseequivalenttotheimportorusingstatementsdefinedbyJavaandC#(albeitwithouttheisolationfeaturesthatthoseotherlanguagessupport).
Ilikeusingnestednamespaces,probablybecauseItendtowritemyserver-sidecodeinC#,whichencouragesthesameapproach.Tomakecreatingthenamespacessimpler,Irelyonthefactthatglobalvariablesareactuallydefinedaspropertiesonthewindowbrowserobject.Thismakesiteasytocreatevariablesbynamewithoutrelyinginthedreadedevalfunction,asListing9-3shows.
Listing9-3.CreatingNestedNamespacesUsingaFunction
createNamespace("com.cheeselux.utils"); function createNamespace(namespace) { var names = namespace.split('.'); var obj = window; for (var i = 0; i < names.length; i++) { if (!obj[names[i]]) { obj = obj[names[i]] = {}; } else { obj = obj[names[i]]; } } }; com.cheeselux.utils.mapProducts = function(func, data, indexer) { $.each(data, function(outerIndex, outerItem) { $.each(outerItem[indexer], function(itemIndex, innerItem) { func(innerItem, outerItem); }); }); } com.cheeselux.utils.composeString = function(bindingConfig) { var result = bindingConfig.value; if (bindingConfig.prefix) { result = bindingConfig.prefix + result; } if (bindingConfig.suffix) { result += bindingConfig.suffix;} return result; }
CHAPTER9WRITINGBETTERJAVASCRIPT
233
ThecreateNamespacefunctiontakesanamespaceasanargumentandbreaksitintosegments.Theobjectthatrepresentseachsegmentiscreatedonlyifitdoesn’talreadyexist,whichmeansthatIdon’tcollidewithanyoneelse’suseofcomorwithothercom.cheeselux.*namespacesthatIcreateinseparateJavaScriptfilesformyproject.
■TipCreatingseparatefilesisentirelyoptional.Youcandefinemultiplenamespacesinasinglefileifyouprefer.Theadvantageofasinglefileisthatthebrowserhastomakeonlyonerequesttogetallofyourcode.Ifyoudolikeusingmultiplefiles,thenyoucansimplyconcatenatethemintoonewhenyoureleaseyourwebapp.
Icangoonestepfurtherandmakethenamespaceitselfmoreeasilyconfigurable,asListing9-4demonstrates.ThismakesitmucheasiertorenamemynamespaceifthereisaconflictandmeansthatIcanselectashorternametosavemyselfsometyping.
Listing9-4.MakingNamespacesEasilyConfigurable
function createNamespace(namespace) { var names = namespace.split('.'); var obj = window; for (var i = 0; i < names.length; i++) { if (!obj[names[i]]) { obj = obj[names[i]] = {}; } else { obj = obj[names[i]]; } } return obj; }; var utilsNS = createNamespace("cheeselux.utils"); utilsNS.mapProducts = function(func, data, indexer) { $.each(data, function(outerIndex, outerItem) { $.each(outerItem[indexer], function(itemIndex, innerItem) { func(innerItem, outerItem); }); }); } utilsNS.composeString = function(bindingConfig) { var result = bindingConfig.value; if (bindingConfig.prefix) { result = bindingConfig.prefix + result; } if (bindingConfig.suffix) { result += bindingConfig.suffix;} return result; }
IhaveupdatedthecreateNamespacefunctionsothatitreturnsthenamespaceobjectitcreates.Thisallowsmetocreateanamespaceandassigntheresultasavariable,whichIcanthenusetoadd
CHAPTER9WRITINGBETTERJAVASCRIPT
234
functionstothenamespace.IfIneedtochangethenameofthenamespace,thenIhavetodoitonlyinthecalltothecreateNamespacemethod(and,ofcourse,inanycodethatreliesonmyfunctions).Inthisexample,Ihaveshortenedmynamespacebydroppingthecomprefix.Theoddsoftherebeingaconflictarestillprettyslim,butifitdoesarise,itisasimpleenoughmattertoadapt.
UsingSelf-executingFunctionsOnedrawbackoftheprevioustechniqueisthatIendupcreatinganotherglobalvariable,utilsNS.Thisisstillabetterapproachthandefiningallofmyvariablesglobally,butitissomewhatself-defeating.
Icanaddressthisbyusingaself-executingfunction.ThistechniquereliesonthefactthataJavaScriptvariabledefinedwithinafunctionexistsonlywithinthescopeofthatfunction.Theself-executingaspectmeansthatthefunctionrunswithoutbeingexplicitlyinvokedfromanotherpartofthecode.Thetrickistodefineafunctionandhaveitexecuteimmediately.Itiseasiertoseethestructureofaself-executingfunctionwhenthereisn’tanyothercode:
(function() { ...statements go here... })();
Tomakeafunctionself-execute,youwrapitinparenthesesandthenapplyanotherpairofparenthesesattheend.Thisdefinesandcallsthefunctioninasinglestep.Anyvariablesdefinedwithinthefunctionaretidiedupafterthefunctionhasfinishedexecutinganddon’tendupintheglobalnamespace.Listing9-5showshowIcanapplythistomyutilityfunctions.
Listing9-5.UsingaSelf-executingFunctiontoDefineNamespaces
(function() { function createNamespace(namespace) { var names = namespace.split('.'); var obj = window; for (var i = 0; i < names.length; i++) { if (!obj[names[i]]) { obj = obj[names[i]] = {}; } else { obj = obj[names[i]]; } } return obj; }; var utilsNS = createNamespace("cheeselux.utils"); utilsNS.mapProducts = function(func, data, indexer) { $.each(data, function(outerIndex, outerItem) { $.each(outerItem[indexer], function(itemIndex, innerItem) { func(innerItem, outerItem); }); }); } utilsNS.composeString = function(bindingConfig) { var result = bindingConfig.value;
CHAPTER9WRITINGBETTERJAVASCRIPT
235
if (bindingConfig.prefix) { result = bindingConfig.prefix + result; } if (bindingConfig.suffix) { result += bindingConfig.suffix;} return result; } })();
Theonlyglobalvariablethatisleftisthecheeseluxnamespaceobject.Myfunctionsaredefinedwithinthecheeselux.utilsnamespace,andmyutilsNSvariableistidiedupbythebrowserwhentheself-executingfunctionhasfinished.
Consumingafunctiondefinedinthiswayisstilljustamatterofreferringtothefunctionviathenamespace,likethis:
cheeselux.utils.mapProducts(function(item) { if (item.id == id) { item.quantity(0); } }, cheeseModel.products, "items");
CreatingPrivateProperties,Methods,andFunctionsInJavaScript,everyproperty,method,andfunctionisavailableforusefromanyotherpartofthecodethatcreatesorcanaccessthem.Thismakesitdifficulttoindicatewhichmembersareintendedforusebyothersandwhicharetheinternalimplementationsoffeatures.
Thedifferenceisimportant;youwanttobeabletochangetheinternalimplementationtofixbugsoraddnewfeatureswithouthavingtoworryifsomeonehascreatedadependencythatyouweren’texpecting.Anyoneusingyourcodeneedstoknowwhatpropertiesandmethodstheycanrelyonnottochangewithoutduenotice.JavaScriptdoesn’thaveanykeywordsthatcontrolaccess(suchaspublicandprivate,whicharefoundinotherlanguages)andsoweneedtofindalternativeapproachestoaddressthisshortfall.
Thesimplestsolutiontothisproblemistoadoptanamingconventionthatmakesitclearthatsomepropertiesandmethodsarenotintendedforpublicuse.Themostwidelyadoptedconventionistoprefixprivatenameswithanunderscorecharacter(_).
MycomposeStringfunctionisanidealcandidatetobeprivate.Iusethisfunctiononlyinmycustomdatabindings,andIwanttobefreetochangeeveryaspectofthisfunction(includingitsveryexistence)asmybindingsevolve.Thereisnoreasonforanyotherprogrammertodependonthisfunction,eveniftheyusemybindings.Listing9-6showstheunderscorenamingstyleappliedtothisfunctionandthedatabindingsthatrelyonit.
Listing9-6.ApplyingaNamingConventiontoDenoteaPrivateFunction
(function() { function createNamespace(namespace) { var names = namespace.split('.'); var obj = window; for (var i = 0; i < names.length; i++) { if (!obj[names[i]]) { obj = obj[names[i]] = {}; } else { obj = obj[names[i]]; } } return obj;
CHAPTER9WRITINGBETTERJAVASCRIPT
236
}; var utilsNS = createNamespace("cheeselux.utils"); utilsNS.mapProducts = function(func, data, indexer) { $.each(data, function(outerIndex, outerItem) { $.each(outerItem[indexer], function(itemIndex, innerItem) { func(innerItem, outerItem); }); }); } utilsNS._composeString = function(bindingConfig) { var result = bindingConfig.value; if (bindingConfig.prefix) { result = bindingConfig.prefix + result; } if (bindingConfig.suffix) { result += bindingConfig.suffix;} return result; } })(); ko.bindingHandlers.formatAttr = { init: function(element, accessor) { $(element).attr(accessor().attr, cheeselux.utils._composeString(accessor())); }, update: function(element, accessor) { $(element).attr(accessor().attr, cheeselux.utils._composeString(accessor())); } } ko.bindingHandlers.formatText = { update: function(element, accessor) { $(element).text(cheeselux.utils._composeString(accessor())); } } ...
Adoptinganamingconventiondoesn’tpreventothersfromusingprivatemembers,butitdoessignalthatdoingsoisagainstthewishesofthedeveloperandthattheproperty,method,orfunctionissubjecttochangewithoutnotice.Itisimportanttouseanamingconventionthatiswidelyadopted(suchastheunderscore)orthatisimmediatelyobvious(suchasprefixingnameswiththewordprivate).
Analternativeapproachistolimitthescopeofprivatefunctionssothattheyarenotdefinedaspartofthenamespace.Thispreventsthefunctionfrombeingaccessedelsewhereinthewebapp,butitmeansthatallofthedependenciesonthatfunctionmustappearwithinthesameself-executingfunction,whichisn’talwayspractical.Listing9-7showshowthisapproachworks.
CHAPTER9WRITINGBETTERJAVASCRIPT
237
Listing9-7.UsingaSelf-executingFunctiontoKeepaFunctionPrivate
(function() { function createNamespace(namespace) { var names = namespace.split('.'); var obj = window; for (var i = 0; i < names.length; i++) { if (!obj[names[i]]) { obj = obj[names[i]] = {}; } else { obj = obj[names[i]]; } } return obj; }; var utilsNS = createNamespace("cheeselux.utils"); utilsNS.mapProducts = function(func, data, indexer) { $.each(data, function(outerIndex, outerItem) { $.each(outerItem[indexer], function(itemIndex, innerItem) { func(innerItem, outerItem); }); }); } function _composeString(bindingConfig) { var result = bindingConfig.value; if (bindingConfig.prefix) { result = bindingConfig.prefix + result; } if (bindingConfig.suffix) { result += bindingConfig.suffix;} return result; } ko.bindingHandlers.formatAttr = { init: function(element, accessor) { $(element).attr(accessor().attr, _composeString(accessor())); }, update: function(element, accessor) { $(element).attr(accessor().attr, _composeString(accessor())); } } ko.bindingHandlers.formatText = { update: function(element, accessor) { $(element).text(_composeString(accessor())); } } })();
CHAPTER9WRITINGBETTERJAVASCRIPT
238
The_composeStringfunctionisneverdefinedaspartofthelocalorglobalnamespacesandisavailableonlyforuseinthesameenclosingself-executingfunction.ThistechniqueworksbecauseJavaScriptsupportsclosures,whichbringsvariablesandfunctionsinscopeevenwhentheyaredefinedinthismanner.
ManagingDependenciesPackagingupmyfunctionsintonamespacesmakesthemmoremanageableandhelpscleanuptheglobalnamespace,butthereisstillonemajorissue:dependenciesonotherlibraries.Inthesectionsthatfollow,Ishowyouatechniqueformanagingdependenciesinlibrariesthatisstartingtogaininpopularityandthatyoucanusetomakeyourcodeeasiertoshareandeasiertoworkwith.
UnderstandingAssumedDependencyProblemsTherearetwokindsofdependencyinanexternalJavaScriptfilesuchasutils.js.Thefirstkindisanassumeddependency,whereIjustusethefunctionalityofalibraryandassumeitwillbeavailable.Ihavedonethisalotinutils.js,especiallywithjQuery.AnassumeddependencyplacesresponsibilityontheHTMLdocumentthatusesaJavaScriptfiletoloadtherequiredlibrariesandtodosobeforemycodeisexecuted.ThemapProductsfunctionisagoodexampleofanassumeddependency:
utilsNS.mapProducts = function(func, data, indexer) { $.each(data, function(outerIndex, outerItem) { $.each(outerItem[indexer], function(itemIndex, innerItem) { func(innerItem, outerItem); }); }); }
ThisfunctionassumesthatthejQuery$.eachmethodwillbeavailable.Ifyouwanttousethisfunction,thenyouneedtoensurethatjQueryisloadedandreadybeforeyoucallmapProducts.Listing9-8showsaverysimplejQueryMobilewebappthatmakesuseofthemapProductsfunction.Thereisnothingnewinthistinywebapp,butIamgoingtouseittodemonstratedifferentdependencyissuesandsolutionsinthesectionsthatfollow.
Listing9-8.ASimpleWebAppThatUsesaJavaScriptFileThatContainsanAssumedDependency
<!DOCTYPE html> <html> <head> <title>CheeseLux</title> <link rel="stylesheet" type="text/css" href="jquery.mobile-1.0.1.css"/> <link rel="stylesheet" type="text/css" href="styles.mobile.css"/> <script src="jquery-1.7.1.js" type="text/javascript"></script> <script type="text/javascript"> $(document).bind("mobileinit", function() { $.mobile.autoInitializePage = false; }); </script> <script src="jquery.mobile-1.0.1.js" type="text/javascript"></script> <script src='knockout-2.0.0.js' type='text/javascript'></script> <script src='modernizr-2.0.6.js' type='text/javascript'></script>
CHAPTER9WRITINGBETTERJAVASCRIPT
239
<script src='utils.js' type='text/javascript'></script> <meta name="viewport" content="width=device-width, initial-scale=1"> <script> var cheeseModel = { selectedCount: ko.observable(0) }; $.getJSON("products.json", function(data) { cheeseModel.products = data; $(document).ready(function() { ko.applyBindings(cheeseModel); $.mobile.initializePage(); $('a[data-role=button]').click(function(e) { var count = 0; cheeselux.utils.mapProducts(function(inner, outer) { if (outer.category == e.currentTarget.id) { count++; } }, cheeseModel.products, "items") cheeseModel.selectedCount(count); }); }); }); </script> </head> <body> <div data-role="page" id="page1" data-theme="a"> <fieldset class="middle" data-role="controlgroup" data-type="horizontal" data-bind="foreach: products"> <a data-role="button" data-bind="text: category, attr: {id: category}"></a> </fieldset> <div class="middle results" data-bind="visible: selectedCount"> There are <span data-bind="text: selectedCount"></span> cheeses in this category </div> </div> </body> </html>
NoteThisisanentirelyuselesswebappinitsownright.Abuttonisdisplayedforeachcheesecategory,andclickingthebuttondisplaysthenumberofcheeseswithinthatcategory.Ignore,ifyouwill,thefactthatthereareeasierwaystoobtainthisinformationthanusingthemapProductsmethodandthattherearethreecheesesineverysinglecategory.Thiswitlesswebappisperfectfordemonstratingthekeyaspectsofdependencymanagement.
CHAPTER9WRITINGBETTERJAVASCRIPT
240
UnderstandingDirectlyResolvedDependenciesThetinywebappworksbecausejQueryhasbeenloadedlongbeforeIcallthemapProductsfunction.ThesituationwouldbedifferentifIrewrotethewebapptouseadifferenttoolkit.Mostprogrammersdothesamethingwhentheyfirstunderstandthatassumeddependenciesareaproblem:theyassumecontrolofthesituationandtakedirectactiontofixit.Listing9-9showsatypicalsolution.
Listing9-9.TakingDirectActiontoResolveAssumedDependencies
(function() { function createNamespace(namespace) { ...code removed for brevity... }; var utilsNS = createNamespace("cheeselux.utils"); Modernizr.load({ load: 'jquery-1.7.1.js', complete: function() { utilsNS.mapProducts = function(func, data, indexer) { $.each(data, function(outerIndex, outerItem) { $.each(outerItem[indexer], function(itemIndex, innerItem) { func(innerItem, outerItem); }); }); } } }) ...code removed for brevity... })();
Inthislisting,IhavetakenresponsibilityforresolvingmydependencyonjQuerybyusingModernizrtoloaditbeforecreatingmymapProductsfunction.(TheloadpropertyinaModernizr.loadobjectspecifiesthattheJavaScriptfileshouldalwaysbeloaded.)
Indoingthis,Ihavetransformedanassumeddependencyintoadirectlyresolveddependency.AdirectlyresolveddependencyiswhenIrelyonanotherJavaScriptlibraryandItakedirectactiontomakemycodework,usuallybyloadingthelibrarymyself.
UnderstandingtheProblemsCausedbyResolvingaDependencyDirectlyresolvingadependencycausesalotofproblems.First,IcreatedanassumeddependencyonModernizrtoensurethatjQueryisloaded,whichisn’tahugestepforward.ButtherealdamageisthatIhavemadesurethatthemapProductsfunctionworks;however,indoingso,Ihaveunderminedthestabilityofthewebappitself.
Toseetheproblem,loadthewebapp,andreloadthepageafewtimes.Therearetwoissues.Ifthewebappworks,youhaveencounteredjusttheleastseriousone,whichisthatthejQuerylibraryhasbeenloadedtwice.YoucanseethisinthebrowserdevelopertoolsorintheconsoleoutputfromtheNode.jsserverthatprintsouteachURLthatisrequested.Hereisthelistoffilesloadedbythewebappasreportedbytheserver,withannotationstohighlightthetwoloadsforjQuery:
CHAPTER9WRITINGBETTERJAVASCRIPT
241
The "sys" module is now called "util". It should have a similar interface.
Ready on port 80 Ready on port 81 GET request for /example.html GET request for /jquery.mobile-1.0.1.css GET request for /styles.mobile.css GET request for /jquery-1.7.1.js <-- first load GET request for /jquery.mobile-1.0.1.js GET request for /knockout-2.0.0.js GET request for /modernizr-2.0.6.js GET request for /utils.js GET request for /products.json GET request for /jquery-1.7.1.js <-- second load GET request for /images/ajax-loader.png
Youcantellwhetheryouhaveencounteredonlythefirstproblembecauseyouwillseethreebuttons,andclickingoneofthemmakesamessageappear.Youknowthatyouhaveencounteredthesecondproblemifyoujustgetanemptywindow.Figure9-1showsbothoutcomes.
Figure9-1.Thetwooutcomesthatarisefromadirectlyresolveddependency
Thesecondproblemisaracecondition,anditwon’talwaysmanifestitselfwhenyouareloadingalloftheresourcesfromthewebappfromthelocalmachine.IftheAjaxrequestcompletesafterModernizrhasloadedthejQuerylibraryandexecutedthecallbackfunction,thenyouwillgettheblankwindow,andtherewillbeanerrormessageintheJavaScriptconsolelikethis:
Uncaught TypeError: Cannot call method 'initializePage' of undefined
Theexactwordingwillvaryfrombrowsertobrowser,buttheproblemisthatthecallto$.mobile.initializePagehasfailedbecausethereisno$.mobileobject.Tohelpforcetheproblemtoappear,IhaveaddedaspecialURLtotheNode.jsserverthatintroducesadelayinreturningtheJSONcontent.Totriggerthisdelay,changethenameoftheJSONfilerequestedbythegetJSONmethod,asshowninListing9-10.
1
CHAPTER9WRITINGBETTERJAVASCRIPT
242
Listing9-10.DeliberatelyIntroducingaDelayintheAjaxRequestfortheJSONData
... <script> var cheeseModel = { selectedCount: ko.observable(0) }; $.getJSON("products.json.slow", function(data) { cheeseModel.products = data; $(document).ready(function() { ko.applyBindings(cheeseModel); $.mobile.initializePage(); ... code removed for brevity... }); }); </script> ...
Requestingproducts.json.slowinsteadofproducts.jsonwilladdaone-seconddelaytotheAjaxrequestthatwillforcetheAjaxrequesttotakelongerthanModernizrrequirestoloadthejQuerylibrary.Youcanedittheserver.jsfiletoaddalongerdelayifyoudon’tseetheproblem,butone-secondconsistentlycausesthewhitescreenforme.
TipThisispartofwhatmakesthisproblemsonasty;itusuallywon’tappearduringdevelopmentbecausetheAjaxrequestwillcompletesoquickly.Unfortunately,itdoesappearindeploymentwhenrequestsaremadetobusyserversovercongestednetworks.Ifyoueverfindyourselfgettinguserreportsofblankscreensthatyoucan’treplicate,itisalwaysagoodideatoseewhetheryourlibrariesareself-resolvingdependencies.
HereisthesequenceofeventswhentheAjaxrequestcompletesbeforeModernizrhasloadedjQuery:
1. jQueryisloadedbythebrowserfromthescriptelementinexample.html andsetsupthe$shorthandreference.
2. jQueryMobileisloadedandaddsthemobilepropertytothejQuery$shorthand.
3. TheAjaxrequestcompletes,andthe$.mobile.initializePagemethodiscalled.
4. ModernizrloadsthejQuerylibraryagain,whichreplacesthe$shorthandwithanobjectthatdoesn’thavethejQueryMobilemobileproperty.
CHAPTER9WRITINGBETTERJAVASCRIPT
243
Thisisthebest-casescenariowherejQueryisloadedandexecutedtwice,butatleastthewebappworks.ThesequencechangeswhentheAjaxrequestcompletesafterModernizrhasloadedjQuery:
1. jQueryisloadedbythebrowserfromthescriptelementinexample.html andsetsupthe$shorthandreference.
2. jQueryMobileisloadedandaddsthemobilepropertytothejQuery$shorthand.
3. ModernizrloadsthejQuerylibraryagain,whichreplacesthe$shorthandwithanobjectthatdoesn’thavethejQueryMobilemobileproperty.
4. TheAjaxrequestcompletes,andthe$.mobile.initializePagemethodiscalled.
Youcanseetheproblem:thecallto$.mobile.initialPageismadeafterthesecondinstanceofjQueryhasbeenloadedandthe$shorthandhasbeenredefined,whicherasesthemobileproperty.TheeffectisthatloadingjQueryasecondtimehasunloadedjQueryMobileandsothewebappdiesahorribledeath.Eveninthebest-casescenario,theonlyreasonthatthewebappworksisbecauseitissosimple;anycalltoajQueryMobilefunctionwillcauseaproblemonceModernizrhascausedthemobileobjecttobedeleted.
TipThereisasecondraceconditioninthissituation.ThemapProductsfunctionisn’tdefineduntilModernizrhasloadedthejQuerylibrary,whichmeansthatadelayinprocessingtherequest(becausetheserverorthenetworkisbusy)canleadtothecodeintheinlinescriptelementcallingmapProductsbeforeitexists.Iamnotgoingtodemonstratethisissue,butyougettheidea:directlyresolveddependenciesareextremelydangerous.
MakingaBadProblemintoaSubtleBadProblemBeforemovingtoarealdependencysolution,Iwanttoshowyouacommonattemptatfixingthedouble-loadingproblem:testingtoseewhetherthelibraryisloaded,likethis:
... Modernizr.load({ test: $.each, nope: 'jquery-1.7.1.js', complete: function() { utilsNS.mapProducts = function(func, data, indexer) { $.each(data, function(outerIndex, outerItem) { $.each(outerItem[indexer], function(itemIndex, innerItem) { func(innerItem, outerItem); }); }); } } }) ...
CHAPTER9WRITINGBETTERJAVASCRIPT
244
IhaveusedModernizrtotestsomeindicatorthatjQueryhasalreadybeenloadedandusethenopepropertytoloadtheJavaScriptfileifithasn’t.Applyingthistechniquetomytinyexamplewebappwillmakeeverythingwork.Butitisn’tarealsolution,andwhilethenewproblemIcreatedoccurslessfrequently,itismuchhardertotrackdown.
TheunderlyingproblemisthatIamstilljusttryingtomakemycodework.Ifutils.jsistheonlyfilethatusesthistechnique,theneverythingwillbefine,withtheexceptionthatthemapProductsfunctionmaynotbedefinedinatimelyenoughmannerifthejQuerylibrarydoesneedtobeloadedandthereisadelayintherequest.However,ifthistechniqueisusedinmorethanonefile,thenthereisaverysubtleracecondition.ImaginethattherearetwofilesthatuseModernizrtotestforjQuery:fileA.jsandfileB.js.Mostofthetime,thesequenceofeventswillbethis:
1. ThebrowserexecutesthecodeinfileA.js,whichtestsforjQuery.jQueryhasn’tbeenloaded,soModernizrrequeststhefileandthenexecutesthecompletefunction.
2. ThebrowserexecutesthecodeinfileB.js,whichtestsforjQuery.jQueryhasbeenloadedviafileA.js,andModernizrexecutesthecompletefunctionwithoutneedingtoloadanyfiles.
However,Modernizrrequestsareasynchronous,whichmeansthatthebrowserwillcontinuetoexecuteJavaScriptcodewhileModernizrwaitsfortheresponsefromtheserver.So,ifthetimingisjustright,thesequencewillreallybeasfollows:
1. ThebrowserexecutesthecodeinfileA.js,whichtestsforjQuery.jQueryhasn’tbeenloaded,soModernizrrequeststhefile.
2. ThebrowsercontinuestoexecutecodewhileModernizriswaitingandbeginsprocessingfileB.js.TheModernizrrequestfromfileA.jshasn’tcompletedyet,sofileB.jscausesModernizrtomakeasecondrequestforthejQueryfile.
3. ThefileA.jsrequestcompletes,jQueryisloaded,andthefileA.jscompletefunctionisexecuted.
4. ThefileB.jsrequestcompletes,jQueryisloadedforasecondtime,andthefileB.jscompletefunctionisexecuted.
AnypropertiesthatthecompletefunctioninfileA.jsaddstothejQuery$shorthandwillbelostwhenModernizrloadsjQueryagain.Thissequenceoccursinfrequently,butwhenitdoes,itcankillthewebappbydeletingessentialfunctionalityrequiredinatleastoneoftheJavaScriptfiles.Youmightthinkthatinfrequentproblemsareacceptable,butinfrequentcanstillbeaseriousissuewhenyourwebapphasmillionsofusers.
UsingtheAsynchronousModuleDefinitionTheonlyrealwaytoeliminateraceconditionsandduplicatedlibraryloadingistodealwithdependenciesinacoordinatedway,andthismeanstakingresponsibilityforloadingdependenciesoutofindividualJavaScriptfilesandconsolidatingthem.ThebestmodelfordoingthisistheAsynchronousModuleDefinition(AMD),whichI’llexplainanddemonstrateinthesectionsthatfollow.
CHAPTER9WRITINGBETTERJAVASCRIPT
245
DefininganAMDModuleDefiningamoduleisprettysimpleandhingesontheuseofthedefinefunction.Listing9-11showshowIhavecreatedamoduleinanewfilecalledutils-amd.js.Youdon’thavetoincludeamdinthefilename;that’sjustmypreferencebecauseIliketomakeitasobviousaspossibletotheconsumersofmycodethattheyaredealingwithAMD.ProvidingthedefinefunctionistheresponsibilityoftheAMDloader.AsanauthorofAMDmodules,youcanrelyonthedefinefunctionbeingpresentwithouthavingtoworryaboutwhichloaderisbeingusedorhowthefunctionisimplemented.
Listing9-11.Theutils-amd.jsFile
define(['jquery-1.7.1.js'], function() { return { mapProducts: function(func, data, indexer) { $.each(data, function(outerIndex, outerItem) { $.each(outerItem[indexer], function(itemIndex, innerItem) { func(innerItem, outerItem); }); }); }, composeString: function(bindingConfig) { var result = bindingConfig.value; if (bindingConfig.prefix) { result = bindingConfig.prefix + result; } if (bindingConfig.suffix) { result += bindingConfig.suffix;} return result; } }; });
ThedefinefunctioncreatesanAMDmodule.Thefirstargumentisanarrayofthelibrariesthatthecodeinthemoduledependson.Thesecondargumentisafunction,knownasthefactoryfunction,thatcontainsthemodulecode.OnlyoneAMDmodulecanbedefinedinafile,andsinceIliketokeepthefunctionalitydefinedinamodulenarrowlyfocused,myutils-amd.jsfilecontainsjustthemapProductsandcomposeString functions.(I’llreturntosomeoftheothercodefromutils.jsinawhile.)
AnAMDmodulecanrelyonallofthedeclareddependenciesbeingloadedbeforethefactoryfunctionisexecuted.Inthiscase,Ihavedeclaredadependencyonjquery-1.7.1.js,andIcanassumethatthisJavaScriptfilewillbeloadedandjQuerywillbeavailableforusewhenIsetupmymapProductsandcomposeStringfunctions.TheresultfromthefactoryfunctionisanobjectwhosepropertiesarethefunctionsIwanttoexportforuseelsewhereinthewebapp.AnyvariablesorfunctionsthatIdefineandthatarenotpartoftheresultobjectwillbetidiedupwhenthefactoryfunctionhasexecutedwithoutpollutingtheglobalnamespace.
TipNoticethatthereisnonamespaceinmymodule.OneofthenicefeaturesofAMDisthatitisuptotheconsumerofmymoduletodecidehowtorefertothefunctionalitythatIdefine,asI’lldemonstrateinthenextsection.
CHAPTER9WRITINGBETTERJAVASCRIPT
246
UsinganAMDModuleAMDsolvesthedependencyissuesbyhavingasingleresourceloadertakeresponsibilityforloadinglibraries.Thisloaderisresponsibleforexecutingamodule’sfactoryfunctionandensuringthatthelibrariesitreliesonareloadedandreadybeforethishappens.Themainmeansofcommunicationbetweenamoduleandtheloaderisthroughthedefinefunction,whichtheloaderisresponsibleforimplementing.
Bystandardizingtheloadingprocess,thedecisionaboutwhichloadertouseislefttotheconsumerofAMDmodules,ratherthantheauthor.So,Idon’thavetoworryaboutresolvingdependencieswhenIwriteanAMDmodule,andIdon’tevenhavetoworryabouthowtheywillbedealtwith.
AlthoughtheAMDformatisgainingpopularity,notallresourceloaderssupportAMD.ThisincludesModernizr.load,whichIhavebeenusingtoloadlibrariessofarinthisbook(andtodemonstratewhythisisabadideainthischapter).MyfavoriteAMD-awareloaderisrequireJS,whichyoucandownloadfromhttp://requirejs.org.YoucanseehowIhaveappliedrequireJStomytinywebappinListing9-12.
Listing9-12.UsingrequireJStoLoadAMDModules
<!DOCTYPE html> <html> <head> <title>CheeseLux</title> <link rel="stylesheet" type="text/css" href="jquery.mobile-1.0.1.css"/> <link rel="stylesheet" type="text/css" href="styles.mobile.css"/> <script src='require.js' type='text/javascript'></script> <meta name="viewport" content="width=device-width, initial-scale=1"> <script> var libs = [ 'utils-amd', 'device-amd', 'custombindings-amd', 'jquery-1.7.1.js', 'knockout-2.0.0.js', 'modernizr-2.0.6.js' ]; require(libs, function(utils, device) { var cheeseModel = { selectedCount: ko.observable(0) }; $(document).bind("mobileinit", function() { $.mobile.autoInitializePage = false; }); $.getJSON("products.json", function(data) { cheeseModel.products = data; device.detectDeviceFeatures(function(deviceConfig) { cheeseModel.device = deviceConfig; $(document).ready(function() { ko.applyBindings(cheeseModel);
CHAPTER9WRITINGBETTERJAVASCRIPT
247
requirejs(['jquery.mobile-1.0.1.js'], function() { $.mobile.initializePage(); $('a[data-role=button]').click(function(e) { var count = 0; utils.mapProducts(function(inner, outer) { if (outer.category == e.currentTarget.id) { count++; } }, cheeseModel.products, "items") cheeseModel.selectedCount(count); }); }); }); }); }); }); </script> </head> <body> <div data-role="page" id="page1" data-theme="a"> <fieldset class="middle" data-role="controlgroup" data-type="horizontal" data-bind="foreach: products"> <a data-role="button" data-bind="text: category, attr: {id: category}"></a> </fieldset> <div class="middle results" data-bind="fadeVisible: selectedCount()"> There are <span data-bind="text: selectedCount"></span> cheeses in this category </div> </div> </body> </html>
DeclaringDependenciesThefirstthingtodoisremoveallofthescriptelementsintheheadsectionofthedocumentandreplacethemwithasingleelementthatimportsrequireJS.ThisensuresthatrequireJShasacompleteviewofallofthedependenciesinthewebappandthatyoudon’tenduploadingscriptfilestwiceiftheyarerequiredindependentlibraries.
... <script src='require.js' type='text/javascript'></script> <meta name="viewport" content="width=device-width, initial-scale=1"> <script> var libs = [ 'utils-amd', 'device-amd', 'custombindings-amd', 'jquery-1.7.1.js', 'knockout-2.0.0.js',
CHAPTER9WRITINGBETTERJAVASCRIPT
248
'modernizr-2.0.6.js' ]; require(libs, function(utils, device) { ...
ThemostimportantfeatureofanAMDloaderistherequirefunction,whichisthecounterparttodefine.Therequirefunctiontakestwoarguments:anarrayofmodulesandscriptfilesthatthewebappdependsonandacallbackfunctiontoexecutewhentheyareallloaded.Ifindthatdefiningthedependencyarrayasavariablemakesmycodemorereadable,butthatispurelyapersonalpreference.
NoteTheAMDmoduletakescareoftheproblemsaroundhowdependenciesareresolved,butitstillrequiresthattheJavaScriptfilesareavailablefromthewebserver.Whensharingyourcodewithothers,youwillstillneedtoletthemknowwhichlibrariesyoudependuponandmakeitclearthatyouareusingAMDandsotheywillneedanAMDloader.
Noticethatsomeoftheitemsinthedependencyarrayhavea.jssuffixandothersdon’t.NotallofthedependenciesorawebappwillbewrittenasAMDmodules.IfyoupassrequireJSthenameofaJavaScriptfile(i.e.,witha.jssuffix),thenitwillloadthefileandexecutethecodeinsideofitjustlikeanyregularresourceloader.
Ifyouomitthe.jssuffix,thenrequireJSassumesyouhavespecifiedanAMDmoduleandactsaccordingly.Itwilladdthe.jssuffixwhenitrequeststhefilefromtheserver,andwhenitreceivestheresponse,itwilllookforthedefinefunctioninordertodiscoverthedependenciesandthefactoryfunction.
TipByforcingeachfiletocontainonlyonemodule,AMDincreasesthenumberofHTTPrequeststhatarerequiredtogetthescriptsforawebapp.Inthisexample,Ihavegonefromonefile(utils.js)tothree(utils-amd.js,device-amd.js,andcustombindings-amd.js).IwouldhaveendedupwithmoreifIhadproperlypackagedupallofthefunctionsthatutils.jscontained.Toaddressthis,requireJSsupportsaserver-sideoptimizerthatwillconcatenatemultipleAMDmodulefilesintoasingleresponse.Seehttp://requirejs.org/docs/optimization.htmlfordetails.
DealingwithCallbackArgumentsForeachAMDmoduleinthelistpassedtorequire,thereisacorrespondingargumentpassedtothecallbackfunction.Eachargumentissettotheobjectreturnedbythefactoryfunctioninthemodule.Thisisanicealternativetonamespaces;theconsumerofthemodulegetstodecidehowtorefertothemodulefunctionsratherthanthecreator.
Thefirstmoduleinmylistisutils-amd,andthiscorrespondstotheutilargumentinmycallbackfunction.WhenIwanttousethemapProductsfunctiondefinedbythemodule,Imakeacalllikethis:
CHAPTER9WRITINGBETTERJAVASCRIPT
249
utils.mapProducts(function(inner, outer) { if (outer.category == e.currentTarget.id) { count++; } }, cheeseModel.products, "items") cheeseModel.selectedCount(count);
IfIlaterstartusingaregularJavaScriptlibrarythatusesutilsasaglobalvariable,IcaneasilychangethewaythatIrefertothecodeintheutils-amdmodulebyrenamingtheargumentforthecallbackfunction.And,sincethefunctionsarescopedwithinthecontextofthecallbackargument,AMDmodulesdon’tpollutetheglobalnamespaceatall.
So,whyaretherethreeAMDmodulesinthelistbutonlytwocallbackarguments?Theansweristhatmodulesarenotrequiredtoreturnanobjectiftheydon’tneedtoexportfunctions,andthisistheapproachIhavetakenwiththecustombindings-amdmodule,whichyoucanseeinListing9-13.
Listing9-13.AnAMDModuleThatDoesn’tExportFunctions
define(['utils-amd', 'jquery-1.7.1.js', 'knockout-2.0.0.js'], function(utils) { ko.bindingHandlers.formatAttr = { init: function(element, accessor) { $(element).attr(accessor().attr, utils.composeString(accessor())); }, update: function(element, accessor) { $(element).attr(accessor().attr, utils.composeString(accessor())); } } ko.bindingHandlers.fadeVisible = { init: function(element, accessor) { $(element)[accessor() ? "show" : "hide"](); }, update: function(element, accessor) { if (accessor() && $(element).is(":hidden")) { var siblings = $(element).siblings(element.tagName + ":visible"); if (siblings.length) { siblings.fadeOut("fast", function() { $(element).fadeIn("fast"); }) } else { $(element).fadeIn("fast"); } } } } });
Inthismodule,Isimplyaddmycustomdatabindingstotheko.bindingHandlersobject,andtherearenonewfunctionstoexportdirectlyfromthemoduleforuseelsewhere.
CHAPTER9WRITINGBETTERJAVASCRIPT
250
TipNoticethatthecustombindings-amdmoduledependsontheutils-amdmodule.TheAMDloaderisresponsibleforensuringthatallthedependenciesareresolved,whichmakesreusingmodulesverysimple.
Therequirecallbackfunctiondoesreceiveanargumentwhenamodulethatdoesn’treturnanobjectisloaded,butthevalueofthatargumentisnull.So,Icouldeasilyhavewrittenmycallbackfunctionlikethis:
require(libs, function(utils, device, bindings) { ... }
Butthereislittlepointbecausethebindingsobjectwillbenull.Theorderoftheargumentsalwaysreflectstheorderofthemodulesintherequirelist,soIalwaysputthemodulesthatdon’treturnobjectsattheendofthelistsothatIcanomitthenullargumentsthatcorrespondtothem.
DeclaringInlineDependenciesItisn’talwayspossibletodeclareallofthedependenciesatthestartofascriptblock.Asanexample,inordertopreventjQueryMobilefromautomaticallyprocessingthedocument,IneedtoloadjQueryandsetupaneventhandlerbeforethejQueryMobilelibraryisloaded.Youcansimplycalltherequirejsfunctiontodeclaredependencieswithinarequirestatement,likethis:
... requirejs(['jquery.mobile-1.0.1.js'], function() { $.mobile.initializePage(); $('a[data-role=button]').click(function(e) { var count = 0; utils.mapProducts(function(inner, outer) { if (outer.category == e.currentTarget.id) { count++; } }, cheeseModel.products, "items") cheeseModel.selectedCount(count); }); }); ...
Inthisway,Iamabletodeclaremydependencieswithouthavingtoloadallofthecodefilesatonce.ThisgrantsmespacebetweenjQueryandjQueryMobilebeingloadedinwhichIcansetupmyeventhandler.
ThisisalsothetechniqueIhaveusedinthedevice-amdmoduletoreplacetheModernizr.loadmethod.Listing9-14showsthecodefromChapter7whereIloadapolyfillbasedonthepresenceofabrowserfeature.
CHAPTER9WRITINGBETTERJAVASCRIPT
251
Listing9-14.LoadingaPolyfillUsingModernizr
... Modernizr.load([{ test: window.matchMedia, nope: 'matchMedia.js', complete: function() { var screenQuery = window.matchMedia('screen AND (max-width: 500px)'); deviceConfig.smallScreen = ko.observable(screenQuery.matches); if (screenQuery.addListener) { screenQuery.addListener(function(mq) { deviceConfig.smallScreen(mq.matches); }); } deviceConfig.largeScreen = ko.computed(function() { return !deviceConfig.smallScreen(); }); setInterval(function() { deviceConfig.smallScreen(window.innerWidth <= 500); }, 500); } }, { complete: function() { callback(deviceConfig); } }]); ...
TheModernizrsyntaxisexcellent;Ilovebeingabletocombinethetest,loadingthedependencyandthecallbackfunctionsoelegantly.TherequireJSequivalentisshowninListing9-15,whichshowsthedevice-amd.jsfile.
Listing9-15.LoadingaPolyfillUsingrequireJS
define(['modernizr-2.0.6.js', 'knockout-2.0.0.js'], function() { return { detectDeviceFeatures: function(callback) { var deviceConfig = {}; deviceConfig.landscape = ko.observable(); deviceConfig.portrait = ko.computed(function() { return !deviceConfig.landscape(); }); var setOrientation = function() { deviceConfig.landscape(window.innerWidth > window.innerHeight); } setOrientation();
CHAPTER9WRITINGBETTERJAVASCRIPT
252
$(window).bind("orientationchange resize", function() { setOrientation(); }); setInterval(setOrientation, 500); if (window.matchMedia) { var orientQuery = window.matchMedia('screen AND (orientation:landscape)') if (orientQuery.addListener) { orientQuery.addListener(setOrientation); } } function setupMediaQuery() { var screenQuery = window.matchMedia('screen AND (max-width: 500px)'); deviceConfig.smallScreen = ko.observable(screenQuery.matches); if (screenQuery.addListener) { screenQuery.addListener(function(mq) { deviceConfig.smallScreen(mq.matches); }); } deviceConfig.largeScreen = ko.computed(function() { return !deviceConfig.smallScreen(); }); setInterval(function() { deviceConfig.smallScreen(window.innerWidth <= 500); }, 500); callback(deviceConfig); } if (window.matchMedia) { setupMediaQuery(); } else { requirejs(['matchMedia.js'], function() { setupMediaQuery(); }); } } }; });
Thisisalesselegantapproach,butitdoesn’tsufferfromtheproblemsIdescribedearlierinthechapter.Ifyouareworkingonalargeprojectorsharingcodewithothers,thenasingle,coordinatedapproachtodependencesisessential,evenifthecodestyleisn’tquiteassmooth.
CHAPTER9WRITINGBETTERJAVASCRIPT
253
UnitTestingClient-SideCodeThelasttopicthatIwanttocoverinthisbookisunittesting.Thetoolsforunittestingwebappsarenotassophisticatedasthosefordesktoporserver-sidecode,buttheyarestillprettygood,andyouwillfinditeasytoembraceclient-sideunittestingaspartofyourdevelopmentcycle—ifyouareabelieverinunittesting,anyway.
AsIsaidatthebeginningofthischapter,Iamnotgoingtolectureyouabouttheimportanceoftestingortellyouwhenyoushouldbegintestingyourcode.Frommyownexperience,Iresistedunittestingforalongtime,inpartbecauseofthenumberofzealotsthatkeptinsistingthattestingbedoneatacertaintimeandinacertainway.Thesedays,Ihavecometoseethevalueinunittesting,butwhenandhowunittestingisbestappliedvariesfromprojecttoprojectandprogrammertoprogrammer.Iamabigbelieverinwritingbetter-qualitycode,butIhaveanintensedislikeforrigidapproachesthattreateverysituationinthesameway.
Withthatinmind,Iamgoingtobrieflyintroduceyoutotheclient-sidetestingtoolthatIliketouseandthenleaveyoutofigureouthowtoapplyit.Likeallofthetechniquesinthisbook,youshouldpickwhatworksforyou,adapteverythingtoyourownneeds,andsimplyignoreanythingthatdoesn’tsolveanyproblemsyouarefacing.
UsingQUnitIuseQUnit,whichisthetooldevelopedbythejQueryteamfortheirunittesting.Itissimpleandeffectiveandworkswell.YoucangetQUnitfromhttp://github.com/jquery/qunit.ToinstallQUnit,downloadtheQUnitpackageandcopythequnit.jsandqunit.cssfilesfromthequnitfolderinthearchivetotheNode.jscontentfolder.
QUnittestsarerunfromanHTMLdocument,andthereisabasicstructureofelementsrequiredinthisdocumentsothatQUnitcandisplaythetestresults.Listing9-16showsthetemplatethatIusedwhentestingAMDmodules,whichIhavecreatedasthefiletests.htmlinthecontentdirectory.
Listing9-16.AQUnitTemplateDocumentforAMDTesting
<!DOCTYPE html> <html> <head> <link rel="stylesheet" type="text/css" href="qunit.css"/> <script src='require.js' type='text/javascript'></script> <script src='jquery-1.7.1.js' type='text/javascript'></script> <script src='qunit.js' type='text/javascript'></script> <script type="text/javascript"> $(document).ready(function() { require(["utils-amd"], function(utils) { module("Utils-AMD Module"); // tests for utils-amd module will go here }); }); </script> </head> <body> <h1 id="qunit-header">AMD Tests</h1> <h2 id="qunit-banner"></h2> <div id="qunit-testrunner-toolbar"></div>
CHAPTER9WRITINGBETTERJAVASCRIPT
254
<h2 id="qunit-userAgent"></h2> <ol id="qunit-tests"></ol> <div id="qunit-fixture">test markup, will be hidden</div> </body> </html>
TouseQUnit,ensurethatthescriptandCSSfilesyoucopiedintothecontentdirectoryareimportedintothedocument.
ForeachmoduleIwanttotest,IusetheQUnitmodulefunctiontodenotethestartofaseriesoftestsanduserequireJStoloadthemodulecode.(TheQUnitmodulefunctionisn’trelatedtoAMDmodules;itjustgroupstogetherasetofrelatedtestsintheoutputdisplay.)
ThemarkupaddedtothetemplateallowsQUnittodisplaytheresults.Youcanchangethemarkuptoformatyourresultsdifferently,andinformationaboutthemeaningofeachelementcanbefoundathttp://docs.jquery.com/QUnit,alongwiththefullAPIdocumentation.
IhaveaddedjQuerytomylistofscriptimports,butQUnitdoesn’trequirejQuerytorun.IfindjQueryusefulforcreatingmorecomplextests,asI’lldemonstrateshortly.
TipBecarefulifyouareusingrequireJStoloadQUnit.TheQUnitlibraryinitializesitselfinresponsetotheloadeventonthewindowbrowserobject,andthiseventisusuallytriggeredbeforerequireJShasloadedthejQuerylibraryandexecutedthecallbackfunction.IfyouabsolutelymustuserequireJS,thenyoucanmakeacalltoQUnit.load()intherequireJScallbackfunction.
AddingTestsforaModuleWiththebasicstructureinplace,Icanbegintoaddtestsformymodule.IamgoingtokeepthingssimpleandperformsomeargumenttestsonthecomposeStringfunction,makingsurenullargumentsdon’tcauseoddresults.Listing9-17showstheadditionofteststothetests.htmlfile.
Listing9-17.AddingTeststothetests.htmlFile
<!DOCTYPE html> <html> <head> <link rel="stylesheet" type="text/css" href="qunit.css"/> <script src='require.js' type='text/javascript'></script> <script src='jquery-1.7.1.js' type='text/javascript'></script> <script src='qunit.js' type='text/javascript'></script> <script type="text/javascript"> $(document).ready(function() { require(["utils-amd"], function(utils) { module("Utils-AMD Module"); test("Null prefix and suffix", function() { var config ={ prefix: null, suffix: null, value: "value"
CHAPTER9WRITINGBETTERJAVASCRIPT
255
}; equal(utils.composeString(config), "value"); }); test("Null value", function() { var config ={ prefix: "prefix", suffix: "suffix", value: null }; equal(utils.composeString(config), "prefixsuffix"); }); test("No value property", function() { var config ={ prefix: "prefix", suffix: "suffix", }; equal(utils.composeString(config), "prefixsuffix"); }); }); }); </script> </head> <body> <h1 id="qunit-header">AMD Tests</h1> <h2 id="qunit-banner"></h2> <div id="qunit-testrunner-toolbar"></div> <h2 id="qunit-userAgent"></h2> <ol id="qunit-tests"></ol> <div id="qunit-fixture">test markup, will be hidden</div> </body> </html>
Eachtestisdefinedwiththetestfunction,withargumentsforthenameofthetestandafunctionthatcontainsthetestcode.IneachofthefourtestsIhaveadded,Icreateanobjectwiththeprefix,suffix,andvaluepropertiesthatarepassedtomyfunctionviamycustomdatabindingsandpassthistothecomposeStringfunction,whichIaccessthroughtheutilsargumenttomyrequireJScallbackfunction,likethis:
equal(utils.composeString(config), "prefixvalue");
Likemostunittestpackages,QUnitprovidesaseriesofassertionsthattesttheresultofanoperation.Inthiscase,IhaveusedtheequalfunctiontocheckthattheresultfromcallingthecomposeStringfunctionmatchesmyexpectation.Arangeofdifferentassertionsareavailable,andyoucanseethefulllistathttp://docs.jquery.com/QUnit.
Toruntheunittests,simplyloadtests.htmlintothebrowser.QUnitwillperformeachtestinturnandusethemarkupasacontainerfortheresults.MycomposeStringfunctionpassesoneofthetestsandfailstheothertwo.Theresultsaredisplayedinthebrowser,asshowninFigure9-2.
CHAPTER9WRITINGBETTERJAVASCRIPT
256
Figure9-2.ExecutingunittestsonthecomposeStringfunction
ThereisabuginthecomposeStringfunction,whichdoesn’tchecktoseewhetherthevaluepropertyoftheobjectpassedastheargumentexistsorhasbeenassignedavalue.Tofixthisproblem,ImakethechangeshowninListing9-18andrunthetestsagain.
Listing9-18.FixingthecomposeStringFunction
... composeString: function(bindingConfig) { var result = bindingConfig.value || ""; if (bindingConfig.prefix) { result = bindingConfig.prefix + result; } if (bindingConfig.suffix) { result += bindingConfig.suffix;} return result; } ...
Icanrunindividualtestsagainor,byreloadingthedocument,runallofthetests.Mysimplefixresolvestheproblemwiththetwobrokentests,andreloadingtests.htmlgivesmetheall-clear.
CHAPTER9WRITINGBETTERJAVASCRIPT
257
UsingjQuerytoPerformTestsonHTMLIamnotgoingtowriteacompletesetoftestsformymodulesbecauseQUnitbehavesjustlikeanyotherunittestpackage,exceptitoperatesonJavaScriptinthebrowser,especiallyforself-containedfunctionslikecomposeStringwheretheinputandtheresultareallexpressedinJavaScript.
However,aslightlydifferentapproachisrequiredwhentheeffectorresultofthecodebeingtestedisexpressedinHTML.ThisisthereasonthatIincludedjQueryinmyQUnittesttemplate,andtodemonstratethistechnique,IwillwritesometestsfortheformatAttrbindinginthecustombindings-amdmodule,whichisshowninListing9-19.
Listing9-19.TheformatAttrBindingfromthecustombindings-amdModule
ko.bindingHandlers.formatAttr = { init: function(element, accessor) { $(element).attr(accessor().attr, utils.composeString(accessor())); }, update: function(element, accessor) { $(element).attr(accessor().attr, utils.composeString(accessor())); } }
jQuerymakesiteasytocreate,use,andtestfragmentsofHTMLwithoutneedingtoaddthemtothedocument.Listing9-20showsadditionstotests.htmlfortheformatAttrbinding.
Listing9-20.UnitTestingUsingHTMLFragments
<!DOCTYPE html> <html> <head> <link rel="stylesheet" type="text/css" href="qunit.css"/> <script src='require.js' type='text/javascript'></script> <script src='jquery-1.7.1.js' type='text/javascript'></script> <script src='qunit.js' type='text/javascript'></script> <script type="text/javascript"> $(document).ready(function() { require(["utils-amd"], function(utils) { module("Utils-AMD Module"); // other utils-amd tests removed for brevity test("No value property", function() { var config ={ prefix: "prefix", suffix: "suffix", }; equal(utils.composeString(config), "prefixsuffix"); }); }); require(["custombindings-amd", "knockout-2.0.0.js"], function() { module("Custombindings-AMD Module"); test("Correct attribute applied", function() { var viewModel = {
CHAPTER9WRITINGBETTERJAVASCRIPT
258
cat: "British" }; var testElem = $("<a></a>").attr("data-bind", "formatAttr: {attr: 'href', prefix: '#', value: cat}")[0]; ko.applyBindings(viewModel, testElem); equal(testElem.attributes.length, 2); equal($(testElem).attr("href"), "#British"); }); }); }); </script> </head> <body> <h1 id="qunit-header">AMD Tests</h1> <h2 id="qunit-banner"></h2> <div id="qunit-testrunner-toolbar"></div> <h2 id="qunit-userAgent"></h2> <ol id="qunit-tests"></ol> <div id="qunit-fixture">test markup, will be hidden</div> </body> </html>
IhaveaddedanewtestthatusesjQuerytocreateanaelementandapplyadata-bindattribute.IfyoupassanHTMLfragmenttothejQuery$shorthandfunction,theresultisaDOMAPIelementthatisnotattachedtothedocument.Asabonus,Idon’thavetomakesurethatthesingleanddoublequotesinthedata-bindattributeareproperlyescapedwhenusingthejQueryattrmethod:
var testElem = $("<a></a>").attr("data-bind", "formatAttr: {attr: 'href', prefix: '#', value: cat}")[0];
NoticethatIusedanarray-styleindexertogetthefirstelementintheobjectreturnedbythejQuery$shorthandfunction.Theko.applyBindingsmethodworksontheDOMAPIobjectratherthanjQueryobjectsandsoIneedtounwraptheaelementIhavecreatedfromthejQueryobject.Atthispoint,IcangetKnockout.jstoapplybindingstomyHTMLfragmentusingmytestviewmodel:
ko.applyBindings(viewModel, testElem);
Totesttheresult,IusetheQUnitequalfunctionandboththeDOMAPIandjQuerytoinspecttheresult:
equal(testElem.attributes.length, 2); equal($(testElem).attr("href"), "#British");
jQuerymakesiteasytocreateandprepareHTMLfortestingandchecktheresults,andasthisexampleshows,youcanusetheDOMAPItogetinformationabouttheelementsafterthetesthascompleted.Asyoucansee,jQueryandQUnittogethermaketestingeveryaspectofawebapppossibleand,forthemostpart,easytodo.
CHAPTER9WRITINGBETTERJAVASCRIPT
259
SummaryInthischapter,IshowedyouthetoolsandtechniquesIusetowritebetterJavaScript,notbetterinthesenseofamorecompleteuseofthelanguagefeaturesbutbetterinthesenseofeasierforotherstoworkwith,easierformetomaintain,and,withtheapplicationofunittesting,sotheuserwillexperiencefewerproblems.Thesetechniques,combinedwiththosefromearlierchapters,giveyouasolidfoundationonwhichtobuildscalable,dynamic,andflexiblewebappsthatareeasytouseandeasytomaintain.Goodluckonallofyourprojects,andremember,asIsaidinChapter1,thatanythingworthdoingontheserversideisworthconsideringfortheclientside,too.
261
Index
A
Ajaxrequests,129addingAjaxGETrequest,130–131addingAjaxURLtomainmanifest,135addingAjaxURLtomanifestNETWORK
section,135errorhandling,133–134POSTrequestbehavior,135products.jsonFile,131restructuring,131–133
Applicationcacheentries,122Applicationcachespecification,126Asynchronousmoduledefinition(AMD)
callbackarguments,248–250definition,245dependencydeclaration,247–248dependencyissues,246–247factoryfunction,245inlinedependencies,250–252
B
Bidirectionalbindings,58–60
C
Cache-controlheader,122CheeseLux,9–12
addingrouting,101–105browser,105enhancingviewmodel,106managingapplicationstate,107–108mapProductsfunction,106
Clickevent,25–26Code
fragment,6HTMLdocument,5
Contentdistributionnetwork(CDN),16CSSclass,22–24
D
DataStorage,browser.SeeHTML5localstoragefeature
Defaultactionsmanagement,27–29Designpatterns,4Desktopwebbrowser,7Dynamicbasket,29
E
Emptybasket,71–74Event,definition,24
F
Fallbackentries,122–126Flowcontrolbindings,52–53$function,18–19
G
Graphicdesignandlayouts,4
H
Hovermethod,jQuery,26HTMLeditor,7HTML5HistoryAPI
preservingviewmodelstate,96–97restoringapplicationstate,99–101storingapplicationstate,98–99
HTML5localstoragefeature,137–139complexdatastorage(seeIndexedDB)withformelements,143–144forJSONdata,139–141withobjects,141–142withofflinewebapplications
addingbuttons,154–155
INDEX
262
HTML5localstoragefeature,withofflinewebapplications(cont.)
cachedCheeseLuxwebapp,150–153createDialogfunction,156–157enhanceViewModelfunction,153–154scriptelementchanges,155–156
persistentforms,142–143sessionstorage
benefits,148–149semi-persistentobservabledataitem,149
synchronizingviewmodels,144KOsubscribemethod,146maindocumentmodification,147–148persistentObservablefunction,144,146–
147StorageEventobject,145
HTMLElementproperties,35
I
IndexedDB,156DBOobject,158–160locatingobjects
usingcursor,166usingIndex,167bykey,165–166
onupgradeneededproperty,160–161successoutcomes,161towebapplication,162–165WebSQL,157workingprinciple,157
J
JavaScriptdependenciesinlibraries,238
AMDmodule(seeAsynchronousmoduledefinition(AMD))
assumeddependency,238–239directlyresolveddependency,240double-loadingproblem,243–244issues,240–243
globalnamespaces,229–230globalvariables,230namespaces
configuration,233–234definition,230–231nested,231–232nested,usingafunction,232–233self-executingfunction,234–235
namingcollision,229property,methodandfunction,235–238unittesting,253
addingtests,254–256jQuery,257–258QUnit,253–254
JavaScriptlibraries,7JavaScriptpolyfilllibraries,129jQuery
addClassmethod,23bindmethod,changeandkeyupevents,32–
33customselectors,20hovermethod,26importing,15–18methodchaining,23methodsforinsertingelementsindocument,
22statement,19UIbutton,43–44UItoolkit,42–43
jQueryMobilecontentchanges,214–215eventsequence,211–212
disablingautomaticprocessing,212–213pageinitevent,213–214
pages,201–202widgets,202
K
Knockout(KO)databindings,51definition,49library,49–53
koobject,50
L
Latentcontent,29,31–32
M
Methodchaining,23Methodpairs,23Mobilebrowseremulator,7MobileWebApps,195
CheeseLuxMobileWebAppbasicimplementation,209formatTextdatabinding,210–211
INDEX
263
initialversion,206–209duplicatingelementsusingtemplates,215
withcustomdata,218data-bindattribute,218–220usingtwo-passdatabindings,215–218
getIndexOfCategoryfunction,226goals,205jQueryMobile
askmobile.htmldocument,198CheeseLuxwebapp,204dataattributes,201events,203installation,201setCookie,203
mobiledevicedetectioncapabilities,197–198useragent,195–196
multipagemodeladdingsupport,220–223changePagemethod,225mappingpagenamestoroutes,224–225navigation,223replacingradiobuttonswithanchors,223
Mouseenterandmouseleaveevents,26–27
N
Node.js,8
O
Offlinewebapps,109Ajaxrequests
addingAjaxGETrequest,129–131addingAjaxURLtomainmanifest,135addingAjaxURLtomanifestNETWORK
section,135errorhandling,133–134POSTrequestbehavior,135products.jsonfile,131restructuring,131–133
HTML5applicationcacheacceptingchangestomanifest,115–116addingmanifesttoHTMLdocument,
112–113addingnetworkandfallbackentries,122–
126cachedcontent,113–114controlofcacheupdateprocess,116–122manifestfile,111–112
monitoringaddingelementsandbindings,128detectingstateofnetwork,126–128
POSTrequestbehavior,135reviseddocument,109–111
P, Q
Polyfill,98POSTrequestbehavior,135
R
RecurringAjaxrequestspolyfills,129ResponsiveWebApps,169
screenorientation,184–188screensize
adaptingsourcedata,183adaptingwebapplayout,179–183conditionaljQueryUIstyling,183–184CSSmediaqueries,173–174detectDeviceFeaturesfunction,177–178imageloading,178–179JavaScriptmediaqueries,174–175matchMediafeature,176–177polyfill,175–176removingelements,184
touchinteractionapplicationroutes,193detectingtouchsupport,189–190navigation,191–193touchSwipelibrary,190–191
viewport,169–172
S, T
Singlehandlerfunction,27Submitbuttonupgradation
CSSclass,22–24$function,18–19inputelementselectionandhiding,19–21jQuery,15–18newelementinsertion,21–22
U
URLroutingCheeseLux
addingrouting,101–104
INDEX
264
URLrouting,CheeseLux(cont.)browser,104enhancingviewmodel,106managingapplicationstate,107–108mapProductsfunction,106
consolidatingroutesaddingdefaultroute,89–90optionalsegments,88–89unexpectedsegmentvalues,86–88variablesegments,85–86
event-drivencontrolstonavigationbridgingeventsandrouting,92–94bridgingURLroutingandJavaScript
events,90–92selecteddatabinding,94
usingHTML5HistoryAPIhistory.replaceStatemethod,95preservingviewmodelstate,96–97restoringapplicationstate,99–101stepsdemonstratingtheissue,95storingapplicationstate,98–99
simpleroutedwebapplication,77–78addingnavigationmarkup,81–83addingroutinglibrary,79addingviewmodelandcontentmarkup,
79–81applyingcontrolsandelements,83–85
V
Valuebindings,51–52Viewmodel,47
addingmoreproducts,53–54dynamicbasket
addingbasketlineitems,66–69addingbasketstructureandtemplate,70–
71addingsubtotals,64–66emptybasket,71–74removingitems,71
generatingcontent,61–63modelcreation
addingdatatodocument,48
adoptingviewmodellibrary,49bidirectionalbindings,58–60extendingthemodel.60–61generatingcontent,49–53observabledataitems,55–58
resetting,47–48reviewing,63–64URLrouting,79–80
W, X, Y, Z
Webappdevelopmentprinciples,15dynamicbasketdata
addingbasketelements,29–31bindmethod,changeandkeyupevents,
32–33changingformtarget,37–39latentcontent,31–32overalltotalcalculation,35–37subtotalcalculation,33–34subtotaldisplay,34–35
eventhandlingclickevent,25–26defaultactions,28–29usingeventobject,27mouseenterandmouseleave,26–27singlehandlerfunction,27
JavaScriptdisabledandenabled,39–40JavaScript-onlypolicy,41non-JavaScriptusers,40submitbuttonupgradation
CSSclass,22–24$function,18–19inputelementselectionandhiding,19–21jQuery,15–19newelementinsertion,21–22
UItoolkitcreatingjQueryUIbutton,43–44settingupjQueryUI,42
Webserver,7whitelistentries,122WURFLdatabase,195
ProJavaScriptforWebApps
AdamFreeman
ii
ProJavaScriptforWebApps
Copyright©2012byAdamFreeman
Thisworkissubjecttocopyright. Allrightsareres ervedbythePublisher,whetherthewholeorpartof thematerialis concerned, specificallyth erightsof translati on,repr inting,reuseo fillus trations,recitation,broadcasti ng,reproductiononmicrof ilmsori nany otherphysicalway ,an dt ransmissionori nformationstor agean dre trieval,electronicadaptation,computersof tware,orbysi milarord issimilarmethodologynowknownorhereaf terdeveloped.Exemptedfromthislegalreservationarebriefexcerptsinconnectionwithreviewsorscholarlyanalysisormaterialsuppliedspecificallyforthepurposeofbeingenteredandexecutedonacomputersystem,forex clusiveusebythepurchaserofthework.DuplicationofthispublicationorpartsthereofispermittedonlyundertheprovisionsoftheCopyrightLawofthePublisher'slocation,ini tscurrentversion,andpermissionforusemustalwaysbeobtained fromSpringer.PermissionsforusemaybeobtainedthroughRightsLinkattheCopyrightClearanceCenter.ViolationsareliabletoprosecutionundertherespectiveCopyrightLaw.
ISBN-13(pbk):978-1-4302-4461-5
ISBN-13(electronic):978-1-4302-4462-2
Trademarkedn ames,logos,an d imagesmayapp earin this book.Ratherthanus eatrademarks ymbolwith everyoccurrenceofatrademarkedname,logo,orima geweusethe names,logos,and imagesonlyina neditorialf ashionandtothebenefitofthetrademarkowner,withnointentionofinfringementofthetrademark.
Theuseinthis publicationof tradenames,tr ademarks, servicema rks,a nds imilarterms,ev enifth eya re notidentifiedassuch,isnottobeta kenasanexpres sionofopinion astowhet herornottheyaresubjecttoproprietary rights.
Whiletheadviceandinf ormationinthis bookarebelievedtobe trueandaccurateatthedateof publication,neithertheauthorsnor theeditorsnorth epublishercanacceptanylegal responsibilityforanyerrorsoro missionsthatmaybemade.Thepublishermakesnowarranty,expressorimplied,withrespecttothematerialcontainedherein.
PresidentandPublisher:PaulManningLeadEditor:BenRenow-ClarkeDevelopmentEditor:LouiseCorriganTechnicalReviewer:RJOwenEditorialBoard: Ste veAn glin, EwanBuckin gham,Gary Corn ell,Louise Corrigan ,Morgan Erte l,Jon athan
Gennick,JonathanHassell, RobertHutchin son, MichelleLowman,JamesMarkh am,MatthewM oodie,JeffOlson,J effreyP epper,D ouglas Pundick,Ben R enow-Clarke,D ominicSha keshaft,G wenanSp earing,M attWade,TomWelsh
CoordinatingEditor:JenniferL.BlackwellCopyEditor:KimWimpsettCompositor:BythewayPublishingServicesIndexer:SPiGlobalArtist:SPiGlobalCoverDesigner:AnnaIshchenko
DistributedtothebooktradeworldwidebySpringerScie nce+BusinessMediaNewYork, 233SpringStreet,6thFloor,New York,NY 10013.Phone 1-800-SPRINGER,f ax(20 1)348 -4505, e-mail [email protected],orv isitwww.springeronline.com.
Forinformationontranslations,[email protected],orvisitwww.apress.com.
Apressand friendsofEDbook smaybe purchasedinbulkf oracademic,corporate,orpromo tionaluse.eBoo kversionsandlicensesarealsoavailableformostti tles.Formoreinformation,referenceourSpecialBulkSales–eBookLicensingwebpageatwww.apress.com/bulk-sales.
Anysourcecodeorothersupplementary materialsref erencedbytheauthori n thiste xtisav ailabletore adersatwww.apress.com.Fordetailed inf ormationabouthowtoloca teyourbook’ssourcecode, goto www.apress.com/source-code.
iii
Dedicatedtomylovelywife,JacquiGriffyth.
v
Contents
AbouttheAuthor................................................................................................... xii
AbouttheTechnicalReviewer ............................................................................. xiii
Acknowledgments ............................................................................................... xiv
Chapter1:GettingReady ........................................................................................1
AboutThisBook.................................................................................................................1
WhoAreYou? ........................................................................................................................................... 1
WhatDoYouNeedtoKnowBeforeYouReadThisBook?........................................................................ 2
WhatIfYouDon’tHaveThatExperience? ................................................................................................ 2
IsThisaBookAboutHTML5?................................................................................................................... 2
WhatIstheStructureofThisBook? ......................................................................................................... 2
DoYouDescribeDesignPatterns? ........................................................................................................... 4
DoYouTalkAboutGraphicDesignandLayouts?..................................................................................... 4
WhatIfYouDon’tLiketheTechniquesorToolsIDescribe? .................................................................... 5
IsThereaLotofCodeinThisBook? ........................................................................................................ 5
WhatSoftwareDoYouNeedforThisBook?......................................................................6
GettingtheSourceCode........................................................................................................................... 6
GettinganHTMLEditor............................................................................................................................. 7
GettingaDesktopWebBrowser............................................................................................................... 7
GettingaMobileBrowserEmulator.......................................................................................................... 7
GettingtheJavaScriptLibraries ............................................................................................................... 7
GettingaWebServer................................................................................................................................ 7
IntroducingtheCheeseLuxExample .................................................................................9
CONTENTS
vi
FontAttribution................................................................................................................12
Summary .........................................................................................................................13
Chapter2:GettingStarted ....................................................................................15
UpgradingtheSubmitButton ..........................................................................................15
PreparingtoUsejQuery.......................................................................................................................... 15
UnderstandingtheReadyEvent ............................................................................................................. 18
SelectingandHidingtheInputElement ................................................................................................. 19
InsertingtheNewElement ..................................................................................................................... 21
ApplyingaCSSClass.............................................................................................................................. 22
RespondingtoEvents ......................................................................................................24
HandlingtheClickEvent......................................................................................................................... 25
HandlingMouseHoverEvents................................................................................................................ 26
UsingtheEventObject ........................................................................................................................... 27
DealingwithDefaultActions .................................................................................................................. 28
AddingDynamicBasketData ..........................................................................................29
AddingtheBasketElements................................................................................................................... 29
ShowingtheLatentContent ................................................................................................................... 31
RespondingtoUserInput ....................................................................................................................... 32
CalculatingtheOverallTotal................................................................................................................... 35
ChangingtheFormTarget...................................................................................................................... 37
UnderstandingProgressiveEnhancement.......................................................................39
RevisitingtheButton:UsingaUIToolkit..........................................................................42
SettingUpjQueryUI................................................................................................................................ 42
CreatingajQueryUIButton .................................................................................................................... 43
Summary .........................................................................................................................45
CONTENTS
vii
Chapter3:AddingaViewModel...........................................................................47
ResettingtheExample.....................................................................................................47
CreatingaViewModel.....................................................................................................48
AdoptingaViewModelLibrary............................................................................................................... 49
GeneratingContentfromtheViewModel............................................................................................... 49
TakingAdvantageoftheViewModel ..............................................................................53
AddingMoreProductstotheViewModel .............................................................................................. 53
CreatingObservableDataItems ............................................................................................................. 55
CreatingBidirectionalBindings .............................................................................................................. 58
AddingaDynamicBasket................................................................................................64
AddingSubtotals .................................................................................................................................... 64
AddingtheBasketLineItemsandTotal ................................................................................................. 66
FinishingtheExample ............................................................................................................................ 71
Summary .........................................................................................................................74
Chapter4:UsingURLRouting...............................................................................77
BuildingaSimpleRoutedWebApplication......................................................................77
AddingtheRoutingLibrary ..................................................................................................................... 78
AddingtheViewModelandContentMarkup ......................................................................................... 79
AddingtheNavigationMarkup ............................................................................................................... 81
ApplyingURLRouting ............................................................................................................................. 83
ConsolidatingRoutes .......................................................................................................85
UsingVariableSegments........................................................................................................................ 85
UsingOptionalSegments ....................................................................................................................... 88
AddingaDefaultRoute........................................................................................................................... 88
AdaptingEvent-DrivenControlstoNavigation.................................................................89
UsingtheHTML5HistoryAPI ...........................................................................................94
AddingHistoryStatetotheExampleApplication ................................................................................... 95
CONTENTS
viii
AddingURLRoutingtotheCheeseLuxWebApp ...........................................................101
MovingthemapProductsFunction....................................................................................................... 105
EnhancingtheViewModel ................................................................................................................... 105
ManagingApplicationState.................................................................................................................. 106
Summary .......................................................................................................................108
Chapter5:CreatingOfflineWebApps.................................................................109
ResettingtheExample...................................................................................................109
UsingtheHTML5ApplicationCache..............................................................................111
UnderstandingWhenCachedContentIsUsed ..................................................................................... 113
AcceptingChangestotheManifest...................................................................................................... 115
TakingControloftheCacheUpdateProcess ....................................................................................... 116
AddingNetworkandFallbackEntriestotheManifest ......................................................................... 122
MonitoringOfflineStatus...............................................................................................126
UnderstandingwithAjaxandPOSTRequests ...............................................................129
UnderstandingtheDefaultAjaxGETBehavior...................................................................................... 131
AddingtheAjaxURLtotheMainManifestorFALLBACKSections....................................................... 135
AddingtheAjaxURLtotheManifestNETWORKSection ...................................................................... 135
UnderstandingthePOSTRequestBehavior.......................................................................................... 135
Summary .......................................................................................................................136
Chapter6:StoringDataintheBrowser ..............................................................137
UsingLocalStorage.......................................................................................................137
StoringJSONData ................................................................................................................................ 139
StoringFormData ................................................................................................................................ 142
SynchronizingViewModelDataBetweenDocuments ..................................................144
UsingSessionStorage...................................................................................................148
UsingLocalStoragewithOfflineWebApplications.......................................................149
UsingLocalStoragewithOfflineForms ............................................................................................... 153
CONTENTS
ix
UsingPersistenceintheOfflineApplication......................................................................................... 154
StoringComplexData ....................................................................................................156
CreatingtheIndexedDBDatabaseandObjectStore ............................................................................ 158
IncorporatingtheDatabaseintotheWebApplication .......................................................................... 162
LocatinganObjectbyKey .................................................................................................................... 165
LocatingObjectsUsingaCursor........................................................................................................... 166
LocatingObjectsUsinganIndex .......................................................................................................... 167
Summary .......................................................................................................................167
Chapter7:CreatingResponsiveWebApps.........................................................169
SettingtheViewport ......................................................................................................169
RespondingtoScreenSize............................................................................................173
UsingMediaQuerieswithJavaScript................................................................................................... 174
AdaptingtheWebAppLayout .............................................................................................................. 179
RespondingtoScreenOrientation .................................................................................184
IntegratingScreenOrientationintotheWebApp ................................................................................. 186
RespondingtoTouch .....................................................................................................188
DetectingTouchSupport ...................................................................................................................... 189
UsingTouchtoNavigatetheWebAppHistory ..................................................................................... 191
IntegratingwiththeApplicationRoutes ............................................................................................... 193
Summary .......................................................................................................................194
Chapter8:CreatingMobileWebApps ................................................................195
DetectingMobileDevices ..............................................................................................195
DetectingtheUserAgent...................................................................................................................... 195
DetectingDeviceCapabilities ............................................................................................................... 196
CreatingaSimpleMobileWebApp ...............................................................................198
InstallingjQueryMobile ........................................................................................................................ 201
UnderstandingthejQueryMobileDataAttributes ................................................................................ 201
CONTENTS
x
DealingwithjQueryMobileEvents. ..................................................................................................... 203
StoringtheUser’sDecision . ................................................................................................................ 203
DetectingtheUser’sDecisionintheWebApp . ................................................................................... 204
BuildingtheMobileWebApp.........................................................................................205
ManagingtheEventSequence . ........................................................................................................... 211
PreparingforContentChanges . .......................................................................................................... 214
DuplicatingElementsandUsingTemplates ..................................................................215
UsingTwo-PassDataBindings. ........................................................................................................... 215
AdoptingtheMultipageModel. ......................................................................................220
ReworkingCategoryNavigation . ......................................................................................................... 223
ReplacingRadioButtonswithAnchors . .............................................................................................. 223
MappingPageNamestoRoutes . ........................................................................................................ 224
ExplicitlyChangingPages . .................................................................................................................. 225
AddingtheFinalChrome ...............................................................................................225
Summary .......................................................................................................................228
Chapter9:WritingBetterJavaScript..................................................................229
ManagingtheGlobalNamespace ..................................................................................229
DefiningaJavaScriptNamespace. ...................................................................................................... 230
UsingSelf-executingFunctions. .......................................................................................................... 234
CreatingPrivateProperties,Methods,andFunctions ...................................................235
ManagingDependencies ...............................................................................................238
UnderstandingAssumedDependencyProblems. ................................................................................ 238
UnderstandingDirectlyResolvedDependencies . ................................................................................ 240
MakingaBadProblemintoaSubtleBadProblem. ............................................................................. 243
UsingtheAsynchronousModuleDefinition. ........................................................................................ 244
UnitTestingClient-SideCode ........................................................................................253
UsingQUnit . ......................................................................................................................................... 253
CONTENTS
xi
AddingTestsforaModule.................................................................................................................... 254
UsingjQuerytoPerformTestsonHTML............................................................................................... 257
Summary .......................................................................................................................259
Index ...................................................................................................................261
xii
About the Author
AdamFreemanisanexperiencedITprofessionalwhohasheldseniorpositionsinarangeofcompanies,mostrecentlyservingaschieftechnologyofficerandchiefoperatingofficerofaglobalbank.Nowretired,hespendshistimewritingandrunning.
xiii
About the Technical Reviewer
RJOwenistheleadexperienceplanneratEffectiveUI,focusingoncustomerinsightwork,includingethnographicresearch,designvalidation,co-creationexercises,andexpertdesign.RJstartedhiscareerasasoftwaredeveloperandspenttenyearsworkinginC++,Java,andFlexbeforemovingtothedesignresearchandcustomerinsightteamatEffectiveUI.Hetrulylovesgooddesignandunderstandingwhatmakespeopletick.RJholdsanMBAandabachelor’sinphysicsandcomputerscience.Heisafrequentspeakeratmanyindustryevents,includingWeb2.0,SXSW,andAdobeMAX.
xiv
Acknowledgments
IwouldliketothankeveryoneatApressforworkingsohardtobringthisbooktoprint.Inparticular,IwouldliketothankJenniferBlackwellforkeepingmeontrackandBenRenow-Clarkeforcommissioningandeditingthistitle.Iwouldalsoliketothankmytechnicalreviewer,RJOwen,whoseeffortsmadethisbookfarbetterthanitwouldhavebeenotherwise.