Nautil 开发文档

Nautil 是一款强大的基于 react 的跨平台业务系统前端 MVC 框架。

前言

MVC 是 Nautil 的核心理念。区别于其他前端框架,Nautil 强调前端分层:数据层、表现层、逻辑层。用 Nautil 开发,需要抱着强烈的分层思想进行开发,从而能够让我们的项目代码实现高内聚低耦合,以便我们在长期迭代中,即使升级 react 的版本,也仍然能使我们最早的设计符合当下的业务需求。在 Nautil 中,你会用到 Model, Controller, Component, Service 去构建一套 MVC 的开发模式。当你只是完成简单的 UI 开发时,你只会用到 Component。但是当你需要灵活的处理用户输入与操作交互时,你就需要引入 Controller 来整合 Component 和状态管理、事件管理等。而当你需要对业务对象进行更好的数据控制时,你就需要再引入 Model,用以创建领域模型,以理清楚业务实体与应用系统的联系。最后,如果你需要从应用整体层面处理好数据流或业务流,你就会借助 Service 来处理系统层面的控制。传统的 react 应用开发基本上全部集中在视图层开发,也就是只有 Component 的体系,而 Nautil 整合了业务系统的行业共性,完善了 Controller, Model, Service 的体系,是一个完备的 MVC 体系。

基于 react 的应用开发非常灵活,是现代前端开发的利器。然而,由于 react 仅仅是一个 UI 库,它不提供框架级别的解决方案,所以当你使用它开发应用时,非常麻烦。虽然有一个庞大的生态支持 react 成为一个应用开发的基础,但是由于太过复杂,导致实际开发中,各种解决方案杂糅到一起,使得应用非常庞大臃肿,且不好维护,依赖的变化也给后期应用维护提升了成本。特别是 redux,虽然是基于非常优秀的思想开发,却由于太过复杂的样板,在实际应用中不仅不能带来开发的便捷性,而且会增加代码体积。

而 Nautil 是一个新的工程性框架,她主要要解决的是,抛弃在生态中挑选的纠结过程,向开发者提供一揽子工程思路解决方案。它虽然基于 react,但是并没有一味的去迎合 react 社区,它独树一帜,提出自己的 MVC 模式、状态管理、数据管理、观察者模式响应等等,通过这些新的东西,再结合 react 的优秀之处,它完美的实现了新一代框架应该具备的特性。基于更多的选择,Nautil 打破原有的 react 范式,融入我们认为更有利于开发者快速开发的新方法。我们不应该拘泥于编程范式,应该尝试打破范式之争,勇于尝试。

Nautil 这个单词取自 Nautilus 的前面部分。Nautilus(鹦鹉螺)是一种海洋生物,有数亿年的进化历程,被成为海洋中的“活化石”。鹦鹉螺身上藏着非常多令人不可思议的秘密。鹦鹉螺外壳切面所呈现优美的螺线,暗含了斐波拉契数列,而斐波拉契数列的两项间比值也是无限接近黄金分割数。Nautil 希望在前端开发领域打破传统开发思维,创立独特的开发方式。

安装

本章详细阐述你准备使用 Nautil 前的准备工作。

兼容性

Nautil 不支持 IE8 及以下版本,因为它使用了 IE8 无法模拟的 ECMAScript 特性(例如 Proxy)。

当前版本

最新稳定版本:v0.29
稳定版本对应分支:master
开发中分支:dev

NPM

npm i nautil

起步

本章开始详细讲解 Nautil 的使用。

你是怎样的开发者?

Nautil 的开发有一定的门槛,作为前端开发者,我们希望你可以使用 Nautil 完成自己的应用开发,但是,我们也不希望你在基础能力不足的情况下,勉强的去使用它。Nautil 需要开发者拥有以下基础能力:

适用场景

我们做开发,应该选择适合的语言、适合的工具,对于简单 web 需求,使用 jQuery 和一个样式库就可以马上开始撸,但是对于工程应用级别的项目,则需要较为完备的体系。如果你面对如下项目,那么选择 Nautil 将会令你事半功倍:

为什么选择 Nautil?

当你打算写一个项目的时候,如果你选择 react 来写,你会特别麻烦,你需要在市面上选择、学习一大堆生态链库,让你特别烦躁。而这只是第一步,接下来,你需要配置编译环境,也是烦得不得了。最后,你还要忍受 react 默认的数据流行为。Nautil 的目标,就是让开发者学习的少,但是又可以随心所欲。

我提供了一揽子的解决方案,不需要开发者自己去选择(虽然你可以自己去选择):

这些东西都是你所需要的,但是很抱歉,它们都不是 react 生态里面最流行的,然而,它们一定是最方便的。我并不觉得使用 redux 有多酷,相反,我觉得它太麻烦了,虽然它确实很酷,但是对于开发者而言,写那么多和写应用本身无关的代码逻辑,以及所衍生出来的问题,让人很烦恼。最后,我内置了一个极其简单的 Store 来使用,下文会有详细的介绍。

Nautil 的核心理念就是“为应用开发设计最简单的工程性解决方案”,而非为技术而设计。包括 react 官方本身,很多人都在一套被虚拟出来的技术体系中炫技,但是本质上,如果没有 react 本身所产生的一系列问题,也就没有这些炫技的机会。这些自嗨中,似乎忘却了,使用框架的目的最核心最重要的是做应用开发。如何做好应用开发?简单,易懂。这两个单词,是我近来最喜欢的词。而 Nautil 除了在一开始要理解“观察者模式”的核心之外,一切都很简单。

观察者模式

观察者模式(Observer Pattern),定义对象间的一种一对多依赖关系,使得每当一个对象状态发生改变时,其相关依赖对象皆得到通知并被自动更新。在编程意义上,首先有两个对象,一个对象被称为观察者,另一个对象被称为被观察者。观察者(例如 O)将自己的一个方法(例如 O.dispatch)交给被观察者(例如 T)暂时保管,这个过程被称为“订阅”。当被观察者对应的部分发生变化时,会调用该方法(T 调用 O.dispatch),这个过程被称为“分发”或“发布”。此时,该方法会使观察者随之发生变化(O.dispatch 会修改 O 的内部状态,从而让 O 发生变化)。

观察者模式是整个 Nautil 的核心理念,开发者必须掌握这一概念,才不至于在 Nautil 的开发中迷路。

为什么这么说?我们先来看下,具体在 Nautil 中如何发挥观察者模式的作用:

import { React, Component, Text, observe, inject, pipe } from 'nautil'
import store from './store'

export class ObservedComponent extends Component {
  render() {
    const { state } = this.attrs
    return (
      <Text>{state.age}</Text>
    )
  }
}

export default pipe([
  observe(store), // 对 store 进行观察,当 store 中的 state 发生变化时,触发组件更新渲染
  inject('state', store.state), // 将 store.state 以 'state' 作为名字注入到组件的 props 中
])(ObservedComponent)

这是最便捷的在 Nautil 中使用观察者模式的方法之一。observe 指令通过识别 store,自动对其进行观察,并在 store 发生变化时更新组件。有了这个利器,store 可以与其他组件共享,在其他组件中更新 store 也会触发 ObservedComponent 的更新。

我们通过订阅一个被观察对象的变动,来触发整个页面的变化,没有变动,就不会有界面的变化。因此,如果不理解观察者模式,无法在 Nautil 中编程。其实这非常好理解,通过变动触发变动,这不是很好理解吗?通常的,我们通过订阅某些容器里面的数据的变动,来触发变动。但是如何触发变动这个过程你不用关心,你只需要调用 Observer 组件或使用 observe 指令就可以完成这个过程,而不需要关心它是怎么实现的。

