React Ways1——函数即组件

1,279 阅读5分钟

未经审视的代码是不值得写的

​ —— 沃兹吉硕德

React 中有一个经典的公式:

const View = f(data)

从这个公式里我们可以提取出两个特点:

  • 视图由函数定义——函数即组件
  • 视图的展示与 data 有关——数据驱动

接下来,我们就从这两点出发,来探讨探讨 React 的编程模式

函数即组件——声明式编程

函数即组件,顾名思义,就是一个函数就可以是一个组件。 在 React 中,组件一般有两种形式:

  • 类组件

    class MyClassComp extends React.Component {
    	render () {
    		return <div className="my-comp"></div>
    	}
    }
    
  • 纯函数组件(无状态组件)

    const MyPureFuncComp = () => (
    	<div className="my-comp"></div>
    )
    

纯函数描述的组件一目了然,但是类组件是否就不那么“函数即组件”了呢?

这就像偶像剧的剧情一样毫无惊喜——并非如此。

首先,我们知道,在 JavaScript 中,class 其实更像是函数对象的语法糖,本质上还是原型及原型链那一套,没出圈儿!

其次,在实际的开发场景下,囿于当前的浏览器形势,我们产出的代码更多时候需要兼容到 es5 这个没有 class 概念的标准。

所以我们会看到上面的 MyClassComp 在生产环境下会这样产出:

"use strict";

function _inheritsLoose(subClass, superClass) { subClass.prototype = Object.create(superClass.prototype); subClass.prototype.constructor = subClass; subClass.__proto__ = superClass; }

var MyClassComp =
/*#__PURE__*/
function (_React$Component) {
  _inheritsLoose(MyClassComp, _React$Component);

  function MyClassComp() {
    return _React$Component.apply(this, arguments) || this;
  }

  var _proto = MyClassComp.prototype;

  _proto.render = function render() {
    return React.createElement("div", {
      className: "myClassComp"
    });
  };

  return MyClassComp;
}(React.Component);

其中 _inheritsLoose 函数用于实现继承,在它下面,MyClassComp 被编译成了一个函数!

好的,现在我们无须担心函数即组件这个概念的准确性了。同时,自 Hooks 在 React 16.8 正式 release 后,函数写法的组件会越来越多。

PS:代码中的 /#*__PURE__*/ 的作用是将纯函数(即无副作用)标记出来,方便编译器在做 tree-shaking 的时候可以放心的将其剥除

那么,为什么 React 要使用函数作为组件的最小单元呢?

答案就是声明式编程(Declarative Programming)

声明式编程

In computer science, declarative programming is a programming paradigm—a style of building the structure and elements of computer programs—that expresses the logic of a computation without describing its control flow. ——Wiki

根据维基的解释可知,与命令式编程相对立,声明式编程更加注重于表现代码的逻辑,而不是描述具体的过程。

也就是说,在声明式编程的实践过程中,我们需要更多的告知计算机我们需要什么——比如调用一个具体的函数,而不是用一些抽象的关键字来一行一行的实现我们的需求。

在这个模型下,数据是不可变的,这就避免如死锁等变量改变带来的问题。

这不就是封装方法?

是的,JavaScript 作为一门基于对象的语言,封装是很常见的 coding 方式。但封装的目的是为了通过对外提供接口来隐藏细节和属性,加强对象的便捷性和安全性。而声明式编程不仅要将对象内部的细节封装起来,更要将各种流程封装起来,在需要实现该流程的时候可以直接使用该封装。

举个例子,现有如下一个数据结构,我们要通过一个方法将其中名字的各个部分用空格连接起来,然后返回一个新数组,

const raw = [
    {
        firstName: 'Daoming',
        lastName: 'Chen'
    },
    {
        firstName: 'Scarlett',
        lastName: 'Johnson'
    },
    {
        firstName: 'Samuel',
        lastName: 'Jackson'
    },
    {
        firstName: 'Kevin',
        lastName: 'Spacy'
    }
]

我们很容易想到,一个 for 循环即可,

