基于JSX的动态数据绑定

基于JSX的动态数据绑定

笔者在 2016-我的前端之路:工具化与工程化一文中提及,前端社区用了15年的时间来分割HTMLJavaScriptCSS,但是随着JSX的出现仿佛事物一夕回到解放前。在AngularVue.jsMVVM前端框架中都是采用了指令的方式来描述业务逻辑,而JSX本质上还是JavaScript,即用JavaScript来描述业务逻辑。虽然JSX被有些开发者评论为丑陋的语法,但是笔者还是秉持JavaScript First原则,尽可能地用JavaScript去编写业务代码。在前文 React初窥:JSX详解中我们探讨了JSX的前世今生与基本用法,而本部分我们着手编写简单的面向DOMJSX解析与动态数据绑定库;本部分所涉及的代码归纳于 Ueact 库。

JSX解析与DOM元素构建

元素构建

笔者在 JavaScript语法树与代码转化实践一文中介绍过Babel的原理与用法,这里我们仍然使用Babel作为JSX语法解析工具;为了将JSX声明转化为 createElement 调用,这里需要在项目的.babelrc文件中做如下配置:

{
  "plugins": [
    "transform-decorators-legacy",
    "async-to-promises",
    [
      "transform-react-jsx",
      {
        "pragma": "createElement"
      }
    ]
  ]
}

这里的 createElement函数声明如下:

/**
 * Description 从 JSX 中构建虚拟 DOM
 * @param tagName 标签名
 * @param props 属性
 * @param childrenArgs 子元素列表
 */
export function createElement(
  tagName: string,
  props: propsType,
  ...childrenArgs: [any]
) {}

该函数包含三个参数,分别指定标签名、属性对象与子元素列表;实际上经过Babel的转化之后,JSX文本会成为如下的函数调用(这里还包含了ES2015其他的语法转化)

// ...
  (0, _createElement.createElement)(
  'section',
  null,
  (0, _createElement.createElement)(
  'section',
  null,
  (0, _createElement.createElement)(
  'button',
  { className: 'link', onClick: handleClick },
  'Custom DOM JSX'
  ),
  (0, _createElement.createElement)('input', {
  type: 'text',
  onChange: function onChange(e) {
  console.log(e);
  }
  })
  )
  ),
// ...

在获取到元素标签之后,我们首先要做的就是创建元素;创建元素 createElementByTag 过程中我们需要注意区分普通元素与SVG元素:

export const createElementByTag = (tagName: string) => {
  if (isSVG(tagName)) {
  return document.createElementNS('http://www.w3.org/2000/svg', tagName);
  }
  return document.createElement(tagName);

};

属性处理

在创建了新的元素对象之后,我们需要对createElement函数传入的后续参数进行处理,也就是为元素设置对应的属性;基本的属性包含了样式类、行内样式、标签属性、事件、子元素以及朴素的HTML代码等。首先我们需要对子元素进行处理:

const children = flatten(childrenArgs).map(child => {
  // 如果子元素同样为 Element,则创建该子元素的副本
  if (child instanceof HTMLElement) {
    return child;
  }

  if (typeof child === 'boolean' || child === null) {
    child = '';
  }

  return document.createTextNode(child);
});

这里可以看出,对createElement函数的执行是自底向上执行的,因此传入的子元素参数实际上是已经经过渲染的HTML元素。接下来我们还需要对其他属性进行处理:

...// 同时支持 class 与 className 设置
const className = props.class || props.className;

// 如果存在样式类,则设置
if (className) {
  setAttribute(tagName, el, 'class', classNames(className));
}

// 解析行内样式
getStyleProps(props).forEach(prop => {
  el.style.setProperty(prop.name, prop.value);
});

// 解析其他 HTML 属性
getHTMLProps(props).forEach(prop => {
  setAttribute(tagName, el, prop.name, prop.value);
});

// 设置事件监听,这里为了解决部分浏览器中异步问题因此采用同步写法
let events = getEventListeners(props);

for (let event of events) {
  el[event.name] = event.listener;
}...

React中还允许直接设置元素的内部HTML代码,这里我们也需要判断是否存在有dangerouslySetInnerHTML属性:

if (setHTML && setHTML.__html) {
  el.innerHTML = setHTML.__html;
} else {
  children.forEach(child => {
    el.appendChild(child);
  });
}

到这里我们就完成了针对JSX格式的朴素的DOM标签转化的createElement函数,完整的源代码参考这里

简单使用

这里我们依旧使用 create-webpack-app脚手架来搭建示例项目,这里我们以简单的计数器为例描述其用法。需要注意的是,本部分尚未引入双向数据绑定,或者说是自动状态变化更新,还是使用的朴素的DOM选择器查询更新方式:

// App.js
import { createElement } from "../../../src/dom/jsx/createElement";

// 页面内状态
const state = {
  count: 0,
};

/**
 * Description 点击事件处理
 * @param e
 */
const handleClick = (e) => {
  state.count++;
  document.querySelector("#count").innerText = state.count;
};

