all repos — gomponents @ b0cec2ca4942ef8455090e71d1026efab673a7ee

HTML components in pure Go

Reduce number of allocations for rendering elements and attributes (#265) This change does some optimizations to reduce the number of memory heap allocations. Also: - Checks for `io.StringWriter` and uses that if available. - Pre-allocates byte slices for commonly used strings. - Fixed the benchmarks to not count `strings.Builder` allocations. These are the benchmark results from before the changes (with the benchmark fix): ``` go test -bench . -benchmem ./... goos: darwin goarch: arm64 pkg: maragu.dev/gomponents cpu: Apple M3 Max BenchmarkAttr/boolean_attributes-16 25371446 46.81 ns/op 40 B/op 3 allocs/op BenchmarkAttr/name-value_attributes-16 13534495 88.24 ns/op 72 B/op 4 allocs/op BenchmarkEl/normal_elements-16 14068998 86.28 ns/op 48 B/op 5 allocs/op PASS ok maragu.dev/gomponents 4.894s PASS ok maragu.dev/gomponents/components 0.163s goos: darwin goarch: arm64 pkg: maragu.dev/gomponents/html cpu: Apple M3 Max BenchmarkLargeHTMLDocument-16 526 2092062 ns/op 2950444 B/op 90031 allocs/op PASS ok maragu.dev/gomponents/html 1.463s PASS ok maragu.dev/gomponents/http 0.168s ? maragu.dev/gomponents/internal/assert [no test files] PASS ok maragu.dev/gomponents/internal/import 0.135s ``` After: ``` go test -bench . -benchmem ./... goos: darwin goarch: arm64 pkg: maragu.dev/gomponents cpu: Apple M3 Max BenchmarkAttr/boolean_attributes-16 51947022 19.75 ns/op 8 B/op 1 allocs/op BenchmarkAttr/name-value_attributes-16 18138727 64.87 ns/op 24 B/op 2 allocs/op BenchmarkEl/normal_elements-16 21048692 55.48 ns/op 24 B/op 2 allocs/op PASS ok maragu.dev/gomponents 3.687s PASS ok maragu.dev/gomponents/components 0.158s goos: darwin goarch: arm64 pkg: maragu.dev/gomponents/html cpu: Apple M3 Max BenchmarkLargeHTMLDocument-16 714 1535017 ns/op 2630426 B/op 40028 allocs/op PASS ok maragu.dev/gomponents/html 1.398s PASS ok maragu.dev/gomponents/http 0.171s ? maragu.dev/gomponents/internal/assert [no test files] PASS ok maragu.dev/gomponents/internal/import 0.137s ```

Markus Wüstenberg
commit

b0cec2ca4942ef8455090e71d1026efab673a7ee

parent

bf0db98f68ce95c18bb060913a6602de981fa715

5 files changed, 228 insertions(+), 77 deletions(-)

changed files
M .github/workflows/ci.yml.github/workflows/ci.yml
@@ -54,6 +54,18 @@ benchmark:
name: Benchmark runs-on: ubuntu-latest + strategy: + matrix: + go: + - "1.18" + - "1.19" + - "1.20" + - "1.21" + - "1.22" + - "1.23" + - "1.24" + - "1.25" + steps: - name: Checkout uses: actions/checkout@v5
@@ -61,14 +73,14 @@
- name: Setup Go uses: actions/setup-go@v5 with: - go-version-file: go.mod + go-version: ${{ matrix.go }} check-latest: true - name: Run Benchmarks run: | - echo "# Benchmark Results" >> $GITHUB_STEP_SUMMARY + echo "# Benchmark Results for Go ${{ matrix.go }}" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY - go test -bench=. ./... 2>&1 | tee -a $GITHUB_STEP_SUMMARY + go test -bench . -benchmem ./... 2>&1 | tee -a $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY lint:
M MakefileMakefile
@@ -1,6 +1,6 @@
.PHONY: benchmark benchmark: - go test -bench=. ./... + go test -bench . -benchmem ./... .PHONY: cover cover:
M gomponents.gogomponents.go
@@ -55,7 +55,7 @@ func (n NodeFunc) Render(w io.Writer) error {
return n(w) } -// Type satisfies nodeTypeDescriber. +// Type satisfies [nodeTypeDescriber]. func (n NodeFunc) Type() NodeType { return ElementType }
@@ -67,6 +67,12 @@ _ = n.Render(&b)
return b.String() } +var ( + lt = []byte("<") + gt = []byte(">") + ltSlash = []byte("</") +) + // El creates an element DOM [Node] with a name and child Nodes. // See https://dev.w3.org/html5/spec-LC/syntax.html#elements-0 for how elements are rendered. // No tags are ever omitted from normal tags, even though it's allowed for elements given at
@@ -75,76 +81,99 @@ // If an element is a void element, non-attribute children nodes are ignored.
// Use this if no convenience creator exists in the html package. func El(name string, children ...Node) Node { return NodeFunc(func(w io.Writer) error { - return render(w, &name, children...) - }) -} + var err error -func render(w2 io.Writer, name *string, children ...Node) error { - w := &statefulWriter{w: w2} + sw, ok := w.(io.StringWriter) - if name != nil { - w.Write([]byte("<" + *name)) + if _, err = w.Write(lt); err != nil { + return err + } + + if ok { + if _, err = sw.WriteString(name); err != nil { + return err + } + } else { + if _, err = w.Write([]byte(name)); err != nil { + return err + } + } for _, c := range children { - renderChild(w, c, AttributeType) + if err = renderChild(w, c, AttributeType); err != nil { + return err + } + } + + if _, err = w.Write(gt); err != nil { + return err + } + + if isVoidElement(name) { + return nil } - w.Write([]byte(">")) + for _, c := range children { + if err = renderChild(w, c, ElementType); err != nil { + return err + } + } - if isVoidElement(*name) { - return w.err + if _, err = w.Write(ltSlash); err != nil { + return err } - } - for _, c := range children { - renderChild(w, c, ElementType) - } + if ok { + if _, err = sw.WriteString(name); err != nil { + return err + } + } else { + if _, err = w.Write([]byte(name)); err != nil { + return err + } + } - if name != nil { - w.Write([]byte("</" + *name + ">")) - } + if _, err = w.Write(gt); err != nil { + return err + } - return w.err + return nil + }) } -// renderChild c to the given writer w if the node type is t. -func renderChild(w *statefulWriter, c Node, t NodeType) { - if w.err != nil || c == nil { - return +// renderChild c to the given writer w if the node type is desiredType. +func renderChild(w io.Writer, c Node, desiredType NodeType) error { + if c == nil { + return nil } // Rendering groups like this is still important even though a group can render itself, // since otherwise attributes will sometimes be ignored. if g, ok := c.(Group); ok { for _, groupC := range g { - renderChild(w, groupC, t) + if err := renderChild(w, groupC, desiredType); err != nil { + return err + } } - return + return nil } - switch t { + switch desiredType { case ElementType: - if p, ok := c.(nodeTypeDescriber); !ok || p.Type() == ElementType { - w.err = c.Render(w.w) + if p, ok := c.(nodeTypeDescriber); !ok || p.Type() == desiredType { + if err := c.Render(w); err != nil { + return err + } } case AttributeType: - if p, ok := c.(nodeTypeDescriber); ok && p.Type() == AttributeType { - w.err = c.Render(w.w) + if p, ok := c.(nodeTypeDescriber); ok && p.Type() == desiredType { + if err := c.Render(w); err != nil { + return err + } } } -} -// statefulWriter only writes if no errors have occurred earlier in its lifetime. -type statefulWriter struct { - w io.Writer - err error -} - -func (w *statefulWriter) Write(p []byte) { - if w.err != nil { - return - } - _, w.err = w.w.Write(p) + return nil } // voidElements don't have end tags and must be treated differently in the rendering.
@@ -173,44 +202,85 @@ _, ok := voidElements[name]
return ok } +var ( + space = []byte(" ") + equalQuote = []byte(`="`) + quote = []byte(`"`) +) + // Attr creates an attribute DOM [Node] with a name and optional value. // If only a name is passed, it's a name-only (boolean) attribute (like "required"). // If a name and value are passed, it's a name-value attribute (like `class="header"`). // More than one value make [Attr] panic. // Use this if no convenience creator exists in the html package. func Attr(name string, value ...string) Node { - switch len(value) { - case 0: - return &attr{name: name} - case 1: - return &attr{name: name, value: &value[0]} - default: + if len(value) > 1 { panic("attribute must be just name or name and value pair") } -} -type attr struct { - name string - value *string + return attrFunc(func(w io.Writer) error { + var err error + + sw, ok := w.(io.StringWriter) + + if _, err = w.Write(space); err != nil { + return err + } + + // Attribute name + if ok { + if _, err = sw.WriteString(name); err != nil { + return err + } + } else { + if _, err = w.Write([]byte(name)); err != nil { + return err + } + } + + if len(value) == 0 { + return nil + } + + if _, err = w.Write(equalQuote); err != nil { + return err + } + + // Attribute value + if ok { + if _, err = sw.WriteString(template.HTMLEscapeString(value[0])); err != nil { + return err + } + } else { + if _, err = w.Write([]byte(template.HTMLEscapeString(value[0]))); err != nil { + return err + } + } + + if _, err = w.Write(quote); err != nil { + return err + } + + return nil + }) } +// attrFunc is a render function that is also a [Node] of [AttributeType]. +// It's basically the same as [NodeFunc], but for attributes. +type attrFunc func(io.Writer) error + // Render satisfies [Node]. -func (a *attr) Render(w io.Writer) error { - if a.value == nil { - _, err := w.Write([]byte(" " + a.name)) - return err - } - _, err := w.Write([]byte(" " + a.name + `="` + template.HTMLEscapeString(*a.value) + `"`)) - return err +func (a attrFunc) Render(w io.Writer) error { + return a(w) } // Type satisfies [nodeTypeDescriber]. -func (a *attr) Type() NodeType { +func (a attrFunc) Type() NodeType { return AttributeType } // String satisfies [fmt.Stringer]. -func (a *attr) String() string { +func (a attrFunc) String() string { var b strings.Builder _ = a.Render(&b) return b.String()
@@ -219,6 +289,10 @@
// Text creates a text DOM [Node] that Renders the escaped string t. func Text(t string) Node { return NodeFunc(func(w io.Writer) error { + if w, ok := w.(io.StringWriter); ok { + _, err := w.WriteString(template.HTMLEscapeString(t)) + return err + } _, err := w.Write([]byte(template.HTMLEscapeString(t))) return err })
@@ -227,6 +301,10 @@
// Textf creates a text DOM [Node] that Renders the interpolated and escaped string format. func Textf(format string, a ...interface{}) Node { return NodeFunc(func(w io.Writer) error { + if w, ok := w.(io.StringWriter); ok { + _, err := w.WriteString(template.HTMLEscapeString(fmt.Sprintf(format, a...))) + return err + } _, err := w.Write([]byte(template.HTMLEscapeString(fmt.Sprintf(format, a...)))) return err })
@@ -235,6 +313,10 @@
// Raw creates a text DOM [Node] that just Renders the unescaped string t. func Raw(t string) Node { return NodeFunc(func(w io.Writer) error { + if w, ok := w.(io.StringWriter); ok { + _, err := w.WriteString(t) + return err + } _, err := w.Write([]byte(t)) return err })
@@ -243,6 +325,10 @@
// Rawf creates a text DOM [Node] that just Renders the interpolated and unescaped string format. func Rawf(format string, a ...interface{}) Node { return NodeFunc(func(w io.Writer) error { + if w, ok := w.(io.StringWriter); ok { + _, err := w.WriteString(fmt.Sprintf(format, a...)) + return err + } _, err := fmt.Fprintf(w, format, a...) return err })
@@ -271,7 +357,12 @@ }
// Render satisfies [Node]. func (g Group) Render(w io.Writer) error { - return render(w, nil, g...) + for _, c := range g { + if err := renderChild(w, c, ElementType); err != nil { + return err + } + } + return nil } // If condition is true, return the given [Node]. Otherwise, return nil.
M gomponents_test.gogomponents_test.go
@@ -60,16 +60,22 @@ }
func BenchmarkAttr(b *testing.B) { b.Run("boolean attributes", func(b *testing.B) { + var sb strings.Builder + for i := 0; i < b.N; i++ { a := g.Attr("hat") - _ = a.Render(&strings.Builder{}) + _ = a.Render(&sb) + sb.Reset() } }) b.Run("name-value attributes", func(b *testing.B) { + var sb strings.Builder + for i := 0; i < b.N; i++ { a := g.Attr("hat", "party") - _ = a.Render(&strings.Builder{}) + _ = a.Render(&sb) + sb.Reset() } }) }
@@ -140,17 +146,33 @@ assert.Equal(t, `<div><br><br></div>`, e)
}) t.Run("returns render error on cannot write", func(t *testing.T) { - e := g.El("div") - err := e.Render(&erroringWriter{}) - assert.Error(t, err) + // This weird little constructs makes sure we test error handling of all writes + for i := 0; i <= 33; i++ { + t.Run(fmt.Sprintf("failing write %v", i), func(t *testing.T) { + e := g.El("div", g.Attr("id", "hat"), g.Attr("required"), g.El("span"), + g.Group{g.El("span"), g.Attr("class", "party-hat")}, + g.Text("foo"), g.Textf("ba%v", "r"), g.Raw("baz"), g.Rawf("what comes after %v?", "baz")) + + w := &erroringWriter{failingWrite: i} + err := e.Render(w) + assert.Error(t, err) + + sw := &stringWriter{w: &erroringWriter{failingWrite: i}} + err = e.Render(sw) + assert.Error(t, err) + }) + } }) } func BenchmarkEl(b *testing.B) { b.Run("normal elements", func(b *testing.B) { + var sb strings.Builder + for i := 0; i < b.N; i++ { e := g.El("div") - _ = e.Render(&strings.Builder{}) + _ = e.Render(&sb) + sb.Reset() } }) }
@@ -161,10 +183,29 @@ _ = e.Render(os.Stdout)
// Output: <div><span></span></div> } -type erroringWriter struct{} +type erroringWriter struct { + failingWrite int + actualWrite int +} func (w *erroringWriter) Write(p []byte) (n int, err error) { - return 0, errors.New("no thanks") + if w.failingWrite == w.actualWrite { + return 0, errors.New("no thanks") + } + w.actualWrite++ + return 0, nil +} + +type stringWriter struct { + w io.Writer +} + +func (w *stringWriter) Write(p []byte) (n int, err error) { + return w.w.Write(p) +} + +func (w *stringWriter) WriteString(s string) (n int, err error) { + return w.w.Write([]byte(s)) } func TestText(t *testing.T) {
@@ -295,6 +336,12 @@ e := g.Group{g.El("div"), g.El("span")}
assert.Equal(t, "<div></div><span></span>", e) assert.Equal(t, "<div></div>", e[0]) assert.Equal(t, "<span></span>", e[1]) + }) + + t.Run("returns render error on cannot write", func(t *testing.T) { + e := g.Group{g.El("span")} + err := e.Render(&erroringWriter{}) + assert.Error(t, err) }) }
M html/elements_test.gohtml/elements_test.go
@@ -168,7 +168,7 @@ }
} func BenchmarkLargeHTMLDocument(b *testing.B) { - b.ReportAllocs() + var sb strings.Builder for i := 0; i < b.N; i++ { elements := make([]g.Node, 0, 10000)
@@ -181,7 +181,8 @@ )
} doc := Div(elements...) - var sb strings.Builder _ = doc.Render(&sb) + + sb.Reset() } }