11 Milyon Karakterlik HTML’i Mobil WebView’da Açmak: Virtual Chunking

Görsel Gemini tarafından oluşturuldu.

“227 belgeyi tarayıcıya yönlendireceğiz,” demiştik. Sonra Türk Ticaret Kanunu’nu kurtarmaya karar verdik.


Altı yıllık Xiaomi Redmi Note 9S’im var. Benim için çok şey yaptı bu telefon. Çok güzel fotoğraflar çekti, anılar biriktirdi. Düşürdüm, ıslattım; pili şişti, değişti; Android 12'de bıraktı beni. Büyük oyunlar oynamıyordum açıkçası ve normal kullanımda kolay kolay hiç donmamıştı, kasmamıştı. Ta ki mobil uygulamamızda 6102 sayılı Türk Ticaret Kanunu’nu Vue.js altyapılı Capacitor uygulamamızda açmayı deneyene kadar.

Ekran karardı. Dokundum, tepki yok. Tekrar dokundum, yine yok. Telefon elimin altında ısınmaya başladı. WebView main thread’i boğmuştu; içinde bir WebView çalışan native sarmalayıcımız, 11 milyon karakterlik HTML bloğunu sindirmeye çalışırken hayatını kaybediyordu.

Müşteri, “Bu ekran neden yavaş açılıyor?” diye sormuştu. Gerçeği söylemedim. Bunun yerine, “İnceleyelim,” dedim.

Önce Ölçtük, Sonra Anladık

Sezgiye güvenmek yerine, sorunlu belgeleri hemen farklı cihazlarda açtık. Ekibe kasan belgeleri söyledik, onlar da cihazlarında denediler.

Yazar tarafından Datawrapper da yapılmış tablo.

Yazar tarafından Datawrapper da yapılmış tablo.

Normal yazdıklarım da ya belge değişiklik geçmişi görüntülenirken, ya da değişiklikler gezilirken mutlaka telefonu kilitliyordu. Tablo netleşince karar da netleşti: 1,5 milyon karakterin üstündeki belgeler riskliydi. Bu eşiği geçen 227 belge var sistemde. Plan basitti: Bu belgeleri uygulamada açmak yerine kullanıcıyı cihazın tarayıcısına yönlendir. Basit, düşük riskli, yarım saatlik iş.

Ama satış ekibinden geri bildirim geldi: Müşteri ziyaretinde bu durum şikâyet konusu olmuş. “Mobil uygulama dışında belgelerin açılması kabul edilemez,” dediler.

Ben de açıkça söyledim: “Cihazlarımızın RAM’i yetmiyor. 1,5 milyon karakterli belgede bile değişiklik geçmişi doluysa uygulama donuyor. Tarayıcıya göndermekten başka çözüm göremiyorum. Native uygulama yazsak bile bu kadar büyük ve kalabalık bir HTML’in görüntülenmesini garanti edemeyiz.”


“Belgeyi Parçalara Böl”

Bir süre sonra yöneticim, “Belgeyi parçalara bölsek, sadece ekranda görünen kısmı render etsek yine olmaz mı acaba?” dedi.

“Ya o gün ‘hayır, hiçbir yol yok’ dememe rağmen aklıma takılmıştı, ben de tam bunu araştırıyordum,” dedim.

Gerçekten de daha “göremiyorum” kelimesi ağzımdan çıkarken içimden, “Nasıl ya, bir yolu olmalı,” demiştim bile. Reddit’te binlerce yorumlu, kendisi de yorumları da çok uzun konular var:  O sayfalar nasıl açılıyor? Donanımhaber’de yüzlerce sayfalık forum konuları var; içlerinde tablolar, alıntılar, resimler, kalın ve italik yazılar... Bir noktada onlar da bu sorunla karşılaşmış ve bir çözüm bulmuş olmalıydı.

Utanmadan “hayır” derken bir ara Donanımhaber’de de çalıştığım geldi aklıma. İlk iş yerimdi. 2016–2017 yılları sanıyorum. O dönemde Android geliştiriciydim. Web servislerden bize JSON tipinde cevap gelirdi. Veriyi DB’de nasıl tuttuklarını hiç merak etmemiştim. Yağız abiye sordum. Meğer haberler tarafında içerik HTML tutuluyormuş — ama ham göndermiyorlar, kendi yazdığı ayrıştırıcıdan (parser) geçirip mobil uygulamalara yine JSON olarak gönderiyormuş. Biz ise API’den ham HTML alıyorduk ve bunu değiştirme lüksümüz yoktu. Yağız abinin yaptığını yapabilseydik bu yazıyı yazmazdık.

