Belum lama ini, saya menemukan tugas yang agak sederhana dan sekaligus menarik: mengimplementasikan terminal read-only dalam aplikasi web. Ketertarikan pada tugas diberikan oleh tiga aspek penting:
- dukungan untuk urutan pelarian dasar ANSI
- mendukung setidaknya 50.000 jalur data
- menampilkan data begitu tersedia.
Pada artikel ini saya akan berbicara tentang bagaimana ini diterapkan dan bagaimana kemudian dioptimalkan semuanya.
Penafian: Saya bukan pengembang web yang berpengalaman, jadi beberapa hal mungkin terlihat jelas bagi Anda, dan kesimpulan atau keputusannya salah. Untuk koreksi dan klarifikasi, saya akan berterima kasih.
Kenapa terserah
Seluruh tugas adalah sebagai berikut: skrip berjalan di server (bash, python, dll) dan menulis sesuatu ke stdout. Dan kesimpulan ini harus ditampilkan di halaman web saat diterima. Pada saat yang sama, seharusnya akan terlihat seperti di terminal (dengan pemformatan, transfer kursor, dll.)
Saya tidak mengontrol skrip itu sendiri dan hasilnya dengan cara apa pun dan menampilkannya dalam bentuk murni.
Tentu saja, antara antarmuka web dan skrip harus ada perantara - server web. Dan jika tidak untuk menyembunyikan - Saya sudah punya aplikasi web dan server, dan entah bagaimana berfungsi. Skemanya terlihat seperti ini:

Namun sebelumnya, server bertanggung jawab atas pemrosesan dan pemformatan. Dan saya ingin memperbaikinya karena sejumlah alasan:
- pemrosesan data ganda - parsing pertama di server, kemudian transformasi menjadi komponen html pada klien
- algoritma tidak optimal karena persiapan data untuk klien
- beban berat di server - pemrosesan output dari satu skrip bisa sepenuhnya memuat utas tunggal di server
- dukungan yang tidak lengkap untuk urutan Escape ANSI
- bug halus
- klien melakukannya dengan sangat buruk dengan menampilkan bahkan 10k garis diformat
Oleh karena itu, diputuskan untuk mentransfer seluruh logika parsing ke aplikasi web, dan hanya menyiarkan data mentah yang mengalir ke server
Pernyataan masalah
Sebagian dari teks tersebut datang ke klien. Klien harus menguraikannya menjadi komponen: teks biasa, umpan baris, carriage return, dan perintah ANSI khusus. Tidak ada jaminan dalam integritas bagian - satu perintah atau kata dapat datang dalam paket yang berbeda.
Perintah ANSI dapat memengaruhi format teks (warna, latar belakang, gaya), posisi kursor (tempat teks selanjutnya akan ditampilkan), atau untuk menghapus bagian layar.
Contoh tampilannya:

Selain itu, mungkin ada URL di antara teks yang juga perlu dikenali dan disorot.
Kami mengambil perpustakaan yang sudah jadi dan ...
Saya mengerti bahwa pemrosesan semua perintah yang benar dan cepat bukanlah tugas yang mudah. Karena itu, saya memutuskan untuk mencari perpustakaan yang sudah jadi. Dan, lihatlah , aku segera menemukan xterm.js . Komponen terminal siap pakai, yang sudah digunakan di banyak tempat dan, di samping itu, "sangat cepat, bahkan termasuk renderer berakselerasi GPU" . Yang terakhir adalah yang paling penting bagi saya, karena Saya akhirnya ingin mendapatkan klien yang sangat cepat.
Terlepas dari kenyataan bahwa saya suka menulis sepeda motor sendiri, saya sangat senang bahwa saya tidak hanya dapat menghemat waktu, tetapi juga mendapatkan banyak fungsi yang berguna secara gratis.
Butuh jam 2 siang untuk mencoba menghubungkan terminal dan saya tidak bisa mengatasinya. Tentu saja
Ketinggian garis yang berbeda, pemilihan bengkok, ukuran terminal yang adaptif, API yang sangat aneh, kurangnya dokumentasi waras ...
Tetapi saya masih memiliki sedikit inspirasi dan saya percaya bahwa saya dapat mengatasi masalah ini.
Sampai saya memberi makan garis uji 10k ke terminal ... Dia meninggal. Dan mengubur sisa-sisa harapan saya.
Deskripsi algoritma akhir
Pertama-tama, saya menyalin algoritma saat ini diimplementasikan dalam python dan mengadaptasinya untuk javascript (hanya menghapus kurung kurawal dan satu lagi untuk sintaks).
Saya tahu semua pro dan kontra utama dari algoritma lama, jadi saya hanya perlu meningkatkan tempat yang tidak efektif di dalamnya.
Setelah musyawarah, coba-coba, saya menetapkan pilihan berikut: kami membagi algoritma menjadi 2 komponen:
- model untuk mem-parsing teks dan menyimpan status "terminal" saat ini
- pemetaan yang menerjemahkan model ke dalam HTML
Model (struktur dan algoritma)
- Semua baris disimpan dalam array (nomor baris = indeks dalam array)
- Gaya teks disimpan dalam array yang terpisah.
- Posisi kursor saat ini disimpan dan dapat diubah dengan perintah
- Algoritme itu sendiri memeriksa input data karakter dengan karakter:
- Jika ini hanya teks, tambahkan ke baris saat ini
- Jika garis terputus, maka tambah indeks baris saat ini
- Jika ini adalah salah satu karakter perintah, maka kita memasukkannya ke dalam buffer perintah dan menunggu karakter berikutnya
- Jika buffer perintah sudah benar, maka jalankan perintah ini, jika tidak kita tulis buffer ini sebagai teks
- Model ini memberi tahu pendengar tentang baris mana yang telah berubah setelah pemrosesan teks yang masuk
Dalam implementasi saya, kompleksitas algoritma adalah O ( n log n ), di mana log n adalah persiapan baris yang diubah untuk pemberitahuan (keunikan dan penyortiran). Pada saat penulisan ini, saya menyadari bahwa untuk kasus khusus, Anda dapat menghilangkan log n , karena garis paling sering ditambahkan ke akhir.
Tampilan
- Menampilkan teks sebagai elemen HTML
- Jika string telah berubah, ganti sepenuhnya semua elemen string
- Memecah setiap baris berdasarkan gaya: setiap segmen yang bergaya memiliki elemennya sendiri
Dengan struktur seperti itu, pengujian adalah tugas yang cukup sederhana - kami mentransfer teks ke model (dalam satu paket atau bagian) dan hanya memeriksa keadaan saat ini dari semua baris dan gaya di dalamnya. Dan untuk menampilkan hanya beberapa tes, karena selalu menggambar ulang garis yang diubah.
Keuntungan penting juga kemalasan tampilan tertentu. Jika dalam satu bagian teks kita menimpa baris yang sama (misalnya, progress bar), maka setelah model bekerja, untuk tampilan akan terlihat seperti satu baris yang diubah.
DOM vs Kanvas
Saya ingin sedikit membahas mengapa saya memilih DOM, meskipun tujuannya adalah kinerja. Jawabannya sederhana - kemalasan. Bagi saya, merender semua yang ada di Canvas sendiri sepertinya tugas yang cukup menakutkan. Sambil mempertahankan kegunaan: menyoroti, menyalin, mengubah ukuran layar, tampak rapi, dll. Contoh xterm.js dengan jelas menunjukkan kepada saya bahwa ini tidak mudah sama sekali. Render mereka di kanvas jauh dari ideal.
Selain itu, men-debug pohon DOM di browser dan kemampuan untuk menutupi tes unit merupakan keuntungan penting.
Pada akhirnya, tujuan saya adalah 50k baris, dan saya tahu bahwa DOM harus berurusan dengan ini, berdasarkan karya algoritma lama.
Optimalisasi
Algoritma sudah siap, debugged, dan perlahan tapi pasti berhasil. Sudah waktunya untuk membuka profiler dan mengoptimalkan. Ke depan, saya akan mengatakan bahwa sebagian besar optimasi adalah kejutan bagi saya (seperti biasanya terjadi).
Pembuatan profil dilakukan pada garis 10k, yang masing-masing berisi elemen bergaya. Jumlah total elemen DOM adalah sekitar 100rb.
Tidak ada pendekatan dan alat khusus yang digunakan. Hanya Alat Dev Chrome dan beberapa peluncuran untuk setiap pengukuran. Dalam praktiknya, hanya nilai pengukuran absolut (berapa detik untuk menyelesaikan) berbeda dalam peluncuran, tetapi tidak rasio persentase antara metode. Karena itu, saya menganggap teknik ini cukup kondisional.
Di bawah ini saya ingin membahas lebih rinci tentang peningkatan yang paling menarik. Dan sebagai permulaan, grafik dari apa yang sebelumnya:

Semua gambar profil dibuat setelah implementasi, dengan deoptimisasi kode dari memori.
string.trim
Pertama-tama, saya menemukan string.trim yang tidak dapat dimengerti yang menghabiskan jumlah CPU yang sangat nyata (menurut saya ini sekitar 10-20%)

