React 可视化开发工具 shadow-widget 最佳实践(上)

本文介绍 "React + Shadow Widget" 应用于通用 GUI 开发的最佳实践,只聚焦于典型场景下最优开发方法。分上、下两篇讲解,上篇概述最佳实践,介绍功能块划分。

Thumbnail

1. 最佳实践概述

按遵循 ES5 与 ES6+ 区分,Shadow Widget 支持两种开发方式,一是用 ES5 做开发,二是搭建 Babel 转译环境用 ES6+ 做开发,之所以划分两大类,因为它们之间差别不仅仅是 javascript 代码转译,而是涉及在哪个层面定义 React Class,进而与源码在上层还是下层维护,以及与他人如何协作等相关。

如本系列博客《shadow-widget 的非可视开发方法》一文介绍,用 ES5 定义 React class 的方式是:

var MyButton = T.Button._createClass( {
  getDefaultProps: function() {
    var props = T.Button.getDefaultProps();
    // props.attr = value;
    return props;
  },
  
  getInitialState: function() {
    var state = this._getInitialState(this);
    // ...
    return state;
  }
  
  $onClick: function(event) {
    alert('clicked');
  }
});

而用 ES6+ 开发,这么定义 React class:

class MyButton_ extends T.Button_ {
  constructor(name,desc) {
    super(name,desc);
  }
  
  getDefaultProps() {
    var props = super.getDefaultProps();
    // props.attr = value;
    return props;
  }
  
  getInitialState() {
    var state = super.getInitialState();
    // ...
    return state;
  }

  $onClick: function(event) {
    alert('clicked');
  }
}

var AbstractButton = new MyButton_();  // MyButton_ is WTC
var MyButton = AbstractButton._createClass(); // MyButton is React class

由于 ES6+ 语法能兼容 ES5,所以,即使采用 ES6+ 开发方式,前一种 ES5 的 React class 定义方法仍然适用。但,自定义扩展一个 WTC 类必须用 ES6+,就象上面 "class MyButton_ extends T.Button_" 语法,只能在 ES6+ 下书写。

考虑到用 ES5 编程不必搭建 Babel 开发环境,ES5 能被 ES6+ 兼容,向 ES6+ 迁移只是整体平移,不必改源码。加上 Shadow Widget 及第 3 方类库,已提供够用的基础 WTC 类(这意味着我们并不迫切依赖于用 ES6+ 扩展 WTC),所以,我们将 Shadow Widget 最佳实践确定为:用 ES5 实施主体开发

Shadow Widget 最佳开发实践的大致操作过程如下:

  1. 创建一个新的工程,参见《Shadow Widget 用户手册》(下面简称《手册》)中 “5.1.1 创建工程” 一节
    应选择一个合适的 "网页样板" 来创建,Shadow Widget 是一个可继承重用的 lib 库体系,最基础的是 shadow-widget 库自身,其上还有 shadow-slidepinp-blogs 等扩展库,各个扩展项目一般会提供它本层的网页样板(通常放在 /output/shared/pages/ 目录下)。
  2. 在创建的网页文件追加 代码
    然后在 your_file.js 文件编写 ES5 代码。
  3. 使用 Shadow Widget 的可视设计器设计用户界面
    用户界面设计的结果以转义标签的形式,保存在你的 "*.html" 网页文件中,然后你可以在 your_file.js 同步编写 JS 代码。
  4. 完成开发与测试后,把相关的 html, js, css 等文件上传发布到服务器发布
    因为不必做 ES6 转译,发布操作很直接。或许您要调整 js, css, png 等文件位置,或许您需 minify 某个 JS 文件,这些都是前端开发的基本技能,不是 Shadow Widget 特有的。

最佳实践还建议多用 idSetter 函数定义各 component 的行为,不用(或少用)在 main[path] 定义投影类的方式,因为 idSetter 的函数式风格,让 MVVM 与 Flux 两种框架的交汇点处理起来更便利。

接下来,在展开细节介绍之前,我们先梳理一下 Shadow Widget 技术体系的几个特色概念。

2. p-statev-state

p-statev-state 是 uglee 在 《少妇白洁系列之 React StateUp Pattern, Explained》 一文提出的概念,我们借用过来解释 React 中的数据流转模式。p-statepersistent state,是生命周期超过组件本身的 state 数据,即使组件从 DOM 上销毁,这些数据仍然需要在组件外部持久化。v-statevolatile state,是生命周期和组件一样的 state 数据,如果组件从 DOM 上销毁,这些 state 将一起销毁。