function getFullNames (data) {
    const res = []
    
    for (let i = 0; i < data.length; i++) {
        res.push(data[i].firstName + ' ' + data[i].lastName)
    }
    
    return res
}

那么问题来了,以上的数据看起来相当的标准,因此我们只需要连接 firstName 和 lastName。然而,有一天数据结构中加入了中间名,姑且叫 midName 吧,为了正确的输出全名,我们不得不修改一下 getFullNames 方法——搞个 if…else 来判断 midName 是否可用?这个思路可以排除了,因为它并没有考虑扩展性,如果将来录入了一个不知道多少个中间名的俄罗斯人怎么办?

好吧,我们用 Object.keys() 先将所有部分提取出来,然后嵌套一个 for 循环将它们都拼在一起。

这看起来并没有什么问题,但我们仔细分析一下这个算法的目的——将名字用空格连接起来,我们并不需要关心这些名字究竟属于什么部分,直须安顺序将这些值提取就行——对,我们可以用 Object.values() 来实现这个改写,

function getFullNames (data) {
    const res = []
    
    for (let i = 0; i < data.length; i++) {
        res.push(Object.values(data[i]).join(' '))
    }
    
    return
}

这甚至无须手动的拼接这些值,告诉计算机让 join 来完成它。

PS:Object.values 属于 es2017 标准,在使用它的时候需要加上对应的 preset 或 polyfill,当然了,也可以在你的方法库中实现一个。

It is the end?

No。既然我们已经省去了一个 for 循环命令,何不再省一个?来吧,

function getFullNames (data) {
    return data.map(item => Object.values(item).join(' '))
}

// 甚至再简单一点
const getFullNames = data => data.map(item => Object.values(item).join(' '))

一行代码!

想想原来的命令式写法,不支持中间名的情况下就有 9 行,若是再嵌套一层循环,这个如此简单的需求看着就不那么简单了。

我们分析分析现在的 getFullNames:

  1. 我们需要遍历里面所有的对象并返回一个新的数组,就调用 map 方法,它正好满足这个需要
  2. 对于里面每个元素,有未知的属性个数,但实际上我们只关注它们的值,那就用 Object.values 来提取这些值吧
  3. 拿到这些值后我们要用空格将它们连接起来,Object.values 返回一个数组,那就顺势交给 join。
  4. 返回 map 的返回值
  5. 在该过程中,没有额外的变量引用源数据,而所有的方法也未对源数据做修改,其中,唯一出现的变量 item 在整个流程中值是不变的,在使用完毕之后马上就会被回收,无污染性。所以,综合来看,getFullNames 是安全的。

可以看到,在这个分析过程中,我们注重的是流程的逻辑,而实现每个逻辑点的时候,我们都可以用现成的方式去得到想要的结果,换言之,我们是在一个个的求值过程中去达到目的,而非在一大堆代码中挣扎。

简单总结一下声明式编程的优点:

  • 复用性:封装是声明式编程的一大要点,而封装的主要目的之一就是复用代码
  • 安全性:明确关注点,省去了不必要的变量声明和对象引用,防止副作用。
  • 可读性:方法的调用代替直接编写流程,使得代码更加直观
  • 逻辑性:显然通过语义化的方法名能更加清楚的体现代码的逻辑

当然了,在这些优点之下,对开发者的编程素质也有相当的要求。比如代码规范,所有有意义的封装都是为了复用,那么其规范性就必须得提起来,包括注释以及代码格式,我们都知道良好的代码规范是提升团队编程效率的重中之重;其次,前面提到了“有意义的封装”,这意味着,并非所有的流程都需要隐藏起来,封装到什么程度?哪些东西需要被封装?该如何封装?这都是需要在实践中逐渐总结的,我们也称其为封装的粒度问题。

好了,说了这么多,back to React!

首先 React 的核心思想是组件化,其最小的粒度单元就是组件,还记得前面提到的吗——函数即组件!