Store 和 State

状态(State)是用于控制组件 UI 界面的对象型数据。一旦一个组件的状态是某个值,那么它对应的 UI 界面就是确定的。Nautil 提供的 State 和 vue.js 中的数据更新方式相似,直接修改对象属性,就会触发 Store 进行响应。

仓库(Store)是用于保存 State 的容器。一个 Store 内只保管一份 State,同时,Store 提供了一些接口,用于更新数据、获取数据、监听数据变化。

我们来看下,在 Nautil 中具体如何使用类似 vue 中的直接修改 state:

import { Component } from 'nautil'

class MyComponent extends Component {
  state = {
    name: 'tom',
    age: 10,
  }

  render() {
    return (
      <div>
        <span>{this.state.name}: {this.state.age}</span>
        <button onClick={() => this.$state.age ++}>grow</button> {/* 注意,这里使用 this.$state 而非 this.state */}
      </div>
    )
  }
}

Nautil 仍然遵循了 react 所提出的 immutable 数据流的理念,在 Nautil 中的视图编程(Component, Store)都遵循这一理念,直接修改 state 是不被允许的,但是通过 this.$state 却可以像 vue 中一样,直接操作 state(内部使用了 Proxy 进行代理,实际产生的仍然是 immutable 的 state)。

接下来,我们看下同样的理念在 Store 中的运用:

import { React, Store, inject } from 'nautil'

const store = new Store({
  name: 'tomy',
  age: 10,
})

export function FuncComponent() {
  return (
    <div>
      <span>{store.state.name}: {store.state.age}</span>
      <button onClick={() => store.dispatch(state => state.age ++)}>age ++</button>
  )
}
export default obseve(store)(FuncComponent)

上面的代码并非最佳实践,但它说明了如何去使用一个具有响应式效果的 Store。结合上一节“观察者模式”中的演示代码,本例中执行 state.age ++ 时,会更新 store 中的 state。而 ObservedComponent 会因为 store 的变化而更新渲染,所以,FuncComponent 中的按钮通过这种方式,实现了更新 ObservedComponent。

另外,redux 中整个应用只有一个 store,这个 store 实际上被作为 redux 这个包内部的一部分。它的思想局限于一个 web 应用中只会有一个 react 应用。但实际上,我们可能在一个 web 应用中同时运行多个 react 应用。Nautil 的 Store 不限制个数,当然,为了同一管理,我们也建议开发者,对于一个应用而言,只创建 1-3 个 Store,方便你更好的对整个应用的总体状态进行观察。

响应式

Nautil 应用基于观察者模式实现数据变化带来的界面响应。虽然 Nautil 支持 react 的 setState 方法,但是我们并不这么推荐,因为我们不提倡一个组件内内置自己的 state,而应该更多的考虑使用外部数据来驱动组件本身的渲染。

Nautil 依然遵循单向数据流模式(但同时支持双向数据流),但是在组件内更新外部传入的数据时稍有不同。传统 react 组件更新外部通过 props 传入的数据时,需要通过一个 onClick 这样的回调函数将更新信号返回给组件的外部,再由外部来更新数据后,将更新后的数据通过 props 再次传入。虽然这样做逻辑上没有问题,保证了数据干净和组件干净。但是,这样的写法,开发体验实在是太差了。

出于改善这种开发体验的目的。Nautil 借鉴了 mobx 的观察者模式思路。我们在组件内更新 props 传入的对象时,直接修改该对象的某个属性即可。上述的回调再传入过程,通过观察者模式和 store 的内部管道完成。如果你熟悉 redux 的话,一定知道 react-redux 的 connect 函数,Nautil 基于观察者模式解决相同的问题。

我们用真实的代码来体验一下:

// store.js
const store = new Store({ count: 0, })
export default store
// some.js
import { React, Component, Section, Text, Button, observe } from 'nautil/components'
import store from './store'

// 对于 Some 组件本身而言,store 中 state 的变化,不会触发 Some 的更新。
// 更新将交给上层组件去做。
export default class Some extends Component {
  render() {
    return <Section>
      <Text>{store.state.count}</Text>
      <Button onHit={() => store.dispatch(state => state.count ++)}>+</Button>
    </Section>
  }
}
// app.js
import { Component, Store, Observer } from 'nautil/components'
import Some from './some'
import store from './store'

export default class App extends Component {
  render() {
    return <Observer 
      subscribe={dispatch => store.subscribe(dispatch)} 
      unsubscribe={dispatch => store.unsubscribe(dispatch)} 
      dispatch={this.weakUpdate}
    >
      <Some />
    </Observer>
  }
}

store 在多个组件之间共享,这些组件都被一个顶层组件使用(上面的 APP),并且在该顶层组件中使用观察者模式订阅 store 的变化,来触发更新界面,这样,在任何组件中更新了 store 中的数据,就可以触发整个应用的更新。

当然,不引用全局变量,使用 props 从外部传数据是最好的,这在前面的“观察者模式”“Store 和 State”这两部分中已经用实例代码演示了。

双向绑定

Nautil 支持属性级别的双向绑定。双向绑定时,使用 $ 符合作为属性前缀。一个用于双向绑定的属性的值,是一个可被用于监听的观察者模式参数。双向绑定完全打破了 react 生态单向数据流的主流思想,但带来的好处不言而喻,它可以避免复杂的回调地狱,最好的例子就是一个简单的显示和隐藏效果。

class Modal extends Component {
  static props = {
    $show: Boolean, // 声明这是一个需要双向绑定的属性,外部传入时必须传入特定结构的值
  }
  render() {
    return (
      <If is={this.attrs.show}>
        <Section>
          <Button onHint={() => this.$attrs.show = false}>close</Button>
        </Section>
      </If>
    )
  }
}

function Some() {
  const $show = useState(false)
  const [, setShow] = $show
  return (
    <Section>
      <Button onHint={() => setShow(true)}></Button>
      <Modal $show={$show} /> {/* $show={[show, setShow]} */}
    </Section>
  )
}

上面代码中的红色部分是个神奇的操作。虽然从语言上,你看不出什么不同之处,但是你回想一下 react 的原生操作,就会发现,这样的操作在 react 生态中是不被允许的,而在 nautil 却可以实现双向绑定,触发界面更新。(需要注意的是,这里的 this.$attrs 虽然是响应式的,但它不是所有属性都是响应式的,只有以 $ 开头传入的属性是响应式的,而且依赖于外部逻辑完成响应,因此,和 vue 的 this 还是有非常大的不同)。需要注意,虽然在声明时要求传入的 prop 为 $age,但是在 this.attrs 上,却要使用 this.attrs.age 读取。

蓝色部分需要被注意,它是一个特定结构,一个含有两个元素的数组。第一个元素是作为传入 prop 的值,第二个元素是响应内部发生变化时的函数 reactive。当 Sub 中执行了 this.$attrs.show = false 时,show 变化后的值作为参数,传给 reactive 函数,并执行 reactive 函数,在 reactive 函数中去更新当前组件的状态,从而触发界面变化。

必须声明为双向绑定的属性,才能直接在 this.$attrs 上进行操作,否则不会有任何效果。另外,对 this.$attrs 的操作,不会改写 this.attrs 的值,而是仅调用外部传入的 reactive。如果外部传入的 reactive 没有修改传入的值,那么也不会有界面的更新。例如,上面的 setShow 没有带来 show 的变化,那么 Modal 也不会有任何变化。