结合 Flux 框架,v-state 就是 comp.props.xxxcomp.state.xxx 数据,p-state 就是 store 里的数据,这么说虽有失严谨,但大致如此。如果未使用 Flux 框架,对 comprender() 过程产生影响的所有数据中,全局变量或其它节点(包括上级节点)中的属性,都算当前节点的 p-state

不过,v-statep-state 划分是静态的,相对而言的。比如,初始设计界面只要求显示摄氏度(Celsius)格式的温度值,然后觉得要适应全球化应用,摄氏度与华氏度(Fahrenheit)都得显示,再往后发现,Celsius 与 Fahrenheit 并列显示不够友好,就改成动态可配置,取国别信息后自动设成两者中一个。这种设计变迁中,“当前温度格式” 与 “并列显示或只显示一种” 的配置数据经常在 v-statep-state 之间变迁。

React 工具链上几个 Flux 框架主要区别在于,如何定位与使用 p-state,它们对 v-state 使用基本一致,我们拿 reflux、redux、shadow-widget 三者分别举例。

Reflux 采用多 store,其 store 设计与 component 很接近,可以这么简单理解:既然跨 Component 存在数据交互,父子关系可以用 props 传递,非父子关系传不了,怎么办呢?那就设立第三方实体(也就是 store)处理此事。Redux 采用单 store,把它理解成一大坨全局变量就好,它以 action 设计为提纲,围绕 action 组织 reducer 函数,而 Reflux 中提纲挈领的东西则是 store 中的数据,围绕数据组织 action 定义。若对比这两者,Reflux 方式更易理解,需求分解与设计展开过程更人性化,不过,Reflux 没有突破 React 固有限制,因为多 store 模式,实践中大家经常很纠结某项数据该放在 component 中,还是放在 store 中呢?如前所述,一项数据是否为 v-state 是相对的,产品功能叠代后,数据经常要从 v-state 提升到 p-state,或者,若原设计偏于宽泛,还需将 p-state 降回 v-state。Reflux 困境在于 Store 设计与 Component 不对称,顺应来回变迁的成本较高。

Shadow Widget 也是多 Store,Component 自身就是 store,这克服了 Reflux 主要不足。另外结合 MVVM 架构的可视化特点,Shadow Widget 还克服了 redux 主要不足。

3. 几种 Lift State Up 方式

Shadow Widget 介绍了一种 “逆向同步 & 单向依赖” 的机制,在如下节点树中,nodeE 要使用 nodeC 中的数据,但 nodeC 生存周期与 nodeE 并不一致,所以,引入一种机制,在它们共同的父节点 nodeA 设置一个属性(比如 attrX),nodeC 中的该数据能自动同步到 nodeA 中,然后让 nodeE 只依赖 nodeA 中的数据(比如 attrX),只要 NodeE 还存活,父节点 nodeD 与 nodeA 必然存活。

  nodeA
  +-- nodeB
  |   +-- nodeC
  +-- nodeD
  |   +-- nodeE

React 官方介绍了一种 "Lifting State Up" 方法,借助函数式编程的特点,把控制界面显示效果的变量,从子节点提升到父节点,子节点的事件函数改在父节点定义,就达到 Lift State Up 的效果。

既然提升 state 能突破 React 对数据传递的限制,那么,极端一点,能否把所有用到的数据都改成全局变量呢?答案当然可以,不过缺少意义,这么做,无非将分散在各节点的逻辑,转移到处理一堆全局变量而己,设计过程本该分解,而非合并。可视节点分层分布本是天然的功能划分方式,放弃它改换门庭无疑把事情搞复杂了,可恶的 Redux 就是这么干的。