export default (
  <div className="header">
    {" "}
    <section>
      {" "}
      <section>
        {" "}
        <button className="link" onClick={handleClick}>
          Custom DOM JSX{" "}
        </button>
        <input
          type="text"
          onChange={(e) => {
            console.log(e);
          }}
        />
         {" "}
      </section>{" "}
    </section>
    <svg>
      <circle cx="64" cy="64" r="64" style="fill: #00ccff;" />
       {" "}
    </svg>
    <br />
     {" "}
    <span id="count" style={{ color: "red" }}>
      {state.count} {" "}
    </span>{" "}
  </div>
);

// client.js
// @flow

import App from "./component/Count";

document.querySelector("#root").appendChild(App);

数据绑定

当我们使用Webpack在后端编译JSX时,会将其直接转化为JavaScript中函数调用,因此可以自然地在作用域中声明变量然后在JSX中直接引用;不过笔者在设计Ueact时考虑到,为了方便快速上手或者简单的H5页面开发或者已有的代码库的升级,还是需要支持运行时动态编译的方式;本部分我们即讨论如何编写JSX格式的HTML模板并且进行数据动态绑定。本部分我们的HTML模板即是上文使用的JSX代码,不同的是我们还需要引入babel-standalone以及Ueactumd模式库:

然后在本页面的script标签中,我们可以对模板进行渲染并且绑定数据:

<script>
  var ele = document.querySelector("#inline-jsx");

  Ueact.observeDOM(
    ele,
    {
      state: {
        count: 0,
        delta: 1,
        items: [1, 2, 3],
      },
      methods: {
        handleClick: function () {
          this.state.count += this.state.delta;
          this.state.items.push(this.state.count);
        },
        handleChange: function (e) {
          let value = parseInt(e.target.value);
          if (!Number.isNaN(value)) {
            this.state.delta = value;
          }
        },
      },
      hooks: {
        mounted: function () {
          console.log("mounted");
        },
        updated: function () {
          console.log("updated");
        },
      },
    },
    Babel
  );
</script>

这里我们调用Ueact.observeDOM函数对模板进行渲染,该函数会获取指定元素的 outerHTML 属性,然后通过Babel动态插件进行编译:

let input = html2JSX(ele.outerHTML);

let output = Babel.transform(input, {
  presets: ["es2015"],
  plugins: [
    [
      "transform-react-jsx",
      {
        pragma: "Ueact.createElement",
      },
    ],
  ],
}).code;

值得一提的是,因为HTML语法与JSX语法存在一定的差异,我们获取渲染之后的DOM对象之后,还需要对部分元素语法进行修正;主要包括了以下三个场景:

  • 自闭合标签处理,即 <input > => <input />
  • 去除输入的HTML中的事件监听的引号,即onclick="{methods.handleClick}"=> onclick={methods.handleClick}
  • 移除value值额外的引号,即value="{state.a}"=> value={state.a}

到这里我们得到了经过Babel转化的函数调用代码,下面我们就需要去执行这部分代码并且完成数据填充。最简单的方式就是使用 eval 函数,不过因为该函数直接暴露在了全局作用域下,因此并不被建议使用;我们使用动态构造Function的方式来进行调用:

/**
 * Description 从输入的 JSX 函数字符串中完成构建
 * @param innerContext
 */
function renderFromStr(innerContext) {
  let func = new Function(
    "innerContext",
    `
 let { state, methods, hooks } = innerContext;
 let ele = ${innerContext.rawJSX}
 return ele;
  `
  ).bind(innerContext); // 构建新节点

  let newEle: Element = func(innerContext); // 使用指定元素的父节点替换自身

  innerContext.root.parentNode.replaceChild(newEle, innerContext.root); // 替换完毕之后删除旧节点的引用,触发 GC

  innerContext.root = newEle;
}

innerContext 即包含了我们定义的StateMethods等对象,这里利用JavaScript词法作用域(Lexical Scope)的特性进行变量传递;本部分完整的代码参考这里

变化监听与重渲染

笔者在 2015-我的前端之路:数据流驱动的界面中讨论了从以DOM为核心到数据流驱动的变化,本部分我们即讨论如何自动监听状态变化并且完成重渲染。这里我们采用监听JavaScript对象属性的方式进行状态变化监听,采用了笔者另一个库 Observer-X,其基本用发如下:

import { observe } from "../../dist/observer-x";

const obj = observe(
  {},
  {
    recursive: true,
  }
);

obj.property = {};

obj.property.listen((changes) => {
  console.log(changes);
  console.log("changes in obj");
});

obj.property.name = 1;

obj.property.arr = [];

obj.property.arr.listen((changes) => {
  // console.log('changes in obj.arr');
});

// changes in the single event loop will be print out

setTimeout(() => {
  obj.property.arr.push(1);

  obj.property.arr.push(2);

  obj.property.arr.splice(0, 0, 3);
}, 500);

核心即是当某个对象的属性发生变化(增删赋值)时,触发注册的回调事件;即:

// ...
// 将内部状态转化为可观测变量
let state = observe(innerContext.state); // ...
state.listen((changes) => {
  renderFromStr(innerContext);
  innerContext.hooks.updated && innerContext.hooks.updated();
}); // ...

完整的在线Demo可以查看基于JSXObserver-X的简单计数器

上一页
下一页