此外,如果你希望使用更优雅的写法,你可以使用内置的 createTwoWayBinding 函数:

import { createTwoWayBinding } from 'nautil/utils'
class Some extends Component {
  state = {
    show: false,
  }
  render() {
    const $state = createTwoWayBinding(this.state, state => this.setState(state))
    return <Model $show={$state.show} />
  }
}

这样看上去更优雅,不需要传入数组,也不需要传入 state(直接传入 state 像是传入 state 整个值作为 show 的属性值一样,不是很好)。而通过 createTwoWayBinding 函数,就可以将一个对象转化为可读取属性的对象,实际上 $state.show 的返回值也是一个符合双向绑定的数组,但是在阅读和理解上更容易理解一些。

跨平台开发

Nautil 的设计目标之一,就是实现跨平台开发,特别是移动端的跨平台开发。实现一处写代码,根据不同的平台编译后即可运行的效果。要实现跨平台编程,需要遵循一些基本的规则:

开发时,应该关注:

JSX 渲染语法

在 Nautil 的理念中,存在这样的一条有象征意义的 slogan:

Nautil is React, Nautil is more than React.

Nautil 和 react 几乎是完全兼容的(16.8+ 版本的 react),你可以把以前写的 react 组件直接拿到 Nautil 中使用(不考虑跨平台,只想使用更新的写作方式),也可以把基于 Nautil 写的代码放到其他 react 应用中使用,相互之间不会造成任何兼容性问题(但是打包的代码量可能会比直接使用 react 写的代码大一点)。

因此,写 Nautil 应用和写 react 应用没有任何差别。我们仍然使用 jsx 进行渲染结构的编写,语法上没有任何差别。

生命周期函数

我们简化生命周期函数,因此,在开发 Nautil 时,你可以忘记有关 react 生命周期函数的一切,使用更简短的生命周期钩子函数。

内置组件

使用 Nautil 进行开发时,必须使用内置的组件进行开发。和原生 react 开发 web 应用不同,我们不能使用 div/span 这种 HTML 标签作为基础组件,而是要使用 Nautil 的内置组件(如 Section, Text 等)代替。这是为跨平台准备的,只有使用内置组件构建起来的 nautil 应用才能构建出跨平台的应用。

内置组件包含如下几类:

这些组件都是由 Nautil 默认导出的,你可以在下文详细了解。

跨平台构建

不同的平台,使用不同的入口文件,使用不同的构建配置。例如,我们在 web 上:

import { mount } from 'nautil/dom'
import App from './app.jsx'

mount('#app', App)

而在 react-native 上:

import { register } from 'nautil/native'
import App from './app.jsx'

register('ProjectName', App)

在小程序上:

import { createApp } from 'nautil/miniapp'
import App from './app.jsx'

export default createApp(App)

虽然它们看上去非常相似,但是背后的平台不同,所要求的构建工具也不同。

组件

虽然 Nautil 的组件本质上就是 react 组件,但是在具体细节上和 react 组件还是有所不同。本章将详细讲解 Nautil 组件在 react 组件之外还具备的新特征。

import { Component } from 'nautil'

Props 声明和检查

和 react 的 propTypes 很像,你可以通过 props 属性对组件接收的 props 进行声明和类型、结构进行检查。

props 声明

通过 static props 属性,你可以要求组件所传入的 props 必须包含哪些属性。nautil 的属性包含三种:

普通属性和 react 属性一样,没有差别。双向绑定属性用 $ 开头进行声明。on 开头的属性被认为是一个 handler 属性,用于类似 react 中 onClick 这类属性。

props 校验

通过 static props 的属性值,我们可以对传入的 props 的值进行校验。nautil 中的类型校验遵循 tyshemo 的类型校验方法。在撰写一个类型时,你需要从 nautil 中引入已支持的类型规则。

import { Dict, Component } from 'nautil'

class SomeComponent extends Component {
  static props = {
    person: new Dict({
      head: Object,
      body: Object,
      foot: Object,
    }),
  }
}

对于三种不同的属性,其校验规则也稍有不同:

其中,双向绑定属性、事件属性,无论在 props 中有无声明,在运行时都会进行基本类型校验。

削减代码

在 process.env.NODE_ENV === 'producition' 时,校验会被禁用。也就是说,在正式环境中,组件 props 的结构、类型校验是禁用掉的,不会执行,并且通过 webpack 的 tree-shaking,可以将部分代码削减掉。

Attrs 属性

Nautil 组件继承自 react 组件,也包含了 props 属性,可以通过 this.props 属性读取原始的 props。但是,由于我们 Nautil 组件的特性,双向绑定属性和事件属性,所以原始 props 没有意义,无法正常发挥 nautil 的特性。

Attrs 值

从 props 上的属性经过处理后产生 this.attrs。this.attrs 和 this.props 不是包含与被包含关系,this.attrs 上的值包含两部分:

对于 this.props 上的 className, style, children 属性,并不在 attrs 中,on 开头的事件属性也不在其中。

双向绑定

通过直接反写 this.$attrs 可以完成双向绑定。

reneder() {
  return <Button onHint={() => this.$attrs.age ++}></Button>
}

双向绑定的用法在前文已经详细解释过。需要注意的是,只有外部传入时以 $ 开头的属性才会触发双向绑定。比如外部传入 age 属性,则不会触发双向绑定操作 this.$attrs.age ++ 操作不会产生任何作用(甚至不会修改 this.attrs)。

在 static props 中未声明属性为双向绑定属性,但是在传入时使用了双向属性标记 $,那么该属性在内部仍然是一个双向绑定属性。在 static props 中声明了这个属性为双向绑定属性,那么外部传入时,只允许传入双向绑定结构,而不允许传入普通属性值。

stylesheet

和 react 组件不同,nautil 传入样式时,不需要区分 className 和 style,直接通过独立的 stylesheet 属性传入。组件内部会自动进行识别,当值为 object 时自动识别为 style 或一个 className 的 mapping,传入 string 时自动识别为 className。

通过处理之后,this 上会多出 this.className 和 this.style,你可以在 render 中使用这两个属性。

chidlren

虽然你可以通过 this.props.children 读取,但 nautil 增加 this.children 帮你快速读取当前组件的内部组件。

on:streams

Nautil 的事件属性可以像 react 的事件属性一样使用,但是名称会有不同,例如 nautil 中不会有 onClick 属性,而是采用 onHit 作为替代。这是因为在跨平台开发时,我们将通过新的名字统一不同平台的事件名字。

事件属性你可以像 react 中一样使用,例如:

<Button onHit={e => console.log(e)}>click</Button>

但同时,nautil 基于 rxjs 开发了一套更丰富的事件流机制。在开发中,可以让抽象的事件更加独立完成某些操作。我们先来看一个例子:

class Some extends Component {
  static props = {
    onHit: false, // on 开头的事件属性在校验时会被忽略其值,使用内部的规则自动进行校验
    $age: Number,
    $weight: Number,
  }
  
  init() {
    // 通过 on 来为事件流绑定新的动作。
    // on 绑定的动作,不会影响外部传入的 onHit 已经生效的回调动作
    this.on('Hit', () => this.$attrs.age ++)
    this.on('Hit', () => this.$attrs.weight = this.attrs.age * 10)
  }
  render() {
    return (
      <Button onHit={this.Hit$}>grow</Button>
    )
  }
}

