Custom JSX Factory Function

Question

We usually use jsx in react projects. When we get used to the syntax of jsx, we may not be able to do without jsx. What if we want to use jsx in a native js project instead of react?

Solution

React officially has released a new jsx transform, stripped of jsx-runtime to parse jsx separately, can use jsx without react, and officially cooperate with babel and typescript, use babel or ts configuration to parse jsx, both are provided Concise configuration scheme. Or, we can also customize a jsx parsing function.

Option 1: Babel configuration

  1. Install the dependency package first
npm update @babel/core @babel/preset-react
  1. Then configure the babel.config.json file
{
  "presets": [
    ["@babel/preset-react", {
      "runtime": "automatic"
    }]
  ]
}

Option 2: typescript configuration

To use jsx in ts, use tsx. Typescript 4.1 supports the tsconfig.json configuration of React 17's jsx and jsxs factory functions

  • Use "jsx":"react-jsxdev" in development environment
  • Use "jsx":"react-jsx" in production environment for example:
// ./src/tsconfig.json
{
    "compilerOptions": {
        "module": "esnext",
        "target": "es2015",
        "jsx": "react-jsx",
        "strict": true
    },
    "include": [
        "./**/*"
    ]
}

Option 3: Customize the tsx factory function

Customize a set of jsx parsing factory functions to understand the parsing process of jsx. The following demonstrates a ts version of the tsx parsing function.

  1. First define a jsxFactory.ts to define and export factory functions
// --- jsxFactory.ts ---
/* https://gist.github.com/borestad/eac42120613bc67a3714f115e8b485a7
 * Custom jsx parser
 * See: tsconfig.json
 *
 *   {
 *     "jsx": "react",
 *     "jsxFactory": "h",
 *     "lib": [
 *       "es2017",
 *       "dom",
 *       "dom.iterable"
 *     ]
 *   }
 *
 */
interface entityMapData {
  [key: string]: string;
}
export const entityMap: entityMapData = {
  "&": "amp",
  "<": "lt",
  ">": "gt",
  '"': "quot",
  "'": "#39",
  "/": "#x2F",
};