Reddit ise HTML’den JSON’a geçişi çoktan yapmıştı. Hatta 2010’ların başında yapmışlardı.

Cevap hep aynı felsefeye dayanıyor: Ekranda görünen içeriği render et, görünmeyeni DOM dışında tut. HTML veri tutan -veya bir dönem tutmuş- bütün büyük platformların bulduğu çözüm bu. Biz de aynı yere vardık.

Aslında bu işlem, modern frontend dünyasında “Virtual Scrolling” veya “Windowing” olarak bilinen tekniğin, dokümantasyon bazlı ve hiyerarşik bir HTML yapısına uyarlanmış hâliydi. Bu noktada en büyük düşmanımız TBT’ydi (Total Blocking Time). 11 milyon karakteri tek seferde DOM’a basmaya çalışmak, tarayıcının main thread’ini dakikalarca kilitlemek demekti. Biz bu süreci parçalara bölerek ana thread’i özgür bıraktık.


Sorun: Tarayıcı Her Şeyi Bellekte Tutar

Teknik olmayan versiyondan başlayalım.

Bir web sayfası açıldığında tarayıcı, sayfanın tamamını bellekte tutar. Kaydırıp geçtiğiniz bölüm de daha görmediğiniz bölüm de RAM’de oturur, bekler. Sayfa küçükse sorun yok. Ama bu belgeler küçük değil: yüzlerce <dfn> elementi, yüzlerce tablo satırı, iç içe <div> 'ler, onlarca yıllık değişiklik geçmişi…

Yahu Türkiye Cumhuriyeti’nden bahsediyoruz. Yasanın, yönetmeliğin değişmesinin haddi hesabı yok. Her dokunuşta, her kaydırmada tarayıcı bu devasa ağacı yeniden hesaplamak zorunda.

Buna “layout thrashing” deniyor. Mobil cihazda fiziksel olarak hissettiren bir donukluğa yol açıyor. Gariban telefonum Xiaomi Redmi Note 9S bunun tanığıdır.


Adım 1: HTML’i Akıllıca Parçalamak

İlk soru: Belgeyi nereden kesersiniz?

  • Rastgele keserseniz veya 10.000 karakterde bir bölerseniz açık <table> bırakırsınız, kopuk <div> hiyerarşisi oluşur. Tarayıcı patlar. 

  • Neden basit bir split veya regex ile bölmedik? Çünkü HTML'in o meşhur well-formed olmayan dünyasında, iç içe geçmiş (nested) <div> veya <table> yapılarını regex ile güvenli bir şekilde kesmek çok daha zor. 11 milyon karakterli bir belgenin her yeri için geçerli, hatasız bir regex yazmak gerçekten çok ama çok daha zor. İmkânsız demiyorum bak, zor yoldan da olsa öğrendik imkânsız dememeyi.

HTML etiketleri (tag) arasındaki hiyerarşiyi bozmamak adına tarayıcının kendi parse mekanizmasını (DOMParser) kullandık. Dokümanı önce standardize ettik, sonra mantıklı düğüm noktalarından kestik.

Bunun için DOMParser devreye giriyor. Ham HTML'i önce tarayıcının kendi motoruna parse ettiriyoruz: Etiket hataları düzeliyor, ağaç standardize ediliyor. Sonra üst seviye node'ları geziyoruz. Her p, dıv, table, h1-h6 bir sayaç artırıyor. 20'ye ulaşınca blok kesiliyor. Bundan sonra blok chunk yerine “blok” diyeceğiz:

code
async splitRawAsync(htmlString, chunkSize) {
  const parser = new DOMParser();
  const doc = parser.parseFromString(htmlString, 'text/html');
  const elements = Array.from(contentRoot.childNodes);let currentChunk = '';
  let paragraphCount = 0;
  let lastYieldTime = performance.now();
  for (const el of elements) {
      if (el.nodeType === 8) continue; // comment node'ları atla
      currentChunk += el.outerHTML || el.textContent;
      if (['P', 'DIV', 'TABLE', 'UL', 'PRE', 'H1', 'H2', 'H3'].includes(el.tagName))
        paragraphCount++;
      if (paragraphCount >= chunkSize) {
        chunks.push(currentChunk);
        currentChunk = ''; paragraphCount = 0;
      }
      // Her 50ms'de event loop'a nefes ver
      if (performance.now() - lastYieldTime > 50) {
        await new Promise(resolve => setTimeout(resolve, 0));
        lastYieldTime = performance.now();
      }
    }
  }
...
}

JavaScript tek bir thread’de çalışır. Thread’i şöyle düşün: tek şerit bir yol. Hem uygulama kodu hem de tarayıcının “ekranı çiz” komutu bu yolda sırayla ilerler, yan yana gidemez.

Parse işlemi başladığında bu yolu tamamen kapatır. “Lütfen bekleyiniz…” spinner’ını çizmek isteyen tarayıcı arkada bekliyor ama geçemiyordur. Kullanıcı ekrana dokunuyor, tepki yok. Ekran donmuş görünüyor.

setTimeout(resolve, 0) şunu diyor: "Ben şu an yolu bırakıyorum, sıranın sonuna geçiyorum." O kısa anda tarayıcı yola girebiliyor : Spinner çiziliyor, dokunuşlara tepki veriliyor. Sonra parse işlemi kaldığı yerden devam ediyor.

performance.now() ile her 50 ms'de bir bu "yolu bırakma" işlemini yapıyoruz. Çok sık yapsan parse yavaşlar, hiç yapmazsan ekran donar. 50 ms, bu ikisi arasındaki denge noktası.

Burada kritik bir CS tercihi yaptık: 11 milyon karakterlik veriyi DOM Node olarak değil, String olarak rawChunks dizisinde tuttuk. String olarak saklanan veri RAM'de çok daha az yer kaplarken sadece ihtiyaç anında DOM'a render edilmesi, dokümanın total memory footprint'ini ciddi oranda düşürdü.

Blokları oluştururken aynı anda üç şeyi de işaretliyoruz: Renkli arka planlı element var mı (coloredChunks), <mark> var mı (markChunks), <pre> var mı (preChunks)? Bu listeler sonraki adımlarda navigasyonun omurgası oluyor.


Adım 2: Aşağı Kaydırdıkça Yükle

Bloklar hazır. Şimdi sormamız gereken soru şu: Uygulama kaydırmanın neresinde olduğunu nasıl bilecek, ne zaman yeni blok yükleyecek?

Cevap: sentinel div. İçeriğin en altına görünmez bir div koyuyoruz. IntersectionObserver bu div'i izliyor ; ama tam ekrana girince değil, 1000 px öncesinden tetikleniyor. Kullanıcı sona yaklaşmadan yükleme başlıyor, hatta bitiyor; kullanıcı hızlı kaydırsa da farkında bile olmuyor. Kaydırma çubuğunun uzunluğu gerçekçi kalsın diye spacer div’lerin yükseklikleri buna göre ayarlanıyor.

code
setupObservers() {
  this.bottomObserver = new IntersectionObserver((entries) => {
    if (entries[0].isIntersecting && this.visibleEnd < this.rawChunks.length) {
      this.renderBottom(4); // 4 chunk daha DOM'a ekle
    }
  }, { rootMargin: '1000px 0px 1000px 0px' })
  this.bottomObserver.observe(this.$refs.bottomSentinel)
}

Sonraki yazılımcı için bir not: Top trimming’i -yukarıya kaydırılınca görünmez blokları DOM’dan silmeyi- denedik. Mobilde momentum kaydırma bozuldu, ekran titreşti. Kaldırdık. Kod yorumuna not düştük: “Top trimming was intentionally removed for smooth mobile scrolling. Eh, tabii ki en dibe inip tekrar yukarı kaydırma durumunu da test ettik, sorun olmuyordu.


Adım 3: Blok Sınırları Aşıldığında “Madde 47’ye Git”