上面这个组件,在没有蓝色部分的情况下,也可以正常运行。蓝色的部分,在此基础上,对 onHit 事件新增了订阅。也就是说,当用户点击 Button 的时候,不仅会触发组件外部传入的 onHit 回调,而且还会触发新增的两个订阅,这使得关于事件的管理可分离后进行。

对于事件的传入,有 3 种形式:

这也就是 onHit$ 的魔力所在。我们用代码来演示怎么使用:

import { map } from 'rxjs/operators'

class App extends Component {
  render() {
    return <Some onHint={[map(x => x * x), x => console.log(x)]} />
  }
}

这种情况下必须注意,订阅函数必须被传入(放在数组最后一个位置)。实际上,只传入一个函数的情况,在内部实际上处理成数组对待,只不过该数组只有一个元素,即该订阅函数。有关事件流、管道操作符等,请详细阅读 rxjs 的文档了解如何使用。

update

Nautil 组件上有一个 this.update 方法,用于更新当前组件。它有丰富的功能:

组件上同时存在如下四个方法用于更新:

在使用时,不需要使用 this.update.bind(this) 这种方法进行绑定,直接使用 this.update, this.forceUpdate, this.weakUpdate 即可,它自己已经经过绑定了。

生命周期

Nautil 组件的生命周期和 react 组件一致,在前文已经介绍过。

具体不同阶段的调用如下图演示:

可以看到,所有生命周期钩子函数比 react 原生的生命周期钩子函数简化且丰富了不少。其中 onDigested 是比较特殊的一个生命周期函数,在原生 react 中你找不到对应的处理时间点,这是 Nautil 新增的。在 Nautil 中,组件需要在 props 或 state 变化时进行处理,以生成新的 this.attrs 或完成类型校验等其他工作,这个过程叫 digest。另外,onAffected 则在挂载和更新阶段都会调用,非常有助于处理一些需要在两个阶段都处理的一些任务。

Render

在 nautil 中,你可以在 class 组件中写 hooks 了,区别在于,你需要用 Render(props) 代替 render()。在 Render 中你仍然可以使用 this 来调用组件 class 上写的方法。例如:

class MyComponent extends Component {
  Render(props) {
    const [state, setState] = useState(0)
    const some = this.getSome() // 可以调用 this 上的方法
    ...
  }
}

虽然使用 class 组件可以很好的组织代码,但是,有的时候,hooks 可以起到非常大的缓存作用。hooks 函数的第二个参数可以解决很多复杂的场景。通过 Render 你可以同时发挥 class 组件和 hooks 的威力。

状态管理

现在,你需要忘掉 redux,它实在是太冗余了,像一个笨拙的大象,匍匐在 react 这个汗血宝马身上。我们在 react 开发中为什么需要一个可全局使用的数据存储器,这个问题相信你已经很清楚了吧。Nautil 提供了极其轻便的 Store 用于管理全局数据。

import { Store, Provider, Consumer, useStore, connect, applyStore } from 'nautil'

Store

Nautil 中的 Store 是一个轻便的状态管理工具,它提供了 dispatch 方法,可以用 immutable 的写法来修改状态。和 vue 不同,store 中的数据是动态的,不需要你提前规定都有哪些属性,store 中的数据是 immutable 的,可被观察,可恢复,可随时添加新属性。

创建 Store 极为简单。你只需要传入一个默认值即可。你甚至可以传入一个空对象。

const store = new Store({
  a: 1,
})

// 获取状态
const state = store.getState()

// 修改状态
store.setState({ a: 2 })
store.dispatch(state => state.a ++)

// 重置状态为第一次实例化时传入的值
store.resetState()

// 订阅状态变化
store.subscribe((next, prev) => { .. })
// 取消订阅
store.unsubscribe(fn)

以上就是 Store 的所有能力,极其简单(源码),且拥有魔法。

结合组件使用

在 Nautil 中你必须结合组件使用 Store,以发挥它的效果。这里面涉及到监听 Store 变化和重新渲染。实际上操作非常简单:

import { React, Component, Store, Provider, Consumer } from 'nautil'

const store = new Store({
  a: 1,
})

class App extends Component {
  return (
    <Provider store={store}>
      <Some />
      <Trigger />
    </Provider>
  )
}

class Some extends Component {
  render() {
    return (
      <Consumer>
        {store => <span>{store.state.a}</span>}
      </Consumer>
    )
  }
}

class Trigger extends Component {
  render() { 
    return (
      <Consumer>
        {store => <button onClick={() => store.dispatch(state => state.a ++)}>grow</button>}
      </Consumer>
    )
  }
}

在上面的代码中,我们在 Some 和 Trigger 之间共享了同一个 store,它们都能够根据 store 中 state 的变化来重新渲染界面。

除了使用 Consumer 来渲染子组件外,你也可以使用 connect 来封装。

const Trigger = connect(store => ({ a: store.state.a, grow: () => store.dispatch(state => state.a ++) }))(class extends Component {
  render() {
    const { a, grow } = this.props
    ...
  }
})

connect 的第一个参数是一个 map 函数,用以接收 store,并返回一个对象,这个对象将被增加到 props 上传给被封装的组件。而实际上,这个 map 也可以传给 Consumer 组件,例如:

<Consumer map={store => ({ ... })}>
  ...
</Consumer>

connect 的第二个参数是一个 watch 数组,用以决定当发现 store 中 state 的哪些属性发生变化时,才对内部进行重新渲染。同样,这个 watch 也可以传给 Consumer:

<Consumer map={store => ({ ... })} watch={['a']}>
   ...
</Consumer>

局部状态共享

有时候,我们不需要在整个应用共享一个 store,我们只需要在一个小范围内共享一个 store。此时,我们可以用到比较小巧的一些方案。

第一种,使用 useStore hooks 函数:

import { useStore } from 'nautil'

function MyComponent() {
  const { state, setState } = useStore(store, ['a'])
  ...
}

useStore 用于作为 hooks 函数监听 store 中 state 的变化,并触发更新。它直接返回传入的 store,但是由于 store 上的 state, setState, dispatch 可以直接解构出来使用,所以上面的这段代码看上去非常优雅。它的第二个参数是上文提到的 watch 列表,有助于我们缩小观察的范围,提升性能。

第二种,使用 applyStore 创建共享函数。

import { applyStore } from 'nautil'

const { useStore, connect } = applyStore(store)

这里的 useStore 和上面的 useStore 异曲同工之妙,只是不再传入 store 作为参数。而 connect 也是一样的,观察的是这里传入的 store,而非 Provider 提供的 store。

通过上面两种方法,我们非常灵活的在几个组件小范围内的共享一个 store 来控制状态变化。

导航/路由

对于一个框架而言,路由是必须要解决的。react 虽然是一个优秀的 UI 库,却无法满足开发者对应用开发的所有需求。包括路由在内的其他应用部件它都没有提供。为了解决这个痛点,Nautil 并没有直接采纳 react-router 等第三方组件,而是将提供一个路由方案。

Navigation

导航器,对于应用来说是用户得以在不同界面之间切换的重要部分。对于开发者而言,也可以通过导航快速了解应用都是由哪些界面组成的。

然而,react-router 提供了一种将路由组件化的开发方式,这种方式值得学习。但和 redux 一样,由于它默认认为一个应用只会有一个 react 应用实例。另外,不同的平台上,也需要不同的路由管理来解决问题。为此,我们提供了统一的导航器。

import { Navigation } from 'nautil'

我们通过 Navigation 创建一个导航器实例,可以被任何 nautil 应用使用。

const navigation = new Navigation(config)

