数据流驱动的界面

数据流驱动的界面

广义的数据流驱动的界面有很多的理解,其一是界面层的从以DOM操作为核心到逻辑分离,其二是数据交互层的前后端分离。在jQuery时代,我们往往将DOM操作与逻辑操作混杂在一起,再加上模块机制的缺乏使得代码的可读性、可测试性与可维护性极低;随着项目复杂度的增加、开发人员的增加与时间的推移,项目的维护成本会以几何级数增长。随着ES6 Modules的广泛应用,我们在前端开发中更易于去实践SRP单一职责原则,也更方便地去编写单元测试、集成测试等来保证代码质量。而像ReactVue这样现代的视图层库为我们提供了声明式组件,托管了从数据变化到DOM操作之间的映射,使得开发者能够专注于业务逻辑本身。并且Redux, MobX这样独立的状态管理库,又可以将产品中的视图层与逻辑层剥离,保证了逻辑代码的易于测试性与跨端迁移性,促进了前端的工程化步伐。

而随着从传统的前后端未分离的巨石型Web应用,到SPA这样的富客户端前后端分离应用,前后端各成体系,能够应用不同的技术选型与项目架构。原本由服务端负责的数据渲染工作交由前端进行,并且规定前端与服务端之间只能通过标准化协议进行通信,给与了双方更好地灵活性与适应性。前后端分离也促成了组织架构上的分离,由早期的服务端开发人员顺手去写个界面转变为完整的前端团队构建工程化的前端架构。近两年来随着无线技术的发展和各种智能设备的兴起,互联网应用演进到以API驱动的无线优先(Mobile First)和面向全渠道体验(omni-channel experience oriented)的时代,BFF这样前端优先的API设计模式与GraphQL这样的查询语言也得到了大量的关注与应用。

DOM操作与逻辑的剥离

命令式编程与声明式编程

An imperative approach (HOW): I see that table located under the Gone Fishin’ sign is empty. My husband and I are going to walk over there and sit down.

A declarative approach (WHAT): Table for two, please.

The imperative approach is concerned with HOW you’re actually going to get a seat. You need to list out the steps to be able to show HOW you’re going to get a table.The declarative approach is more concerned with WHAT you want, a table for two.

Imperative: C, C++, Java

Declarative: SQL, HTML

(Can Be) Mix: JavaScript, C#, Python

Think about your typical SQL or HTML example,

SELECT * FROM Users WHERE Country=Mexico;
<article>
  <header>
    <h1>Declarative Programming</h1>

    <p>Sprinkle Declarative in your verbiage to sound smart</p>
  </header>
</article>
const photos = ["photoMoon", "photoSun", "photoSky"];

const cameras = ["canon", "nikon", "Sony"];

// can be const since it's an array will keep as let to identify that it's being changed

let artists = [
  {
    name: "Thomas Kelley",

    link: "https://unsplash.com/@thkelley",
  },
  {
    name: "Thomas Kvistholt",

    link: "https://unsplash.com/@freeche",
  },
  {
    name: "Guillaume Flandre",

    link: "https://unsplash.com/@gflandre",
  },
];

function artistsCorrect() {
  for (let i = 0; i < artists.length; i++) {
    artists[i].photo = photos[i];

    artists[i].camera = cameras[i];
  }
}

artistsCorrect();

console.log("result ->", artists);
const photos = ["photoMoon", "photoSun", "photoSky"];

const cameras = ["canon", "nikon", "Sony"];

const artists = [
  {
    name: "Thomas Kelley",

    link: "https://unsplash.com/@thkelley",
  },
  {
    name: "Thomas Kvistholt",

    link: "https://unsplash.com/@freeche",
  },
  {
    name: "Guillaume Flandre",

    link: "https://unsplash.com/@gflandre",
  },
];

function artistsCorrect(artistsList, photosList, camerasList) {
  return artistsList.map((artist, index) => {
    const extraValues = {
      photo: photosList[index],

      camera: camerasList[index],
    };

    return Object.assign({}, artist, extraValues);
  });
}

const result = artistsCorrect(artists, photos, cameras);

console.log("result ->", result);

声明式组件

jQuery作为了影响一代前端开发者的框架,是前端工具的典型代表,它留下了璀璨的痕迹与无法磨灭的脚印;笔者则是以jQuery为符号,代指以DOM节点操作为核心的前端开发风格。所谓以DOM操作为核心,即要插入数据或者更改数据,都是直接操作DOM节点,或者手工的构造DOM节点;譬如从服务端获得一个用户列表之后,会通过构造 <i> 节点的方式将数据插入到DOM树中。而随着现代浏览器的发展与逐步统一的原生API的丰富,应用复杂度的增加,并且直接的DOM操作不支持同构渲染与跨平台渲染,还存在着较大的性能缺陷,前端逐步从以DOM为中心过渡到了以数据/状态为中心。

