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

1 file changed, 154 insertions(+), 63 deletions(-)

changed files
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.