我们可以将这种思维理解成,React 就是将一个个函数按照一定的逻辑关系组合起来,最终构建出我们想要的应用。这也几乎就是声明式编程的思维。

因此,一个好的 React 组件,也应当具有前文提到的声明式编程的优点,并且有更深的含义:

  • 复用性:对于组件来说,复用性体现在其是否与其他组件有太多不必要的耦合,你不一定会真的复用它,但是保持其独立性对于维护有着相当积极的意义

  • 安全性:因业务复杂程度的关系,组件不一定能保证完全没有副作用(几乎不可能),但是它们对流程来说应当是透明可见的。也就是说,开发者应当知道一个组件会产生哪些副作用,以及它们会在其他地方产生什么影响,尽力使得整体依然是可控的。

  • 可读性:这涉及到组件的整体设计,包含命名和接口等因素,举个例子,我们设计一个时针组件:

    // 时针的英文为 hour hand,那么我们有如下的选择
    const HH = () => (<div className="hh"></div>)
    const SZ = () => (<div className="sz" />)
    const HourHand = () => (<div className="hour-hand" />)
    const ShiZhen = () => (<div className="shizhen" />)
    

    显然,前两种方式容易让人摸不着头脑,它们需要进一步的阅读代码才能推断出其作用,这还是对其逻辑性乐观的情况下。

    第三种方案则一目了然,几乎没有推理成本,对于项目的交接以及维护的便捷性都大有裨益。

    第四种方案呢,同样一目了然,但这只对懂汉语拼音的开发者有效,如果你的项目向全世界开源了,那么对于外国友人来说,可能依然和没开源一样。

    好了,我们确定这个时针组件叫 HourHand 了,那我们应该怎么使用它呢?

    // 联想到时钟的形态,我们首先会意识到的就是旋转角度,那组件的接口或许是这样
    import PropTypes from 'prop-types'
    
    const HourHand = props => (
    	<div
            className="hour-hand"
            style={{
                transform: `rotate(${props.deg}deg)`
            }}
        />
    )
    
    HourHand.propTypes = {
        deg: PropTypes.number
    }
    

    这看起来并没有什么问题,但是从逻辑上来说,时针的含义是角度吗?当然不是,应该是当前的小时,而我们知道小时之间的角度偏移为 30°,因此,为了使其整体更具有逻辑性,我们优化一下:

    import PropTypes from 'prop-types'
    
    const HourHand = props => (
    	<div
            className="hour-hand"
            style={{
                transform: `rotate(${this.props.hour * 30}deg)`
            }}
        />
    )
            
    HourHand.propTypes = {
        hour: (props, propName) {
            if (props[propName] < 0 || props[propName] > 12) {
    			return new Error('Hour must be a number between 0 and 12.')
            }
    	}
    }
    

    现在这个时针组件的接口就与其本身的含义统一了,下次再使用它的时候,只需关注我们熟悉的小时这个属性,而不用再去关心应当转换什么角度——这个流程已经被封装到组件内部了。

  • 逻辑性:其实这一点人为的因素比较大,因为无论多么优秀的编程模型,只要涉及到了业务,都能被 coding 成难以读懂的代码。而我们使用 React 的最终目的就是实现我们的业务需求,因而提升逻辑性需要我们加强对应用的整体理解。

    不过这里我们可以列举一个 JSX 在逻辑性上的优势。

    在通过模板编译的方式构建视图的框架中,往往需要先在父组件中注册子组件:

    <template>
    	<el-form>
            <el-form-item>
        		<el-input></el-input>
        	</el-form-item>
        </el-form>
    </template>
    
    <script>
        import { Form, FormItem } from 'element-ui'
        export default {
            components: {
                [Form.name]: Form,
                [FormItem.name]: FormItem
            }
        }
    </script>
    
    

    这样写其实已经足够语义化了,没有问题。

    然而我们再仔细想想,其实视图和逻辑本身是应该分离的,但在这个模式下我们除了要在模板中查看组件结构之外,在逻辑中去关注组件的关系,并且 FormFormItem 的父子关系并未得到体现。

    How about JSX's way?

    import { Form, Input } from 'antd'
    
    export default () => (
    	<Form>
        	<Form.Item>
                <Input />
            </Form.Item>
        </Form>
    )
    

    显然,在这种模式下,组件结构可以完美的体现组件关系,我们对视图的关注只需要集中在这个 JSX 代码块中。

    其实,在这个问题上,分离一下关注点似乎也没什么大不了(同时,许多框架也兼容了 JSX);在 components 里注册也可以理解成是配置而非逻辑;甚至,根据习惯和 UI 库实现的不同,我们也可能解构的引入这些逻辑上的子组件。最重要的是,我们如何在不同的模型下优化我们的逻辑。

