denoland / deno_ast

Source text parsing, lexing, and AST related functionality for Deno
https://crates.io/crates/deno_ast
MIT License
156 stars 46 forks source link

feat: precompile JSX to string transform #162

Closed bartlomieju closed 1 year ago

bartlomieju commented 1 year ago

This PR introduces a new JSX transform that is optimized for server-side rendering. It works by serializing the static parts of a JSX template into static string arrays at compile time. Common JSX templates render around 200-500 nodes in total and the transform in this PR changes the problem from an object allocation to string concatenation one. The latter is around 7-20x faster depending on the amount of static bits inside a template. For the dynamic parts, this transform falls back to the automatic runtime one internally.

Quick JSX history

Traditionally, JSX leaned quite heavily on the Garbage Collector because of its heritage as a templating engine for in-browser rendering.

// input
const a = <div className="foo" tabIndex={-1}>hello<span /></div>

// classic Transform
const a = React.createElement(
  "div",
  { className: "foo", tabIndex: -1 },
  "hello",
  React.createElement("span", null)
);

Every element expands to two object allocations, a number which grows very quickly the more elements you have.

const a = {
  type: "div",
  props: {
    className: "foo",
    tabIndex: -1,
    children: [
      "hello",
      {
        type: "span",
        props: null
      }
    ]
  }
}

The later introduced automatic runtime transform, didn't change that fact and merely brought auto importing the JSX factory function, some special handling of the key prop to the table and putting children directly into the props object. The main goal of that transform was to improve the developer experience and avoid an internal deopt that most frameworks ran into by having to copy the children argument around.

// input
const a = <div className="foo" tabIndex={-1}>hello<span /></div>

// automatic Transform
import { jsx } from "react/jsx-runtime";
const a = jsx(
  "div",
  {
    className: "foo",
    tabIndex: -1,
    children: [
      "hello",
      jsx("span", null)
    ]
  }
})

...still expands to many objects:

const a = {
  type: "div",
  props: {
    className: "foo",
    tabIndex: -1,
    children: [
      "hello",
      {
        type: "span",
        props: null
      }
    ]
  }
}

Both transforms are quite allocation heavy and cause a lot of GC pressure. JSX element is at minimum converted into two object allocations and for many frameworks even a third one if they are backed by fiber nodes. This easily leads to +3000 allocations per request.

Precompiling JSX at transpile time

The proposed transform in this PR moves all that work to transpilation time and pre-serializes all the static bits of the JSX template.

// input
const a = <div className="foo" tabIndex={-1}>hello<span /></div>

// this PR
import { jsxssr, jsxattr } from "react/jsx-runtime";

const tpl = ['<div class="foo" ', '>hello<span></span></div>']
const a = jsxssr(tpl, jsxattr('tabindex', -1));

The jsxssr function can be thought of as a tagged template literal. It has very similar semantics around concatenating an array of static strings with dynamic content. To note here is that the className has been automatically transformed to class, same for tabIndex -> tabindex. Instead of creating thousands of short lived objects, the jsxssr function essentially just calls Array.join() conceptually. Only the output string is allocation, which makes this very fast.

Benchmarks

I've put this transform through the tests in a benchmark. The first is the automatic transform with Preact, second is the transform from this PR rendered by Preact and third is a custom HTML transform that skips component instances entirely. The latter is obviously the fastest, as component instances are quite heavy too, and currently those need to be instantiated for backwards compatibility reasons in Preact.

Screenshot 2023-10-20 at 20 26 34

Usefulness for the ecosystem

The transform in this PR was intentionally designed in a way that makes it independent of any framework. It's not tied to Preact or Fresh at all. Rather, by setting a custom jsxImportSource config in deno.json you can point it to your own factory functions. Each factory function needs to implement the following functions:

Note: That custom implementations don't have to adhere to specific return values. The transform only expects a certain signature, so if you can also use it for frameworks which don't need to materialize component instances.

If you're interested in more of the transpilation output, checkout the tests.

CLAassistant commented 1 year ago

CLA assistant check
All committers have signed the CLA.

marvinhagemeister commented 1 year ago

I think the overall JSX transform is now ready to be explored in the real apps. Only thing left is adding more attribute name serialization mappings, and probably some rust stuff as I just hacked to get it to work. Other than that I think I accounted for all use cases I could come up with. I think the transform is ready.