JavaScript objektumok azonosítása
Néhányszor belefutottam abba a problémába JavaScriptnél, hogy csak nehezen lehet létrehozni objektum kulcsos mapeket ill. seteket. Ezért úgy döntöttem, hogy írok egy kis bejegyzést ennek a mikéntjéről. Ez a probléma többek között abban az esetben merül fel, amikor van két objektumokat tartalmazó gyűjteményünk, és a kettő metszetére van szükségünk úgy, hogy az nem tartalmazhatja kétszer ugyanazt az objektumot. Erre a leghétköznapibb példa, ha több CSS kiválasztót használunk egy lekérdezésben.
<p class="red underlined">
piros és aláhúzott
</p>
<p class="red">
piros
</p>
<p class="underline">
aláhúzott
</p>
Először dobjunk össze egy nagyon egyszerű parsert a CSS kiválasztókhoz. Annyit kell tudnia, hogy elemnév és osztálynév alapján ki tudjon választani elemeket, és ezeket egy setben dobja vissza. Egyelőre set helyett elég a tömb használata:
var Set=Array;
(function ()
{
var parseMultiSelectorText=function (multiSelectorText)
{
var selectors=[];
multiSelectorText.split(",").forEach(function (selectorText)
{
selectors.push(parseSelectorText(selectorText));
});
return createMultiSelector(selectors);
};
var parseSelectorText=function (selectorText)
{
var selectorPattern=new RegExp("^([\\w]+)\\.([\\w]+)$");
var match=selectorText.match(selectorPattern);
var tagName=match[1];
var className=match[2];
return createSelector(tagName,className);
};
var createMultiSelector=function (selectors)
{
return function (context)
{
var selectedNodes=new Set();
selectors.forEach(function (selector)
{
selectedNodes.push.apply(selectedNodes,selector(context));
});
return selectedNodes;
};
};
var createSelector=function (tagName,className)
{
return function (context)
{
var selectedNodes=[];
Array.prototype.forEach.call(context.getElementsByTagName(tagName),function (node)
{
if (String(" "+node.className+" ").indexOf(" "+className+" ")!=-1)
{
selectedNodes.push(node);
}
});
return selectedNodes;
};
};
$=function (multiSelectorText)
{
return parseMultiSelectorText(multiSelectorText)(document);
};
})();
Felhasználtam az Array.forEach()
-et.
if (!Array.prototype.forEach)
{
Array.prototype.forEach = function(fun)
{
if (this === void 0 || this === null)
throw new TypeError();
var t = Object(this);
var len = t.length >>> 0;
if (typeof fun !== "function")
throw new TypeError();
var thisp = arguments[1];
for (var i = 0; i < len; i++)
{
if (i in t)
fun.call(thisp, t[i], i, t);
}
};
}
Ha teszteljük most a selectorokat,
window.onload=function ()
{
alert($("p.red,p.underlined"));
};
akkor látszik, hogy egy négy elemű tömböt ad vissza a $
függvény. Ez azért van, mert a piros és aláhúzott paragrafus kétszer lett kiválasztva; egyszer mint piros, és egyszer mint aláhúzott. A dupla bejegyzéseket az új elemek felvételekor érdemes szűrni.
A szűrést kétféleképpen lehet megoldani:
- Végigmegyünk a teljes gyűjteményen, és minden elemet összehasonlítunk az újonnan hozzáadandó elemmel. Ha találunk egyezést, akkor nem adjuk hozzá a gyűjteményhez az elemet, mert már benne van.
- Az új elemhez lekérünk egy azonosító kódot, megnézzük, hogy az azonosító kód szerepel e már a gyűjteményben. Ha igen, akkor nem adjuk hozzá újra. Ez a megoldás azért jobb, mert egyszerű típusokat már lehet kulcsnak használni mapekhez. A JavaScript alapból minden kulcsot stringre alakít, és úgy használ fel az objektumoknál.
Most csináljuk egy egyszerű setet. Kell neki egy push()
és egy toString()
metódus.
var Set=function ()
{
this.table={};
};
Set.prototype.push=function ()
{
var set=this;
Array.prototype.forEach.call(arguments,function (value)
{
set.add(value);
});
return this;
};
Set.prototype.add=function (value)
{
var key=
(
("identity" in value)
?id=value.identity()
:value
);
if (!this.table.hasOwnProperty(key))
{
this.table[key]=value;
}
return this;
};
Set.prototype.forEach=function (fun,thisp)
{
if (!thisp)
{
thisp=this;
}
for (var i in this.table)
{
if (this.table.hasOwnProperty(i))
{
fun.call(thisp,this.table[i],i,this);
}
}
return thisp;
};
Set.prototype.toArray=function ()
{
var array=[];
this.forEach(function (value)
{
array.push(value);
});
return array;
};
Set.prototype.toString=function ()
{
return this.toArray().toString();
};
Mivel nincs identity()
metódus beállítva a paragrafusokhoz, ezért most csak egy paragrafus szerepel majd a listában, ugyanis a toString()
minden paragrafusra ugyanaz lesz.
Most jön a dolog lényegi része: hogyan oldjuk meg az objektumok egyedi azonosítását? A legegyszerűbb, ha számozzuk az objektumokat, és az adott objektum számát eltároljuk egy tulajdonságban.
(function ()
{
var i=0;
var table={};
Object.prototype.identity=function ()
{
if (!this.__hashcode__)
{
this.__hashcode__=++i;
table[i]=this;
}
return this.__hashcode__;
};
Object.identify=function (i)
{
return table[i];
};
var hasOwnProperty=Object.prototype.hasOwnProperty;
Object.prototype.hasOwnProperty=function (property)
{
return (hasOwnProperty.call(this,property) && property!="__hashcode__");
};
})();
A hasOwnProperty()
-t azért kell felülírni, hogy a tároló tulajdonság esetleges iterálásnál ne szerepeljen.
Ez a megoldás Firefox és a többi böngésző esetén jól vizsgázik, Internet Explorer 8 alatt viszont ugyanúgy egy elem marad a setben. Ez azért van, mert MSIE-ben a DOM Node-oknak semmi köze nincs az Object
hez, így hiába írjuk át az Object.prototype
-ot, az nem lesz hatással rájuk. Ezt a problémát jelenlegi ismereteim szerint egyedül behavior használatával lehet áthidalni. A behavior ElementNode-okra alkalmazható, szóval TextNode-okra a módszer nem használható – de általában nem is keresünk TextNode-okra.
Először csináljuk meg a HTC fájlt:
<PUBLIC:COMPONENT lightWeight="true">
<script type="text/javascript">
for (var property in Object.prototype)
{
element[property]=Object.prototype[property];
}
</script>
</PUBLIC:COMPONENT>
A komponens az aktuális elemet jelöli, taget nem adtuk meg, mert minden elemre alkalmazzuk a behaviort, a lightWeight
attribútum pedig ahhoz kell, hogy új elemeknél is működjön a dolog. A component
-en belüli részben a szkriptnél az element az aktuális elemet jelöli. Annyit csinálunk, hogy átmásoljuk az Object.prototype
-ból az összes tulajdonságot az aktuális elemre.
Ahhoz, hogy működjön a dolog, MSIE esetén az összes elemre alkalmazni kell a behaviort, és át kell írni a document.createElement()
-et is, hogy az új elemekhez is hozzáadjuk a viselkedést.
<!--[if IE]>
<style type="text/css">
*{behavior:url(identity.htc)}
</style>
<script type="text/javascript">
(function ()
{
var createElement=document.createElement;
document.createElement=function ()
{
var element=createElement.apply(this,arguments);
element.addBehavior("identity.htc");
return element;
};
})();
</script>
<![endif]-->
Így már MSIE esetén is működik a metszet kiválasztása.
Összegezve a böngészők közötti eltérések miatt már egy ilyen – amúgy egyszerűnek tűnő – dolog is csak nehezen és csak részben megvalósítható.
■
Kipróbált módszer?
Nekem egy általános halmaz típusnál nagyon hiányozna a primitív típusok használata. Típushibát kapunk erre, ha nem objektum a value érték: ("identity" in value)
Közben nem arról van szó, hogy null és az undefined értéken kívül minden másnak lehetne azonosítója? Bár a primitív elemek azonosítójával azért vannak problémák:
Végül egy apróság, hogy konstruktivitás is legyen a hozzászólásomban. :)
A „lényegi” résznél a table lehetne tömb (szerintem szebb lenne:).
"Ez tényleg gyorsabb?" - Egy
- Egy hash-ben azonosítani egy konkrét elemet gyorsabb valószínűleg, mint ciklusban végigpásztázni adott esetben soktucat elemet és mindet egyesével ellenőrizni, hogy azonos-e az éppen hozzáadni kívánt elemmel.
Egyébként egy csomó helyen nem értem a kódot:
1. Array.forEach()-nél a 13-ik sor értékadása felesleges, az arguments[1]-et minden további nélkül át lehetne adni a 17-ik sorban thisp helyett, mivel thisp sehol sincs használva, csak ott.
2. A Set-et létrehozó script-ben a push() deklarációját biztosan így oldottam volna meg - valamivel szerintem érthetőbbé válik a kód:
Elkerülendő a behavior használatát, az Object.prototype.identity()-t a DOM object-ekre is ráhúznám, például DOMNode.prototype.identity() deklarálásával, vagy akár on the fly: ha egy hozzáadni tervezett objektumnak nem létezik ilyen metódusa, akkor hozzárendelném a metódust.
(Megj.: A toString()-ekre a magyarázatom csupán annyi, hogy a kód így elvileg strict-ebb lesz, nem vagyok biztos benne, hogy szabvány szerint a valami->string konverziót a JS parser-nek kéne megoldania)
Re: "Ez tényleg gyorsabb?"
Ezért kérdeztem rá, hogy használat közben is megállja a helyét a kód? :)
Mindemellett én nem vagyok biztos abban, hogy egy tömbben egy indexOf lassabb, minthogy minden változóhoz létrehozunk egy plusz metódust, illetve azonosításnál plusz függvényhívás, egy különálló tömbben az összes változónkat gyűjtjük stb.)
Az én verzióm a Set.push-ra, ha már... :)
Set-nél és Map-nél is úgy
Set-nél és Map-nél is úgy lehet megoldani, hogy vagy két objektumban tárolod a dolgokat, vagy beteszel valamilyen prefix-et a hashCode elé, ami megkülönbözteti a sima szövegektől, mondjuk tabot vagy ilyesmit... Nyilván ha általánosan oldja meg az ember, akkor lassabb lesz, mintha külön csinál egy HashMap-et meg HashSet-et. Azt hiszem így hívják java-ban azokat, amiknél a hashCode a kulcs. Szerintem primitív típusoknál ott sincs értelmezve a hashCode, de lehet, hogy tévedek.
Azt, hogy a ciklusnál gyorsabb e teljesen felesleges tesztelni, mert nyilvánvaló, hogy az. A primitív típusoknál az identity mindig mást ad, nem jegyzi meg őket, szóval őket jobb a saját értékükkel azonosítani, azazhogy a toString-el. Gondolkodtam azon, hogy az Object.prototype.toString-et írjam át olyanra, hogy hashCode-ot adjon, így nem kéne tesztelni, hogy primitív típusról van e szó, de aztán a végén elvetettem ezt a megoldást a Function, Array, Date és RegExp miatt, jobb ha látjuk, hogy mi van ezekben az objektumokban.
@prom3theus:
Az Array.forEach-et a developer.mozilla.org-ról másoltam be, kicsit módosítottam rajta, aztán az arguments-es rész úgy maradt bent. Nyilván ha én írtam volna, akkor thisp helyett scope vagy context lenne ott.
A scope visszaadása sokszor segít a láncok képzésénél, persze jobban átlátható, ha minden külön sorban van, valóban rontja az átláthatóságot.
Felesleges ennyi short-if, meg az identifiable is, elég lenne a primitív típusok toString metódusait betenni az identity tulajdonsághoz, úgy már nem hashCode-t adnának vissza, hanem a rendes értéküket. Esetleg még az Object.prototype-ost lehetne módosítani a tabulátorral, ahogy fentebb írtam, és úgy már kész is lenne.
(A null az jogos az "identity" in -nél.)
Nem tudom, lehet, hogy nem tettem elég világossá, msie-ben ha DOM Node tulajdonságát akarod manipulálni, akkor hibaüzenetet kapsz. Ez azért van, mert ezek a Node-ok igazából nem javascript objektumok, nem lehet beállítani rajtuk tulajdonságot, sőt még lekérni sem. Az "identitity" in this is ezért van bent, mert arra nem dobnak hibaüzentet. Egyedül a htc az, amivel rá lehet őket venni, hogy viszonylag normálisan viselkedjenek :-P Ne kérdezd, hogy ennek mi a logikája, szerintem aki ezt microsoftnál kitalálta az sem tudja...
Olyan szempontból nincs sok értelmük ezeknek, hogy az ember úgyis keretrendszert használ, és nem ír ilyesmiket a nulláról. :-) Azért érdekes, hogy még ilyen apróságot is mennyire nehéz cross-browser megírni. MSIE tényleg egy rémálom fejlesztői (meg felhasználói) szempontból.
Azt nézem, hogy van egy üres
Amúgy a htc-re azt írták, hogy nagyon eszi a processzort, dehát sajna nincs jobb megoldás :S Majd ha több időm lesz (az még 1-2 hónap), akkor megtákolom úgy a rendszert, hogy javascript szúrja be a style tag-et, ha szükséges. Egyelőre nem tudom, hogy ilyen menet közbeni DOM manipulációnál alkalmazza e az összes meglévő node-ra a htc-t vagy sem. Az igazán jó az lenne ha mindezt XML-re is meg tudnám csinálni, de sajnos lehetetlen :S
Nem tudom, mennyire
Valamennyire hasonló dologról
Szebben, könnyebben, gyorsabban
Ez a sourceIndex érdekes,
Tákoltam is rajta, és megy. Úgy néz ki, mintha ff alatt nem lenne sourceIndex (__hashcode__-al állítja be a dolgokat). Msie alatt érdekesen viselkedik, az új elemeknek -1-es értéket ad, szóval lehet, hogy azokat a létrehozáskor be kell szúrni valahova... Ez sem tökéletes megoldás, de talán jobb, mint a htc, még tesztelni kéne...
A sourceIndex és compareDocumentPosition()
Bár ott a w3 oldalán nincs a doksiban, hogy mit ad vissza, itt egy táblázat rá:
---------------------------------
000000 | 0 | Elements are identical.
000001 | 1 | The nodes are in different documents (or one is outside of a document).
000010 | 2 | Node B precedes Node A.
000100 | 4 | Node A precedes Node B.
001000 | 8 | Node B contains Node A.
010000 | 16 | Node A contains Node B.
100000 | 32 | For private use by the browser.
Egy node egzakt azonosítására az id szolgálna.
További érdekes olvasmány lehet:
Comparing Document Position
contains() for Mozilla
Sizzle.sortOrder()
Oks, köszi.
garbage collector
A biztos gyorsabb, mert miért ne lenne gyorsabb válaszok nem nagyon győznek meg. :)
A nagy probléma a fenti scripttel, hogy létrehozunk egy tömböt, amiben minden változó bekerül, ezeket a változókat az egyik segédfüggvény segítségével bármikor el is érhetjük. Ha, ezt tényleg minden objektumnál akarjuk használni, akkor 15 perc használat után fejre áll a Firefox is. (legalábbis nagyon durván belassul) Ezzel a tömbbel kikapcsoljuk a garbage collector-t.
Szerintem ezzel sokkal többet vesztünk a réven, mint nyerünk a vámon. :)
Jó, hát egy nagy központi