从本质上看,Redux 把 state 数据全局化了(成为单 store),但它又以 action 主导切割数据,你并不能直接存取全局 store,而是改由 action 驱动各个 reducer,各 reducer 只孤立处理它自身可见的 state。由此我有两点推论:

  1. 弃用界面现成的分解方式,改建另一套体系并不明智
    就像描述双人博击,最直接的方式是先区分场上谁是谁,谁出击,谁防守,出击者挥拳,防守者缩头躲避。Redux 行事风格是先设计 “挥拳”、“缩头” 之类的 action,然后分解实施这些 action,来驱动各种 state 变化。该模式之所以行得通,不是 Redux 有多好,而是人脑太奇妙,编程中除了脑补产品应用场景,偶尔还会插帧处理俊男靓女图片 :)
  2. 数据隔离是必需的,否则无法应对大规模产品开发
    后文我们将介绍最佳实践中的数据隔离方法,以功能场景为依据。

4. 功能块

为方便说明问题,我们取 React 官方 "Lifting State Up" 一文介绍的,判断温度是否达到沸点的应用场景,编写一段样例代码。

我们想设计如下界面:

Temperature

4.1 样例程序的功能

如果输入温度未超沸点,界面显示 "The water would not boil",若超沸点则显示 "would boil"。另外,用于输入温度的方框(即后述的 field 节点)要求可配置,用 scale='c' 指示以摄氏度表示,标题提示 "Temperature in Celsius",否则 scal='f' 指示华氏度,提示 "in Fahrenheit"

我们在 Shadow Widget 可视设计器中完成设计,存盘后生成的转义标签如下:

legend

然后在 JS 文件编写如下代码:

if (!window.W) { window.W = new Array(); W.$modules = [];}
W.$modules.push( function(require,module,exports) {

var React = require('react');
var ReactDOM = require('react-dom');
var W = require('shadow-widget');

var main = W.$main, utils = W.$utils, ex = W.$ex;
var idSetter = W.$idSetter;

if (W.__design__) return;

(function() { // functionarity block

var selfComp = null, verdictComp = null;
var scaleNames = { c:'Celsius', f:'Fahrenheit' };

idSetter['calculator'] = function(value,oldValue) {
  if (value <= 2) {
    if (value == 1) {      // init
      selfComp = this;
      this.defineDual('temperature', function(value,oldValue) {
        if (Array.isArray(value) && verdictComp) {
          var scale = value[0], degree = value[1];
          var isBoil = degree >= (scale == 'c'?100:212);
          verdictComp.duals['html.'] = isBoil?
            'The water would boil.':
            'The water would not boil.';
        }
      });
    }
    else if (value == 2) { // mount
      verdictComp = this.componentOf('verdict');
      
      var field = this.componentOf('field');
      var inputComp = field.componentOf('input');
      var legend = field.componentOf('legend');
      var sScale = field.props.scale || 'c';
      legend.duals['html.'] = 'Temperature in ' + scaleNames[sScale];
      
      inputComp.listen('value',onInputChange.bind(inputComp));
      this.duals.temperature = [ sScale,
        parseFloat(inputComp.duals.value) || 0
      ];
    }
    else if (value == 0) { // unmount
      selfComp = verdictComp = null;
    }
    return;
  }
  
  function onInputChange(value,oldValue) {
    var scale = this.parentOf().props.scale || 'c';  // 'c' or 'f'
    var degree = parseFloat(value) || 0; // take NaN as 0
    selfComp.duals.temperature = [scale,degree];
  }
};

})();

});

上面 if (W.__design__) return 一句,让其后代码在 __design__ 态时(即,在可视设计器中)不生效。

4.2 功能块

按我们最佳实践的做法,界面可视化设计的结果保存在页面 *.html 文件,而界面的代码实现(包括定义事件响应、绑捆数据驱动等)在 JS 文件编写。所以,上面例子的设计结果包括两部分:*.html 文件中的转义标签与 *.js 文件中的 javascript 脚本。

多个组件共同完成某项特定功能,他们合起来形成逻辑上的整体叫做 “功能块” (Functionarity Block)。典型的 JS 文件通常按这个样式编写:

if (!window.W) { window.W = new Array(); W.$modules = [];}
W.$modules.push( function(require,module,exports) {

// 全局变量定义
var React = require('react');
var ReactDOM = require('react-dom');
var W = require('shadow-widget');

var main = W.$main, utils = W.$utils, ex = W.$ex;
var idSetter = W.$idSetter;

if (W.__design__) return;

// 功能块定义
(function() {

// ....

})()

// 初始化定义
main.$onLoad.push( function() {
  // ...
});

});

头部用来定义若干全局变量,然后定义功能块,功能块可能有多个,上面举例的判断温度是否超沸点,比较简单,定义一个功能块就够了,最后定义 main.$onLoad 全局初始化函数。