以上部分内容看起来有些像是在聊函数式编程,没有错,函数式编程是最常见的声明式编程的子范式之一,在实际开发中,我们还体会到许多函数式编程的理念。

以上就是对”函数即组件“这一大概念的基本诠释,理解起来并不难,但是最重要的是如何将其最优的实践到实际开发中,这是需要我们不断探索的。Ok,Let‘s next into the next plate。

数据驱动——单向数据流

在 JS 的世界中,对象是最基本的数据形式之一;而在 React 的世界中,驱动视图更新的因可能来自 state 的更新,也可能来自 props 的更新——它们都是对象。也就是说数据决定了 React 应用的展示形态,这些形态当且仅当数据发生改变的时候才会更新(这里先回避直接操作 dom 的场景),这是不折不扣的数据驱动

顺势的,我们来理解一下 React 的数据驱动方式——单向数据流

单向数据流

单向数据流双向数据流相对,是一种通道内只允许单向数据传递的数据流形式。也就是说,在同一链路上,只有一种数据流向,类似于通信工程中的单工信道

而双向数据流则是上允许同一链路有两种数据流向,如 MVVM 的双向绑定形式。其在概念上类似于双工信道。进一步的,在代码层面,同一时段只能有一种方向的工作,所以在实际的工作方式上它更接近半双工

那么,我们就从两种信道的角度来理解探索两种数据流的特点及使用场景:

  • 单工信道通常被应用在电视、广播等纯内容输出的场景。在这种情况下,数据的来源及其下发的目标都是可追踪可记录的,并且可以保证数据的统一性
  • 半双工信道最常见的应用即是对讲机,其特点是在一方在响应(发)的时候,另一方只能接收(收)。如果两边同时响应(收发并行),那么信道便不能正常工作了

回过头来,我们前端开发的主要目的就是将数据视觉化的输出给用户,那么,单向数据流(单工信道)自然是具有得天独厚的优势的——从 QA 到线上,前端的一大目标就是在同一状态下对同一角色的用户有同一的展示形式。

对,提到用户,用户的行为是如何引起的视图变化的呢?

再一次回到 React。前面我们提到了,在 React 中所有的视图变化都来自于数据的变化,而数据存储在状态中,因此,无论是用户还是其他副作用,引起视图变化的原因都是他们修改了状态,我们看看在 React 中这是如何进行的:

class MyComp extends React.Component {
    state = {
        showName: true
    }

    hideName = () => {
        this.setState({
            showName: false
        })
    }

    render () {
		return (
        	<div>
            	{this.state.showName ? (<div>Samuel</div>) : null}
                <button onClick={this.hideName}>hide name</button>
            </div>
        )
    }
}

在上面的代码中,我们实现了通过一个按钮来隐藏显示名字的组件,可以看到,点击按钮后,会触发 hideName 方法,而这个方法中只做了一件事,就是调用 setState 方法来修改 state,而 setState 方法则会去开启更新视图的流程。

看到了吗,hideName 本身并不知道会对视图会有什么影响,它只是影响了状态,而 render 才知道如何根据这些状态来渲染界面。我们可以得到一个简单的示意图:

single flow

如此就清晰多了——行为到状态同样也是单向的!

因此,在 React 中,状态到视图的更新行为到状态的修改,是两条相互独立的通道,这意味着,在这个基础上,所有的行为和变化都可以追踪,可控性非常的强。

