Preact 源码阅读(零)- JSX到vdom的转换

2,428 阅读7分钟

JSX是一个具有灵活性、功能较强的“模版语法”,它不仅仅具有常见的模版语法的功能,如Art、Jade中的功能,更支持Javascript语法的扩展,让我们使用Javascript编写模版。在React + webpack +babel的框架体系里,JSX是如何转换到Vdom的那。

本文研究下JSX语法到vdom的基本流程,研究其核心的三个转换流程,流程如下:

  • JSX到AST的转换。主要是使用babel-parser完成JSX AST的转换。
  • AST的裁剪处理。主要是Preact完成函数的替换,createElement/createFragment的替换。
  • createVnode。虚拟dom的生成与创建。

1. JSX AST的转换

基于React/Preact + Babel的技术选型,我们编写的JSX语法,最终都通过babel转换jsx ast tree。JSX的转换工具有多个,如下所示(github.com/facebook/js…):

  • babylon (@babel/parser):babel转换工具,将jsx转换jsx ast tree。
  • flow-parser:
  • typescript
  • esprima
  • acorn-jsx。

我们最常用的就是@babel/parser, jsx的处理是复杂的,我们简单看下AST的产物。我们看到一个JSX元素有类似的如下结构:

  • JSXElement元素,标识当前为JSXElement。
  • JSXOpeningElemen, 开始标签元素,关键属性标签名称、attributes。
  • children元素,存储当前JSXElement的子元素。
  • JSXClosingElement, 闭合标签元素,关键属性闭合标签名称。 JSX既支持丰富的JSX Attributes,又可以与JS代码结合在一起,这构成了强大的功能。理论上,我们可以给React Props传递任意的属性,包括组件、基本类型、引用类型、函数、JSX等,这可以我们通过props传递任意的内容给子组件。另一方面,JSX与JS代码结合在一起,让我们可以通过各种操作去处理各种数据,更新JSX元素,保持了高度的灵活性。

2. AST的处理

Babel得到JSX的AST后,React createElement/createFragment是如何转换的,其主要使用了babel-plugin-transform-react-jsx完成这样的功能。例如,对于我们1中的case,使用babel-plugin-ransform-react-jsx转换成了类似的代码。

"use strict";
React.createElement("div", 
  { className: "A" }, 
  React.createElement("div", 
   { className: "B" }, 
   "hello world!"
  )
);

我们以@bebel/plugin-transform-react-jsx 7.11.3版本为例来分析下React JSX到React Function Call的转换。@bebel/plugin-transform-react-jsx目前存在两种模式:

  • Classic, 经典模式。v6至今一直支持的模式,功能稳定。

  • Automic, 自动模式。v7.9.0添加的模式,默认开启JSX编译的功能。

    两者模式的功能是类似,我们分析Classic是如何实现JSX到React Function Call的功能。这里我们可以先看下@bebel/plugin-transform-react-jsx的参数配置:

  • rumtime: 模式选择,默认classic。

  • throwIfNamespace: XML命名时是否报错,默认为true。

  • Pragma: JSX Expression替换名称,默认React.createElement。

  • pragmaFrag:JSX fragments替换名称,默认React.Fragment。

看完@bebel/plugin-transform-react-jsx的基本参数后,我们可以看下它需要处理的功能部分:

  • JSX到React.createElement的转换。
  • JSX Attributin的转换。
  • JSX Children的处理。

2.1 @bebel/plugin-transform-react-jsx的代码结构

babel的插件在于visitor的功能设计,我们看到@bebel/plugin-transform-react-jsx的visitor设计如下:

  • JSXNamespacedName/JSXSpreadChild,不支持的功能标识。
  • Program,Program预处理。
  • JSXAttribute,JSX属性处理。
  • JSXElement, JSXElement节点处理。
  • JSXFragment,JSXFragment节点处理。 基于visitor的结构,我们按照“初始化”阶段、JSXElement、JSXFragment来介绍分析JSX的转换流程。

2.2 “初始化”阶段

在transform-classic.js可以看到,该babel的插件的功能可以分为4个部分:

  • 初始化options及定义Pre/Post。
  • Program。
  • JSXAttribute。

2.2.1 options的处理及定义Pre/Post

options的初始化,我们只关心pragma/pragmaFrag的处理,在React里是 React.createElement /React.Fragment,在Preact里是Preact. createElement/Preact.Fragment。