export const escapeHtml = (str: object[] | string) =>
  String(str).replace(/[&<>"'\/\\]/g, (s) => `&${entityMap[s]};`);

// To keep some consistency with React DOM, lets use a mapper
// https://reactjs.org/docs/dom-elements.html
export const AttributeMapper = (val: string) =>
  ({
    tabIndex: "tabindex",
    className: "class",
    readOnly: "readonly",
  }[val] || val);

// tslint:disable-next-line:no-default-export
export function DOMcreateElement(
  tag: Function | string,
  attrs?: { [key: string]: any },
  ...children: (HTMLElement | string)[]
): HTMLElement {
  attrs = attrs || {};
  const stack: any[] = [...children];

  // Support for components(ish)
  if (typeof tag === "function") {
    attrs.children = stack;
    return tag(attrs);
  }

  const elm = document.createElement(tag);

  // Add attributes
  for (let [name, val] of Object.entries(attrs)) {
    name = escapeHtml(AttributeMapper(name));
    if (name.startsWith("on") && name.toLowerCase() in window) {
      elm.addEventListener(name.toLowerCase().substr(2), val);
    } else if (name === "ref") {
      val(elm);
    } else if (name === "style") {
      Object.assign(elm.style, val);
    } else if (val === true) {
      elm.setAttribute(name, name);
    } else if (val !== false && val != null) {
      elm.setAttribute(name, escapeHtml(val));
    } else if (val === false) {
      elm.removeAttribute(name);
    }
  }

  // Append children
  while (stack.length) {
    const child = stack.shift();

    // Is child a leaf?
    if (!Array.isArray(child)) {
      elm.appendChild(
        (child as HTMLElement).nodeType == null
          ? document.createTextNode(child.toString())
          : child
      );
    } else {
      stack.push(...child);
    }
  }

  return elm;
}

export const DOMcreateFragment = (
  attrs?: { [key: string]: any },
  ...children: (HTMLElement | string)[]
): (HTMLElement | string)[] => {
  return children;
};

  1. Supporting factory function d.ts declaration file
// --- jsxFactory.d.ts ---
declare namespace JSX {
  type Element = string;
  interface IntrinsicElements {
    [eleName: string]: any;
  }
}

  1. Then add jsx configuration in tsconfig.json
{
    "compilerOptions":{
        // ...Other configuration
        "jsx": "preserve",
        "jsxFactory": "DOMcreateElement",
        "jsxFragmentFactory": "DOMcreateFragment",
    }
    
}

For example, the following reference

{
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "lib",
    "target": "ESNext",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "lib": ["ESNext", "dom", "dom.iterable"],
    "moduleResolution": "Node",
    "strict": true,
    "sourceMap": true,
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "noEmit": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "declaration": true,
    "declarationDir": "./lib",
    "declarationMap": true,
    "baseUrl": "./",
    "jsx": "preserve",
    "jsxFactory": "DOMcreateElement",
    "jsxFragmentFactory": "DOMcreateFragment",
    "allowJs": true
  },
  "include": ["./src"]
}
  1. Generally speaking, this can be used, just introduce DOMcreateElement in each file ending with .tsx, but if you use esbuild, you can also automatically inject DOMcreateElement and DOMcreateFragment in the esbuild configuration

A reference esbuild configuration. In the following example, @/helper/jsxFactory is the directory where jsxFactory.ts is located

esbuild: {
    jsxFactory: "DOMcreateElement",
    jsxFragment: "DOMcreateFragment",
    jsxInject: `import { DOMcreateElement, DOMcreateFragment } from '@/helper/jsxFactory';`,
}

Option 4: Concise jsx factory function

The simple version of jsx can be customized and extended according to this simple version

Version one

const appendChild = (parent, child) => {
  if (Array.isArray(child))
    child.forEach((nestedChild) => appendChild(parent, nestedChild));
  else
    parent.appendChild(child.nodeType ? child : document.createTextNode(child));
};

export const DOMcreateElement = (tag, props, ...children) => {
  if (typeof tag === 'function') return tag(props, children);

  const element = document.createElement(tag);

  Object.entries(props || {}).forEach(([name, value]) => {
    if (name.startsWith('on') && name.toLowerCase() in window) {
      element.addEventListener(name.toLowerCase().substr(2), value);
    } else {
      element[name] = value;
      // element.setAttribute(name, value.toString());
    }
  });

  children.forEach((child) => {
    appendChild(element, child);
  });

  return element;
};

export const DOMcreateFragment = (props, ...children) => {
  return children;
};

Version two

/**
 * A helper function that ensures we won't work with null values
 * @param val
 * @param fallback
 */
function nonNull(val, fallback) {
  return Boolean(val) ? val : fallback;
}

/**
 * How do we handle children. Children can either be:
 * 1. Calls to DOMcreateElement, return a Node
 * 2. Text content, returns a Text
 * @param children
 */

function DOMparseChildren(children) {
  return children.map((child) => {
    if (typeof child === 'string') {
      return document.createTextNode(child);
    }

    return child;
  });
}

/**
 * How do we handle regular nodes.
 * 1. We create an element
 * 2. We apply all properties from JSX to this DOM node
 * 3. If available,we append all children.
 * @param element
 * @param properties
 * @param children
 */

function DOMparseNode(element, properties, children) {
  const el = document.createElement(element);
  Object.keys(nonNull(properties, {})).forEach((key) => {
    el[key] = properties[key];
  });

  DOMparseChildren(children).forEach((child) => {
    el.appendChild(child);
  });

  return el;
}

/**
 * Our entry function.
 * 1. Is the element a function,than it's a functional component.
 *    We call this function (pass props and children of course)
 *    and return the result.We expect a return value of type Node
 * 2. If the element is a string, we parse a regular node
 * @param element
 * @param properties
 * @param children
 */

export function DOMcreateElement(element, properties, ...children) {
  if (typeof element === 'function') {
    return element({
      ...nonNull(properties, {}),
      children,
    });
  }

  return DOMparseNode(element, properties, children);
}

Reference

Comments