这种模式就好比开发者为应用构建了一个神经中枢,整个应用躯干都受这个神经中枢的控制,如果应用出了问题,便可以在中枢中进行行为的回溯,对症下药。

同时,在前端非常流行的 Flux 应用架构也同样采用了单向数据流模型,由其衍生出的各种状态管理框架则在不断地体现着这个模型的优越性。

与 双向绑定 共存

说到这儿,我们还是得提一提双向绑定,尽管 React 0.15 版开始就不提供这种数据绑定方式了,但它依然是被其他框架所采用的现代前端开发的关键技术之一。

在 Vue 和 Angular 中,双向绑定是一个很常见的交互处理方案,对于各类表单控件,它有很强的即时同步能力。然而,这也意味着,它的工作频率会非常的高,对于一些规模较小的应用来说,这种鸡毛蒜皮儿的小事儿影响可能不大,但应用一旦扩展起来,状态树将会越来越复杂,我们就应该尽量减少这种可控性较差的实践。

譬如,Vuex 的诞生,在技术栈层面重新梳理了 Vue 的状态管理方式,而 Vuex 的模式也是由 Flux 思想演变过来的,同样具有单向数据流的特点。这时候,Vue 的开发者们可以重新思考双向绑定与整体状态的结合形式,以在保证应用稳定性的情况下最大化发挥这种高效数据处理方式的能力。