这里需要注意,一个应用一般只有一个导航器,但是,Nautil 允许一个程序中存在多个应用。

配置

我们来看下如何进行配置。对于不同的平台,配置几乎是一样的,有些选项,在 web 平台上生效,在其他平台不生效。

{
  mode: 'history', // 可选
  searchQuery: '_url', // 可选,当 mode 为 search 时生效
  base: '/', // 可选
  routes: [ // 必须
    {
      name: 'home',
      path: '/home',
      children: [ // 子路由,可选
        {
          name: 'sub', // 实际名字为 home.sub
          path: '/sub', // 实际路径为 /home/sub
          params: {}, // 可选,默认传入什么 params
        },
      ],
    },
  ],
  maxHistoryLength: 20, // 可选,最多保存多少个历史记录
  defaultRoute: 'home', // 可选,默认路由,没传的时候使用第一个 route
}

由于 Nautil 的目标平台不单单包含 web,因此,导航系统实际上的工作模式要依赖不同平台的能力。实际上,navigation 支持 4 种模式运行:

后三种都依赖于浏览器的 URL 进行识别和控制。但即使在浏览器种,我们也可以使用内存模式,让界面的变化不依赖于浏览器 URL 的控制。

在配置选项中,你可以直接将对应的页面组件配置到路由中。这个部分会在下文 Navigator 中讲解。

state 和 status

在 Navigation 实例上也有一个 state,它上面收集了当前的 route, url 等信息,用来在某些情况下做判定使用。但是,我们要面临一个情况,就是当我们导航到一个不存在的路由时,导航器会返回给我一个 $onNotFound 的事件,这时,state 不会被更新或置空,而是保留上一个正确的路由信息。那么怎么区分这种情况呢?实例上会有一个 status 属性,它的值为 0 或 1. 0 表示 not found,而 1 表示正常进入某个页面,因此,在做判定时,一定要通过这两个属性同时来进行判定。

监听导航变化

对于 Navigation 实例而言,我们可以通过 on 方法对它进行监听,当导航到一个新的路由时,对应的监听回调会被执行,(一般发生在 $onEnter 之后),通过 off 方法解除监听。

navigation.on('home', state => console.log(state), exact)

监听的对象有:

navigation.on(/book\/[0-9]+/, callback) // 通过正则监听对应的 URL
navigation.on('parent.child', callback, true) // 通过 name 精确监听 parent.child 这个路由
navigation.on('/book/:id', callback) // 通过 path 监听

结合观察者模式,在 Nautil 中使用导航器的监听功能,可以实现根据导航的变化而切换页面。

事件

事件的监听也是通过 on 方法,只不过事件监听时有特殊的标识,所有事件名称以 $ 开头,从而和路由分开。

is

在 Navigation 实例上存在一个 is 方法,用于判定当前的导航器状态是否和传入的参数匹配。

if (navigation.is(match, exact)) {}

判定当前的导航器状态是否和传入的 match 匹配。

这个判定逻辑,会被 Route 使用。另外,需要注意,这里的判定逻辑和 on 的监听绑定稍有不同。* 在监听绑定中,表示任何变动都会被监听,而 * 在 is 中仅表示匹配非 not found 状态。

Navigator

在 Nautil 中你可以使用 Observer 组件来使用观察者模式,通过订阅 navigation 实例来达到根据路由切换页面的目的,但是,实践起来会非常麻烦。Nautil 内置了导航相关的组件:Navigator, Route, Navigate,从而让你更方便的使用。

导航者组件(Navigator)创建一个区域,用于在这个区域中,根据导航器状态来切换界面。

import { Navigator } from 'nautil/components'

这个组件的作用是创建一个区域,这个区域内按照路由规则来进行。

Navigator 的渲染有 3 种方式。在进行渲染的时候,按照这 3 种方式顺序检查,如果存在某一种方式的时候,就直接使用该方式进行渲染。

配置式渲染

当开启 inside 属性时,它会去读取 navigation 配置上的 route.component,使用该配置进行渲染,并忽略掉内部元素。

<Navigator navigation={navigation} inside />

此时不需要传入 dispatch 属性。

const navigation = new Navigation({
  routes: [
    {
      name: 'home',
      path: '/home',
      component: Home, // 使用哪个组件渲染 home 这个 route
      props: { title: 'Home' }, // 渲染时给 component 传入什么 props
      animation: 600,
    },
  ],
  notFound: NotFound, // 未匹配路径时渲染哪一个组件
})

这种配置方法,有点像 vue-router 或 ui-router 的做法,即 navigation 本身已经包含了路由渲染。配置式渲染时,这些组件会在 props 中接收一个 navigation。

函数式渲染

当 Navigator 的 children 是一个函数时,使用该函数渲染界面。该函数将接收 navigation.

<Navigator navigation={navigation}>
  {Navi}
</Navigator>

function Navi(props) {
  const { navigaition } = props
  return <Text>{navigation.status ? navigation.state.name : 'not found'}</Text>
}

这里创建了一个函数 Navi 用以在 Navigator 中使用。由于函数接收一个对象,里面包含 navigation,因此,可以对那些只接收 navigation 的函数组件实现复用。

函数式渲染无需传 dispatch 属性。在每次导航器状态发生变化时,都会重新执行一次,得到需要的渲染结果。由于每次变动都就执行,性能上偏弱。

元素式渲染

当 Navigator 里面有独立的 jsx 片段时,会被直接采用。

<Navigator navigation={navigation} dispatch={this.update}>
   <Route match="home" component={Home} />
   <Route match="page1" component={Page1} />
   <Route match="!" component={NotFound} />
</Navigator>

此时的 Navigator 仅仅像是一个简写版的 Observer,它内部展示什么内容完全没有约束,你不一定要使用 Route 来进行路由,甚至可以使用 Switch-Case 组件来完成路由。

此时,必须传入 dispatch 属性,用以更新界面。

 

以上三种渲染方式具有先后顺序,如果对于一个 Navigator 而言,两种或以上方式同时存在时,优先选用第一种,其次第二种,最后才是配置式。

自动污染

在 Navigator 内部使用 Route 和 Navigate 组件时,会被自动注入 navigation 属性。对于应用级别的开发,为了方便写作,可以直接不向 Route 和 Navigate 传 navigation 属性。但是为了复用方便,或提高阅读性,建议最好还是传 navigation,这样对于在其他 nautil 应用中使用该组件也可以正常且准确的使用。

Route

路由组件,用以根据 navigation 的当前状态,决定使用什么内容进行渲染。

import { Route } from 'nautil/components'

参数

它接收如下属性:

<Route navigation={navigation} match={/book\/[0-9]+/}>
  <Book id={navigation.state.params.id} />
</Route>

注:如果在 Navigator 内部使用 Route,则无需传入 navigation,Navigator 会自动污染 Route。

组件式渲染

通过传入 component 属性,让 Route 知道使用什么组件进行渲染。此时接收 component 和 props 这两个属性:

<Route navigation={navigation} match="some" component={Some} props={{ title: 'Some' }}>
  <Text>children</Text>
</Route>

props 是可选的,需要的时候才传入。同时 Some 会接收到一个 navigation 的 props,Some 组件可以利用该实例做一些判断。另外,还能接收内部的 children 到 component 中使用。

函数式渲染

和 Navigator 很像,在 Route 内部只有一个函数时,将使用该函数进行渲染。

<Route navigation={navigation} match="home">
  {Home}
</Route>

function Home(props) {
  const { navigation } = porps
  const { state } = navigation
  const { params } = state
  const { title } = params
  return <Text>{title}</Text>
}