之所以将一个功能块用一个函数包裹,主要为了构造独立的命名空间(Namespace),比如前面举例的代码:

(function() { // functionarity block

var selfComp = null, verdictComp = null;
var scaleNames = { c:'Celsius', f:'Fahrenheit' };

idSetter['calculator'] = function(value,oldValue) {
  // ...
};

})();

由功能块函数构造的 Namespace 也称 “功能块空间”(Functionarity Block Space),在功能块内共享的变量在此定义,比如这里的 selfComp, verdictComp, scaleNames 变量。

4.3 功能块入口节点

一个功能块的入口节点是特殊节点,它的生存周期反映了功能块的生存周期。它的各层子节点若还存在(即在 unmount 之前),入口节点必然存在。因为入口节点的生存期能完整覆盖它各级子节点的生存期,所以,我们一般在入口节点定义 idSetter 函数,承担本功能块的主体逻辑处理。

上例的功能块定义了如下节点树:

 Panel (key=calculator)
  +-- Fieldset (key=field)
  |   +-- Legend (key=legend)
  |   +-- Input (key=input)
  +-- P (key=verdict)

入口节点是 calculator 面板,结合该节点的 idSetter 函数书写特点,我们接着介绍 Shadow Widget 最佳实践如何处理 "功能块" 之内的编程。

 

1) 为方便编程,不妨在 “功能块空间” 多定义变量

因为 “功能块空间” 的变量不外泄到其它功能块,我们不必担心多定义变量会给其它部分编码带来 Side Effects。功能块里各个节点,只要不是动态创建、删除、再创建那种,都可定义成 “功能块空间” 的变量,我们一般在入口节点 idSetter 函数的 unmount 代码段(即 if (value == 0)),把各个节点的变量置回 null 值。

对于动态增删的节点,不妨用 this.componentOf(sPath) 动态方式定位。

 

2) 功能块内的数据主体流向,宜在界面设计时就指定

在功能块的 idSetter 函数也能以编程方式设计节点间数据流向,考虑到界面设计与数据流规则直接相关,能以描述方式(转义标签形式)表达数据流的,尽量用描述方式,不方便的才用 JS 编程方式去实现。因为,一方面,Shadow Widget 的指令式 UI 描述能力够强,另一方面,这么做有助于让 MVVM 中的 ViewModel 集中,从而降低设计复杂度。

界面设计时,不妨多用下述技巧:

  1. $for=''$$for='' 开启一层 callspace,方便其下节点的可计算属性用 duals.attr 引用数据。
  2. 善用 $trigger 同步数据
  3. 如果节点层次复杂,不妨采用导航面板(NavPanelNavDiv),用 "./xx.xx" 相对路径方式让节点定位更方便

 

3) 善用变量共享机制

若按 React 原始开发方式编码,不借助任何 Flux 框架工具,大家肯定觉得编程很不方便,因为各节点除了能往子节点单向传递 props 外,与其它节点的交互几乎隔了一道黑幕。然而,不幸的是,React 几个主流的 Flux 工具,均没有妥善解决几个主要问题,上面提到的 Reflux、Redux 均如此,React 官方的 react-flux 更难用。

相对而言,Shadow Widget 的解决方案好很多,一方面,在 Component 节点引入 “双源属性”,功能强大,能让基于过程组装的 UI 渲染,过渡到 以属性变化来驱动渲染,即:除了 “功能块” 的入口节点需集中编写控制逻辑,其它节点的编程,基本简化为定制若干 duals 函数(用 defineDual() 注册)。另一方面,Shadow Widget 借助 Functionarity Block 抽象层来重组数据,以功能远近作聚合依据,明显比以 Action 驱动的 Reducer 分割要高明。

从本质上讲,拎取 “功能块抽象层” 也是 Lift State Up 的一种手段,限制更少,结合于 JS 编程也更自然。虚拟 DOM 树中的各 component 节点有隔离措拖,不能互相识别,但函数编程没什么限制,比如上面例子,selfComp = this 把一个 Component 赋给 “功能块空间” 的变量 selfComp 后,同在一个功能块的其它函数都能使用它了。

(未完,下篇待续...)

 

你可能感兴趣的:(javascript,react.js,frontend,frameworks,wysiwyg)