Eee, normal aşağı kaydırma akışını yaptık, çalışıyor. Aferin bize. Peki, bizim arayüzü gözünüzde canlandırın: Belgenin solunda bir panel var. Bu panelde belgenin madde listesi var, değişen bölüme gitme butonları var, tüm değişikliklere gitme butonu var... Var oğlu var. Maddeye tıklanınca oraya pürüzsüzce kaydırılmalı. Kolay görünüyor. Değil.

Ekran görüntüsü yazar tarafından alınmıştır.

Ekran görüntüsü yazar tarafından alınmıştır.

Sorun: Tıklanan madde veya gidilmek istenen değişiklik henüz DOM’da olmayabilir. Sayfa açılışında yalnızca ilk 2 blok yüklü. Madde 47, 12. blokta olabilir. Önce rawChunks içinde ID veya metin arıyoruz, hangi blokta olduğunu bulup yükletiyoruz ve pürüzsüz kaydırma (smooth scrolling) yapıyoruz.

code
handleMaddeStart(targetElement) {
  // Önce DOM'da ara
  const el = document.getElementById(targetElement.id);
  if (el && document.contains(el)) {
    window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - 95 });
    return;
  }
// Hangi chunk'ta bu ID var?
  const chunkIndex = this.rawChunks.findIndex(chunk =>
    chunk.includes(`id="${targetElement.id}"`) ||
    chunk.includes(targetElement.textContent.trim())
  );
  if (chunkIndex !== -1) {
    this.isNavigating = true;
    this.ensureChunkRenderedAndScroll(chunkIndex, () => {
      const found = document.getElementById(targetElement.id);
      if (found)
        window.scrollTo({ top: found.getBoundingClientRect().top + window.scrollY - 95 });
    });
  }
}

Zanaatkârlık bazen kodda değil, cihazın içgüdülerini anlamaktadır. iOS WebView’ın (Webkit), drawer animasyonu bitmeden DOM yerleşimini (layout) tam stabilize etmediğini fark ettik. Bu yüzden Android’de 50 ms yeten bekleme süresini iOS tarafında 350 ms’ye çekerek kullanıcıya o pürüzsüz “smooth” geçişi sağladık.


Adım 4: 11 Milyon Karakterin Arasındaki Değişen Bölümler

Sistemimizdeki hukuki metinlerde değişiklikler renk vurgusuyla işaretleniyor: sarı, kırmızı, cyan. “Değişen bölüme git” denince bu elementler arasında sırayla gezilmeli.

Tüm elementler aynı anda DOM’da yok. Çözüm: sayfa yüklendikten sonra arka planda sessiz sedasız çalışan buildColoredItemsBackground tarayıcısı.

code
async buildColoredItemsBackground() {
  for (let chunkIdx = 0; chunkIdx < this.rawChunks.length; chunkIdx++) {
    const renderedEl = this.$refs.chunksEl?.querySelector(`[data-chunk-idx="${chunkIdx}"]`);
    if (renderedEl) {
          // DOM'da: elementleri tag'le, rawChunks'ı güncelle
          Array.from(renderedEl.getElementsByTagName("*")).forEach(el => {
            if (checkBg(el)) {
              el.setAttribute('data-colored-idx', String(coloredCounter));
              coloredItems.push({ chunkIdx, coloredIdx: coloredCounter++ });
            }
          });
          this.rawChunks[chunkIdx] = renderedEl.innerHTML;
        } else {
          // Henüz render edilmemiş: DOMParser ile parse edip tag'le
          const doc = parser.parseFromString(this.rawChunks[chunkIdx], 'text/html');
          Array.from(doc.body.getElementsByTagName("*")).forEach(el => {
            if (checkBg(el)) {
              el.setAttribute('data-colored-idx', String(coloredCounter));
              coloredItems.push({ chunkIdx, coloredIdx: coloredCounter++ });
            }
          });
          this.rawChunks[chunkIdx] = doc.body.innerHTML;
        }
        if (chunkIdx % 5 === 4)
          await new Promise(resolve => setTimeout(resolve, 0));
      }
    }

Sonuçta elimizde [{ chunkIdx: 3, coloredIdx: 0 }, { chunkIdx: 12, coloredIdx: 1 }] gibi bir harita var. "Sonraki değişiklik" tıklanınca haritada bir adım ilerliyoruz. Hedef blok render edilmemişse yüklüyoruz, data-colored-idx ile elementi buluyoruz, kaydırıyoruz. Tüm değişiklikler bitti mi? "Sayfa başına dönmek ister misiniz?" sorusu geliyor.