在现代Web开发中,无论是Angular、Web Components、React还是Vue.js,都是以声明式组件为编程基础;我们在上文中提到过,声明式编程的核心理念在于描述做什么,通过声明式的方式我们能够以链式方法调用的形式对于输入的数据流进行一系列的变换处理。譬如以jQuery开发简单的表单校验与提交,最简单直观的命令式编程方式如下:

// Email field
$("#email-field").on("blur", function () {
  username = $(this).val();
  if (username == "") {
    $("#email-error").html("Please enter email address");
    $("#signup-button").attr("disabled", true);
  } else {
    checkValues();
  }
});

这种方式也显而易见的存在很多的代码冗余,导致整体的可读性与重构性降低,譬如邮箱与密码这两个输入域都会在失去焦点时进行验证,并且判断是否设置按钮失效。而在声明式编程中,我们可以将公用的部分业务逻辑代码,即是偏向于计算的、表达式形式的代码剥离出来,可以得到如下的封装:

function checkIfEmpty(e) {
  return !e.target.value;
}
function checkIfBothEmpty(noEmail, noPass) {
  return noEmail || noPass;
}

function getEmailMessage(noEmail) {
  return noEmail ? "Please enter email address." : "";
} // Email field

var email = $("#email-field").asEventStream("blur").map(checkIfEmpty);
email.map(getEmailMessage).assign($("#email-error"), "html");

代码更加清晰易懂,并且对于空判断这些公共逻辑代码的提出也方便了我们进行重构或者对于业务逻辑的变化进行快速响应。未来如果我们需要添加CheckboxDialogBox等控件时,声明式的代码增加会远小于命令式,并且我们也只是需要创建新的数据流而已。而采用JSX的方式,我们可以更加直观地将布局与逻辑混合编排:

<select value={this.state.value} onChange={this.handleChange}>
  {somearray.map((element) => (
    <option value={element.value}>{element.text}</option>
  ))}
</select>

JSX并非等价于HTML元素,通过Babel等转换工具其会被

组件化也允许我们更好地分割前端代码:

声明式组件并非禁绝了DOM操作,而是将DOM操作这样与浏览器运行时的交互交付给了框架,开发者可以专注于上层应用逻辑的开发;这种分层隔离本身也保证了应用的可迁移性,譬如React NativeWeex这样的框架,就允许我们将相同的代码分别渲染到不同的原生平台中。声明式组件的核心函数即是 View = f(Template, Props)(State)

Virtual DOM

如我们所知,在浏览器渲染网页的过程中,加载到HTML文档后,会将文档解析并构建DOM树,然后将其与解析CSS生成的CSSOM树一起结合产生爱的结晶 ——RenderObject树,然后将RenderObject树渲染成页面(当然中间可能会有一些优化,比如RenderLayer)。这些过程都存在与渲染引擎之中,渲染引擎在浏览器中是于JavaScript引擎(JavaScriptCore也好V8也好)分离开的,但为了方便JS操作DOM结构,渲染引擎会暴露一些接口供JavaScript调用。由于这两块相互分离,通信是需要付出代价的,因此JavaScript调用DOM提供的接口性能不咋地。各种性能优化的最佳实践也都在尽可能的减少DOM操作次数。而虚拟DOM干了什么?它直接用JavaScript实现了DOM(大致上)。组件的HTML结构并不会直接生成DOM,而是映射生成虚拟的JavaScript DOM结构,React又通过在这个虚拟DOM上实现了一个diff算法找出最小变更,再把这些变更写入实际的DOM中。这个虚拟DOMJS结构的形式存在,计算性能会比较好,而且由于减少了实际DOM操作次数,性能会有较大提升。React渲染出来的HTML标记都包含了data-reactid属性,这有助于React中追踪DOM节点。很多人第一次学习React的时候都会觉得JSX语法看上去非常怪异,这种背离传统的HTML模板开发方式真的靠谱吗?(2.0版本中Vue也引入了JSX语法支持)。我们并不能单纯地将JSX与传统的HTML模板相提并论,JSX本质上是对于React.createElement函数的抽象,而该函数主要的作用是将朴素的JavaScript中的对象映射为某个DOM表示。其大概思想图示如下:

在现代浏览器中,对于JavaScript的计算速度远快于对DOM进行操作,特别是在涉及到重绘与重渲染的情况下。并且以JavaScript对象代替与平台强相关的DOM,也保证了多平台的支持,譬如在ReactNative的协助下我们很方便地可以将一套代码运行于iOSAndroid等多平台。总结而言,JSX本质上还是JavaScript,因此我们在保留了JavaScript函数本身在组合、语法检查、调试方面优势的同时又能得到类似于HTML这样声明式用法的便利与较好的可读性。

GUI应用程序架构的变迁

函数式编程与Redux

函数式编程

在软件开发中有一句名言:共享的可变状态是万恶之源,即 Mutable shared state is the root of all evil in concurrent systems.,而函数式编程正是能够彻底解决共享状态。函数式编程本质上也是一种编程范式(Programming Paradigm ),其代表了一系列用于构建软件系统的基本定义准则;它强调避免使用共享状态(Shared State)、可变状态(Mutable Data)以及副作用(Side Effects ),整个软件系统由数据驱动,应用的状态在不同纯函数之间流动。与偏向命令式编程的面向对象编程而言,函数式编程其更偏向于声明式编程,代码更加简洁明了、更可预测,并且可测试性也更好;典型的函数式编程语言有ScalaHaskell等,而其编程思想在Go、Swift、JavaScript、Python乃至于Java中都有着广泛而深远的实践应用。

共享状态(Shared State)可以是存在于共享作用域(全局作用域与闭包作用域)或者作为传递到不同作用域的对象属性的任何变量、对象或者内存空间。在面向对象编程中,我们常常是通过添加属性到其他对象的方式共享某个对象。共享状态问题在于,如果开发者想要理解某个函数的作用,必须去详细了解该函数可能对于每个共享变量造成的影响。往往多个并发请求会导致的数据一致性错乱也就是触发所谓的竞态条件(Race Condition ),而不同的调用顺序可能会触发未知的错误,这是因为对于共享状态的操作往往是时序依赖的。

纯函数指那些仅根据输入参数决定输出并且不会产生任何副作用的函数。纯函数最优秀的特性之一在于其结果的可预测性:

var z = 10;
function add(x, y) {
  return x + y;
}
console.log(add(1, 2)); // prints 3
console.log(add(1, 2)); // still prints 3
console.log(add(1, 2)); // WILL ALWAYS print 3

副作用指那些在函数调用过程中没有通过返回值表现的任何可观测的应用状态变化,常见的副作用包括但不限于修改任何外部变量或者外部对象属性、在控制台中输出日志、写入文件、发起网络通信、触发任何外部进程事件、调用任何其他具有副作用的函数等。在函数式编程中我们会尽可能地规避副作用,保证程序更易于理解与测试。Haskell或者其他函数式编程语言通常会使用 Monads来隔离与封装副作用。在绝大部分真实的应用场景进行编程开始时,我们不可能保证系统中的全部函数都是纯函数,但是我们应该尽可能地增加纯函数的数目并且将有副作用的部分与纯函数剥离开来,特别是将业务逻辑抽象为纯函数,来保证软件更易于扩展、重构、调试、测试与维护。这也是很多前端框架鼓励开发者将用户的状态管理与组件渲染相隔离,构建松耦合模块的原因。不可变对象(Immutable Object )指那些创建之后无法再被修改的对象,与之相对的可变对象(Mutable Object )指那些创建之后仍然可以被修改的对象。

const a = Object.freeze({
  foo: "Hello",
  bar: "world",
  baz: "!",
});

a.foo = "Goodbye";

// Error: Cannot assign to read only property 'foo' of object Object

函数式编程倾向于重用一系列公共的纯函数来处理数据,而面向对象编程则是将方法与数据封装到对象内。这些被封装起来的方法复用性不强,只能作用于某些类型的数据,往往只能处理所属对象的实例这种数据类型。而函数式编程中,任何类型的数据则是被一视同仁,譬如map()函数允许开发者传入函数参数,保证其能够作用于对象、字符串、数字,以及任何其他类型。JavaScript中函数同样是一等公民,即我们可以像其他类型一样处理函数,将其赋予变量、传递给其他函数或者作为函数返回值。而高阶函数(Higher Order Function )则是能够接受函数作为参数,能够返回某个函数作为返回值的函数。

const add10 = (value) => value + 10;
const mult5 = (value) => value * 5;
const mult5AfterAdd10 = (value) => 5 * (value + 10);

TFRPMobX

上一页