İçindekiler
GoUI'deki her yeniden render aynı hattan geçer: yeni HTML'i render et, onu küçük bir ağaca ayrıştır, önceki render'dan gelen ağaca karşı diff'le, ve ortaya çıkan yama listesini WebSocket üzerinden gönder. Bu belge, bu ağacı, yama formatını, ve diff algoritmasını, belirli bir değişikliğin ürettiği yamaları neden ürettiğini anlayabileceğiniz ve nerede dikkatli olmanız gerektiğini bilebileceğiniz kadar açıklar.
Bu belge boyunca kullanılan modül yolu: github.com/zatrano/goui. İlgili
paket diff'tir (diff/node.go, diff/diff.go, diff/patch.go,
diff/serialize.go).
1. Node
// diff/node.go
type Node struct {
Tag string
Text string
Attrs map[string]string
Key string
Children []*Node
}
-
Eleman düğümleri, boş olmayan bir
Tag'e (örn."div","li") veAttrs'e sahiptir. -
Metin düğümleri,
Tag == ""'a sahiptir (isTextNodetam olarak bunu kontrol eder) ve içerikleriniText'te taşırlar. Sadece boşluk içeren metin düğümleri, ayrıştırma sırasında tamamen düşürülür (convertHTMLNodebunları atlar), böylece girintili, güzel biçimlendirilmiş Go string literal'ları hayalet metin düğümleri oluşturmaz. -
Key, ayrıştırma sırasında mevcutsa birdata-keyözniteliğinden otomatik olarak doldurulur:for _, attr := range n.Attr { node.Attrs[attr.Key] = attr.Val if attr.Key == "data-key" { node.Key = attr.Val } }data-key,Attrs'te de kalır (dolayısıyla DOM'da hâlâ normal bir HTML özniteliğidir) —Key, ondan türetilen sadece diff-zamanı bir kolaylıktır.
diff.ParseHTML(htmlStr string) (*Node, error), bir parçayı
(golang.org/x/net/html'nin bir <div> bağlamıyla parça ayrıştırıcısını
kullanarak) Children'ı gerçek üst seviye düğümler olan sentetik bir
Tag: "root" düğümüne ayrıştırır. diff.Serialize(node *Node) string
tersini yapar — ağacı gezer ve HTML'i geri yazar, deterministik çıktı
için öznitelikleri alfabetik olarak sıralar (hem yamalardaki tam düğüm
HTML'i için hem de testler için kullanılır).
2. Patch ve PatchOp
// diff/patch.go
type PatchOp string
const (
OpReplace PatchOp = "replace"
OpUpdateText PatchOp = "update_text"
OpSetAttr PatchOp = "set_attr"
OpRemoveAttr PatchOp = "remove_attr"
OpInsert PatchOp = "insert"
OpRemove PatchOp = "remove"
OpMove PatchOp = "move"
)
type Patch struct {
Op PatchOp `json:"op"`
Path []int `json:"path"`
Tag string `json:"tag,omitempty"`
Text string `json:"text,omitempty"`
Attr string `json:"attr,omitempty"`
Value string `json:"value,omitempty"`
HTML string `json:"html,omitempty"`
Key string `json:"key,omitempty"`
FromIdx int `json:"from_idx,omitempty"`
ToIdx int `json:"to_idx,omitempty"`
}
Patch, tel üzerinden gerçekten geçen şeydir (JSON olarak, bir
"render" frame'inin payload'ı içinde). Tek bir yeniden render sıfır,
bir, veya birçok yama üretebilir.
2.1 Path
Path, bir çocuk indeksleri listesidir, bir CSS seçicisi veya bir
DOM API yolu değil. Bileşenin kök elemanından başlayarak (bkz. §3
aşağıda), tekrar tekrar N'inci "anlamlı çocuğu" (bir eleman, veya
boş-olmayan bir metin düğümü; istemcinin meaningfulChildren() yardımcısı
sunucu tarafında ayrıştırıcının kullandığı tam olarak aynı filtreleme
kuralını uygular) alarak yürünür. Path: [], her zaman "kökün kendisi"
anlamına gelir.
2.2 Yedi op
| Op | Anlam | İlgili alanlar |
|---|---|---|
replace |
Path'teki düğümü taze serileştirilmiş HTML ile değiştir. Bir bileşenin çok ilk render'ı (Path: []) için ve bir düğümün etiketi değiştiğinde kullanılır. |
HTML, Tag, Key |
update_text |
Bir metin düğümünün içeriğini yerinde değiştir (çevreleyen yapının yeniden ayrıştırılması olmadan). | Text |
set_attr |
Path'teki elemanda bir özniteliği ayarla/üzerine yaz. |
Attr, Value |
remove_attr |
Path'teki elemandan bir özniteliği kaldır. |
Attr |
insert |
Path'te yeni bir çocuk ekle (HTML olarak verilmiş), Path'teki son indeks ebeveynin çocukları arasındaki konum olsun. |
HTML, Tag, Key |
remove |
Path'teki çocuğu kaldır. |
Key, Tag |
move |
Mevcut, key'li bir çocuğu, ebeveyni içinde (ebeveyn Path ile tanımlanır) FromIdx'ten ToIdx'e, HTML'ine dokunmadan yeniden konumlandır. |
Key, FromIdx, ToIdx |
İstemcinin yama uygulayıcısı (client/goui.js'deki applyPatch), bunların
her birini tam olarak açıklandığı gibi uygular, checked/selected/
disabled/readOnly üzerinde set_attr/remove_attr için boolean-özellik
senkronizasyonu dahil (bunun neden önemli olduğu için bkz.
14-troubleshooting.md), ve bir şey uygulamadan
önce data-goui-ignore'u da kontrol eder — bkz. §6 ve
11-file-uploads.md/rich-text notları.
3. Yollar sayfaya değil bileşen köküne göre relatiftir
Bileşen nerede mount edildiğinden bağımsız olarak, Path'in her zaman
tek bir bileşene göre relatif olmasını sağlamak için iki şey birlikte
çalışır:
-
decorateComponentHTML(ws/session.go),Render()'ın döndürdüğü her ne isedata-goui-component="<id>"ile etiketler:Render()tam olarak bir kök eleman döndürdüyse, o elemanın kendisidata-goui-componentözniteliğini alır — sarmalayıcı eklenmez.- Sıfır çocuk döndürdüyse (boş string), bir yer tutucu
<div data-goui-component="<id>"></div>kullanılır. - Birden fazla kardeş kök eleman döndürdüyse, bunlar sentetik bir
<div data-goui-component="<id>">...</div>içine sarılır.
-
parseComponentTree, ardındanParseHTML'in her zaman ürettiği sentetikTag: "root"düğümünü açar, ve — tam olarak bir üst seviye çocuk olduğunda — diff kökü olarak"root"sarmalayıcısı yerine o çocuğu (gerçekdata-goui-componentelemanı) kullanır:// ws/session.go func parseComponentTree(html string) (*diff.Node, error) { tree, err := diff.ParseHTML(html) if err != nil { return nil, err } if len(tree.Children) == 1 { return tree.Children[0], nil } return tree, nil }
Sonuç: Render(), tam olarak bir kök eleman döndürmelidir. Öyle
yaparsa, o eleman hem diff için kullanılan ağaç kökü hem de istemcinin
DOM'da aradığı [data-goui-component] elemanı olur
(this.mount.querySelector('[data-goui-component="..."]')), dolayısıyla
Path: [0], "sayfadaki ikinci üst seviye şey" veya "GoUI'nin markup'ınızın
etrafına eklediği sarmalayıcı div" değil, hep "benim ilk çocuğum" demektir.
Render() birden fazla kardeş döndürürse, GoUI hâlâ çalışır (sentetik
sarmalayıcı aracılığıyla), ama DOM'da ve her yol hesaplamasında ekstra,
aksi halde anlamsız bir <div> kazanırsınız — zararsız, ama fallback'e
güvenmek yerine çoklu-eleman çıktıyı kendi tek kök etiketinizle sararak
kaçınmaya değer.
4. Diff algoritması
diff.Diff(old, new *Node) []Patch, her iki ağacı da kilit adımda
(lock-step) gezer, path = []'ten başlayarak (diff/diff.go'daki
diffNode):
- Nil durumları —
old == nil && new != nil→insert;old != nil && new == nil→remove; ikisi de nil ise → hiçbir şey. - Etiket uyuşmazlığı — aynı konumda farklı
Tag→ tüm düğümüreplaceet (bu, eleman-metin-düğümü takaslarını da kapsar, çünkü bir metin düğümününTag'i her zaman""'dır). - İkisi de metin düğümü —
Text'i karşılaştır; sadece gerçekten değiştiyseupdate_textyay. - Aynı etiketli elemanlar — önce öznitelikleri diff'le, sonra çocukları diff'le.
4.1 Öznitelik diff'leme
diffAttrs, eski ve yeni öznitelik key'lerinin birleşimini
(union), deterministik yama sıralaması için sıralanmış olarak hesaplar,
ve her key için:
- sadece
new'de mevcut →set_attr - sadece
old'da mevcut →remove_attr - her ikisinde de mevcut ama farklı değer →
set_attr - her ikisinde de mevcut, aynı değer → yama yok
4.2 Çocuk diff'leme: indeksli vs. key'li
diffChildren, her ebeveyn düğüm için, herhangi bir tarafın
çocuklarının bir data-key içerip içermediğine bağlı olarak iki
stratejiden birini seçer:
func diffChildren(oldChildren, newChildren []*Node, path []int, patches *[]Patch) {
if hasAnyKey(oldChildren) || hasAnyKey(newChildren) {
diffKeyedChildren(oldChildren, newChildren, path, patches)
return
}
diffIndexedChildren(oldChildren, newChildren, path, patches)
}
İndeksli diff'leme (bu çocuk listesinde hiçbir yerde key yok)
tamamen konumsaldır: ortak öneki (prefix) indeks-indeks diff'ler
(her çifte özyinelemeli olarak inerek), sonra herhangi bir ekstra yeni
kuyruk çocuğunu insert eder, sonra herhangi bir ekstra eski kuyruk
çocuğunu (sonundan başlayarak, böylece daha önceki indeksler geçerli
kalır) remove eder. Bu, sadece sonunda büyüyen/küçülen listeler için
etkili ve doğrudur, ama ortadan yeniden sıralanan bir liste için,
indeksli diff'leme "ilk farklı indeksten itibaren her şeyi
değiştir/güncelle"ye dejenere olur — key'li listelerin var olma
sebebinin tam olarak bu olması.
Key'li diff'leme — bkz. §5.
5. Key'li listeler: bir key→konum map'i, bir LCS değil
"Bu öğe konum 3'teydi ve şimdi konum 0'da" eşleştirme görevini
(rastgele yeniden sıralamalar için minimum yama sayısı ile) optimal
olarak çözmek, klasik olarak bir en-uzun-ortak-alt-dizi (LCS) algoritması
ile çözülür. GoUI LCS'i uygulamaz. diffKeyedChildren (diff/diff.go)
bunun yerine:
- Önceden dört küçük map inşa eder:
oldByKey/oldPosvenewByKey/newPos, gerçekten key'i olan çocuklar içindata-key'den düğüme/indekse. Key'li bir listede key'siz çocuklar, key'li mantığın yanında satır içi olarak konumsal olarak diff'lenir. oldChildren'ı tersten gezer ve key'inewChildren'da artık var olmayan herhangi bir key'li çocuk için birremoveyaması yayar.- Tek bir boolean belirler,
hasInsertOrRemove: bir key bir tarafta var ama diğerinde yoksa true'dur (yani liste gerçekten üye kazandı veya kaybetti, sadece mevcut üyeleri yeniden sıralamadı). newChildren'ı ileri gezer:- Key'siz yeni çocuk → eski listede aynı indekste her ne varsa ona
karşı konumsal olarak diff'le (veya orada hiçbir şey yoksa
insert). - Eşleşen eski key'i olmayan key'li yeni çocuk →
insert. - Eşleşen eski key'i olan key'li yeni çocuk:
!hasInsertOrRemoveve konumu değiştiyse (oldPos[key] != newPos[key]), birmoveyaması yay (FromIdx: oldPos[key], ToIdx: newPos[key]).- İçerik/öznitelik değişiklikleri hareket eden (veya sabit kalan)
key'li bir öğede yakalanmaya devam etsin diye her zaman
diffNode(oldChild, newChild, ...)'a da özyinelemeli olarak in.
- Key'siz yeni çocuk → eski listede aynı indekste her ne varsa ona
karşı konumsal olarak diff'le (veya orada hiçbir şey yoksa
Kısacası: bu bir key→konum araması, bir alt dizi algoritması değil.
Mevcut bir key için değişen her konum, kendi bağımsız move yaması
olur, orijinal önce/sonra konum map'lerinden hesaplanır (daha önceki
hareketler uygulanırken yeniden hesaplanmaz). Yaygın durumlar için —
ekleme, öne ekleme, bir öğeyi kaldırma, veya tek bir öğeyi taşıma —
tam olarak beklediğiniz yamaları üretir. Eşzamanlı çok-öğeli yeniden
sıralamalar için (örn. aynı anda birkaç çifti takas etme, veya tam bir
karıştırma), yayılan move yamaları, her biri uygulandıktan sonra çocuk
sırası değişen canlı bir DOM'a karşı sırayla uygulanır; indeksler herhangi
bir hareket uygulanmadan önce alınan anlık görüntülerden türetildiğinden,
birkaç eşzamanlı hareketin bir dizisi, uygun bir LCS-tabanlı reconciler'ın
yapacağı gibi minimal (veya hatta açıkça doğru görünen) bir DOM
operasyonları dizisine indirgenmesi garanti edilmez. Key'li listeleriniz
sürükle-bırak-yeniden-sıralama veya çoklu-seçim-ve-taşıma UI'sini
destekliyorsa bunu özellikle test edin, ve tek seferde bir konumu
değiştirmeyi (örn. "öğeyi bir yukarı/aşağı taşı" kontrolleri) veya ağır
yeniden sıralamada su sızdırmaz doğruluk gerekiyorsa tek bir render'dan
gelen birçok eşzamanlı move yamasına güvenmek yerine ebeveyn listenin
tam bir replace'ini yaymayı tercih edin.
6. data-goui-ignore
Key'lerden bağımsız olarak, data-goui-ignore ile işaretlenmiş herhangi
bir alt ağaç, yamalar uygulanırken istemci tarafından atlanır — bkz.
client/goui.js'deki isGoUIIgnored/applyPatch. Bu, DOM'u reconciler
tarafından asla dokunulmaması gereken istemciye ait widget'lar için
(Quill, CodeMirror) vardır; bunu sunucu tarafında core.ErrSkipRender
ile birleştirin, böylece sunucu bu widget'lara durumu yansıtan olaylar
için bir yama kümesi hesaplamayı bile denemez. Tam desen için
14-troubleshooting.md'ye bakın.
7. Dinamik listeler için data-key — pratik rehberlik
Yeniden sıralanabilecek, filtrelenebilecek, veya ortadan eklenip
çıkarılabilecek (sadece bir uçtan büyüyen/küçülen listelere değil)
render ettiğiniz herhangi bir listenin tekrarlanan kök elemanına
data-key="<stable-id>" ekleyin:
var b strings.Builder
b.WriteString("<ul>")
for _, item := range items {
b.WriteString(`<li data-key="` + html.EscapeString(item.ID) + `">`)
b.WriteString(html.EscapeString(item.Label))
b.WriteString("</li>")
}
b.WriteString("</ul>")
Genel kurallar:
- Key, aynı mantıksal öğe için render'lar arasında stabil olmalıdır (bir veritabanı ID'si, bir dilim indeksi değil — bir dilim indeksi amacın tamamını yener, çünkü yeniden sıralamada değişen tam olarak odur).
- Belirli bir çocuk listesinin tamamı veya hiçbiri pratikte key
taşımalıdır;
hasAnyKey, bir çocuk bile birdata-key'e sahip olduğu anda tüm kardeş liste için key'li modu tetikler, dolayısıyla aksi halde key'siz bir listedeki bir<li>üzerindeki başıboş bir key, hepsi için (daha yavaş, hareket-farkında) key'li yolu zorlar. - Key'ler yalnızca o bir ebeveynin doğrudan çocuklarını etkiler.
İç içe listelerin kendi öğelerinde kendi
data-key'lerine ihtiyacı vardır; key'ler yayılmaz. - Sadece kuyrukta büyüyen key'siz listeler (sohbet mesajları, etkinlik akışları, "daha fazla yükle" sayfalama) indeksli diff'leme altında zaten optimaldir — orada key eklemeyin, yama-sayısı faydası olmadan öznitelik gürültüsü ekler.
8. Performans notları
- Öznitelik diff'leme, düğüm başına O(key sayısı)'dır, deterministik sıralama için bir sıralamayla — birçok özniteliği olan elemanlar için bile ihmal edilebilir.
- İndeksli çocuk diff'leme, paylaşılan önek için O(min(eski, yeni))'dir, artı kuyruk ekleme/kaldırma için O(|Δuzunluk|) — ucuzdur, ve bu, markup'ın büyük çoğunluğu için varsayılan yoldur (çoğu elemanın hiç key'li çocuğu yoktur).
- Key'li çocuk diff'leme, map'leri inşa etmek için O(n) ve gezmek
için O(n)'dir — karesel (quadratic) bir patlama yok — ama sayısı
en kötü durumda O(n)'e kadar olabilecek
moveyamaları üretir (her öğenin konumu değişti), ki bunların her biri istemci tarafından kendi DOMinsertBeforeçağrısı olarak uygulanır. Tam bir listereplace'i bir HTML string'i ve bir DOM takasıdır; ağır yeniden sıralanmış key'li bir liste onlarca küçük op'a dönüşebilir. Sık sık yeniden sıralanan çok büyük listeler (yüzlerce+ satır) için, her ikisini de ölçün ve listenin tamamının daha kaba birreplace'inin (key'leri bırakın, veya tam bir yeniden render'ı zorlayın) birçokmoveyamasından gerçekten daha ucuz olup olmadığını düşünün — GoUI bunu sizin için karar vermez. Serialize, yalnızcareplace/insertpayload HTML'i için çağrılır — sadeceset_attr/update_text/remove_attr/move'a ihtiyaç duyan düğümler, alt ağaçlarını yeniden serileştirme maliyetini asla ödemez, ki bu, dar güncellemeleri (örn. sadece-metin bir sayaç), etiketi değişen bir elemana sık sık değişen içeriği sarmak yerine (bu, tam birreplace'i zorlar) tercih etmenin ana sebebidir.- Tüm hat,
core.ErrSkipRenderdöndürmeyen herHandleEventçağrısında bir kez çalışır. Gürültülü istemci olaylarını (g-debounce,forms/*boyunca kullanılır) debounce edin, böylece hızlı bir daktilocu her tuş vuruşunda bir ayrıştır+diff'le+yamala döngüsünü tetiklemez — mevcut konvansiyon için (tipik olarak 100–350ms)TextInput/RichTextEditoralan uygulamalarına bakın.