元素式渲染

内部包含想要使用的 jsx 元素。

<Route navigation={navigation} match="some">
  <Text>some</Text>
</Route>

配置式渲染

和 Navigator 的配置式渲染很像,但是 Route 仅在 match 通过的情况下读取当前被访问的路由的 component 配置进行渲染。

<Route navigation={navigation} match="some" />

在 navigation 配置中如下配置:

{
  routes: [
    {
      name: 'some',
      patch: '/some',
      component: Some,
      props: { title: 'some' },
    },
  ],
}

Some 组件也会被注入一个名为 navigation 的 props。

动画

在大部分应用中,切换界面时,有动画转场的需求。此时,我们可以通过 Route 的 animation 属性搭配内部动画组件来实现。

<Route navigation={navigation} match="some" animation={600} />

animation 属性的作用有两个:

再结合下文要讲的 Animation 组件,我们可以实现我们自己的动画转场效果:

<Route navigation={navigation} match="some" animation={600} component={Animation} props={{
  enter: '600 fade:in',
  leave: '600 fade:out',
}}>
  <Text>content</Text>
</Route>

由于 Animation 组件是通过 show 属性来控制进入和离开的,而 Route 正好像组件注入了 show 属性,所以正好可以搭配起来使用。

上面这这段代码,实现了进入到某个路由和从该路由离开时的动画效果,虽然很简单,但是开发者可以根据这个思路,实现更复杂的动画效果。

可移植性

和 react-router 不同,nautil 中的 Route 组件具有高可移植性。它是可以独立使用的,而非必须依赖 Navigator 组件。当你在使用 Route 组件时,如果是被包含在 Navigator 内部,可以不传入 navigation,但是这样的坏处是不可移植,在其他应用中使用组件时,必须再在外部使用同一个 Navigator。但是假如你传入了 navigation 属性,那么这个组件可以在任何 react 应用中使用。

Navigate

导航组件,用于在应用中切换路由,实现页面的跳转。

import { Navigate } from 'nautil/components'

它接收如下参数:

<Navigate to={-1}>back</Navigate>
<Navigate to="some" params={{ title: 'Some' }} replace>some</Navigate>
<Navigate to="app:some/page1" open>app</Naviagate>

导航组件会有一个问题,就是当我们希望触发的元素不同时,会有一些差异。例如:

<Navigate to="some">
  <Section><Text>Title</Text></Section>
  <Section><Text>content</Text></Section>
</Navigate>

此时,由于内部存在多个元素,所以 Navigate 会自动添加一个 Section 作为包裹。如果像前面的代码一样,内部是直接的文字,那么它会选择用 Text 进行包裹。而如果里面只有一个元素,则它不自己创建包裹,而是直接将触发事件绑定到该元素上。

你可以通过传入 component 属性来控制使用什么元素进行添加包裹。例如:

<Navigate to="some" component={Button}>some</Navigate>

则会用一个 Button 包裹文本。只要你传入了 component,那么内部元素就会被作为子元素包裹在该组件内。传入 component 时,支持传入 props 属性,用于向 component 传入属性。

Storage

Nautil 内置了一个 Storage 对象,用于提供持久化存储。由于 Nautil 的一个目标是实现跨平台开发,但是在不同平台上,所提供的持久化存储工具并不相同,因此,我们通过内置的 Storage 抹平这些差异,让开发者在自己的代码中无需去适配不同平台。

但是在使用时需要注意,Nautil 内置的 Storage 是异步的,而非同步的,这一点一定要注意。

import { Storage } from 'nautil'

async function init() {
  const data = await Storage.getItem('key')
  await Storage.setItem('key', 2)
  await Storage.removeItem('key')
}

该对象仅提供上述 3 个接口使用。

多语言国际化

在我多年的项目经验中发现,所有的中文项目,但凡运营超过 2 年,一定会有多语言国际化需求。但是由于早期开发的时候抱着“到时候再说”的心态,导致真正要开始实施的时候,改动极其大,完全不知道该如何下手,甚至带来整个前端代码结构的变化。而实际上,在一个项目中加入国际化功能其实比较容易,只是在一开始设计上,就要尊从一些写法规则。“到时候再说”的心态应该改改,应该在一开始的时候就加入国际化接口,按照多语言的方式去实施,虽然实际产品上线的时候可能只会有一门语言,但是,后期要上线其他语言的时候,便利程度难以用言语表达。

在 Nautil 中提供了独立的 I18n 模块,该模块基于著名的 i18next 开发。

import { I18n, Language, T, Locale } from 'nautil/i18n'

I18n

通过 I18n 我们可以得到一个多语言控制器示例。

const i18n = new I18n(config)

其中,config 完全遵循 i18next 的配置。但是 i18n 的接口经过处理,比 i18next 更好用:

注意,对一门语言进行命名时,要遵循国际标准。例如,中文一定要以 zh-CN 这种形式命名。

Language

和 Navigator 的功能差不多,当一个应用需要对某个区域实行多语言时,需要通过 Language 组件将其包起来。

<Language i18n={i18n} dispatch={this.update}>
  <SomeComponent />
</Language>

只有这样,当语言发生切换的时候,内部的所有本地化处理,才会更新。

另外,Language 和 Navigator 一样,也会对其内部的 T, Locale 组件自动污染 i18n 属性,因此,其内部的 T, Locale 组件不需要传 i18n. 但是还是那句老话,为了组件的复用性,建议仍然传入 i18n 属性。

T

该组件用于翻译和显示语言文本。

<T i18n={i18n} t="myHome">my home</T>

属性

可接受的属性包含:

渲染

T 组件的渲染按照如下顺序逻辑进行渲染:

<T t={i18n => i18n.t('my_home')} />
<T t="my_home" s="worker" /> ## i18n.t('worker:my_home')
<T>{i18n => i18n.t('my_home')}</T>
<T>My Home</T>

Locale

该组件用于创建一个可切换语言的区域。

<Locale i18n={i18n} to="zh-CN"><Button>切换至中文</Button></Locale>

它的渲染规则和 Navigate 非常像。

内置逻辑组件

在前文我们已经指出 Nautil 包含了 4 类内置组件。本章主要讲解其中的“逻辑组件”。逻辑组件的主要作用是进行逻辑控制,根据不同的条件来决定是否渲染或如何渲染其内部子组件。

条件逻辑组件

通过条件判断来决定组件的显示与隐藏。

If/ElseIf/Else

代替 jsx 的 hyper 语法,用组件来控制,让 jsx 更纯粹。

import { If, ElseIf, Else } from 'nautil/components'
<If is={boolean} render={() => 
  <Section><Text>if</Text></Section>
}>
  <ElseIf is={boolean} render={() =>
    <Section><Text>else if</Text></Section>
  } />
  <Else render={() => 
    <Section><Text>else</Text></Section>
  } />
</If>

通过上述演示代码,你可以很好的了解这三个组件具体怎么使用了吧。

内部支持函数,以方便在某些情况下不应该对组件实例化时进行处理。

<If is={bool}>
  {() => <Text>should not init when loaded</Text}
</If>

Swich/Case

也是用于条件控制的组件。

<Switch of={value}>
  <Case is={target} render={() => 
    <Text>target</Text>
  } />
  <Case defualt render={() =>
    <Text>default</Text>
  } />
</Switch>

Case 内部支持函数,用以避免组件实例化时,被立即执行。

Show

和 If 不同,Show 会立即渲染其内部自组件,但会通过 is 控制其显示或隐藏。

