Μερικά κόλπα για να επιταχύνετε την εφαρμογή JavaScript
Bartosz Slysz
Software Engineer
Με την εξέλιξη της τεχνολογίας των φυλλομετρητών, οι εφαρμογές ιστού έχουν αρχίσει να μεταφέρουν όλο και περισσότερη λογική στο front-end, ανακουφίζοντας έτσι τον διακομιστή και μειώνοντας τον αριθμό των λειτουργιών που πρέπει να εκτελέσει. Στις βασικές CRUDs, ο ρόλος του διακομιστή εξαντλείται στην εξουσιοδότηση, την επικύρωση, την επικοινωνία με τις βάσεις δεδομένων και την απαιτούμενη επιχειρησιακή λογική. Η υπόλοιπη λογική των δεδομένων, όπως αποδεικνύεται, μπορεί εύκολα να αντιμετωπιστεί από τον κώδικα που είναι υπεύθυνος για την αναπαράσταση της εφαρμογής στην πλευρά του UI.
Σε αυτό το άρθρο, θα προσπαθήσω να σας δείξω μερικά παραδείγματα και μοτίβα που θα βοηθήσουν να κρατήσουμε τις κωδικός αποτελεσματική, τακτοποιημένη και γρήγορη.
Πριν εμβαθύνουμε σε συγκεκριμένα παραδείγματα - σε αυτό το άρθρο, θα ήθελα να επικεντρωθώ μόνο στην παρουσίαση περιπτώσεων που, κατά τη γνώμη μου, μπορούν να επηρεάσουν την ταχύτητα της εφαρμογής με εκπληκτικό τρόπο. Ωστόσο, αυτό δεν σημαίνει ότι η χρήση ταχύτερων λύσεων είναι η καλύτερη επιλογή σε κάθε πιθανή περίπτωση. Οι παρακάτω συμβουλές θα πρέπει μάλλον να αντιμετωπίζονται ως κάτι που πρέπει να εξετάσετε όταν η εφαρμογή μας εκτελείται αργά, π.χ. σε προϊόντα που απαιτούν απόδοση παιχνιδιών ή πιο προηγμένες γραφικές παραστάσεις στον καμβά, λειτουργίες βίντεο ή δραστηριότητες που θέλετε να συγχρονίσετε σε πραγματικό χρόνο το συντομότερο δυνατό.
Πρώτα απ' όλα - μέθοδοι Array.prototype
Βασίζουμε ένα μεγάλο μέρος της λογικής της εφαρμογής σε πίνακες - την αντιστοίχισή τους, την ταξινόμηση, το φιλτράρισμα, το άθροισμα των στοιχείων και ούτω καθεξής. Με έναν εύκολο, διαφανή και φυσικό τρόπο, χρησιμοποιούμε τις ενσωματωμένες μεθόδους τους που μας επιτρέπουν απλώς να εκτελούμε διάφορους τύπους υπολογισμών, ομαδοποιήσεις κ.λπ. Λειτουργούν παρόμοια σε κάθε περίπτωση - ως όρισμα, περνάμε μια συνάρτηση όπου, στις περισσότερες περιπτώσεις, η τιμή του στοιχείου, ο δείκτης και ο πίνακας ωθούνται εναλλάξ κατά τη διάρκεια κάθε επανάληψης. Η καθορισμένη συνάρτηση εκτελείται για κάθε στοιχείο του πίνακα και το αποτέλεσμα ερμηνεύεται διαφορετικά ανάλογα με τη μέθοδο. Δεν θα επεκταθώ στις μεθόδους Array.prototype καθώς θέλω να επικεντρωθώ στο γιατί εκτελείται αργά σε μεγάλο αριθμό περιπτώσεων.
Οι μέθοδοι Array είναι αργές επειδή εκτελούν μια συνάρτηση για κάθε στοιχείο. Μια συνάρτηση που καλείται από την οπτική γωνία της μηχανής πρέπει να προετοιμάσει μια νέα κλήση, να παρέχει την κατάλληλη εμβέλεια και πολλές άλλες εξαρτήσεις, γεγονός που καθιστά τη διαδικασία πολύ πιο χρονοβόρα από την επανάληψη ενός συγκεκριμένου μπλοκ κώδικα σε μια συγκεκριμένη εμβέλεια. Και αυτή είναι πιθανώς αρκετή γνώση υποβάθρου για να μπορέσουμε να κατανοήσουμε το ακόλουθο παράδειγμα:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() }))),
console.time('Sum by reduce'),
const reduceSum = randomArray
.map(({ value }) => value)
.reduce((a, b) => a + b),
console.timeEnd('Sum by reduce'),
console.time('Sum by for loop'),
let forSum = randomArray[0].value,
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value,
}
console.timeEnd('Sum by for loop'),
console.log(reduceSum === forSum),
})();
Ξέρω ότι αυτή η δοκιμή δεν είναι τόσο αξιόπιστη όσο τα benchmarks (θα επανέλθουμε σε αυτά αργότερα), αλλά ενεργοποιεί ένα προειδοποιητικό φως. Για μια τυχαία περίπτωση στον υπολογιστή μου, αποδεικνύεται ότι ο κώδικας με τον βρόχο for μπορεί να είναι περίπου 50 φορές ταχύτερος αν συγκριθεί με την αντιστοίχιση και στη συνέχεια τη μείωση των στοιχείων που επιτυγχάνουν το ίδιο αποτέλεσμα! Πρόκειται για τη λειτουργία σε κάποιο παράξενο αντικείμενο που δημιουργήθηκε μόνο για να επιτευχθεί ένας συγκεκριμένος στόχος υπολογισμών. Ας δημιουργήσουμε λοιπόν κάτι πιο νόμιμο για να είμαστε αντικειμενικοί σχετικά με τις μεθόδους Array:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() }))),
console.time('Sum by reduce'),
const reduceSum = randomArray
.reduce((a, b) => ({ value: a.value + b.value })).value
console.timeEnd('Sum by reduce'),
console.time('Sum by for loop'),
let forSum = randomArray[0].value,
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value,
}
console.timeEnd('Sum by for loop'),
console.log(reduceSum === forSum),
})();
Ξέρω ότι αυτή η δοκιμή δεν είναι τόσο αξιόπιστη όσο τα benchmarks (θα επανέλθουμε σε αυτά αργότερα), αλλά ενεργοποιεί ένα προειδοποιητικό φως. Για μια τυχαία περίπτωση στον υπολογιστή μου, αποδεικνύεται ότι ο κώδικας με τον βρόχο for μπορεί να είναι περίπου 50 φορές ταχύτερος αν συγκριθεί με την αντιστοίχιση και στη συνέχεια τη μείωση των στοιχείων που επιτυγχάνουν το ίδιο αποτέλεσμα! Αυτό συμβαίνει επειδή η λήψη του αθροίσματος στη συγκεκριμένη περίπτωση με τη μέθοδο reduce απαιτεί την αντιστοίχιση του πίνακα για καθαρές τιμές που θέλουμε να συνοψίσουμε. Ας δημιουργήσουμε λοιπόν κάτι πιο νόμιμο για να είμαστε αντικειμενικοί σχετικά με τις μεθόδους Array:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() }))),
console.time('Sum by reduce'),
const reduceSum = randomArray
.reduce((a, b) => ({ value: a.value + b.value })).value
console.timeEnd('Sum by reduce'),
console.time('Sum by for loop'),
let forSum = randomArray[0].value,
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value,
}
console.timeEnd('Sum by for loop'),
console.log(reduceSum === forSum),
})();
Και όπως αποδεικνύεται, η ενίσχυση 50x μειώθηκε σε ενίσχυση 4x. Ζητούμε συγγνώμη αν απογοητευτήκατε! Για να παραμείνουμε αντικειμενικοί μέχρι το τέλος, ας αναλύσουμε ξανά και τους δύο κώδικες. Πρώτα απ' όλα - αθώες διαφορές που φαίνονται διπλασίασαν την πτώση της θεωρητικής μας υπολογιστικής πολυπλοκότητας: αντί να αντιστοιχίζουμε πρώτα και μετά να προσθέτουμε καθαρά στοιχεία, εξακολουθούμε να λειτουργούμε σε αντικείμενα και σε ένα συγκεκριμένο πεδίο, για να βγάλουμε τελικά για να πάρουμε το άθροισμα που μας ενδιαφέρει. Το πρόβλημα προκύπτει όταν ένας άλλος προγραμματιστής ρίχνει μια ματιά στον κώδικα - τότε, σε σύγκριση με τους κώδικες που παρουσιάστηκαν προηγουμένως, ο τελευταίος χάνει την αφαίρεσή του σε κάποιο σημείο.
Αυτό οφείλεται στο γεγονός ότι από τη δεύτερη λειτουργία που λειτουργούμε σε ένα παράξενο αντικείμενο, με το πεδίο που μας ενδιαφέρει και το δεύτερο, τυπικό αντικείμενο του επαναλαμβανόμενου πίνακα. Δεν ξέρω τι σκέφτεστε σχετικά με αυτό, αλλά από τη δική μου οπτική γωνία, στο δεύτερο παράδειγμα κώδικα, η λογική του βρόχου for είναι πολύ πιο σαφής και πιο σκόπιμη από αυτή την παράξενη εμφάνιση της μείωσης. Και ακόμα, παρόλο που δεν είναι πια το μυθικό 50, εξακολουθεί να είναι 4 φορές ταχύτερο όσον αφορά τον χρόνο υπολογισμού! Καθώς κάθε χιλιοστό του δευτερολέπτου είναι πολύτιμο, η επιλογή σε αυτή την περίπτωση είναι απλή.
Το πιο εκπληκτικό παράδειγμα
Το δεύτερο πράγμα που ήθελα να συγκρίνω αφορά τη μέθοδο Math.max ή, ακριβέστερα, το γέμισμα ενός εκατομμυρίου στοιχείων και την εξαγωγή του μεγαλύτερου και του μικρότερου. Ετοίμασα τον κώδικα, τις μεθόδους για τη μέτρηση του χρόνου, καθώς και, στη συνέχεια, πυροδοτώ τον κώδικα και λαμβάνω ένα πολύ περίεργο σφάλμα - το μέγεθος της στοίβας έχει ξεπεραστεί. Ορίστε ο κώδικας:
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5),
console.time('Math.max with ES6 spread operator'),
const maxBySpread = Math.max(...randomValues),
console.timeEnd('Math.max with ES6 spread operator'),
console.time('Math.max με βρόχο for'),
let maxByFor = randomValues[0],
for (let index = 1; index maxByFor) {
maxByFor = randomValues[index],
}
}
console.timeEnd('Math.max με βρόχο for'),
console.log(maxByFor === maxBySpread),
})();
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5),
console.time('Math.max with ES6 spread operator'),
const maxBySpread = Math.max(...randomValues),
console.timeEnd('Math.max with ES6 spread operator'),
console.time('Math.max με βρόχο for'),
let maxByFor = randomValues[0],
for (let index = 1; index maxByFor) {
maxByFor = randomValues[index],
}
}
console.timeEnd('Math.max με βρόχο for'),
console.log(maxByFor === maxBySpread),
})();
Αποδεικνύεται ότι οι εγγενείς μέθοδοι χρησιμοποιούν αναδρομή, η οποία στην v8 περιορίζεται από τις στοίβες κλήσεων και ο αριθμός της εξαρτάται από το περιβάλλον. Αυτό είναι κάτι που με εξέπληξε πολύ, αλλά κουβαλάει ένα συμπέρασμα: η εγγενής μέθοδος είναι ταχύτερη, αρκεί ο πίνακας μας να μην ξεπερνά έναν ορισμένο μαγικό αριθμό στοιχείων, ο οποίος στην περίπτωσή μου αποδείχθηκε 125375. Για αυτόν τον αριθμό στοιχείων, το αποτέλεσμα για ήταν 5x πιο γρήγορο αν συγκριθεί με τον βρόχο. Ωστόσο, πάνω από τον αναφερόμενο αριθμό στοιχείων, ο βρόχος for κερδίζει σίγουρα - σε αντίθεση με τον αντίπαλο, μας επιτρέπει να έχουμε σωστά αποτελέσματα.
Αναδρομή
Η έννοια που θέλω να αναφέρω σε αυτή την παράγραφο είναι η αναδρομή. Στο προηγούμενο παράδειγμα, την είδαμε στη μέθοδο Math.max και στην αναδίπλωση των επιχειρημάτων, όπου αποδείχθηκε ότι είναι αδύνατο να πάρουμε αποτέλεσμα για αναδρομικές κλήσεις που υπερβαίνουν έναν συγκεκριμένο αριθμό λόγω του περιορισμού του μεγέθους της στοίβας.
Θα δούμε τώρα πώς μοιάζει η αναδρομή στο πλαίσιο κώδικα γραμμένου σε JS, και όχι με ενσωματωμένες μεθόδους.Ίσως το πιο κλασικό πράγμα που μπορούμε να δείξουμε εδώ είναι, φυσικά, η εύρεση του n-οστού όρου στην ακολουθία Fibonacci. Ας το γράψουμε λοιπόν αυτό!
(() => {
const fiboIterative = (n) => {
let [a, b] = [0, 1],
for (let i = 0; i {
if(n < 2) {
return n,
}
return fiboRecursive(n - 2) + fiboRecursive(n - 1),
};
console.time('Fibonacci sequence by for loop'),
const resultIterative = fiboIterative(30),
console.timeEnd('Fibonacci sequence by for loop'),
console.time('Fibonacci sequence by recursion'),
const resultRecursive = fiboRecursive(30),
console.timeEnd('Fibonacci sequence by recursion'),
console.log(resultRecursive === resultIterative),
})();
Εντάξει, στη συγκεκριμένη περίπτωση υπολογισμού του 30ου στοιχείου της ακολουθίας στον υπολογιστή μου, παίρνουμε το αποτέλεσμα σε περίπου 200 φορές μικρότερο χρόνο με τον επαναληπτικό αλγόριθμο.
Υπάρχει, ωστόσο, ένα πράγμα που μπορεί να διορθωθεί στον αναδρομικό αλγόριθμο - όπως αποδεικνύεται, λειτουργεί πολύ πιο αποτελεσματικά όταν χρησιμοποιούμε μια τακτική που ονομάζεται αναδρομή ουράς. Αυτό σημαίνει ότι περνάμε το αποτέλεσμα που λάβαμε στην προηγούμενη επανάληψη χρησιμοποιώντας ορίσματα για βαθύτερες κλήσεις. Αυτό μας επιτρέπει να μειώσουμε τον αριθμό των απαιτούμενων κλήσεων και, κατά συνέπεια, να επιταχύνουμε το αποτέλεσμα. Ας διορθώσουμε τον κώδικά μας αναλόγως!
(() => {
const fiboIterative = (n) => {
let [a, b] = [0, 1],
for (let i = 0; i {
if(n === 0) {
return first,
}
return fiboTailRecursive(n - 1, second, first + second),
};
console.time('Fibonacci sequence by for loop'),
const resultIterative = fiboIterative(30),
console.timeEnd('Fibonacci sequence by for loop'),
console.time('Fibonacci sequence by tail recursion'),
const resultRecursive = fiboTailRecursive(30),
console.timeEnd('Fibonacci sequence by tail recursion'),
console.log(resultRecursive === resultIterative),
})();
Εδώ συνέβη κάτι που δεν περίμενα - το αποτέλεσμα του αλγορίθμου της αναδρομής της ουράς ήταν σε θέση να παραδώσει το αποτέλεσμα (υπολογισμός του 30ου στοιχείου μιας ακολουθίας) σχεδόν δύο φορές πιο γρήγορα από τον επαναληπτικό αλγόριθμο σε ορισμένες περιπτώσεις. Δεν είμαι απόλυτα σίγουρος αν αυτό οφείλεται στη βελτιστοποίηση για την αναδρομή ουράς εκ μέρους του v8 ή στην έλλειψη βελτιστοποίησης του βρόχου for για αυτόν τον συγκεκριμένο αριθμό επαναλήψεων, αλλά το αποτέλεσμα είναι ξεκάθαρο - η αναδρομή ουράς κερδίζει.
Αυτό είναι περίεργο επειδή, ουσιαστικά, ο βρόχος for επιβάλλει πολύ λιγότερη αφαίρεση σε δραστηριότητες υπολογισμού χαμηλότερου επιπέδου και θα μπορούσαμε να πούμε ότι είναι πιο κοντά στη βασική λειτουργία του υπολογιστή. Ωστόσο, τα αποτελέσματα είναι αδιαμφισβήτητα - η έξυπνα σχεδιασμένη αναδρομή αποδεικνύεται ταχύτερη από την επανάληψη.
Χρησιμοποιήστε ασύγχρονες εντολές κλήσης όσο πιο συχνά μπορείτε
Θα ήθελα να αφιερώσω την τελευταία παράγραφο σε μια σύντομη υπενθύμιση σχετικά με μια μέθοδο εκτέλεσης λειτουργιών που μπορεί επίσης να επηρεάσει σημαντικά την ταχύτητα της εφαρμογής μας. Όπως θα πρέπει να γνωρίζετε, JavaScript είναι μια γλώσσα με ένα νήμα που διατηρεί όλες τις λειτουργίες με μηχανισμό event-loop. Πρόκειται για έναν κύκλο που εκτελείται ξανά και ξανά και όλα τα βήματα σε αυτόν τον κύκλο αφορούν αποκλειστικές καθορισμένες ενέργειες.
Για να γίνει αυτός ο βρόχος γρήγορος και να μπορούν όλοι οι κύκλοι να περιμένουν λιγότερο για τη σειρά τους, όλα τα στοιχεία πρέπει να είναι όσο το δυνατόν πιο γρήγορα. Αποφύγετε την εκτέλεση μεγάλων λειτουργιών στο κύριο νήμα - αν κάτι διαρκεί πολύ, προσπαθήστε να μεταφέρετε αυτούς τους υπολογισμούς στο WebWorker ή να τους χωρίσετε σε τμήματα που εκτελούνται ασύγχρονα. Αυτό μπορεί να επιβραδύνει ορισμένες λειτουργίες, αλλά ενισχύει ολόκληρο το οικοσύστημα του JS, συμπεριλαμβανομένων των λειτουργιών IO, όπως ο χειρισμός της μετακίνησης του ποντικιού ή των εκκρεμών αιτήσεων HTTP.
Περίληψη
Εν κατακλείδι, όπως αναφέρθηκε προηγουμένως, το κυνήγι των χιλιοστών του δευτερολέπτου που μπορεί να εξοικονομηθεί με την επιλογή ενός αλγορίθμου μπορεί να αποδειχθεί σε ορισμένες περιπτώσεις άσκοπο. Από την άλλη πλευρά, η παραμέληση τέτοιων πραγμάτων σε εφαρμογές που απαιτούν ομαλή λειτουργία και γρήγορα αποτελέσματα μπορεί να αποβεί μοιραία για την εφαρμογή σας. Σε ορισμένες περιπτώσεις, εκτός από την ταχύτητα του αλγορίθμου, θα πρέπει να τίθεται ένα επιπλέον ερώτημα - λειτουργεί η αφαίρεση στο σωστό επίπεδο; Θα μπορέσει ο προγραμματιστής που διαβάζει τον κώδικα να τον κατανοήσει χωρίς προβλήματα;
Ο μόνος τρόπος είναι να διασφαλιστεί η ισορροπία μεταξύ επιδόσεων, ευκολίας υλοποίησης και κατάλληλης αφαίρεσης και να υπάρχει βεβαιότητα ότι ο αλγόριθμος λειτουργεί σωστά τόσο για μικρές όσο και για μεγάλες ποσότητες δεδομένων. Ο τρόπος για να το πετύχετε αυτό είναι αρκετά απλός - να είστε έξυπνοι, να εξετάζετε τις διάφορες περιπτώσεις κατά το σχεδιασμό του αλγορίθμου και να τον οργανώνετε ώστε να συμπεριφέρεται όσο το δυνατόν πιο αποδοτικά για μέσες εκτελέσεις. Επίσης, καλό είναι να σχεδιάζετε δοκιμές - να είστε βέβαιοι ότι ο αλγόριθμος επιστρέφει τις κατάλληλες πληροφορίες για διαφορετικά δεδομένα, ανεξάρτητα από τον τρόπο λειτουργίας του. Φροντίστε για τις σωστές διεπαφές - ώστε τόσο η είσοδος όσο και η έξοδος των μεθόδων να είναι ευανάγνωστες, σαφείς και να αντικατοπτρίζουν ακριβώς τι κάνουν.
Ανέφερα προηγουμένως ότι θα επανέλθω στην αξιοπιστία της μέτρησης της ταχύτητας των αλγορίθμων στα παραπάνω παραδείγματα. Η μέτρησή τους με το console.time δεν είναι πολύ αξιόπιστη, αλλά αντικατοπτρίζει καλύτερα την τυπική περίπτωση χρήσης. Εν πάση περιπτώσει, παρουσιάζω τα benchmarks παρακάτω - μερικά από αυτά φαίνονται λίγο διαφορετικά από μια απλή εκτέλεση λόγω του γεγονότος ότι τα benchmarks απλώς επαναλαμβάνουν μια συγκεκριμένη δραστηριότητα σε συγκεκριμένο χρόνο και χρησιμοποιούν βελτιστοποίηση v8 για βρόχους.
https://jsben.ch/KhAqb - μείωση έναντι βρόχου for
https://jsben.ch/F4kLY - βελτιστοποιημένη μείωση έναντι βρόχου for
https://jsben.ch/MCr6g - Math.max vs for loop
https://jsben.ch/A0CJB - αναδρομική fibo vs επαναληπτική fibo