Adım 5: Arama Sonuçları Arasında Gezme

Sol panelde arama çubuğu var. Terim <mark> ile highlight ediliyor, ileri/geri butonlarıyla match'ler arasında gezilebiliyor. Sıradaki <mark> henüz render edilmemiş bir bloktaysa ne olacak?

code
handleSearchStart(upOrDown) {
  const marks = Array.from(this.$refs.chunksEl.getElementsByTagName("mark"));
  const currentOffset = window.scrollY + 185;
  if (upOrDown) {
    const targetMark = marks.find(m =>
      m.getBoundingClientRect().top + window.scrollY > currentOffset + 10
    );
    if (targetMark) {
      window.scrollTo({ top: targetMark.getBoundingClientRect().top + window.scrollY - 185 });
      this.isNavigating = false;
    } else {
      // DOM'da yok: markChunks listesinde sonraki chunk'u bul ve yükle
      const nextChunkIdx = this.virtualNavData.markChunks.find(idx => idx >= this.visibleEnd);
      if (nextChunkIdx !== undefined) {
        this.ensureChunkRenderedAndScroll(nextChunkIdx,
          () => this.handleSearchStart(true) // chunk yüklenince tekrar dene
        );
      }
    }
  }
}

Recursive çağrı var ama sonlanma garantili: markChunks listesi tükeniyor. Son match'ten sonra "başa dön" bildirimi geliyor.


Sonuç: 227 Belge Kurtarıldı

“Başka çözüm yok” dediğim andan, 6102 Sayılı Türk Ticaret Kanunu’nun uygulamada akıcı açıldığı ana kadar geçen süreye bakınca şunu görüyorum: Bazen “çözüm yok” derken aslında “çözümü henüz bulmadım” demiş oluyorsunuz.

Donanımhaber yüzlerce sayfalık forum thread’ini bir şekilde açtırdı. Reddit binlerce yorumlu konularını yönetti. Onlar da aynı sorularla karşılaştı, aynı felsefeye ulaştı. Biz de ulaştık.

11,5 milyon karakterlik Gümrük Kararı artık uygulamada açılıyor. 227 belge tarayıcıya yönlendirilmedi, uygulamada kaldı. Notlarla, madde navigasyonuyla, arama ve değişen bölümlere gitme özellikleriyle...

6 yıllık Xiaomi Redmi Note 9S artık donmuyor. Emekli olana kadar da donmayacak. Yasalarımızı ve yapılan değişiklikleri çok rahat görebiliyor, inceleyebiliyorum. Adımları yaptıkça bir sonraki adımı yapamayacağımı, telefonun eninde sonunda yine kasacağını düşünüyor; şirket içindeki ekiplere “Ya mobilde de şu değişikliklere gitme özelliği olmasa olmaz mı?” şeklinde tatlış açıklamalar ve sorular hazırlıyordum ki gerek kalmadı. Yaptıkça yaptım, yapabildim.

Hele belgeyi geçmiş versiyonlarıyla kıyaslamak, değişikliğin hangi satırlarda yapıldığını kullanıcımızın net olarak görmesi için geliştirdiğimiz “Belge Kıyaslama” ekranı da var kiii… Şimdiye kadar anlattıklarımı 2 ile çarpın. İşin daha da uç noktası bu ekrandı. Aynı anda iki farklı 11 milyon karakterlik dokümanı yan yana render edip ikisi arasında kaydırma senkronizasyonunu bozmadan bu performansı korumak, kurduğumuz “Virtual Chunking” yapısının ne kadar ölçeklenebilir olduğunun en büyük kanıtı oldu.

Yazılımda çözümün olmadığı bir senaryo gerçekten çok zor. Ha, ekipteki kaynak sayısı yeterli değildir; şirketin öncelikleri başka projelerde, başka işlerdedir. Bu tarz stratejik kararlara saygımız sonsuz. Onun haricinde gerçekten imkânsız dememek lazım. Bunu da öğrenmiş olduk.


IntersectionObserver, DOMParser ya da "event loop'a nefes vermek"kavramlarından birini araştırmaya gittiyseniz bu yazı amacına ulaşmış demektir.