Nautil 说明文档

Nautil 是一款基于 react 的响应式开发框架,它运用观察者模式,提供丰富的开发套件,提供跨平台开发解决方案,比任何 react 开发方式都要简单且有效。

前言

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

特别是 redux,虽然是基于非常优秀的思想开发,却由于太过复杂的样板,在实际应用中不仅不能带来开发的便捷性,而且会增加代码体积。而 Nautil 是一个新的框架,它虽然基于 react,但是并没有一味的去迎合 react 社区,它独树一帜,提出自己的状态管理、数据管理、观察者模式响应等等,通过这些新的东西,再结合 react 的优秀之处,它完美的实现了新一代框架应该具备的特性。

基于更多的选择,Nautil 打破原有的 react 范式,融入我们认为更有利于开发者快速开发的新方法。我们不应该拘泥于编程范式,应该尝试打破范式之争,勇于尝试。

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

安装

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

兼容性

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

当前版本

最新稳定版本:0.6v
稳定版本对应分支:master
开发中分支:dev

NPM

npm i nautil

构建工具

Nautil 基于 ES6 module 开发,但它所依赖的第三方库不一定基于 ES,而 Nautil 不提供已构建好的文件,因此,仍然需要你使用构建工具编译 Nautil 应用。我为你写好了 CLI 工具 nautil-cli,你可以通过简单的命令开始代码撰写,当然,你也可以通过配置文件,进行更深入的配置。

起步

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

你是怎样的开发者?

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

适用场景

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

为什么选择 Nautil?

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

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

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

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

观察者模式

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

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

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

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

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