<Show is={bool}>
  <Text>show me</Text>
</Show>

默认情况下,Show 会自己创建一个 Section, 但它支持你传入 component 属性来控制使用什么组件。

循环遍历组件

下面的组件帮助你实现循环和遍历。

For

一个数字循环迭代组件。

<For start={0} end={10} step={1} render={i => 
  <Text>{i}</Text>
} />

它也支持内部是一个普通元素,而非函数,这样就可以重复显示该内部子元素。

另外需要注意,For 会包含 end 值。也就是说上面会显示 0-10 这 11 个数字。

Each

一个对象遍历迭代组件。

<Each of={data} render={(value, key) => 
  <Text>{key}: {value}</Text>
} />

of 中可以传入对象或数组。

渲染控制组件

下面将介绍用于渲染控制的组件。

Fragment

空内容组件。一般用于占位置。由于 react 里面不能一次返回多个组件(react 升级之后可放在一个数组中),因此,可以通过 Fragment 组件将这些组件包起来,达到返回一组平行的组件的目的。

Static

静态渲染组件。内部必须是一个函数。接收 shouldUpdate 属性。

<Static shouldUpdate={bool} render={() => 
  <Text>{Date.now()}</Text>
} />

只有当 shouldUpdate 为 true 时,内部才会被更新,否则永远都不会被更新。在一些特定场景下的交互非常有用。

Prepare

准备渲染组件。当遇到一些异步,或动态变化时,你可能希望用一个 loading 效果返回给用户。Prepare 组件用于实现该目的。

<Prepare isReady={bool} loading={<Text>loading</Text>} render={() => 
  <Text>loaded</Text>
} />

它的内部支持函数和普通组件,但是函数仅在 isReady 为 true 的情况下才执行,有助于我们拿到数据后进行渲染。

Observer

用于监听变动的组件,该组件是 Nautil 中非常重要且核心的组件,因为它是实现 Nautil 中观察者模式的最主要助手。它需要三个属性:

<Observer
  subscribe={dispatch => store.watch('*', dispatch)}
  unsubscribe={dispatch => store.unwatch('*', dispatch)}
  dispatch={this.update}
>
  <Text>{store.get('some')}</Text>
</Observer>

上面这个代码,演示了如何观察 store 变化,并且更新视图渲染的过程。

指令

和 vue 中指令的概念不同。Nautil 中的指令是用于修改 Component 默认行为方式的函数生成器。

import { inject, observe, pollute, connect, pipe } from 'nautil/operators'

指令的用法,和 react-redux 的 connect 函数非常像。一般先通过指令创建一个 wrapper 函数,然后再用该函数去包裹目标组件。

observe

用于简化 Observer 操作的一个指令。它接收两个参数:subscribe 和 unsubscribe。

用法一:两个函数

const observeByStore = observer(
  dispatch => store.watch('*', dispatch), 
  dispatch => store.unwatch('*', dispatch)
)
const ObservedComponent = observeByStore(Component)

通过这种方式,你就不需要再在组件内写 Observer 操作了。

用法二:store/model

而且 observe 指令支持直接传入 store 和 model 实例,从而减少麻烦的写作。

const observeByStore = observe(store)

这个时候,第二个参数可接收一个字符串,用于表示具体监听 store/model 上的哪一个属性。

observe(store, 'age') // 监听 store 上的 age 属性

这样可以减小监听范围,但是,如果要监听多个属性,就必须写多个 observe 指令语句。

用法三:prop

对于已知某个 prop 的值是 store 或 model 的,可以直接传入一个字符串,例如:

const observeByStore = observe('store')

而在这种情况下,也可以和用法二一样,传入第二个参数为字符串,表示具体监听某个属性,例如:

observe('store', 'age')

传入字符串的形式不推荐,一般只和其他指令一起使用的时候才这样使用,例如:

const wrap = pipe([
  initialize('model', Model),
  observe('model'), // 在已知 model 属性为 Model 实例的情况下使用,其他情况下不要使用
])

inject

像组件 props 中注入新的属性。

const injectState = inject('state', store.state)
const InjectedComponent = injectState(Component)

这样,组件将自动带上 state={store.state} 这个属性。

inject 第二个参数支持函数,该函数接收组件本身的 props,你可以在该函数中决定新增的 prop 值。

pollute

污染组件内指定内嵌组件的 defaultProps。

const polluteWithI18n = pollute(T, { i18n }) // 内部的所有 T 组件都会 defaultProps.i18n = i18n

需要注意,这里指的内嵌组件不单单包含被包裹组件内的子组件,而且包括往下任何深层级的后代组件。

initialize

实例化一个类,并将该类作为对应的 prop 传递给被包裹的组件。

const wrap = initialize('some', Some, ...args) // Some 是一个 js 类,在内部,它会被实例化。第 3 个参数开始,会作为实例化时的默认参数传入
// 被包裹的组件将会有一个名字为 some 的 prop

之所以提供一个 initialize 指令,是为了在某些情况下,我们希望只在组件实例化的时候同时实例化这个类,得到一个在组件内可用的实例,而非一个外部传入的公用的实例。

比如说,我们希望组件在自己的生命周期里使用一个独立的 store,我们可以这样子:

const defaultData = { ... }
export default pipe([
  initialize('store', Store, defaultData),
  observe('store'),
])(SomeComponent)

class SomeComponent extends Component {
  static props = {
    store: Store,
  }
}

这样就可以在组件内使用一个外部传入的 store,并且,当这个 store 的数据发生变化的时候,组件会自动刷新。

pipe

对组件的包裹可能会一次性进行多指令操作。比如我们既想观察 store,又想将 store.state 注入到组件中,这时我们不可能在函数外在包一层函数。pipe 指令用于解决这种问题。

const WrapperedComponent = pipe([
  observe(store),
  inject('state', store.state),
])(Component)

注意,pipe 的参数是一个数组。

MVC

前文一直都是在阐述有关视图层编程的东西,都是在丰富 react 开发的范式。而本章将带领你进入应用开发更高一层的设计,即以 MVC 的“设计模式”实现应用开发。虽然 MVC 不算是一种设计模式,但是它却深远影响了现代应用开发的架构设计。用 Nautil 开发应用,你可以根据自己的实际场景,渐进式地遵循 MVC。当你只是完成简单的 UI 开发时,你只会用到 Component。但是当你需要灵活的处理用户输入与操作交互时,你就需要引入 Controller 来整合 Component 和状态管理、事件管理等。而当你需要对业务对象进行更好的数据控制时,你就需要再引入 Model,用以创建领域模型,以理清楚业务实体与应用系统的联系。最后,如果你需要从应用整体层面处理好数据流或业务流,你就会借助 Service 来处理系统层面的控制。

Controller

后端框架中 Controller 往往是用户输入的入口,编写一个业务模块可以没有模型和视图,但是必须先编写它的 Controller 来处理用户输入。和后端应用的 Controller 不同,前端领域的 View 才是必不可少的,一个业务模块,可以没有用户输入,但是绝对不能没有用来展示的界面。所以,在前端中,Controller 的地位要低于 View。那么,在前端中,Controller 到底控制什么呢?在 Nautil 中,Controller 是用于控制一个业务在处理用户输入时的枢纽。它上承模型、服务,下接视图、状态管理。在简单的业务模块编写时,我们可能用不上 Controller,只需要借助 Component 及状态管理即可完成。但是当遇到复杂(并非代码量多,而是指需要合理组织代码才能清晰的表达每一个需求点)业务时,将复杂的业务逻辑杂糅在 View 层代码中,就导致该业务的代码混杂一片,视图和逻辑相互交织,难以在长期的维护中清晰的展示一个业务点是怎么工作的。而一个 Controller 通过把该业务点所涉及的模型、服务、视图放在一起,经过拆分组合,有效的组织起该业务的代码管理。