trim () adalah fungsi dasar bahasa. Mengapa menggunakan semacam perpustakaan? Dan bahkan jika itu semacam polyfill, lalu mengapa itu menghidupkan versi terbaru dari chrome?
Sedikit googling dan jawabannya ditemukan: https://babeljs.io/docs/en/babel-preset-env . Secara default, ini memungkinkan polyfill untuk sejumlah besar browser, dan melakukan ini pada tahap kompilasi. Solusi bagi saya adalah menentukan 'targets': '> 0.25%, not dead'
Tetapi pada akhirnya, saya menghapus panggilan trim sepenuhnya, karena tidak perlu.
Vue.js
Tahun lalu, saya mentransfer komponen terminal ke Vue.js. Sekarang saya harus mentransfernya kembali ke vanilla, alasannya ada di tangkapan layar di bawah ini (lihat jumlah baris yang melibatkan Vue.js):

Saya hanya menyisakan pembungkus, gaya, dan pemrosesan mouse di komponen Vue. Semua yang berhubungan dengan pembuatan elemen DOM telah menjadi JS murni, yang terhubung ke komponen Vue sebagai bidang normal (yang tidak dipantau oleh kerangka kerja).
created() { this.terminalModel = new TerminalModel(); this.terminal = new Terminal(this.terminalModel); },
Saya tidak menganggap ini minus atau cacat pada Vue.js. Hanya saja kerangka kerja dan kinerjanya sendiri tidak tercampur dengan baik. Nah, ketika Anda menjatuhkan puluhan dan ratusan ribu objek ke dalam kerangka kerja reaktif, sangat sulit untuk mengharapkan pemrosesan darinya dalam beberapa milidetik. Dan sejujurnya, saya bahkan terkejut bahwa Vue.js melakukannya dengan cukup baik.
Menambahkan Item Baru
Semuanya sederhana di sini - jika Anda memiliki beberapa ribu elemen baru dan Anda ingin menambahkannya ke komponen induk, melakukan appendChild bukanlah ide yang baik. Browser harus melakukan pemrosesan sedikit lebih sering dan menghabiskan lebih banyak waktu untuk rendering. Salah satu efek samping dalam kasus saya adalah perlambatan autoscroll, seperti itu memaksa penghitungan ulang semua komponen yang ditambahkan.

Untuk mengatasi masalah, ada DocumentFragment. Pertama, kita menambahkan semua elemen ke dalamnya, dan kemudian kita menambahkannya ke komponen induk. Browser akan menangani inline dari komponen yang masuk.
Pendekatan ini mengurangi jumlah waktu yang dihabiskan browser untuk merender dan mengatur elemen.
Saya juga mencoba cara lain untuk mempercepat penambahan item. Tak satu pun dari mereka yang dapat menambahkan apa pun di atas DocumentFragment.
span vs div
Bahkan, ini bisa disebut display:inline
(span) vs display:block
(div).
Awalnya, saya memiliki setiap baris dalam rentang dan berakhir dengan karakter baris istirahat. Namun, dalam hal kinerja, ini tidak terlalu efektif: browser harus mencari tahu di mana elemen dimulai dan berakhir. Dengan tampilan: blok, perhitungan seperti itu jauh lebih sederhana.
Mengganti dengan div mempercepat rendering hampir 2 kali.
Sayangnya, dalam hal display:block
menyoroti beberapa baris teks terlihat lebih buruk:

Untuk waktu yang lama saya tidak bisa memutuskan mana yang lebih baik - render 2 detik tambahan atau seleksi manusia. Akibatnya, kepraktisan mengalahkan kecantikan.
Level 10 CSS Wizard
Lain ~ 10% dari waktu rendering terputus oleh CSS "optimasi", yang saya gunakan untuk memformat teks.
Pengalaman dalam pengembangan web dan pemahaman tentang dasar-dasar bermain melawan saya. Saya berpikir bahwa semakin akurat penyeleksi, semakin baik, tetapi khusus dalam kasus saya, ini tidak begitu.
Untuk memformat teks di terminal, saya menggunakan pemilih berikut:
#script-panel-container .log-content > div > span.text_color_green,
Namun (dalam chrome), opsi berikut ini sedikit lebih cepat:
span.text_color_green
Saya tidak terlalu suka pemilih ini, karena terlalu global, tetapi kinerjanya lebih mahal.
string.split
Jika Anda memiliki deja vu karena salah satu poin sebelumnya, maka itu salah. Kali ini bukan tentang polyfill, tetapi tentang implementasi standar di chrome:

(Saya membungkus string.split di defSplit sehingga fungsinya muncul di profiler)
1% adalah hal sepele. Tetapi pengendara sepeda idealis dalam diriku dihantui. Dalam kasus saya, split selalu dilakukan satu karakter pada satu waktu dan tanpa pelanggan tetap. Oleh karena itu, saya menerapkan opsi sederhana. Inilah hasilnya:

Buka cepat function fastSplit(str, separatorChar) { if (str === '') { return ['']; } let result = []; let lastIndex = 0; for (let i = 0; i < str.length; i++) { const char = str[i]; if (char === separatorChar) { const chunk = str.substr(lastIndex, i - lastIndex); lastIndex = i + 1; result.push(chunk); } } if (lastIndex < str.length) { const lastChunk = str.substr(lastIndex, str.length - lastIndex); result.push(lastChunk); } return result; }
Saya percaya bahwa setelah ini, mereka diwajibkan untuk membawa saya ke tim Google Chrome tanpa wawancara.
Optimasi, kata penutup
Optimasi adalah proses tanpa akhir dan sesuatu dapat ditingkatkan tanpa batas. Terutama mengingat bahwa kasus penggunaan yang berbeda memerlukan optimisasi yang berbeda (dan saling bertentangan).
Untuk kasus saya, saya memilih use case utama dan mengoptimalkan waktu operasinya dari 15 detik hingga 5 detik. Tentang ini saya memutuskan untuk berhenti.

Masih ada beberapa tempat yang saya rencanakan untuk ditingkatkan, tetapi ini berkat pengalaman yang didapat.
Bonus Pengujian mutasi.
Kebetulan selama beberapa bulan terakhir saya sering menemukan istilah "pengujian mutasional." Dan saya memutuskan bahwa tugas ini adalah cara yang bagus untuk mencoba binatang ini. Apalagi setelah saya tidak mendapatkan cakupan kode di Webstorm, untuk tes pada karma.
Karena teknik dan perpustakaan itu baru bagi saya, saya memutuskan untuk bertahan dengan sedikit darah: untuk menguji hanya satu komponen - model. Dalam hal ini, Anda dapat dengan jelas menunjukkan file mana yang kami uji, dan suite pengujian mana yang ditujukan untuknya.
Tetapi apa pun yang dikatakan orang, saya harus mengotak-atik banyak untuk mencapai integrasi dengan karma dan webpack.
Pada akhirnya, semuanya dimulai dan setelah setengah jam saya melihat hasil yang menyedihkan: sekitar setengah dari mutan bertahan. Saya membunuh sebagian dengan segera, sebagian lagi untuk masa depan (ketika saya mengimplementasikan perintah ANSI yang hilang).
Setelah itu, kemalasan menang, dan saat ini hasilnya adalah sebagai berikut (untuk 128 tes):
Ran 79.04 tests per mutant on average. ------------------|---------|----------|-----------|------------|---------| File | % score | # killed | # timeout | # survived | # error | ------------------|---------|----------|-----------|------------|---------| terminal_model.js | 73.10 | 312 | 25 | 124 | 1 | ------------------|---------|----------|-----------|------------|---------| 23:01:08 (18212) INFO Stryker Done in 26 minutes 32 seconds.
Secara umum, pendekatan ini tampak sangat berguna bagi saya (jelas lebih baik daripada cakupan kode) dan lucu. Satu-satunya negatif adalah waktu yang sangat lama - 30 menit per kelas terlalu banyak.
Dan yang paling penting, pendekatan ini membuat saya berpikir lagi tentang cakupan 100% dan apakah itu layak mencakup semuanya dengan tes: sekarang pendapat saya bahkan lebih dekat dengan "ya" ketika menjawab pertanyaan ini.
Kesimpulan
Optimalisasi kinerja, menurut saya, adalah cara yang baik untuk mempelajari sesuatu yang lebih dalam. Ini juga merupakan latihan yang baik untuk otak. Dan sangat disayangkan bahwa ini jarang benar-benar diperlukan (setidaknya dalam proyek saya).
Dan seperti biasa, pendekatan "profil pertama, kemudian optimasi" bekerja jauh lebih baik daripada intuisi.
Referensi
Implementasi lama:
Implementasi baru:
Sayangnya, tidak ada demo komponen web, jadi Anda tidak akan dapat menusuknya. Jadi saya minta maaf sebelumnya
Terima kasih atas waktu Anda, saya akan dengan senang hati memberikan komentar, saran, dan kritik yang masuk akal!