const DEFAULT = {
  pragma: "React.createElement",
  pragmaFrag: "React.Fragment",
};
export default declare((api, options) => {
  ...
  // 设置PRAGMA_DEFAULT/PRAGMA_FRAG_DEFAULT
  const PRAGMA_DEFAULT = options.pragma || DEFAULT.pragma;
  const PRAGMA_FRAG_DEFAULT = options.pragmaFrag || DEFAULT.pragmaFrag;
  ...
}

插件还定义了pre/post的方法,用于JSXElement/JSXFragment预处理,pre功能主要用来处理组件、标签。这里我们就可以看到,组件为什么不能小写开头,当我们以小写开头时,react-jsx将其转换为React.createElement('x')。 const visitor = helper({ pre(state) { const tagName = state.tagName; const args = state.args; // 若以小写开头,则标识为标签,创建为string元素 // 若以非小写开头,标识为元素 // github.com/babel/babel… if (t.react.isCompatTag(tagName)) { args.push(t.stringLiteral(tagName)); } else { args.push(state.tagExpr); } }, }); post主要用来处理注释相关,具体可以参见(babeljs.io/docs/en/bab…

post(state, pass) {
  state.callee = pass.get("jsxIdentifier")();
  state.pure = PURE_ANNOTATION ?? pass.get("pragma") === DEFAULT.pragma;
},

2.2.2 Program

plugin-transform-react-jsx的program用来处理评论和初始化一些公共参数。评论可以参照Babel的Issue看下,我们重点看下参数的初始化。通过state设置了对应如下的key对应:

  • jsxIdentifier。创建为React.createElement的标识符。
  • jsxFragIdentifier。创建为React.createFragElement的表示。
  • usedFragment。是否使用了fragment。
  • pragma。React.createElement的设置。
  • pragmaSet。pragma是否设置。
  • pragmaFragSet。pragmaFrag是否设置。
visitor.Program = {
    enter(path, state) {
      const { file } = state;
      let pragma = PRAGMA_DEFAULT;
      let pragmaFrag = PRAGMA_FRAG_DEFAULT;
      let pragmaSet = !!options.pragma;
      let pragmaFragSet = !!options.pragma;

      ...
      // 初始化相关参数,JSXElement处理     
      state.set("jsxIdentifier", createIdentifierParser(pragma));
      state.set("jsxFragIdentifier", createIdentifierParser(pragmaFrag));
      state.set("usedFragment", false);
      state.set("pragma", pragma);
      state.set("pragmaSet", pragmaSet);
      state.set("pragmaFragSet", pragmaFragSet);
    },
    exit(path, state) {
      // usedFragment但pragmaFragSet未设置时
      if (
        state.get("pragmaSet") &&
        state.get("usedFragment") &&
        !state.get("pragmaFragSet")
      ) {
        throw new Error(
          "transform-react-jsx: pragma has been set but " +
            "pragmaFrag has not been set",
        );
      }
    },
  };

2.2.3 JSXAttribute

JSXAttribute的处理很简单,将JSXElement的属性节点转换为jsxExpressionContainer。 典型的场景如<A aProps={<B b='b' />} />

visitor.JSXAttribute = function (path) {
    if (t.isJSXElement(path.node.value)) {
      path.node.value = t.jsxExpressionContainer(path.node.value);
    }
};

2.3 JSXElement/JSXFragment

JSXElement/JSXFragment的节点处理时类似的,我们重点看下JSXElement的处理。JSXElement的处理,关键在于三个处理:

  • JSXElement到React.createElement(Function call)的转换。
  • JSX Attriebute的提取与处理。
  • Children的处理与递归处理。
visitor.JSXElement = {
    exit(path, file) {
      // 调用buildElementCall处理得到Function Call
      const callExpr = buildElementCall(path, file);
      if (callExpr) {
        path.replaceWith(t.inherits(callExpr, path.node));
      }
    },
};

2.3.1 Children的处理

在JSXElement的处理函数里,使用t提供的react buildChildren完成children节点的处理。

function buildElementCall(path, file) {
    if (opts.filter && !opts.filter(path.node, file)) return;
    const openingPath = path.get("openingElement");
    // build chilren
    openingPath.parent.children = t.react.buildChildren(openingPath.parent);
    ...
  }

buildChildren函数处理了三类节点:

  • 文本节点,过滤\r\n\t等空占位符后的文本,若存在push到elements中。
  • JSXExpressionContainer节点。设置child为JSXExpressionContainer.express, push到elements中。
  • 空的表达式节点删除。
export default function buildChildren(node: Object): Array<Object> {
  const elements = [];
  for (let i = 0; i < node.children.length; i++) {
    let child = node.children[i];
    if (isJSXText(child)) {
      cleanJSXElementLiteralChild(child, elements);
      continue;
    }
    if (isJSXExpressionContainer(child)) child = child.expression;
    if (isJSXEmptyExpression(child)) continue;
    elements.push(child);
  }
  return elements;
}

2.3.2 Attribute的处理

attribute的处理相对复杂,结合了useSpread、useBuiltIns属性的设置,感兴趣的,可以深度阅读attribute的相关处理。

function buildElementCall(path, file) {
    ...
    let attribs = openingPath.node.attributes;
    if (attribs.length) {
      attribs = buildOpeningElementAttributes(attribs, file);
    } else {
      attribs = t.nullLiteral();
    }
    args.push(attribs, ...path.node.children);
    ...
}

2.3.3 JSXElement的处理

JSXElement的主要功能是将JSXElement节点转换为Function Call函数,结合代码,其主要的功能流程如下:

  • 初始化Children,如2.3.1所分析。
  • 设置ElementState。插件创建了ElementState元素存储相关的信息,包括节点、名称、参数、call函数等,完成Function.Call的初始化准备工作。
  • pre处理。
  • 属性设置。
  • 返回call。设置最终的call,const call = state.call || t.callExpression(state.callee, args),完成Function Call节点的创建。

1中我们的代码在实际上的业务中是什么样的那,如下图所示,基于Preact 10.4.6 dev模式下得到的JSX的转换代码。我们可以到JSXElement最终转换成了Preact.createElement的嵌套调用。

var _ref3 =
/*#__PURE__*/
Object(preact__WEBPACK_IMPORTED_MODULE_0__["createElement"])("div", {
  className: "A",
}, Object(preact__WEBPACK_IMPORTED_MODULE_0__["createElement"])("div", {
  className: "B",
}, "hello world!"));

3. VDOM的转换

我们以Preact10.4.6为基准,看下Preact是如何实现从JSX到Vdom tree的转换的。

3.1 createVNode

在createElement,createVNode的定义如下:

export function createVNode(type, props, key, ref, original) {
    const vnode = {
        type,    // 节点,对应标签(div)/组件(Component)
        props,   // 属性值, props集合
        key,     // key值, <div key={xx} />
        ref,     // ref值, <div ref={xx} />
        _children: null, // children 节点集合,null/Array
        _parent: null,   // 父节点,子元素的父节点
        _depth: 0,       // 节点深度
        _dom: null,      // 原生dom节点值,
        _nextDom: undefined, // 下一个兄弟节点
        _component: null,    // 实例化的React Component
        constructor: undefined, // React Component 构造函数
        _original: original     // 源节点内容
    };
    // origin节点默认为当前node
    if (original == null) vnode._original = vnode;
    // 存在vnode, 完成vnode初始化
    if (options.vnode) options.vnode(vnode);
    return vnode;
}

preact只保留了最核心的节点值,包括type、props、key、ref等。这些属性值,在后面的diff、html转换中才会使用,后续的分析将会进一步分析相关的属性值。

出去了节点定义外,我们看到Preact的createVNode处理了original、options.vnode,然后就返回了创建的node节点。

export function createVNode(type, props, key, ref, original) {
    const vnode = {...}
    if (original == null) vnode._original = vnode;
    if (options.vnode) options.vnode(vnode);
    return vnode;
}

3.2 createElement

一般对于的JSX Element,Webpack +Babel将其转换为createElement(App, null)的形式,createElement完成节点的创建及初始化工作。

Preact10.4.6的源代码如下,其基本的处理流程如下:

  • 归一化props。去除key和ref之外的props,得到归一化后的normalizedProps。
  • 第三个及之后的参数转化为child数组并设置normalizedProps.children。
  • type为函数(对应Component)时,defaultProps合并到normalizedProps。
  • 调用createVNode生成虚拟的节点,并返回。
export function createElement(type, props, children) {
    let normalizedProps = {},
        i;
    for (i in props) {
        if (i !== 'key' && i !== 'ref') normalizedProps[i] = props[i];
    
    if (arguments.length > 3) {
        children = [children];
        for (i = 3; i < arguments.length; i++) {
            children.push(arguments[i]);
        }
    
    if (children != null) {
        normalizedProps.children = children;
    }

    if (typeof type == 'function' && type.defaultProps != null) {
        for (i in type.defaultProps) {
            if (normalizedProps[i] === undefined) {
                normalizedProps[i] = type.defaultProps[i];
            }
        }
    }
    let result = createVNode(
        type,
        normalizedProps,
        props && props.key,
        props && props.ref,
        null
    );
    return result;
}

分析完Preact createNode/createElement的函数功能,我们看1/2中的demo实际生成的节点结构如下图。

4. 总结

通过对JSX在Preact的转化编译过程,我们可以看到,JSX到Vdom的三个核心节点:

  • Babel AST的转换,完成JSX语法得构建。

  • Babel/plugin-trasnform-react-jsx的转换,将JSXElement/JSXFragElement转换为Function Call,完成编译期的处理。

  • Preact createVnode/createElement的处理,完成vdom tree的构建。

    打个小广告,团队持续扩展中,欢迎各位同学投递。大家可以如下的方式进行投递:

  • 通过如下的链接投递(job.toutiao.com/s/JMpw6Rr).

  • 可以将简历发送我的邮箱(leisureljc@outlook.com),我来给您投递。

5. 参考文档