在 Nautil 中,我们定义一个 Controller,它将拥有一些特殊的能力,基于这些能力,我们可以在 Controller 中比较好的处理用户的输入/交互所带来的数据影响,同时,又响应数据的变化更新视图界面。

import { Controller } from 'nautil'

class CarController extends Controller {
  // CarModel 是一个关于 car 的领域模型,当 CarController 进行实例化时,会自动实例化 CarModel,你可以通过 this.car 读取该模型实例。
  // 当模型数据发生变化时,Controller 会自动更新自己所管理的视图
  static car = CarModel 
  
  // Controller 中通过  Rxjs 来管理事件流,通过 static sell$ 来提前定义一个名为 sell 的事件流,之后你可以使用 this.sell$ 来读取这个流
  static sell$(stream) {
    ...
  }

  // 和 CarModel 一样,Store 也会自动实例化,通过 this.store 来读取,并且在其 state 发生变化时,Controller 会自动更新自己所管理的视图
  static store = Store
 
  // Controller 会在实例化时,获取服务的单例,该单例是一个全局单例,可在任何 Controller 之间共享
  static service = CarService

  // 以大写开头的方法将被视为 Controller 管理的一个组件,该组件内部可以读取 Controller 实例上的属性和方法,并且在模型或 Store 变化时自动更新界面
  CarBasicInfo(props) {
    const { car } = this // 读取了模型实例
    return (
      <>
        <span>型号: {car.type}</span>
        <span>价格:{car.price}</span>
      </>
    )
  }

  // Controller 上的一个普通方法
  loadCarData(carId) {
    this.service.getCarDetail(carId).then(data => this.car.fromJSON(data))
  }
}

一个 Controller 自身其实并不完成各种逻辑的决定,它更多的是调用自己控制着的模型、服务等其他实体的方法,完成数据和视图的调配。有关业务实体的逻辑,放在 Model 中定义,并以方法的形式暴露出来给 Controller 用。有关业务流的逻辑,放在 Serivce 中定义,也是暴露方法给 Controller 用。Controller 的方法,是对其他实体的方法在具体语境下的一个二次封装,从而达到上下游数据流转的通顺。

我们来看一个例子:

import { React, Controller, Component, Store, Section, Input, Text } from 'nautil'

class MyController extends Controller {
  // Controller will initialize this.store of Store, and subscribe the changes to trigger rerendering
  static store = Store

  // Controller will initialize this.price$ of Stream (Rxjs Subject)
  // the initialized stream is passed into the function to be subscribed
  static price$(stream) {
    stream.pipe(e => +e.target.value)
      .pipe((price) => {
        const { count } = this.store.getState()
        return { count, price }
      })
      // a Rxjs Subject can be used as an observer
      .subscribe(this.total$)
  }
  static count$(stream) {
    stream.pipe(e => +e.target.value)
      .pipe((count) => {
        const { price } = this.store.getState()
        return { count, price }
      })
      .subscribe(this.total$)
  }
  static total$(stream) {
    stream.subscribe(({ count, price }) => {
      const total = price * count
      this.store.setState({ price, count, total })
    })
  }

  // a method whose name begins with uppercase will be treated as a component
  InputPrice() {
    const { price } = this.store.getState()
    return (
      <Section>
        <Text>Price:</Text>
        <Input value={price} onChange={this.price$} />
      </Section>
    )
  }

  InputCount() {
    const { count } = this.store.getState()
    return (
      <Section>
        <Text>Count:</Text>
        <Input value={count} onChange={this.count$} />
      </Section>
    )
  }

  ShowTotal() {
    const { total } = this.store.getState()
    return (
      <Section>
        <Text>Total: {total}</Text>
      </Section>
    )
  }
}

class MyComponent extends Component {
  controller = new MyController()

  render() {
    // components defined by controller can be destructed
    const { InputPrice, InputCount, ShowTotal } = this.controller

    return (
      <Section className="foo">
        <InputPrice className="price" />
        <InputCount className="count" />
        <ShowTotal className="total" />
      </Section>
    )
  }
}

在这段代码中,整个业务模块的入口,是 MyComponent 这个组件,它是视图层的东西,因此,我说前端领域和后端完全不同,视图才是入口。但你可以发现,在入口组件中,内容非常少,它只是从 this.controller 上读取预定义好的组件,并进行布局安排(布局,样式等处理)。而在 MyController 中,我定义了这些被用到的组件,但是,这些组件,完全不考虑布局/样式,而只考虑了用户输入和业务展示(交互层面)。也就是说,我们在组织一个业务模块的代码的时候,我们可以抛开界面的布局和样式,从需求文档的业务交互层面进行代码管理。

而再来看 MyController,它虽然把各种交互逻辑拆分成比较小的碎片,但是,一个完整的 Controller,可以完整描述这一个业务模块所拥有的几乎全部交互逻辑。Controller 自己做到了自洽,再没有 MyComponent 作为入口时,它虽然不工作,但是同样为我们描述了有关该业务模块的所有逻辑。这为我们撰写跨端的业务逻辑提供了可能,以前,我们把业务逻辑写在组件或状态管理中,当我们同一个业务既有 PC 端又有移动端时,我们很难从代码中分离出可以复用的部分(或者可以分离,但是代码组织很麻烦)。而在 MVC 驱动下,这种分离或复用变得很自然,因为 Controller 自管理了一个业务模块的所有(模型、服务、状态、交互),所以,到不同端,就是用不同端的视图组件去进行布局和处理即可。这就是 Nautil 中有关 Controller 的思想,有关 MVC 的具体实践之一。

结语

本文详细的讲解了如何使用 Nautil。虽然在某些细节上尚未做到细致,我会在今后慢慢弥补这些未完成的细节。Nautil 是立足解决 react 开发痛点的新框架。它和 react 做到兼容,从而避免开发者以前写过的 react 应用完全不可用。这一点我认为是非常重要的。在我的开发理念中,“简单好用”永远是我认为最重要的一条原则。如果不简单不好用,即使这门技术非常强大,仍然无法吸引我的眼球。

在开发者使用 Nautil 的过程中,一定要时刻记住,观察者模式贯穿始终。如果有兴趣更多的去研究 rxjs,你可能会惊喜的发现,原来 nautil 是如此的先进和了不起。它融合了所有 react 生态中的非主流,非 redux,非单向数据流,非 immutable,非 react-router,甚至,我们提供了新的表单模型(这一块我们还在想办法解决表单模型和视图如何更统一的尝试)而放弃了所谓业界流行的几大表单库。

另外,我们的一个重要目标,是建立跨平台的前端开发框架。真正做到在代码层面实现一处撰写,多平台运行的目标。但是本质上,我们没有改变 react 开发的有趣性,我们在它的基础上进行了扩展。如果你认为其中一部分好,另一部分不好,你可以尝试不使用其中的某些部分,而只是用其中的一部分。现在的构建工具已经足够丰富,可以帮我们把那些不需要的部分从最终的代码中删除而不影响应用的运行。

我还在继续开发 nautil 的细节功能,本文中的思想、原则部分不会在发生变化,但具体的一些细节(例如某些组件的用法)还可能会有调整。如果你对这个项目感兴趣,加入我们吧。

2019-09-22