最后,我们再进一步的考察一下 Vue 和 Angular 双向绑定的本质看看会发生什么,我们以 input 为例:

  • Vue:在 Vue 中,实现 input (包括 textarea) 标签双向绑定的源码如下:

    // input 和 textarea 是比较基础的表单组件,除此之外还有 genRadioModel、genCheckBoxModel 等方法进行对应的标签绑定
    function genDefaultModel (
      el: ASTElement,
      value: string,
      modifiers: ?ASTModifiers
    ): ?boolean {
      const type = el.attrsMap.type
      const { lazy, number, trim } = modifiers || {}
      const needCompositionGuard = !lazy && type !== 'range'
      
      // v-model.lazy 的实现在这里
      const event = lazy
        ? 'change'
        : type === 'range'
          ? RANGE_TOKEN
          : 'input'
    
      let valueExpression = '$event.target.value'
      if (trim) {
        valueExpression = `$event.target.value.trim()`
      }
      if (number) {
        valueExpression = `_n(${valueExpression})`
      }
    
      // 看这里,生成的 code 被传入到了下面的 addHanlder 中 
      let code = genAssignmentCode(value, valueExpression)
      // 如果有输入法守卫,就增加一个判断,当正在输入的时候不触发 code
      if (needCompositionGuard) {
        code = `if($event.target.composing)return;${code}`
      }
    
      // 给该标签设置 value 属性
      addProp(el, 'value', `(${value})`)
      // 给该标签添加事件处理函数
      addHandler(el, event, code, null, true)
      if (trim || number || type === 'number') {
        addHandler(el, 'blur', '$forceUpdate()')
      }
    }
    

    整体下来,就是一个为该元素添加 handler 的过程,深入 genAssignmentCode 似乎可以找到数据绑定的答案,来看看:

    /**
     * Cross-platform codegen helper for generating v-model value assignment code.
     */
    export function genAssignmentCode (
      value: string,
      assignment: string
    ): string {
      const res = parseModel(value)
      if (res.key === null) {
        return `${value}=${assignment}`
      } else {
        return `$set(${res.exp}, ${res.key}, ${assignment})`
      }
    }
    

    一目了然,该方法的作用就是生成将genDetaultModel 中的 valueExpression 赋值给要绑定的 value ,要么直接触发 setter 以启动依赖检测,要么通过 $set 方法通知检测器——最终都是状态树更新引发数据下流带来的视图影响,本质上,这依然是单向数据流。那么是否说明 MVVM 的本质实际上都是单向数据流呢?我们继续往下

    • Angular:Angular 通过 ngModel 指令进行双向绑定,其源码如下(篇幅较长只提取了重要部分,完整源码可戳这里):

      @Directive({
        selector: '[ngModel]:not([formControlName]):not([formControl])',
        providers: [formControlBinding],
        exportAs: 'ngModel'
      })
      export class NgModel extends NgControl implements OnChanges,OnDestroy {
        public readonly control: FormControl = new FormControl();
      
        /**
         * @description
         * Tracks the value bound to this directive.
         */
        // 根据注释,这里即指令要跟踪的值
        @Input('ngModel') model: any;
      
        @Input('ngModelOptions')
        options !: {name?: string, standalone?: boolean, updateOn?: FormHooks};
      
        @Output('ngModelChange') update = new EventEmitter();
      
        // 定义 ngOnChange( Angular 对 change 事件的封装) 的 handler
        ngOnChanges(changes: SimpleChanges) {
            this._checkForErrors();
            if (!this._registered) this._setUpControl();
            if ('isDisabled' in changes) {
                this._updateDisabled(changes);
            }
      
            if (isPropertyUpdated(changes, this.viewModel)) {
                // 调用 _updateValue 来应用新的值
                this._updateValue(this.model);
                this.viewModel = this.model;
            }            
         }           
      
                    
        // 用 control.setValue 给绑定的属性赋值
        private _updateValue(value: any): void {
            resolvedPromise.then(
            () => { this.control.setValue(value, {emitViewToModelChange: false}); });
                   }
      
        }
      }
      

      像之前一样,我们来看看这个 control.setValue 方法干了些什么:

      setValue(value: any, options: {
          onlySelf?: boolean,
          emitEvent?: boolean,
          emitModelToViewChange?: boolean,
          emitViewToModelChange?: boolean
        } = {}): void {
          (this as{value: any}).value = this._pendingValue = value;
          if (this._onChange.length && options.emitModelToViewChange !== false) {
            this._onChange.forEach(
                (changeFn) => changeFn(this.value, options.emitViewToModelChange !== false));
          }
          this.updateValueAndValidity(options);
        }
      

      从这里知道,setValue 方法先是将新值下发给了 change 事件的订阅者们,然后调用了 updateValueAndValidity

      可见,除了将新值赋值给 model 外,ngModel 还“手动”调用了相关的方法进行后续工作,说明在 Angular 中,ngModel 实现的是一个真正的双向通道。

      综上所述,在 Angular 中,单向数据流和双向数据流共同存在。但更需注意的是,上面实现双向绑定的过程中用到的 control 对象,是 FormControl 类的一个实例,也就是说,Angular 将这种模式内聚到了表单控制器之中,使我们有了明确的问题域,这即是在框架层面就将双向绑定的场景进行了规划,从而为庞大而复杂的应用做了准备——聊到 Ng 的时候不就是几乎在聊这样的应用吗?

总结

总结一下本文说了些什么:

  • 函数即组件
    • 函数式 React 组件的本质
    • 这是一种声明式编程的实践
    • 声明式编程使得代码更加灵活、复用性更强
    • 理解这一切,构建更好的 React 应用
  • 单向数据流
    • 这是目前前端世界最流行的数据流形式
    • 它使得应用的数据和行为能更好的被监听和捕获
    • 提升了应用表现的一致性
    • 它可以与双向数据流共存,方式的合理可以发挥它们各自最大的效能

以上贯穿 React 开发的两个最基本的概念,可以说“函数即组件”是 React 应用的各个器官;“单向数据流”就是这个应用的血液管道,支持着各个组件呈现出它们应该的样子;而开发者,就是大脑,为应用注入了灵魂。

理解它们,我们就知道了 React 的基本形态;而能在开发过程中正确地实践它们,应用将会更加优秀。

Hold it if you agree with it~!

如果您尚未尝试 React,或许本文并不能让您马上着手开发,若不嫌弃,还有后文。

如果您已经在 React 的世界中自由翱翔,希望本文能对您有益,或是得到您的批评。

Thanks