export default pipe([
  observe(store),
  inject('state', store.state),
])(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 中具体如何使用:

import { Store } from 'nautil'

export const store = new Store({
  age: 0,
})
export default store
import { Button } from 'nautil/components'
import { inject } from 'nautil/operators'
import store from './store'

export function FuncComponent(props) {
  const { state } = props
  return (
    <Button onHint={() => state.age ++}>age ++</Button>
  )
}
export default inject('state', store.state)(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 { Component } from 'nautil'
import { Section, Text } from 'nautil/components'
import store from './store'

export default class Some extends Component {
  render() {
    return <Section onHint={() => store.state.count ++}>
      <Text>{store.state.count}</Text>
    </Section>
  }
}
// app.js
import { Component, Store } from 'nautil'
import { Observer } from 'nautil/components'
import Some from './some'
import store from './store'

export default class App extends Component {
  render() {
    return <Observer 
      subscribe={dispatch => store.watch('*', dispatch)} 
      unsubscribe={dispatch => store.unwatch('*', dispatch)} 
      dispatch={this.update}
    >
      <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>
    )
  }
}

class Some extends Component {
  render() {
    const { state } = this.attrs
    return (
      <Section>
        <Button onHint={() => state.show = true}></Button>
        <Modal $show={[state.show, show => state.show = show]} />
      </Section>
    )
  }
}

const store = new Store({
  show: false,
})
export default pipe([
  observe(store),
  inject('state', store.state),
])(Some)

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

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

必须声明为双向绑定的属性,才能直接在 this.attrs 上进行操作,否则不会有任何效果。另外,对 this.attrs 的操作,不会改写 this.attrs 的值,而是仅调用外部传入的 reflect。

为了简化双向绑定,nautil 允许你传入一个 store 或 model 或它们的 state 属性来实现快速读取:

const store = new Store({ show: false })
const { state } = store

<Sub $show={state} />
等效于:
<Sub $show={[state.show, show => state.show = show]} />

红色部分是一个缩写,这种缩写仅限于 store 和 model 实例。

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

import { createTwoWayBinding } from 'nautil/utils'

class Some extends Component {
  render() {
    const { state } = this.attrs
    const $state = createTwoWayBinding(state)

    return <Model $show={$state.show} />
  }
}

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

分包引入

和很多 npm 包的设计相同,nautil 采用分包引入的方式,根据不同的接口类型进行分类,这样方便渐进式开发,也方便打包时缩小体积。

以上是通用平台接口,下面是跨平台接口:

一般在进行应用开发时,不考虑渲染平台,将整个应用脱离具体的平台,进行抽象开发。要在某个平台进行渲染时,才在应用的入口文件中调用对应的平台渲染模块进行处理,具体可以看项目下的 examples。

跨平台开发

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 生命周期函数的一切,从 0 开始。

内置组件

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

内置组件包含如下几类:

由于 Nautil 是分包引入的,因此,只有在你需要的时候,才需要引入对应的包,从而可以控制真正引入的文件的大小。基于 ES module 的特性,所有的包都从 nautil/components 引入。当你引入的时候,没有引入某些模块时,会被自动忽略,并通过 webpack 的 tree-shaking 功能,丢掉不需要的代码,从而减少应用最终打包的大小。

跨平台构建

不同的平台,使用不同的入口文件,使用不同的构建配置。例如,我们在 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-cli 工具,你可以通过一条命令就可以解决所有问题。

组件

虽然 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/types 中引入已支持的类型规则。

import { Dict } from 'nautil/types'
import { Component } from 'nautil'

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

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

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

削减代码

在 process.env.NODE_ENV === 'producition' 时,校验会被禁用。也就是说,在正式环境中,组件的校验是禁用掉的,不会执行,并且通过 webpack 的 tree-shaking,可以将部分代码削减掉。但是,这个功能不能去除掉组件的 static props,你只有在使用 nautil-cli 时才能去掉组件上的 props 静态属性。

Attrs 属性

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

Attrs 值

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

对于 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 属性,而是采用 onHint 作为替代。这是因为在跨平台开发时,我们将通过新的名字统一不同平台的事件名字。

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

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

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

class Some extends Component {
  static props = {
    onHint: true, // on 开头的事件属性在校验时会被忽略其值,使用内部的规则自动进行校验
    $age: Number,
    $weight: Number,
  }
  // this.onHint$ 必须在 onDigested 之后使用
  onDigested() {
    this.onHint$.subscribe(() => this.attrs.age ++)
    this.onHint$.subscribe(() => this.attrs.weight = this.attrs.age * 10)
  }
  render() {
    return (
      <Button onHint={() => this.onHint$.next()}>grow</Button>
    )
  }
}

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

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

我们用代码来演示怎么使用:

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.forceUpdate 方法,用于更新当前组件。它们的用法一样,接收一个 callback 回调函数。不同之处在于 this.update 将会进入 shouldUpdate 周期函数进行判断,而 this.forceUpdate 直接忽略该周期函数。

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

生命周期

Nautil 组件的生命周期和 react 组件一致,在前文已经介绍过。但在 react 生命周期基础上,Nautil 新增了如下周期函数:

“批处理”是 Nautil 生成前文所述的 this.attrs, this.children, this.onHint$ 等属性的任务,它的具体位置位于:

而 onDigested 仅在批处理期间被调用,会在上述两个节点之前被调用。

Nautil 生命周期函数示意图

Store

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

import { Store } from 'nautil'

创建 Store

创建 Store 极为简单。你只需要传入一个默认值即可。

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

你甚至可以传入一个空对象。和 vue 不同,store 中的数据是动态的,不需要你提前规定都有哪些属性,store 中的数据可被观察,可恢复,随时添加新属性。

State

当一个 store 创建之后,你可以随时修改它里面的数据。那么怎么改呢?它提供了两种方式,第一种,也是最常用的一种就是使用 State. 你可以这样使用:

const { state } = store

const { a } = state // 读取
state.a = 2 // 写入

上面演示了如何使用和更新 store 中的数据。这是最常用的。

数据接口

但是使用 state 会有一个盲点,假如 store 中不存在 body, 那么你是不能对 state.body.hands 进行操作的。因为遵循 js 对象基本的操作模式,读取或写入时,会报错。

你可以使用 store.get, sotre.set 两个方法来处理这种情况。

const hands = store.get('body.hands') // 获取
store.set('body.hands', 2) // 更新
store.del('body.hands') // 删除

监控数据变化

当 store 中的数据发生变化时,我们通过一个 watch 方法去监控它。watch 的使用很像 angular 的 $watch。

store.watch('body.hands', (newVal, oldVal) => {
  // ...
})

为了实现数据的联动,你可以在 watch 中同时更新另外一个属性,例如:

store.watch('body.hands', (newVal, oldVal) => {
  store.set('body.feet', newVal)
})

这样,当 hands 发生变化时,feet 也会发生变化。但是你需要注意,你不能在做如上监听之后,再进行将 hands 和 feet 进行同步,否则可能会面临死循环。

另外,watch 方法支持第三个参数 deep,用以表示是否支持深度监听。当你监听的属性值是一个对象时,可以使用该参数。

相反,unwatch 用于解绑监听,在你不需要观察动作之后,应该主动解绑监听。

计算属性

Store 支持计算属性。在创建一个 store 实例的时候,我们通过传入计算属性来定义该属性。

const store = new Store({
  name: 'tomy',
  birth: 2009, 

  // age 是一个双向计算属性,可通过 get 实时动态计算值,可设置值时反写当前对象属性
  get age() {
    return new Date().getFullYear() - this.birth
  },
  set age(age) {
    this.birth = new Date().getFullYear() - age
  },

  // height 是一个单向计算属性,只能读取其计算结果值,不能向 height 设置值
  get height() {
    return this.age * 5
  },
  
  // fatherAge 是一个单向计算属性,任何情况下,读取结果都是 undefined,对它设置值时,只会对其他属性产生影响,而不会更新自己的值
  set fatherAge(fage) {
    this.age = fage - 28
  },
})

关于计算属性的更多解释,请阅读这里

批量更新数据

当你希望通过 API 接口返回的数据恢复到 store 中时,或者其他需要,对多个属性进行一次性更新时,你需要用到 update 方法:

await store.update({
  'body.hands': 2,
  'head': 40,
})

它内部有一个机制。当我们通过 update 更新数据时,它是异步的,在异步过程中,如果你多次调用 update 方法更新数据,那么这些 update 会被合并后一次性更新。并且,它返回一个 Promise,用于表示该更新是否成功。但是在更新过程中,如果出错,那么更新就会中断,并且抛出错误,数据会恢复到初始状态。

利用批量更新,可以实现对整个应用进行回放效果。

结合组件使用

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

import { Component, Store } from 'nautil'
import { Observer, Section, Text } from 'nautil/components'

export class Some extends Component {
  static props = {
    store: Store, // 接收外部传入 store
  }
  render() {
    const { store } = this.attrs
    const { state } = store
    return (
      <Observer 
        subscribe={dispatch => store.watch('*', dispatch)}
        unsubscribe={dispatch => store.unwatch('*', dispatch)}
        dispatch={this.update}
      >
        <Section><Text>{state.name}</Text></Section>
        <Section><Text></Text</Section>
      </Observer>
    )
  }
}

导航/路由

对于一个框架而言,路由是必须要解决的。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 传入属性。

数据仓库

一个前端应用中,从后台接口拉取数据是必然的,Nautil 内置了数据请求工具 Depository,用于解决数据的存取。使用 Depository 之后,你不需要再在应用中去构建自己的 ajax 请求。更为现代的前后端数据交互形式,Depository 通过抽象,将数据这种东西形式化为物料,而非流体。

import { Depository } from 'nautil'

数据交互的抽象

前端和后台的数据交互在应用中相对有一定的复杂性。在前端请求后台数据,目前有三种方式:

如何优化管理前后端数据交互呢?我们对数据重新进行抽象。在上述传统理念中,前后端交互情境下,数据是流体,有时间效应,包含“后台接口、请求功能、参数、返回结果”等因素,所以,一般在编程上是异步的。但我们在 Nautil 开发过程中,我们强调观察者模式。我们认为数据是一种物料,用来作为前端界面渲染的依据。对于前端开发而言,我们只需要拿到物料,进行渲染即可。

我们提出了“数据仓库”(Depository)概念。对于前端开发而言,不需要去接触真实的后台,而只需要接触仓库即可。物料被存储在仓库中,每一个物料被分配一个空间,对空间进行编号。当我们需要取出物料时,我们只需要通过这个编号,找到对应的空间,将里面的东西取出来即可。

当一个空间分配好之后,不一定马上有物料,而且物料也不一定是固定不变的。但是对于前端开发者而言,根本不需要关心这些问题,因为我们定义好一个空间之后,它所存储的物料就是我们所需要的,无论物料是否发生变化,对于使用者而言,都是我们所需。

将前端编程和后台接口之间通过数据仓库进行中转,对于开发者而言,所关注的重点发生了转移。对于业务代码部分,只需要关注仓库中是否存在自己所需要的物料,并通过空间编号来使用。但是另一方面,我们需要针对仓库进行编程,通过定义空间属性,让空间可以正确的去后台接口获取数据。另外,我们还需要对仓库进行一些配置,例如缓存、存储介质等。

配置

创建一个数据仓库,有一些特别的选项需要注意:

const depo = new Depository({
  name: 'depo_name', // 必须,仓库名
  
  // 以下选项是作为请求的基本参数,内部使用 axios 作为 ajax 请求库,
  baseURL: '',
  headers: {},
  data: {},
  timeout: 30000,

  // 以下选项作为存储介质的参数,内部使用 storagex 作为存储控制器,
  // 你可以使用 localStorage, indexedDB 等持久化存储介质来保存数据,从而实现刷新后仍然有本地数据
  storage: null,
  stringify: false,
  async: false,
  expire: 0,
})

数据仓库就这样创建好了。但是,现在,它是一个空的仓库,我们没有为它划分空间,就像一块刚买来的硬盘,还没有分区,所以它没有办法存储数据。接下来,我们要在一个这个空的仓库中划分空间。

数据源

数据源(dataSource)是对空间的主要描述,同时,数据源描述了如何从后台 API 拉取数据。一个数据源,包含如下配置信息:

const dataSource = {
  id: 'user', // 对数据空间进行命名
  url: '/api/v2/users/{userId}', // 拉取数据的接口地址
  method: 'get', // 获取数据的请求方式 get|post|headers|options 等
  headers: {}, // 请求的 header
  data: {}, // 请求的时候的基础参数,当 method 为 post 的时候作为发送体的一部分,其余时为 url query 的一部分
  poll: function(res) { return true }, // 轮询,当 poll 为一个函数时,表示这是一个轮询请求,接收响应信息,返回 true 表示轮询结束,返回 false 表示继续等待轮询,轮询周期为 1-5 秒
  validateStatus: function, // 见 axios 的 validateStatus 配置
  validateData: function, // 校验数据,当不通过当前数据时,需要返回一个 Error 实例
  transformer: function, // 数据格式化工具,接收原始数据(仓库中存储的),返回格式化后的数据,一个纯函数,不能在内部对原始数据进行修改,须对数据进行 immutate 操作
}

一个数据源定义了数据空间的名称,数据如何从后端拉取,简单的校验逻辑,以及数据从仓库中读取的时候进行的数据格式转化。

这里 transformer 需要注意一下。一份数据从后台拉取回数据仓库之后,它被存储在仓库中。但是,前端应用可能会出现多个组件从仓库读取同一份数据,然而在真正使用的时候,格式要求各自不同,这时,通过 transformer 函数,就可以对同一份数据进行格式化后,得到不同的结构。transformer 必须是纯函数,不能对原始数据产生任何副作用。

你可能会问,我怎么知道是否要读取同一份数据呢?这一点你不用担心,数据仓库会自己去处理,它会根据一份数据在请求时所使用的 url, method, header, params 等信息进行识别和组合,从而发现和认证是否要读取已经存在的同一份数据。对于这个环节,你不需要深入去思考和理解,只需要在数据源描述中按照自己的需要填写描述信息即可。

depo.register(dataSource)

数据源描述通过 depo.register 方法注册到仓库中,这样,一个数据空间就注册好了,并且以数据源描述中的 id 作为该空间的标识。

使用数据

如何使用数据呢?有两个方法,可以帮助你从仓库中获取数据。

request

这个方法和普通的 ajax 方法非常像,它返回一个 Promise,并且将数据结果在 then 中返回。它有两个参数:

另外,request 有一个特性,它不会随意往后台发送请求,它会检查当前对于同一个数据源而言,是不是已经有一个 request 了,如果已经有一个 request 了,那么它会直接返回已经存在的 request 所对应的 Promise,而不是创建一个新的。这样做既节省宽带流量,也节省请求时间。

get

这个方法是 Depository 的核心。它也是获取数据,但是比 request 更加直接,它直接返回当前数据空间的物料,而非返回一个 Promise。也就是说,通过 get 方法得到的数据是可以直接使用的,是同步的而非异步的。

它的参数和 request 一模一样。

你可以认为 get 返回的是一个缓存,而不是实时数据。每一次数据源被拉取回来之后,其数据被存储在仓库中。而 get 操作直接从空间中获取对应的物料,这个物料是被存储在仓库中的“缓存”。缓存过期,get 方法会自动触发一次 request,去拉取最新的接口数据。但这个过程,你感知不到。

那么带来了一个问题,就是当空间中的物料还没有拉取会来,或者已经过期的时候,怎么办呢?get 方法在这个时候,会返回 undefined。也就是说,这个时候的数据是不可用的。在你的业务代码中,必须有一个对从仓库里获取的数据进行条件判定的逻辑。

class Some extends Component {
  static props = {
    depo: Depository,
    uid: Number,
  }

  render() {
    const { depo, uid } = this.attrs
    const user = depo.get('user', { uid })
    
    // 如果 user 还没有准备好,或者已经过期,则用一个文本提示用户
    return (
      <Prepare isReady={user} loading={<Text>正在等 user 拉取</Text>}>
        <Section><Text>name: {user.name}</Text></Section>
        <Section><Text>age: {user.age}</Text></Section>
      </Prepare>
    )
  }
}

通过上面的代码,你可以知道如何使用 get 方法了吧。

基础示意图

我们用一张图来表示数据仓库、数据物料、数据空间、前端组件之间的关系。

Nautil Depository 示意图:data sources 指数据源描述,id=1 表示对应的数据空间,data storage 指存储物料的介质。

Depository 在前端组件和后台 API 之间建立一个中间区域。上图中,蓝色线代表 ajax 请求的线路,红色线代表数据流向。我们先看蓝色线路。组件从 depo 获取数据时,如果不存在该物料(包含已过期),那么蓝色线路会被触发。depo 取出数据源描述,发送给请求器,发起 ajax 请求。请求器拿到数据之后,并不是马上把数据存到 storage 中,而是要进行一边 validate,只有一切顺利的情况下,才会将数据写入 storage。此时,如果组件再次通过 get 读取数据,那么存储空间所对应的物料就已经存在了,组件就可以正常获取到数据。在数据出仓库之前,根据数据源描述,还可能做一次 transform 操作。

监听数据变化

和 Store 及 Navigation 一样,Depository 是可被观察的。结合前文反复强调的观察者模式,我们可以做到在 Nautil 应用中,即时响应数据仓库中数据的变化。

数据仓库中的数据变化,只会在物料发生变化时被触发。当同一个物料(可能被不同数据空间使用),在 storage 中被存储时,就会触发对应空间的回调,在回调中你可以更新界面,从而达到想要的界面变化效果。我们以上面 get 部分中示例代码里面的 Some 组件作为例子来看下怎么使用:

import { Observer } from 'nautil/components'

export SomeR extends Components {
  render() {
    const { depo, uid } = this.attrs
    return (
      <Observer
        subscribe={dispatch => depo.subscribe('user', dispatch)}
        unsubscribe={dispatch => depo.unsubscribe('user', dispatch)}
        dispatch={this.update}
      >
        <Some depo={depo} uid={uid} />
      </Observer>
    )
  }
}

实际上,通过 observe operator 进行组装会使代码更简单。总之,这就是监听仓库数据发生变化的方法。

subscribe

订阅数据变化。有 3 个参数:

unsubscribe

取消通过 subscribe 订阅的回调函数。

当你不需要监听时,需要即时通过 unsubscribe 取消订阅,避免内存泄露。

保存数据

如何将数据提交到后台进行保存呢?我们要注册一些比较特殊的数据源用于保存/更新/删除服务端数据。数据源的定义和前文所述一致,没有区别,但是仅支持 method 为 post, put, delete, patch 的操作。

配置好数据源之后,调用 depo 上的 save 操作完成和服务端的交互。save 的参数和 get/request 是一致的,没有差别。save 返回一个 Promise,用于表示是否完成操作。

await depo.save('user', { age: 12 })

save 操作还有一个特性。倘若在短时间内多次调用 save 对同一个数据源进行操作,那么它可能会合并多个 save 的请求参数。例如:

depo.save('user', { age: 11 })
depo.save('user', { age: 12 })
depo.save('user', { weight: 60 })

合并之后,实际上只会发送一个 ajax 请求,发送的数据为 { age: 12, weight: 60 }。这样也可以节省流量和交互次数。

注意:save 不可使用 get 的数据源。

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}>
  <Section><Text>if</Text></Section>
<ElseIf is={boolean} />
  <Section><Text>else if</Text></Section>
<Else />
  <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}>
    <Text>target</Text>
  </Case>
  <Case defualt>
    <Text>default</Text>
  </Case>
</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}>
  {i => <Text>{i}</Text>}
</For>

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

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

Each

一个对象遍历迭代组件。

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

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

渲染控制组件

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

Fragment

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

Static

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

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

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

Prepare

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

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

它的内部支持函数和普通组件,但是函数仅在 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

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

connect

连接一个 ReactContext。

const connectState = connect('state', stateContext)
const ConnectedComponent = connectState(Component)

这里的 stateContext 必须是一个用 createContext 生成的 ReactContext。经过上面的步骤之后,被包裹组件上将自带一个 state 的 prop,并且组件会因为 stateContext 上游 Provider 的更新而自动更新。

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 的参数是一个数组。

结语

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

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

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

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

2019-09-22