Giter Site home page Giter Site logo

blog's Introduction

I'm a crazy JavaScript engineer, like algorithm, design pattern, front-end frameworks and data analysis.

  • Want to know what I learned every week? You can find them on weekly.

Anurag's GitHub stats

You can also read my articles in zhihu, juejin or follow the WeChat official account of the weekly:

blog's People

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

blog's Issues

前端数据流哲学

本系列分三部曲:《框架实现》 《框架使用》 与 《数据流哲学》,这三篇是我对数据流阶段性的总结,正好补充之前过时的文章。

本篇是收官之作 《前端数据流哲学》。

1 引言

写这篇文章时,很有压力,如有不妥之处,欢迎指正。

同时,由于这是一篇佛系文章,所以不会得出你应该用 某某 框架的结论,你应该当作消遣来阅读。

2 精读

首先数据流管理模式,比较热门的分为三种。

  • 函数式、不可变、模式化。典型实现:Redux - 简直是正义的化身。
  • 响应式、依赖追踪。典型实现:Mobx。
  • 响应式,和楼上区别是以流的形式实现。典型实现:Rxjs、xstream。

当然还有第四种模式,裸奔,其实有时候也挺健康的。

数据流使用通用的准则是:副作用隔离、全局与局部状态的合理划分,以上三种数据流管理模式都可以实现,唯有是否强制的区别。

2.1 从时间顺序说起

一直在思考如何将这三个思维串起来,后来想通了,按照时间顺序串起来就非常自然。

暂时略过 Prototype、jquery 时代,为什么略过呢?因为当时前端还在野蛮人时代,生存问题都没有解决,哪还有功夫思考什么数据流,设计模式?前端也是那时候被觉得比后端水的。

好在前端发展越来越健康,大坑小坑被不断填上,加上硬件性能的提高,同时需求又越来越复杂,是时候想想该如何组织代码了。

最先映入眼帘的是 angular,搬来的 mvvm **真是为前端开辟了新的世界,发现代码还可以这么写!虽然 angluar 用起来很重,但 mvvm 带来的数据驱动**已经越来越深入人心,随后 react 就突然火起来了。

其实在 react 火起来之前,有一个框架一步到位,进入了 react + mobx 时代,对,就是 avalon。avalon 也非常火,但是一个框架要成功,必须天时、地利、人和,当时时机不对,大家处于 angular 疲惫期,大多投入了 react 的怀抱。

可能有些主观,但我觉得 react 能火起来,主要因为大家认为它就是轻量 angular + 继承了数据驱动**啊,非常符合时代背景,同时一大波概念被炒得火热,状态驱动、单向数据流等等,基本上用过 angular 的人都跟上了这波节奏。

虽然 react 内置了分形数据流管理体系,但总是强调自己只是 View 层,于是数据层增强的框架不断涌现,从 flux、reflux、到 redux。不得不说,react 真的推动了数据流管理的独立,让我们重新认识了数据流管理的重要性。

redux 概念太超前了,一步到位强制把副作用隔离掉了,但自己又没有深入解决带来的代码冗余问题,让我们又爱又恨,于是一部分人把目光转向了 mobx,这个响应式数据流框架,这个没有强制分离副作用,所以写起来很舒服的框架。

当然 mobx 如果仅仅是 mvvm 就不会火起来了,毕竟 angular 摆在那。主要是乘上了 react 这趟车,又有很多质疑 angular 脏检测效率的声音,mobx 也火了起来。当然,作为前端的使命是优化人机交互,所以我们都知道,用户习惯是最难改变的,直到现在,redux 依然是绝对主流。

mobx 还在小范围推广时,另一个更偏门的领域正刚处于萌芽期,就是 rxjs 为代表的框架,和 mobx 公用一个 observable 名词,大家 mobx 都没搞清楚,更是很少人会去了解 rxjs。

当 mobx 逐渐展露头角时,笔者做了一个类似的库:dob。主要动机是 mobx 手感还不够完美,对于新赋值变量需要用一些 extendObservable 等 api 修饰,正好发现浏览器对 proxy 支持已经成熟,因此笔者后来几乎所有个人项目几乎都用 dob 替代了 mobx。

这一时期三巨头之一的 vue 火了起来,成功利用:如果 ”react + mobx 很好用,那为什么不用 vue?“ 的 flag 打动了我。

一直到现在,前端已经发展到可谓五花八门的地步,typescript 打败 flow 几乎成为了新的 js,出现了 ember、clojurescript 之后,各大语言也纷纷出了到 js 的编译实现,陆陆续续的支持编译到 webassembly,react 作者都弃坑 js 创造了新语言 reason。

之前写过一篇初步认识 reason 的精读

能接下来这一套精神洗礼的前端们,已经养出内心波澜不惊的功夫,小众已经不会成为跨越舒适区的门槛,再学个 rxjs 算啥呢?(开个玩笑,rxjs 社区不乏深耕多年的巨匠)所以最近 rxjs 又被炒的火热。

所以,从时间顺序来看,我们可以从 redux - mobx - rxjs 的顺序解读这三个框架。

2.2 redux 带来了什么

redux 是强制使用全局 store 的框架,尽管无数人在尝试将其做到局部化。

当然,一方面是由于时代责任,那时需要一个全局状态管理工具,弥补 react 局部数据流的不足。最重要的原因,是 redux 拥有一套几乎洁癖般完美的定位,就是要清晰可回溯

几乎一切都是为了这两个词准备的。第一步就要从分离副作用下手,因为副作用是阻碍代码清晰、以及无法回溯的第一道障碍,所以 action + reducer 概念闪亮登场,完美解决了副作用问题。可能是参考了 koa 中间件的设计思路,redux middleware 将 action 对接到 reducer 的黑盒的控制权暴露给了开发者。

由 redux middleware 源码阅读引发的函数式热,可能又拉近了开发者对 rxjs 的好感。同时高阶函数概念也在中间件源码中体现,几乎是为 react 高阶组件做铺垫。

社区出现了很多方案对 redux 异步做支持,从 redux-thunk 到 redux-saga,redux 带来的异步隔离**也逐渐深入人心。同时基于此的一套高阶封装框架也层出不穷,建议用一个就好,比如 dva

第二步就是解决阻碍回溯的“对象引用”机制,将 immutable 这套庞大**搬到了前端。这下所有状态都不会被修改,基于此的 redux-dev-tools “时光机” 功能让人印象深刻。

Immutable 具体实现可以参考笔者之前写的一篇精读:精读 Immutable 结构共享

当然,由于很像事件机制的 dispatch 导致了 redux 对 ts 支持比较繁琐,所以对 redux 的项目,维护的时候需要频繁使用全文搜索,以及至少在两个文件间来回跳跃。

2.3 mobx 带来了什么

mobx 是一个非常灵活的 TFRP 框架,是 FRP 的一个分支,将 FRP 做到了透明化,也可以说是自动化。

从函数式(FP),到 FRP,再到 TFRP,之间只是拓展关系,并不意味着单词越长越好。

之前说过了,由于大家对 redux 的疲劳,让 mobx 得以迅速壮大,不过现在要从另一个角度分析。

mobx 带来的概念从某种角度看,与 rxjs 很像,比如,都说自己的 observable 有多神奇。那么 observable 到底是啥呢?

可以把 observable 理解为信号源,每当信号变化时,函数流会自动执行,并输出结果,对前端而言,最终会使视图刷新。这就是数据驱动视图。然而 mobx 是 TFRP 框架,每当变量变化时,都会自动触发数据源的 dispatch,而且各视图也是自动订阅各数据源的,我们称为依赖追踪,或者叫自动依赖绑定。

笔者到现在还是认为,TFRP 是最高效的开发方式,自动订阅 + 自动发布,没什么比这个更高效了。

但是这种模式有一个隐患,它引发了副作用对纯函数的污染,就像 redux 把 action 与 reducer 合起来了一样。同时,对 props 的直接修改,也会导致与 react 对 props 的不可变定义冲突。因此 mobx 后来给出了 action 解决方案,解决了与 react props 的冲突,但是没有解决副作用未强制分离的问题。

笔者认为,副作用与 mutable 是两件事,关于 mutable 与副作用的关系,后文会有说明。也就是 mobx 没有解决副作用问题,不代表 TFRP 无法分离副作用,而且 mutable 也不一定与 可回溯 冲突,比如 mobx-state-tree,就通过 mutable 的方式,完成了与 redux 的对接。

前端对数据流的探索还在继续,mobx 先提供了一套独有机制,后又与 redux 找到结合点,前端探索的脚步从未停止。

2.4 rxjs 带来了什么

rxjs 是 FRP 的另一个分支,是基于 Event Stream 的,所以从对 view 的辅助作用来说,相比 mobx,显得不是那么智能,但是对数据源的定义,和 TFRP 有着本质的区别,似的 rxjs 这类框架几乎可以将任何事件转成数据源。

同时,rxjs 其对数据流处理能力非常强大,当我们把前端的一切都转为数据源后,剩下的一切都由无所不能的 rxjs 做数据转换,你会发现,副作用已经在数据源转换这一层完全隔离了,接下来会进入一个美妙的纯函数世界,最后输出到 dom driver 渲染,如果再加上虚拟 dom 的点缀,那岂不是。。岂不就是 cyclejs 吗?

多提一句,rxjs 对数据流纯函数的抽象能力非常强大,因此前端主要工作在于抽一个工具,将诸如事件、请求、推送等等副作用都转化为数据源。cyclejs 就是这样一个框架:提供了一套上述的工具库,与 dom 对接增加了虚拟 dom 能力。

rxjs 给前端数据流管理方案带来了全新的视角,它的概念由 mobx 引发,但解题思路却与 redux 相似。

rxjs 带来了两种新的开发方式,第一种是类似 cyclejs,将一切前端副作用转化为数据源,直接对接到 dom。另一种是类似 redux-observable,将 rxjs 数据流处理能力融合到已有数据流框架中,

redux-observable 将 action 与 reducer 改造为 stream 模式,对 action 中副作用行为,比如发请求,也提供了封装好的函数转化为数据源,因此,将 redux middleware 中的副作用,转移到了数据源转换做成中,让 action 保持纯函数,同时增强了原本就是纯函数的 reducer 的数据处理能力,非常棒。

如果说 redux-saga 解决了异步,那么 redux-observable 就是解决了副作用,同时赠送了 rxjs 数据处理能力。

回头看一下 mobx,发现 rxjs 与 mobx 都有对 redux 的增强方案,前端数据流的发展就是在不断交融。

我们不但在时间线上,将 redux、mobx、rxjs 串了起来,还发现了他们内在的关联,这三个**像一张网,复杂的交织在一起。

2.5 可以串起来些什么了

我们发现,redux 和 rxjs 完全隔离了副作用,是因为他们有一个共性,那就是对前端副作用的抽象

redux 通过在 action 做副作用,将副作用隔离在 reducer 之外,使 reducer 成为了纯函数。

rxjs 将副作用先转化为数据源,将副作用隔离在管道流处理之外。

唯独 mobx,缺少了对副作用抽象这一层,所以导致了代码写的比 redux 和 rxjs 更爽,但副作用与纯函数混杂在一起,因此与函数式无缘。

有人会说,mobx 直接 mutable 改变对象也是导致副作用的原因,笔者认为是,也不是,看如下代码:

obj.a = 1

这段代码在 js 中铁定是 mutable 的?不一定,同样在 c++ 这些可以重载运算符的语言中也不一定了,setter 语法不一定会修改原有对象,比如可以通过 Object.defineProperty 来重写 obj 对象的 setter 事件。

由此我们可以开一个脑洞,通过运算符重载,让 mutable 方式得到 immutable 的结果。在笔者博客 Redux 使用可变数据结构 有说明原理和用法,而且 mobx 作者 mweststrate 是这么反驳那些吐槽 mobx 缺少 redux 历史回溯能力的声音的:

autorun(() => {
  snapshots.push(Object.assign({}, obj))
})

思路很简单,在对象有改动时,保存一张快照,虽然性能可能有问题。这种简单的想法开了个好头,其实只要在框架层稍作改造,便可以实现 mutable 到 immutable 的转换。

比如 mobx 作者的新作:immer 通过 proxy 元编程能力,将 setter 重写为 Object.assign() 实现 mutable 到 immutable 的转换。

笔者的 dob-redux 也通过 proxy,调用 Immutablejs.set() 实现 mutable 到 immutable 的转换。

组件需要数据流吗

真的是太看场景了。首先,业务场景的组件适合绑定全局数据流,业务无关的通用组件不适合绑定全局数据流。同时,对于复杂的通用组件,为了更好的内部通信,可以绑定支持分形的数据流。

然而,如果数据流指的是 rxjs 对数据处理的过程,那么任何需要数据复杂处理的场合,都适合使用 rxjs 进行数据计算。同时,如果数据流指的是对副作用的归类,那任何副作用都可以利用 rxjs 转成一个数据源归一化。当然也可以把副作用封装成事件,或者 promise。

对于副作用归一化,笔者认为更适合使用 rxjs 来做,首先事件机制与 rxjs 很像,另外 promise 只能返回一次,而且之后 resolve reject 两种状态,而 Observable 可以返回多次,而且没有内置的状态,所以可以更加灵活的表示状态。

所以对于各类业务场景,可以先从人力、项目重要程度、后续维护成本等外部条件考虑,再根据具体组件在项目中使用场景,比如是否与业务绑定来确定是否使用,以及怎么使用数据流。

可能在不远的未来,布局和样式工作会被 AI 取代,但是数据驱动下数据流选型应该比较难以被 AI 取代。

再次理解 react + mobx 不如用 vue 这句话

首先这句话很有道理,也很有分量,不过笔者今天将从一个全新的角度思考。

经过前面的探讨,可以发现,现在前端开发过程分为三个部分:副作用隔离 -> 数据流驱动 -> 视图渲染。

先看视图渲染,不论是 jsx、或 template,都是相同的,可以互相转化的。

再看副作用隔离,一般来说框架也不解决这个问题,所以不管是 react/ag/vue + redux/mobx/rxjs 任何一种组合,最终你都不是靠前面的框架解决的,而是利用后面的 redux/mobx/rxjs 来解决。

最后看数据流驱动,不同框架内置的方式不同。react 内置的是类 redux 的方式,vue/angular 内置的是类 mobx 的方式,cyclejs 内置了 rxjs。

这么来看,react + redux 是最自然的,react + mobx 就像 vue + redux 一样,看上去不是很自然。也就是 react + mobx 别扭的地方仅在于数据流驱动方式不同。对于视图渲染、副作用隔离,这两个因素不受任何组合的影响。

就数据流驱动问题来看,我们可以站在更高层面思考,比如将 react/vue/angular 的语法视为三种 DSL 规范,那其实可以用一种通用的 DSL 将其描述,并转换对应的 DSL 对接不同框架(阿里内部已经有这种实现了)。而这个 DSL 对框架内置数据流处理过程也可以屏蔽,举个例子:

<button onClick={() => {
  setState(() => {
    data: {
      name: 'nick'
    }
  })
}}>
  {data.name}
</button>

如果我们将上面的通用 jsx 代码转换为通用 DSL 时,会使用通用的方式描述结构以及方法,而转化为具体 react/vue/angluar 代码时,就会转化为对应内置数据流方案的实现。

所以其实内置数据流是什么风格,在有了上层抽象后,是可以忽略的,我们甚至可以利用 proxy,将 mutable 的代码转换到 react 时,改成 immutable 模式,转到 vue 时,保持 mutable 形式。

对框架封装的抽象度越高,框架之间差异就越小,渐渐的,我们会从框架名称的讨论中解放,演变成对框架 + 数据流哪种组合更加合适的思考。

3 总结

最近梳理了一下 gaea-editor - 笔者做的一个 web designer,重新思考了其中插件机制,拿出来讲一讲。

首先大体说明一下,这个编辑器使用 dob 作为数据流,通过 react context 共享数据,写法和 mobx 很像,不过这不是重点,重点是插件拓展机制也深度使用了数据流。

什么是插件拓展机制?比如像 VScode 这些编辑器,都拥有强大的拓展能力,开发者想要添加一个功能,可以不用学习其深奥的框架内容,而是读一下简单明了的插件文档,使用插件完成想要功能的开发。解耦的很美好,不过重点是插件的能力是否强大,插件可以触及内核哪些功能、拿到哪些信息、拥有哪些能力?

笔者的想法比较激进,为了让插件拥有最大能力,这个 web designer 所有内核代码都是用插件写的,除了调用插件的部分。所以插件可以随意访问和修改内核中任何数据,包括 UI。

让 UI 拥有通用能力比较容易,gaea-editor 使用了插槽方式渲染 UI,也就是任何插件只要提供一个名字,就能嵌入到申明了对应名字的 UI 插槽中,而插件自己也可以申明任意数量的插槽,内核中也有几个内置的插槽。这样插件的 UI 能力极强,任何 UI 都可以被新的插件替代掉,只要申明相同的名字即可。

剩下一半就是数据能力,笔者使用了依赖注入,将所有内核、插件的 store、action 全量注入到每一个插件中:

@Connect
class CustomPlugin extends React.PureComponent {
  render() {
    // this.props.Actions, this.props.Stores
  }
}

同时,每个插件可以申明自己的 store,程序初始化时会合并所有插件的 store 到内存中。因此插件几乎可以做任何事,重写一套内核也没有问题,那么做做拓展更是轻松。

其实这有点像 webpack 等插件的机制:

export default (context) => {}

每次申明插件,都可以从函数中拿到传来的数据,那么通过数据流的 Connect 能力,将数据注入到组件,也是一种强大的插件开发方式。

更多思考

通过上面插件机制的例子会发现,数据流不仅定义了数据处理方式、副作用隔离,同时依赖注入也在数据流功能列表之中,前端数据流是个很宽泛的概念,功能很多。

redux、mobx、rxjs 都拥有独特的数据处理、副作用隔离方式,同时对应的框架 redux-react、mobx-react、cyclejs 都补充了各种方式的依赖注入,完成了与前端框架的衔接。正是应为他们纷纷将内核能力抽象了出来,才让 redux+rxjs mobx+rxjs 这些组合成为了可能。

未来甚至会诞生一种完全无数据管理能力的框架,只做纯 view 层,内核原生对接 redux、mobx、rxjs 也不是没有可能,因为框架自带的数据流与这些数据流框架比起来,太弱了。

react stateless-component 就是一种尝试,不过现在这种纯 view 层组件配合数据流框架的方式还比较小众。

纯 view 层不代表没有数据流管理功能,比如 props 的透传,更新机制,都可以是内置的。

不过笔者认为,未来的框架可能会朝着 view 与数据流完全隔离的方式演化,这样不但根本上解决了框架 + 数据流选择之争,还可以让框架更专注于解决 view 层的问题。

从有到无

HTML5 有两个有意思的标签:details, summary。通过组合,可以达到 details 默认隐藏,点击 summary 可以 toggle 控制 details 下内容的效果:

<details>
  <summary>标题</summary> 
  <p>内容</p> 
</details>

更是可以通过 css 覆盖,完全实现 collapse 组件的效果。

当然就 collapse 组件来说,因为其内部维持了状态,所以控制折叠面板的 打开/关闭 状态,而 HTML5 的 details 也通过浏览器自身内部状态,对开发者只暴露 css。

在未来,浏览器甚至可能提供更多的原生上层组件,而组件内部状态越来越不需要开发者关心,甚至,不需要开发者再引用任何一个第三方通用组件,HTML 提供足够多的基础组件,开发者只需要引用 css 就能实现组件库更换,似乎回到了 bootstrap 时代。

有人会说,具有业务含义的再上层组件怎么提供?别忘了 HTML components,这个规范配合浏览器实现了大量原生组件后,可能变得异常光彩夺目,DSL 再也不需要了,HTML 本身就是一套通用的 DSL,框架更不需要了,浏览器内置了一套框架。

插一句题外话,所有组件都通过 html components 开发,就真正意义上实现了抹平框架,未来不需要前端框架,不需要 react 到 vue 的相互转化,组件加载速度提高一个档次,动态组件 load 可能只需要动态加载 css,也不用担心不同环境/框架下开发的组件无法共存。前端发展总是在进两步退一步,不要形成思维定式,每隔一段时间,需要重新审视下旧的技术。

话题拉回来,从浏览器实现的 details 标签来看,内部一定有状态机制,假如这套状态机制可以提供给开发者,那数据流的 数据处理、副作用隔离、依赖注入 可能都是浏览器帮我们做了,redux 和 mobx 会立刻失去优势,未来潜力最大的可能是拥有强大纯函数数据流处理能力的 rxjs。

当然在 2018 年,redux 和 mobx 依然会保持强大的活力,就算在未来浏览器内置的数据流机制,rxjs 可能也不适合大规模团队合作,尤其在现在有许多非前端岗位兼职前端的情况下。

就像现在 facebook、google 的模式一样,在未来的更多年内,前后端,甚至 dba 与算法岗位职能融合,每个人都是全栈时,可能 rxjs 会在更大范围被使用。

纵观前端历史,数据流框架从无到有,但在未来极有可能从有变到无,前端数据流框架消失了,但前端数据流**永远保留了下来,变得无处不在。

你不知道的 Javascript(上)

作用域

词法作用域:编译阶段确定(欺骗词法作用域 eval with)

function foo(str){
  "use strict"
  eval(str)
  console.log(a)
}
foo('var a = 2')
function foo(obj){
  with (obj){
    a = 2
  }
}
var o1 = {a:3}
var o2 = {b:3}
foo(o1)
foo(o2)

块作用域 with try/catch let const

for (let i=0; i<10; i++){
  console.log(i)
}
if (true) {
  var a = 1
  let b = 2
  const c = 3
}
console.log(a) // 1
console.log(b) // ReferenceError
console.log(c) // ReferenceError
try{throw 2}catch(a){
  console.log(a) // 2
}
console.log(a) // ReferenceError

提升

定义提升 函数优先

foo() // TypeError
bar() // ReferenceError
var foo = function bar(){}
foo() // 1
function foo(){
  console.log(1)
}
foo = function(){
  console.log(2)
}
foo() // 3
function foo(){
  console.log(1)
}
foo = function(){
  console.log(2)
}
function foo(){
  console.log(3)
}
// 这种语法在最新版浏览器已经被摒弃并且报错,函数在 if 中类似 let 拥有一个局部作用域
foo() // 2
if (true){
  function foo(){console.log(1)}
}else{
  function foo(){console.log(2)}
}

闭包

将内部函数传递到所在作用域以外,它都会持有对原始定义作用域的引用,无论何处执行这个函数都会使用闭包。

function foo(){
  setTimeout(function a(){})
}
function foo(){
  return function a(){}
}
var b = foo()
b()
var b = (function(){
  return function foo(){}
}())
b()

循环+闭包

for (var i=0; i<5; i++){
  setTimeout(function(){
    console.log(i) // 5
  })
}
for (var i=0; i<5; i++){
  var j = i
  setTimeout(function(){
    console.log(j) // 4
  })
}
for (var i=0; i<5; i++){
  (function(){
    var j = i
    setTimeout(function(){
      console.log(j) // 0 1 2 3 4
    })
  })()
}
for (let i=0; i<5; i++){
  setTimeout(function(){
    console.log(i) // 0 1 2 3 4
  })
}
for (var i=0; i<5; i++){
  let j = i
  setTimeout(function(){
    console.log(j) // 4
  })
}
for (var i=0; i<5; i++){
  (function(j){
    setTimeout(function(){
      console.log(j) // 0 1 2 3 4
    })
  })(i)
}

模块机制闭包的作用

var require = (function(name){
  var modules = {}
  function define(name, deps, impl) {
    for (var i=0; i<deps.length; i++){
      deps[i] = modules[deps[i]]
    }
    modules[name] = impl.apply(impl, deps)
  }
  function get(name) {
    return modules[name]
  }
  console.log(name)
  return {
    define: define,
    get: get
  }
}())

require.define('foo', [], function(){
  return {
    hello: function() {console.log('hello world')},
    name:'foo'
  }
})
require.define('bar', ['foo'], function(foo){
  return {
    hello: function() {console.log('bar:' + foo.name)},
    name: 'bar'
  }
})
require.define('user', ['foo', 'bar'], function(foo, bar){
  return {
    hello: function() {console.log('user:' + foo.name +', ' + bar.name)}
  }
})
var foo = require.get('foo')
var bar = require.get('bar')
var user = require.get('user')
foo.hello()
bar.hello()
user.hello()

This

默认绑定 隐式绑定 显式绑定(硬绑定 call apply bind) new绑定

默认绑定

function foo(){
  console.log(this.count) // 1
  console.log(foo.count) // 2
}
var count = 1
foo.count = 2
foo()
严格模式this不会绑定到window
function foo(){
  "use strict"
  console.log(this.count) // TypeError: count undefined
}
var count = 1
foo()

隐式绑定

function foo(){
  console.log(this.count) // 2
}
var obj = {
  count: 2,
  foo: foo
}
obj.foo()

别名丢失隐式绑定

function foo(){
  console.log(this.count) // 1
}
var count = 1
var obj = {
  count: 2,
  foo: foo
}
var bar = obj.foo // 函数别名
bar()

回调丢失隐式绑定

function foo(){
  console.log(this.count) // 1
}
var count = 1
var obj = {
  count: 2,
  foo: foo
}
setTimeout(obj.foo)

显式绑定

function foo(){
  console.log(this.count) // 1
}
var obj = {
  count: 1
}
foo.call(obj)

var bar = foo.bind(obj)
bar()

new绑定

function foo(a){
  this.a = a
}
var bar = new foo(2)
console.log(bar.a) // 2

this丢失

function foo(){
  setTimeout(function() {
    console.log(this.count) // undefined
  })
}
var obj = {
  count: 2
}
foo.call(obj)

局部变量修复

function foo(){
  var self = this
  setTimeout(function() {
    console.log(self.count) // 2
  })
}
var obj = {
  count: 2
}
foo.call(obj)

bind修复

function foo(){
  setTimeout(function() {
    console.log(this.count) // 2
  }.bind(this))
}
var obj = {
  count: 2
}
foo.call(obj)

es6绑定

function foo(){
  setTimeout(() => {
    console.log(this.count) // 2
  })
}
var obj = {
  count: 2
}
foo.call(obj)

对象

类型 string number boolean null undefined object

对象 String Number Boolean Object Function Array Date RegExp Error

var str = 'hello world'
var strObj = new String('hello world')
console.log(strObj.length) // 11
console.log(str.length) // 11 (string -> new String)

属性描述符ES5

var obj = {
  a: 2
}
Object.getOwnPropertyDescriptor(obj, 'a') // {value: 2, writable: true, enumerable: true, configurable: true}

可写

var obj = {}
Object.defineProperty(obj, 'a', {
  value: 2,
  writable: false
})
obj.a = 3
console.log(obj.a) // 2 严格模式TypeError

可枚举

var obj = {b: 1}
Object.defineProperty(obj, 'a', {
  value: 2,
  enumerable: false
})
for (var key in obj){
  console.log(key) // b
}

可配置(不可配置:不能删除,能修改)

var obj = {}
Object.defineProperty(obj, 'a', {
  value: 2,
  configurable: false
})
delete obj.a
console.log(obj.a) // 2

禁止拓展属性

var obj = {}
Object.preventExtensions(obj)
obj.a = 3
console.log(obj.a) // undefined

密封(禁止拓展属性+不可配置)(preventExtensions+each(configurable: false))

var obj = {}
Object.seal(obj)

冻结(密封+不可修改属性值)(seal+each(writable:false))

var obj = {}
Object.freeze(obj)

[[get]]获取对象属性会执行[[get]]操作,自身没有的属性会在原型链上查找,都没有返回undefined

var obj = {}
console.log(obj.a) // undefined 
console.log(a) // ReferenceError

[[put]] 对象赋值触发[[put]],在赋值前会检查对象属性描述,例如不可写会失败

var obj = {
  a: 1
}
Object.defineProperty(obj, 'a', {
  writable: false
})
obj.a = 2
console.log(obj.a) // 1

[[put]] 自身存在setter方法,会优先调用setter,如果原型链上有同名setter,会调用原型链的setter

var obj = {
  a: 1,
  set a(val){
    console.log('nothing happend')
  }
}
obj.a = 2
console.log(obj.a) // undefined set方法会覆盖同名属性

getter

var obj = {
  get a(){
    return 1
  }
}
console.log(obj.a) // 1

setter 一般使用_property_表示屏蔽属性

var obj = {
  set a(val){
    _a_ = val*2
  },
  get a(){
    return _a_ - 1
  }
}
obj.a = 2
console.log(obj.a) // 3

js没有类(继承是复制,js某些部分是引用)

类是一种设计模式,可以被模拟:混入

// 显式混入 ($.extend)
function mixin(sourceObj, targetObj) {
  for (var key in sourceObj) {
    if (!(key in targetObj)) { // 重名不覆盖,实现多态
      targetObj[key] = sourceObj[key]
    }
  }
  return targetObj
}

var obj = {
  a: 1,
  b: function(){console.log(2)}
}

var child = mixin(obj, {
  c:3
})

console.log(child.b.prototype) // obj.b {}
// 隐式混入
var obj = {
  add: function() {
    this.count = this.count ? this.count+1 : 1
  }
}

var child = {
  add: function() {
    obj.add.call(this)
  }
}

obj.add()
console.log(obj.count) // 1
child.add()
console.log(child.count) // 2 隐式混入赋值操作在child而不是obj
// 寄生继承(既是显示又是隐式)
function obj(){
  this.a = 1
}
function child(){
  var o= new obj()
  o.b = 2
  return o
}
var c = new child()
console.log(c.b) //2

原型

prototype

// 原型的设置和屏蔽
var foo = {
  a: 1
}
var bar = Object.create(foo)
console.log(bar.hasOwnProperty('a')) // false 原型设置
bar.a++
console.log(bar.hasOwnProperty('a')) // true 原型被屏蔽
// 设置原型链
// 直接设置原型
function foo(){this.a = 1}
foo.prototype.b = 2
function bar(){}
bar.prototype = foo.prototype // bar.prototype: foo {}
var b = new bar()
console.log(b.a) // undefined
console.log(b.b) // 2

// 无副作用设置原型链
function foo(){this.a = 1}
foo.prototype.b = 2
function bar(){}
bar.prototype = Object.create(foo.prototype) // bar.prototype: object {}
var b = new bar()
console.log(b.a) // undefined
console.log(b.b) // 2

// ES6可以不抛弃原型直接赋值
Object.setPrototypeOf(var.prototype, foo.prototype)

// 常见用法但有调用构造方法的副作用
function foo(){this.a = 1}
foo.prototype.b = 2
function bar(){}
bar.prototype = new foo()
var b = new bar()
console.log(b.a) // 1 副作用:执行了构造函数
console.log(b.b) // 2
// 判断类关系
function foo(){}
var f = new foo()
console.log(f instanceof foo) // true
console.log(foo.prototype.isPrototypeOf(f)) // true
// 如何获取原型
function foo(){}
var a = new foo()

console.log(Object.getPrototypeOf(a) === foo.prototype) // true
// __proto__ 可以获取原型的原因
Object.defineProperty(Object.prototype, '__proto__', {
  get: function(){
    return Object.getPrototypeOf(this)
  },
  set: function(o){
    Object.setPrototype(this, o)
    return o
  }
})

行为委托

行为委托是除了类之外,更清晰的一种继承设计模式

// 类创建实例
function Foo(){
  this.something = function(){}
}
var bar = new Foo()
bar.something()
// 委托创建实例 ES5
var foo = {
  something: function(){}
}
var bar = Object.create(foo) // foo 自身也是个实例
bar.something()
// Object.create模拟
if (!Object.create){
  Object.create = function(obj){
    function F(){}
    F.prototype = obj
    return new F()
  }
}
// 类的继承
function Foo(){}
Foo.prototype.say = function(){console.log(1)}

function Bar(){}
Bar.prototype = Object.create(Foo.prototype) // 或 new Foo()

var b = new Bar()
b.say() // 1
// 对象关联的继承
var Foo = {
  say: function(){console.log(1)}
}
var Bar = Object.create(Foo)

var b = Object.create(Bar) // 或 new Bar()
b.say() // 1

Redux 使用可变数据结构

不可变数据结构 可调式、可预测、可测试,与可变数据结构的响应式、数据修改简单都很好。

当希望使用 reduxDevTools 一键恢复、回溯页面状态时,我们想使用 redux;当频繁修改复杂对象、使用 ts 希望自动提示时,我们想使用 mobx。

1 合二为一

redux 核心能力是不可变数据带来的,mobx 的核心能力是可变数据带来的,如果使用动态修改数据的方式,使用 immutablejs 将其低成本转化为不可变数据,就可以接入 redux 并享受其生态了。

redux

其 store 的代码如下:

import { observable } from "dynamic-object"

export default class TODO {
    private store = observable({
        todos: [{
            text: "learn Redux",
            completed: false,
            id: 0
        }]
    })

    public addTodo(text: string) {
        const id = this.store.todos.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1
        this.store.todos.unshift({
            id,
            text,
            completed: false
        })
    }
}

已经能看到有意思的东西了,我们将 dynamic-object 包装后的动态对象作为 store,直接修改它,但最终转化成了 redux 不可变数据,并且享受 redux-dev-tools 带来的便利。

dynamic-object 与 mobx 理念、功能很像,内部也没有使用 mobx,其特点是不兼容 IE11,但简化了很多 mobx api,使用起来也更加顺手,可以看看 这个在线Demo 体会一下与 mobx 的区别。

2 结合两者特性

将两者碰撞在一起,特性一定会有所取舍。

2.1 依赖注入 or 单一 store

如果纯粹使用 dynamic-object,我们可以利用依赖注入来实现 action 同时操作多个 store 的能力,下面用伪代码描述,可以参考 完整 demo

class UserAction {
    @inject(UserStore) userStore: UserStore
    @inject(ArticleStore) articleStore: ArticleStore

    updateUser (name: string) {
        const user = this.userStore.getUserByName(name)
        this.articleStore.addArticle({
			userId: user.id, // title:
		})
		user.updateArticle()
    }
}

但由于 redux 使用了 combineReducers 聚合成一个大 store,每个 reducer 仅能接触到当前节点的 store,因此上面的用法肯定无法支持。

但是一个 action 操作一个 store 有利于数据之间关系的隔离,也利于我们合理规划数据流,因此依赖注入多个 store 的能力可以抛弃掉,使用 redux 的 action 操作单一 store 的**。

因此可以干脆将 store 内置到 action 中,相当于 initState,action 对 store 的修改可以看作 reducer 使用不可变结构处理了数据,通过 magic 返回了新的 immutable 数据:

import { observable } from "dynamic-object"

export default class User {
    private store = observable({
        name: "小明"
    })

    public changeName(name: string) {
        this.store.name = name
    }
}

以上处理方式,和 redux 如下处理等价:

const initState = {
	name: "小明"
}

function userReducer(state = initState, action) {
	switch (action.type) {
		case "user.changeName":
			return {
				...state,
				name: action.payload
			}
		default:
			return state
	}
}

2.2 mutable or immutable

对于简单情况,redux 的 reducer 看起来并不是很复杂,但是在处理深层次数据结构,redux 就显得很无力,因此建议我们将 state 尽量打平。

但并不是所有情况都适合打平,在重前端的场景,所有数据都存储在 store 中,合理的数据结构描述显然比打平结构更重要。

比如下面是 mutable 和 immutable 对复杂数据修改的对比:

const addBuildings = (currentPlanetIndex, building) => {
	this.gameUser.planets[currentPlanetIndex].buildings.push(building)
}
const addBuilding = (state, action) => {
    return Object.assign({}, state, {
        gameUser: Object.assign({}, state.gameUser, {
            planets: state.gameUser.planets.map((planet, index) => {
                if (index === state.currentPlanetIndex) {
                    planet.buildings = planet.buildings.concat(action.payload)
                    return planet
                }
                return planet
            })
        })
    })
}

对于 immutablejs 的库,我们可以使用 set get 的方式快速修改,但其缺点是没有 IDE 自动提示的支持,后期重构变量名的时候,也很难从字符串中推导出依赖关系。

比较好的整合方式是 mutable 与 immutable 混合:

import { observable } from "dynamic-object"

export default class User {
    private gameUser = observable({
        planets: []
    })

    public addBuildings (currentPlanetIndex, building) {
		 this.gameUser.planets[currentPlanetIndex].buildings.push(building)
	}
}

observable 的背后,每次 setter 操作,都会调用 Immutablejs 的 api 生成新的不可变对象,当所有操作完毕时,将最终生成的不可变对象返回给 reducer。

3 dynamic-object 使用实例

3.1 定义 store

比如定义存储用户信息的 userStore:

import { observable, Action } from "dynamic-object"

class User {
  store = observable({
    name: "小明"
  })

  @Action setName(name) {
    this.store.name = name
  }
}

这里需要注意的是,被定义为 observable 的变量会自动成为 initState,当前 Action 仅能操作这个 store,比如 setName 函数。

3.2 redux 桥接

可以使用 createReduxStore 函数生成 redux 的 store 与 action:

import { createReduxStore } from "dynamic-object"

const { store, actions} = createReduxStore({
	// 3.1 的 User store
	user: User,
	// 另一个 store
	article: Article
})

以上函数生成了 redux Provider 需要的 store,以及与这个 store 绑定的 actions。actions 的结构如下:

{
	user: {
		setName: Function
	},
	article: {
		// ...
	}
}

3.3 接下来都是 redux 的代码了

import * as React from "react"
import * as ReactDOM from "react-dom"
import { connect, Provider } from "react-redux"
import { store, actions } from "./store"

@connect((state, ownProps) => {
  return {
    name: state.user.name
  }
})
class App extends React.PureComponent {
  componentWillMount() {
      actions.user.setName("test")
  }

  render() {
    return (
      <div>
        {this.props.name}
      </div>
    )
  }
}

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById("react-dom")
)

上述 actions 已经被 dispatch 包过了,所以直接调用即可。查看在线 Demo

3.3.1 dispatch 需要唯一的 type,定义 type 的地方在哪?什么机制保证不会冲突?

比如调用 actions.user.setName("test") 这个函数,那么 type 就是 user.setName 这个字符串,因此不会冲突,而且还可以通过 devTools 快速定位函数位置。

3.3.2 直接改值,redux 可以正确运行吗?

通过 createReduxStore 包装后的 actions,调用后会触发 action,store 中书写的 mutable 代码会自动生成 immutablejs 对象,自动生成 reducer 并返回给 redux,所以和直接使用 redux 没有任何区别,因此可以正确运行。

3.3.3 支持直接在 devTools 里执行 dispatch 吗?

可以,见下图:

dispatch

4 总结

dynamic-object 是业余时间写的库,虽然和 mobx 很像,但使用 proxy 解决了很多 Object.defineproperty 无法解决的痛点问题,目前支持下面三种使用方式:

  • 结合 react-redux 将 store 层替换为 mutable 方式,提高开发效率,也是本文介绍的方式
  • 结合 dynamic-react 在 redux 使用 mutable 数据流
  • 结合 dependency-inject 支持依赖注入

各种用法在 github readme 都有 live demo,目前也在当前业务项目中试水,后续会作为底层集成在我们数据团队的数据流解决方案中,敬请期待。

redux applyMiddleware 原理剖析

用法

为了对中间件有一个整体的认识,先从用法开始分析。调用中间件的代码如下:

源码 createStore.js#39

export default function createStore(reducer, preloadedState, enhancer) {
  if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') {
    enhancer = preloadedState
    preloadedState = undefined
  }
}

enhancer 是中间件,且第二个参数为 Function 且没有第三个参数时,可以转移到第二个参数,那么就有两种方式设置中间件:

const store = createStore(reducer, null, applyMiddleware(...))
const store = createStore(reducer, applyMiddleware(...))

再看 源码 中间件的传参:

export default function applyMiddleware(...middlewares) {
  return (createStore) => (reducer, preloadedState, enhancer) => { 
	var store = createStore(reducer, preloadedState, enhancer)
	... 
}

就是为了得到 store,并通过 createStore 创建,上述两种方法因为在 createStore 函数内部传入了自身函数才得以实现 :

export default function createStore(reducer, preloadedState, enhancer) {
	...
	if (typeof enhancer !== 'undefined') {
	  return enhancer(createStore)(reducer, preloadedState)
	}
	...
}

上述代码可以看出,创建 store 的过程完全交给中间件了,因此开启了中间件第三种使用方式:

const store = applyMiddleware(...)(createStore)

applyMiddleware 源码解析

大家对剖析 applyMiddleware 源码都非常感兴趣,因为它实现精简,但含义甚广,再重温其源码

export default function applyMiddleware(...middlewares) {
  return (createStore) => (reducer, preloadedState, enhancer) => {
    var store = createStore(reducer, preloadedState, enhancer)
    var dispatch = store.dispatch
    var chain = []

    var middlewareAPI = {
      getState: store.getState,
      dispatch: (action) => dispatch(action)
    }
    chain = middlewares.map(middleware => middleware(middlewareAPI))
    dispatch = compose(...chain)(store.dispatch)

    return {
      ...store,
      dispatch
    }
  }
}

假设大家都已了解 ES6 7 语法,懂得 compose 函数的含义,并且看过一些源码剖析了,我们才能把重点放在核心原理上:为什么中间件函数有三个传参 store => next => action ,第二个参数 next 为什么拥有神奇的作用?

store

代码前几行创建了 store (如果第三个参数是中间件,就会出现中间件 store 包中间件 store 的情况,但效果是完全 打平 的), middlewareAPI 这个变量,其实就是精简的 store, 因为它提供了 getState 获取数据,dispatch 派发动作。

下一行,middlewares.map 将这个 store 作为参数执行了一遍中间件,所以中间件第一级参数 store 就是这么来的。

next

下一步我们得到了 chain, 倒推来看,其中每个中间件只有 next => action 两级参数了。我们假设只有一个中间件 fn ,因此 compose 的效果是:

dispatch = fn(store.dispatch)

那么 next 参数也知道了,就是 store.dispatch 这个原始的 dispatch.

action

代码的最后,返回了 dispatch ,我们一般会这么用:

store.dispatch(action)

等价于

fn(store.dispatch)(action)

第三个参数也来了,它就是用户自己传的 action.

单一中间件的场景

我们展开代码来查看一个中间件的运行情况:

fn(middlewareAPI)(store.dispatch)(action)

对应 fn 的代码可能是:

export default store => next => action => {
	console.log('beforeState', store.getState())
	next(action)
	console.log('nextState', store.getState())
}

当我们执行了 next(action) 后,相当于调用了原始 store dispatch 方法,并将 action 传入其中,可想而知,下一行输出的 state 已经是更新后的了。

但是 next 仅仅是 store.dispatch, 为什么叫做 next 我们现在还看不出来。

详见 dispatch 后立刻修改 state:

function dispatch(action) {
	...
    currentState = currentReducer(currentState, action)
    ...
}

其中还有一段更新监听数组对象,以达到 dispatch 过程不受干扰(快照效果) 作为课后作业大家独立研究:主要思考这段代码的意图:https://github.com/reactjs/redux/blob/master/src/createStore.js#L63

多中间件的场景

我们假设有三个中间件 fn1 fn2 fn3, 从源码的这两句入手:

chain = middlewares.map(middleware => middleware(middlewareAPI))
dispatch = compose(...chain)(store.dispatch)

第一行代码,我们得到了只剩 next => action 参数的 chain, 暂且叫做:

cfn1 cfn2 cfn3, 并且有如下对应关系

cfnx = fnx(middlewareAPI)

第二行代码展开后是这样的:

dispatch = cfn1(cfn2(cfn3(store.dispatch)))

可以看到最后传入的中间件 fn3 最先执行。

为了便于后面理解,我先把上面代码的含义写出来:通过传入原始的 store.dispatch, 希望通过层层中间件的调用,最后产生一个新的 dispatch. 那么实际上中间件所组成的 dispatch, 从函数角度看,就是被执行过一次的 cfn1 cfn2 cfn3 函数

我们就算不理解新 dispatch 的含义,也可以从代码角度理解:只要执行了新的 dispatch , 中间件函数 cfnx 系列就要被执行一次,所以 cfnx 的函数本身就是中间件的 dispatch

对应 cfn3 的代码可能是:

export default next => action => {
	next(action)
}

这就是这个中间件的 dispatch.

那么执行了 cfn3 后,也就是 dispatch 了之后,其内部可能没有返回值,我们叫做 ncfn3,大概如下:

export default action => {}

其函数自身就是返回值 返回给了 cfn2 作为第一个参数,替代了 cnf3 参数 store.dispatch 的位置。

我们再想想,store.dispatch 的返回值是什么?不就是 action => {} 这样的函数吗?这样,一个中间件的 dispatch 传递完成了。我们理解了多中间件 compose 后可以为什么可以组成一个新的 dispatch 了(其实单一中间件也一样,但因为步骤只有一步,让人会想到直接触发 store.dispatch 上,多中间件提炼了这个行为,上升到组合为新的 dispatch)。

再解释 next 的含义

为什么我们在中间件中执行 next(action) ,下一步就能拿到修改过的 store ?

对于 cfn3 来说, next 就是 store.dispatch 。我们先不考虑它为什么是 next , 但执行它了就会直接执行 store.dispatch ,后面立马拿到修改后的数据不奇怪吧。

对于 cfn2 来说,next 就是 cfn3 执行后的返回值(执行后也还是个函数,内层并没有执行),我们分为两种情况:

  1. cfn3 没有执行 next(action),那 cfn1 cfn2 都没法执行 store.dispatch,因为原始的 dispatch 没有传递下去,你会发现 dispatch 函数被中间件搞失效了(所以中间件还可以捣乱)。为了防止中间件瞎捣乱,在中间件正常的情况请执行 next(action).

这就是 redux-thunk 的核心**,如果 action 是个 function ,就故意执行 action , 而不执行 next(action) , 等于让 store.dispatch 失效了!但其目的是明确的,因为会把 dispatch 返回给用户,让用户自己调用,正常使用是不会把流程停下来的。

  1. cfn3 执行了 next(action), 那 cfn2 什么时候执行 next(action)cfn3 就什么时候执行 next(action) => store.dispatch(action) , 所以这一步的 next 效果与 cfn3 相同,继续传递下去也同理。我看了下 redux-logger 的文档,果然央求用户把自己放在最后一个,其原因是害怕最右边的中间件『捣乱』,不执行 next(action) , 那 logger 再执行 next(action) 也无法真正触发 dispatch .

我在考虑这样会不会有很大的局限性,但后来发现,只要中间件常规情况执行了 next(action) 就能保证原始的 dispatch 可以被继续分发下去。只要每个中间件都按照这个套路来, next(action) 的效果就与 yield 类似。

所以 next 并不是完全意义上的洋葱模型,只能说符合规范(默认都执行了 next(action))的中间件才符合洋葱模型。

koa 的洋葱模型可是有技术保证的,generator 可不会受到代码的影响,而 redux 中间件的洋葱模型,会因为某一层不执行 next(action) 而中断,而且从右开始直接切断。

为什么在中间件直接 store.dispatch(action) ,传递就会中断?

理解了上面说的话,就很简单了,并不是 store.dispatch(action) 中断了原始 dispatch 的传递,而是你执行完以后不调用 next 函数中断了传递。

总结

还是要画个图总结一下,在不想看文字的时候:

image

精读《单页应用的数据流方案探索》

1 引言

前几期精读了前端模块化、语法相关的文章,这次讨论另一个举足轻重的话题:数据流。
数据流在前端的地位与工程化、可视化、组件化是一样重要的,没有好的数据流框架与**的指导,业务代码长期肯定倾向于不可维护的状态,当项目不断增加功能后,管理数据变得更加重要。

早期前端是没有数据流概念的,因为前端非常薄,每个页面只要展示请求数据,不需要数据流管理。

随着前端越来越复杂,框架越来越内聚,数据流方案由分到合,由合又到了分,如今数据流逐渐从框架中解绑,形成了一套通用体系,供各个框架使用。

虽然数据流框架很多,但基本上可以分为 双向数据流党单向数据流党响应式数据流党,分别以 MobxReduxRxjs 为代表呈现三国鼎立之状,顺带一提,对 css 而言也有 css in js 和纯 css党 势均力敌,前端真是不让人省心啊。这次我们来看看民工叔徐飞在 QConf 分享的主题:单页应用的数据流方案探索

2 内容概要

文中主要介绍了响应式编程理念,提到的观点,主要有:

  1. Reactive 数据封装
  2. 数据源,数据变更的归一
  3. 局部与全局状态的归一
  4. 分形**
  5. action 分散执行
  6. app级别数据处理,推荐前端 Orm

整体来看,核心思路是推荐组件内部完成数据流的处理,不用关心使用了 Redux Mobx 或者 Rxjs,也不用关心这些库是否有全局管理的野心,如果全局管理那就挂载到全局,但组件内部还是局部管理。

最后谈到了 Rxjsxstream 响应式数据流的优势,但并未放出框架,仅仅指点了**,让一些读者心里痒痒。但现在太多”技术大牛“把”业界会议“当成了打广告,或者工作汇报的机会,所谓授人以鱼不如授人以渔,这篇文章卓尔不群。

3 精读

一切技术都要看业务场景,民工叔的 单页应用数据流方案 解决的是重前端的复杂业务场景,虽然现在前端几乎全部单页化,但单页也不能代表业务数据流是复杂的,比如偏数据展示型的中台单页应用就不适合使用这套方案。

此文讨论的是纯数据流方案,与 Dom 结合的方案可以参考 cyclejs,但这个库主要搭建了 Reactive -> Dom 的桥梁,使用起来还要参考此文的思路。

3.1 响应式数据流是最好的方案吗?

我认为前端数据流方案迭代至今,并不存在比如:面向对象 -> 函数式 -> 响应式,这种进化链路,不同业务场景下都有各自优势。

面向对象

以 Mobx 为代表,轻前端用的较多,因为复杂度集中在后端,前端做好数据展示即可,那么直接拥抱 js 这种基于对象的语言,结合原生 Map Proxy Reflect 将副作用进行到底,开发速度快得飞起。

数据存储方式按照视图形态来,因为视图之间几乎毫无关联,而且特别是数据产品,后端数据量巨大,把数据处理过程搬到前端是不可能的(为了推导出一个视图形态数据,需要动辄几GB的原始数据运算,存储和性能都不适合在前端做)。

函数式

以 Haskell 为代表,金融行业用的比较多,可能原因是金融对数据正确性非常敏感,不仅函数式适合分布式计算,更重要的是无副作用让数据计算更安全可靠。

个人认为最重要的原因是,金融行业本来很少有副作用,像前端天天与 Dom 打交道的,副作用完全逃不了。

响应式

以 Rxjs 为代表,重前端更适合使用。对于 React native 等 App 级别的开发,考虑到数据一致性(比如修改昵称后回退到文章详情,需同步作者修改后的昵称),优先考虑原始类型存储,更适合抽象出前端 Orm 作为数据源。

其实 Orm 作为数据源,面向对象也很适合,但响应式编程的高层次抽象,使其对数据源、数据变动的依赖可插拔,中等规模使用大对象作为数据源,App 级别使用 Orm 作为数据源,因地制宜。

3.2 分形**

分形**即充血组件的升级版,特点是同时支持贫血组件的被外部控制能力。

分形的优点

分形保证了两点:

  1. 组件和数据流融为整体,与外部数据流隔离,甚至将数据处理也融合在数据管道中,便于调试。
  2. 便于组件复用,因为数据流作为组件的一部分。

如果结合文中的 本地状态 概念,局部数据也放在全局,就出现了第三点好处:

  1. 创建局部数据等于创建了全局数据,这样代码调试可局部,可整体,更加灵活。

本地状态 可以参考 dva 框架的设计,如果没有全局 Redux 就创建一个,否则就挂载到全局 Redux 上。

分形的缺点

对于聊天室或者在线IDE等,全局数据居多,很多交叉绑定的情况,就不适合分形**,反而纯 Redux **更合适。

3.3 数据形态,是原始数据还是视图数据?

我认为这也是分业务场景,文章提到不应该太偏向视图结构数据,是有道理的,意思是说,在适合原始结构数据时,就不要倾向于视图结构数据了。但有必要补充一下,在后端做了大量工作的中台场景,前端数据层非常薄,同时拿到的数据也是后端服务集群计算后的离线数据,显然原始数据结构不可能放在前端,这时候就不要使用原始数据存储了。

3.4 从原始数据到视图数据的处理过程放在哪

文中推荐放在 View 中处理,因为考虑到不想增加额外的 Store,但不知道这个 Store 是否包含组件局部的 Store。业务组件推荐使用内部数据流操作,但最终还是会将视图数据存在全局 Store 中,只是对组件而言,是局部的,对项目而言是全局的,而且这样对特定的情况,比如其他组件复用数据变更的监听可以支持到。

总结

我们到头来还是没有提供一个完美的解决方案,但提供了一个完整的思路,即在不同场景下,如何选择最合适的数据流方案。

最后,不要盲目选型,就像上面提到的,这套方案对复杂场景非常棒,但也许你的业务完全不适合。不要纠结于文中为何没有给出系统化解决方案的 Coding 库,我们需要了解响应式数据流的优势,同时要看清自己的业务场景,打造一套合适的数据流方案。

最后的最后,如有不错的数据流方案,解决了特定场景的痛点,欢迎留言。

如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。

A proxied version of mobx: dob

why would one use proxy to achieve mobx? Because mobx is great, but Object.defineProperty make it uncomfortable to write.

So dob was born, I will list below only changes unique to dob, as well as a summary of my experience on state management frameworks.

1 Introduction

dob not only overrides mobx using proxy, but also comes with dependency injection store management.

2 Different from Mobx

Api similar to Mobx:

Mobx Dob
autorun observe
observable observable
unobservable unobservable
reaction reaction
computed Not needed
extendObservable Not needed
arrays native array
maps native Map & Set

What benefits does proxy bring?

  • Do not bother to initialize any variables.
  • Use the native Map Set.
  • Do not need to add the computed decorator for get().
  • Observable array is no longer an object.

Here's an example:

// Mobx
class Store {
	@observable user = {
		articles: observable.map()
		name: ''
	}
	
	addArticle() {
		this.user.articles.set('Harry Potter', extendObservable({
			price: 59
		}))
	}
	
	changeName() {
		this.user.name = 'Harry Potter'
	}
}

autoRun(() => {
	store.user.articles.get('Harry Potter').price
	store.user.name
})
// Dob
@observable
class Store {
	user = {
		articles: new Map()
		// name no longer needs to be initialized
	}
	
	addArticle() {
		this.user.articles.set('Harry Potter', {
			price: 59
		})
	}
	
	changeName() {
		this.user.name = 'Harry Potter'
	}
}

observe(() => {
	store.user.articles.get('Harry Potter').price
	store.user.name
})

3 store manager management issues

When using redux, a lot of time can not distinguish whether the structured data will be flattened, and then subscribe separately, or can not tell after the subscription data processing should be on the component or the overall.

This is because redux undermines the react fractal design, as discussed in "Recent Discussion Record."

Many redux-based fractal solutions are "pseudo" fractal, secretly use replaceReducer to do some dynamic reducer registration, and then bound to the global.

However, frameworks like Mobx and dob are truly fractal, let's start with the question of how to manage the store manager.

How to manage store

The so-called best practices are based on a convention or constraint that makes code readable and maintainable. Agreed is flexible, non-compliance is okay, constraints are mandatory, you can not run without them. Most of the constraints provided by the framework, such as open strict mode, the prohibition of modification of variables outside the Action. However, the most entangled places are still conventions. I came up with a set of usage conventions that use for this kind of responsive store manager.

The use of store manager, the first thing to do is to manage the data, to solve where the Data store on, and whether it is necessary to use store manager.

Whether to use store

First and foremost, the simplest component certainly does not need store manager. Well, when the component is complex, if the data stream itself has a fractal function, then it's available. The store manager with fractal function, can avaliable a new component that combined with react and store manager, with fractal capability:

import {combineStores, observable, inject, observe} from 'dob'
import {Connect} from 'dob-react'

@observable
class Store {name = 123}

class Action {
  @inject (Store) store: Store

  changeName = () => {this.store.name = 456}
}

const stores = combineStores ({Store, Action})

@Connect (stores)
class App extends React.Component <typeof stores, any> {
  render () {
    return <div onClick = {this.props.Action.changeName}> {this.props.Store.name} </ div>
  }
}

ReactDOM.render (<App />, document.getElementById ('react-dom'))

Dob is such a framework, in the above example, click on the text can trigger refresh, even if no Provider in this root DOM node. This means that this component, independent of any environment, can run as part of any project. Although this component uses the store manager, but no difference with the ordinary React components, you can rest assured that use.

If it is a pseudo-fractal data stream, ReactDOM.render may require a specific Provider to work with, then this component does not have the ability to migrate. If someone else unfortunately installed this component, you need to install a family bucket at the root of the project.

Q: Although components with store manager have full fractal capabilities, there is an invisible dependency on it if this component responds to observable props.

A: Yes, if a component requires that the received props be 'observable' in order to automatically rerender when it changes, that part of the component's functionality will expire when an environment passes normal props. In fact, props belongs to react's universal connection bridge, so the component should only rely on the props of ordinary objects, the internal can then 'observable' it to have a complete ability to migrate.

How to use store

Although React can be fully modularized, modules in actual projects must be divided into non-business related components and business-logic related components, and page modules can also be used as business-logic related components. Data-driven complex website is better, since it is data-driven, then the business-logic related components and data can be moved to the top management of the connection, usually through the top of the page package Provider implementation:

import {combineStores, observable, inject, observe} from 'dob'
import {Connect} from 'dob-react'

@observable
class Store {name = 123}

class Action {
  @inject (Store) store: Store

  changeName = () => {this.store.name = 456}
}

const stores = combineStores ({Store, Action})

ReactDOM.render (
  <Provider {... store}>
    <App />
  </ Provider>
, document.getElementById ('react-dom'))

Just changed the position of the definition of the store, and the way components are used remains unchanged:

@Connect
class App extends React.Component <typeof stores, any> {
  render () {
    return <div onClick = {this.props.Action.changeName}> {this.props.Store.name} </ div>
  }
}

One difference is that @connect does not need to be parameterized, because if Provider is registered globally, it will be passed through by default to Connect. Contrary to fractal, this design can lead to components not being able to migrate to other projects, but the benefits are that they can be moved anywhere in this project.

Fractal components are strongly dependent on the file structure, as long as the desired props are given the ability to do so, whereas the components of the global store manager are almost independent on the file structure and all the props are taken from the global store.

In fact, here, you can find these two points is difficult to merge into one, we can pre-divided into two components business and non-business coupling, business-logic related components rely on global store manager, so that non-related coupling components to maintain Fractal ability.

If you have a better way to manage your Store, you can find it in my [github] (https://github.com/ascoders) in-depth chat.

Should every component be Connected?

For the Mvvm idea library, the Connect concept goes beyond just injecting data (unlike redux) and listening for changes in the data trigger the rerender. So each component needs Connect?

Of course, the components that do not use the store manager not need Connect, but the business-logic related components remain uncertain in the future (business uncertainty), so maintaining Connect for each business component help to improve maintainability.

And Connect may also do other optimization work, such as dob Connect will not only inject data to complete the component automatically render, but also to ensure that the component's PureRender.

Actually, this issue is only a very small one. However, the reality is ironic. In many cases, we will be more tangled in this kind of small idea, so here's a brief discussion.

Whether the store manager should be flattened

Store flattening is largely due to lack of support for immutable js, resulting in very troublesome changes to the underlying data. Although libraries like immutable.js can be quickly manipulated via strings, but, however, this method of use is only temporary, we can not see the js standard recommends that we use the string to access the object properties.

Accessing object properties via strings is similar to lodash's _.get, but there are already [proposal-optional-chaining] (https://github.com/tc39/proposal-optional-chaining) The proposal is resolved at the grammar level, and the same immutable conveniences require a standard way of doing things. You do not actually have to wait for another proposal, using the existing capabilities of js to simulate the effects of native immutable support:

[dob-redux] (https://github.com/dobjs/dob-redux) connecting with react-redux can be done with a mutable wording like:

this.store.articles.push(article)

You can mutable, generate immutable data, and redux docking.

A bit far away, then the essence of store manager flattening is the data format specification issues. For example, [normalizr] (https://github.com/paularmstrong/normalizr) is a standard data specification, and many times we store redundant or misclassified data in the Store.

For the front-end data stream is thin, nor is it just finished processing data. There are many things to do, Such as the use of node microservices on the back-end data standardization package some standard format processing components, the thin data made of zero thickness, the business code can be completely without any perception of simple data flow and so on.

Asynchronous and side effects

Redux naturally use action to isolate the side effects and asynchrony, that in action-only Mvvm development model, how asynchronous should to be isolated? Is Mvvm the perfect solution to Redux's evasive asynchronous problem?

When using the dob framework, the assignment after asynchrony needs to be very careful:

@Action async getUserInfo() {
  const userInfo = await fetchUser()
  this.store.user.data = userInfo // Exceptions will be thrown in strict mode because they break away from the Action scope.

The reason is that await is asynchronous, just writing like synchronization. When an await starts, the stack of the current function has exited, so the subsequent code is not in an Action, so the general solution is to define Action:

@Action async getUserInfo() {
  const userInfo = await fetchUser()
  Action(() => {
    this.store.user.data = userInfo
  })
}

This shows that asynchrony needs to be careful! Redux isolation asynchronous to the Reducer is correct, as long as the data flow changes involved in the operation is synchronized, how strange outside Action, Reducer can sit back and relax.

In fact, redux isolated asynchronous approach with the following code:

@Action async getUserInfo() { // similar redux action
  const userInfo = await fetchUser()
  this.setUserInfo(userInfo)
}

@Action async setUserInfo(userInfo) { // similar redux reduer
  this.store.user.data = userInfo
}

If you do not want to repeat the write Action, this isolation method is also a good choice.

Resend the request automatically

Another benefit of the responsive framework is that it can be triggered automatically, such as automatically triggering requests, triggering actions automatically, and more.

For example, we hope that when the request parameters change, it can automatically resend, in general, need to be written in react:

componentWillMount() {
  this.fetch({ url: this.props.url, userName: this.props.userName })
}

componentWillReceiveProps(nextProps) {
  if (
    nextProps.url !== this.props.url ||
    nextProps.userName !== this.props.userName
  ) {
    this.fetch({ url: nextProps.url, userName: nextProps.userName })
  }
}

In dob such frameworks, the following code functions are equivalent:

import { observe } from 'dob'

componentWillMount() {
  this.signal = observe(() => {
    this.fetch({ url: this.props.url, userName: this.props.userName })
  })
}

The magic is that the callback function is re-executed when the variable used by the observe callback changes. The componentWillReceiveProps make judgments, in fact, is to use the life cycle of react to manually monitor variables change, if you change the trigger request function. but this series of operations can be done by observe function.

observe something like a more automated addEventListener:

document.addEventListener('someThingChanged', this.fetch)

So do not forget to de-listen when the component is destroyed:

this.signal.unobserve()

Recently, our team is also exploring how to make more use of this feature and is considering implementing an automatic request library. If there are good suggestions, it is also very welcome to communicate with each other.

Type derivation

Type deduction is easier if you use a framework like dob or mobx:

import { combineStores, Connect } from 'dob'

const stores = combineStores({ Store, Action })

@Connect
class Component extends React.PureComponent<typeof stores, any> {
  render() {
    this.props.Store // Complete type prompt
  }
}

Store how to refer to each other

Complex data flow must exist between Store and Action mutual reference, more recommended dependency injection approach, which is also one of dob respected good practice.

Of course, dependency injection can not be abused, for example, do not exist circular dependencies, although dependency injection usage is flexible, but before you write the code, you need to have a more complete data stream planning, such as simple users, articles, comments scenes, we can design data flow:

Create UserStore ArticleStore ReplyStore:

import { inject } from 'dob'

class UserStore {
  users
}

class ReplyStore {
  @inject(UserStore) userStore: UserStore

  replys // each.user
}

class ArticleStore {
  @inject(UserStore) userStore: UserStore
  @inject(ReplyStore) replyStore: ReplyStore

  articles // each.replys each.user
}

Each comment relates to the user information, so ReplyStore injected into the UserStore, each article contains author and comment information, so ArticleStore injected UserStore and ReplyStore, you can see the dependencies between the store should be a tree, not a ring.

The final Action on the operation of the Store is done by injection, and because the store has been injected into the End, Action can only operate the corresponding Store, when necessary, then inject additional Store, and there will be no circular dependencies:

class UserAction {
  @inject(UserStore) userStore: UserStore
}

class ReplyAction {
  @inject(ReplyStore) replyStore: ReplyStore
}

class ArticleAction {
  @inject(ArticleStore) articleStore: ArticleStore
}

Finally, it is not advisable to inject the global Store into the local store, or to inject the local Action into the global store. This will destroy the fractal characteristics of the local data flow. It is necessary to ensure the independence of the non-business related components and bind global data flow to business-logic related components.

Action's error handling

A more elegant way is to write a class-level decorator that catches the exception of the Action and throws:

const errorCatch = (errorHandler?: (error?: Error) => void) => (target: any) => {
    Object.getOwnPropertyNames(target.prototype).forEach(key => {
        const func = target.prototype[key]
        target.prototype[key] = async (...args: any[]) => {
            try {
                await func.apply(this, args)
            } catch (error) {
                errorHandler && errorHandler(error)
            }
        }
    })
    return target
}

const myErrorCatch = errorCatch(error => {
    // Report the error message
})

@myErrorCatch
class ArticleAction {
  @inject(ArticleStore) articleStore: ArticleStore
}

When any step triggers an exception, the code after await stops executing and reports the exception to the front-end monitoring platform.

4 In conclusion

To help solve issues under most development scenarios, one should accurately distinguish between business-logic related components and non-related components, design the data flow dependencies before writing the code. And pay attention to separation of asynchronous operations. Under special circumstances you can use the monitor to monitor data changes, thus extends to functionalities such as automatic request resend.

Although the data flow is only a very small part of the project, if you want to maintain a good maintainability of the entire project, you need to pay attention to all aspects mentioned above.

Happy hacking with dob.

Callback Promise Generator Async-Await 和异常处理的演进

根据笔者的项目经验,本文讲解了从函数回调,到 es7 规范的异常处理方式。异常处理的优雅性随着规范的进步越来越高,不要害怕使用 try catch,不能回避异常处理。

我们需要一个健全的架构捕获所有同步、异步的异常。业务方不处理异常时,中断函数执行并启用默认处理,业务方也可以随时捕获异常自己处理。

优雅的异常处理方式就像冒泡事件,任何元素可以自由拦截,也可以放任不管交给顶层处理。

文字讲解仅是背景知识介绍,不包含对代码块的完整解读,不要忽略代码块的阅读。

1. 回调

如果在回调函数中直接处理了异常,是最不明智的选择,因为业务方完全失去了对异常的控制能力。

下方的函数 请求处理 不但永远不会执行,还无法在异常时做额外的处理,也无法阻止异常产生时笨拙的 console.log('请求失败') 行为。

function fetch(callback) {
    setTimeout(() => {
        console.log('请求失败')
    })
}

fetch(() => {
    console.log('请求处理') // 永远不会执行
})

2. 回调,无法捕获的异常

回调函数有同步和异步之分,区别在于对方执行回调函数的时机,异常一般出现在请求、数据库连接等操作中,这些操作大多是异步的。

异步回调中,回调函数的执行栈与原函数分离开,导致外部无法抓住异常。

从下文开始,我们约定用 setTimeout 模拟异步操作

function fetch(callback) {
    setTimeout(() => {
        throw Error('请求失败')
    })
}

try {
    fetch(() => {
        console.log('请求处理') // 永远不会执行
    })
} catch (error) {
    console.log('触发异常', error) // 永远不会执行
}

// 程序崩溃
// Uncaught Error: 请求失败

3. 回调,不可控的异常

我们变得谨慎,不敢再随意抛出异常,这已经违背了异常处理的基本原则。

虽然使用了 error-first 约定,使异常看起来变得可处理,但业务方依然没有对异常的控制权,是否调用错误处理取决于回调函数是否执行,我们无法知道调用的函数是否可靠。

更糟糕的问题是,业务方必须处理异常,否则程序挂掉就会什么都不做,这对大部分不用特殊处理异常的场景造成了很大的精神负担。

function fetch(handleError, callback) {
    setTimeout(() => {
        handleError('请求失败')
    })
}

fetch(() => {
	console.log('失败处理') // 失败处理
}, error => {
	console.log('请求处理') // 永远不会执行
})

番外 Promise 基础

Promise 是一个承诺,只可能是成功、失败、无响应三种情况之一,一旦决策,无法修改结果。

Promise 不属于流程控制,但流程控制可以用多个 Promise 组合实现,因此它的职责很单一,就是对一个决议的承诺。

resolve 表明通过的决议,reject 表明拒绝的决议,如果决议通过,then 函数的第一个回调会立即插入 microtask 队列,异步立即执行

简单补充下事件循环的知识,js 事件循环分为 macrotask 和 microtask。
microtask 会被插入到每一个 macrotask 的尾部,所以 microtask 总会优先执行,哪怕 macrotask 因为 js 进程繁忙被 hung 住。
比如 setTimeout setInterval 会插入到 macrotask 中。

const promiseA = new Promise((resolve, reject) => {
    resolve('ok')
})
promiseA.then(result => {
    console.log(result) // ok
})

如果决议结果是决绝,那么 then 函数的第二个回调会立即插入 microtask 队列。

const promiseB = new Promise((resolve, reject) => {
    reject('no')
})
promiseB.then(result => {
    console.log(result) // 永远不会执行
}, error => {
    console.log(error) // no
})

如果一直不决议,此 promise 将处于 pending 状态。

const promiseC = new Promise((resolve, reject) => {
	// nothing
})
promiseC.then(result => {
    console.log(result) // 永远不会执行
}, error => {
    console.log(error) // 永远不会执行
})

未捕获的 reject 会传到末尾,通过 catch 接住

const promiseD = new Promise((resolve, reject) => {
    reject('no')
})
promiseD.then(result => {
    console.log(result) // 永远不会执行
}).catch(error => {
    console.log(error) // no
})

resolve 决议会被自动展开(reject 不会)

const promiseE = new Promise((resolve, reject) => {
    return new Promise((resolve, reject) => {
        resolve('ok')
    })
})
promiseE.then(result => {
    console.log(result) // ok
})

链式流,then 会返回一个新的 Promise,其状态取决于 then 的返回值。

const promiseF = new Promise((resolve, reject) => {
    resolve('ok')
})
promiseF.then(result => {
    return Promise.reject('error1')
}).then(result => {
    console.log(result) // 永远不会执行
    return Promise.resolve('ok1') // 永远不会执行
}).then(result => {
    console.log(result) // 永远不会执行
}).catch(error => {
    console.log(error) // error1
})

4 Promise 异常处理

不仅是 reject,抛出的异常也会被作为拒绝状态被 Promise 捕获。

function fetch(callback) {
    return new Promise((resolve, reject) => {
        throw Error('用户不存在')
    })
}

fetch().then(result => {
    console.log('请求处理', result) // 永远不会执行
}).catch(error => {
    console.log('请求处理异常', error) // 请求处理异常 用户不存在
})

5 Promise 无法捕获的异常

但是,永远不要在 macrotask 队列中抛出异常,因为 macrotask 队列脱离了运行上下文环境,异常无法被当前作用域捕获。

function fetch(callback) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
             throw Error('用户不存在')
        })
    })
}

fetch().then(result => {
    console.log('请求处理', result) // 永远不会执行
}).catch(error => {
    console.log('请求处理异常', error) // 永远不会执行
})

// 程序崩溃
// Uncaught Error: 用户不存在

不过 microtask 中抛出的异常可以被捕获,说明 microtask 队列并没有离开当前作用域,我们通过以下例子来证明:

Promise.resolve(true).then((resolve, reject)=> {
	throw Error('microtask 中的异常')
}).catch(error => {
	console.log('捕获异常', error) // 捕获异常 Error: microtask 中的异常
})

至此,Promise 的异常处理有了比较清晰的答案,只要注意在 macrotask 级别回调中使用 reject,就没有抓不住的异常。

6 Promise 异常追问

如果第三方函数在 macrotask 回调中以 throw Error 的方式抛出异常怎么办?

function thirdFunction() {
    setTimeout(() => {
        throw Error('就是任性')
    })
}

Promise.resolve(true).then((resolve, reject) => {
    thirdFunction()
}).catch(error => {
    console.log('捕获异常', error)
})

// 程序崩溃
// Uncaught Error: 就是任性

值得欣慰的是,由于不在同一个调用栈,虽然这个异常无法被捕获,但也不会影响当前调用栈的执行。

我们必须正视这个问题,唯一的解决办法,是第三方函数不要做这种傻事,一定要在 macrotask 抛出异常的话,请改为 reject 的方式。

function thirdFunction() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject('收敛一些')
        })
    })
}

Promise.resolve(true).then((resolve, reject) => {
    return thirdFunction()
}).catch(error => {
    console.log('捕获异常', error) // 捕获异常 收敛一些
})

请注意,如果 return thirdFunction() 这行缺少了 return 的话,依然无法抓住这个错误,这是因为没有将对方返回的 Promise 传递下去,错误也不会继续传递。

我们发现,这样还不是完美的办法,不但容易忘记 return,而且当同时含有多个第三方函数时,处理方式不太优雅:

function thirdFunction() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject('收敛一些')
        })
    })
}

Promise.resolve(true).then((resolve, reject) => {
    return thirdFunction().then(() => {
        return thirdFunction()
    }).then(() => {
		return thirdFunction()
    }).then(() => {
    })
}).catch(error => {
    console.log('捕获异常', error)
})

是的,我们还有更好的处理方式。

番外 Generator 基础

generator 是更为优雅的流程控制方式,可以让函数可中断执行:

function* generatorA() {
    console.log('a')
    yield
    console.log('b')
}
const genA = generatorA()
genA.next() // a
genA.next() // b

yield 关键字后面可以包含表达式,表达式会传给 next().value

next() 可以传递参数,参数作为 yield 的返回值。

这些特性足以孕育出伟大的生成器,我们稍后介绍。下面是这个特性的例子:

function* generatorB(count) {
    console.log(count)
    const result = yield 5
    console.log(result * count)
}
const genB = generatorB(2)
genB.next() // 2
const genBValue = genB.next(7).value // 14
// genBValue undefined

第一个 next 是没有参数的,因为在执行 generator 函数时,初始值已经传入,第一个 next 的参数没有任何意义,传入也会被丢弃。

const result = yield 5

这一句,返回值不是想当然的 5。其的作用是将 5 传递给 genB.next(),其值,由下一个 next genB.next(7) 传给了它,所以语句等于 const result = 7

最后一个 genBValue,是最后一个 next 的返回值,这个值,就是函数的 return,显然为 undefined

我们回到这个语句:

const result = yield 5

如果返回值是 5,是不是就清晰了许多?是的,这种语法就是 await。所以 Async Awaitgenerator 有着莫大的关联,桥梁就是 生成器,我们稍后介绍 生成器

番外 Async Await

如果认为 Generator 不太好理解,那 Async Await 绝对是救命稻草,我们看看它们的特征:

const timeOut = (time = 0) => new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve(time + 200)
    }, time)
})

async function main() {
    const result1 = await timeOut(200)
    console.log(result1) // 400
    const result2 = await timeOut(result1)
    console.log(result2) // 600
    const result3 = await timeOut(result2)
    console.log(result3) // 800
}

main()

所见即所得,await 后面的表达式被执行,表达式的返回值被返回给了 await 执行处。

但是程序是怎么暂停的呢?只有 generator 可以暂停程序。那么等等,回顾一下 generator 的特性,我们发现它也可以达到这种效果。

番外 async await 是 generator 的语法糖

终于可以介绍 生成器 了!它可以魔法般将下面的 generator 执行成为 await 的效果。

function* main() {
    const result1 = yield timeOut(200)
    console.log(result1)
    const result2 = yield timeOut(result1)
    console.log(result2)
    const result3 = yield timeOut(result2)
    console.log(result3)
}

下面的代码就是生成器了,生成器并不神秘,它只有一个目的,就是:

所见即所得,yield 后面的表达式被执行,表达式的返回值被返回给了 yield 执行处。

达到这个目标不难,达到了就完成了 await 的功能,就是这么神奇。

function step(generator) {
    const gen = generator()
    // 由于其传值,返回步骤交错的特性,记录上一次 yield 传过来的值,在下一个 next 返回过去
    let lastValue
    // 包裹为 Promise,并执行表达式
    return () => Promise.resolve(gen.next(lastValue).value).then(value => {
        lastValue = value
        return lastValue
    })
}

利用生成器,模拟出 await 的执行效果:

const run = step(main)

function recursive(promise) {
    promise().then(result => {
        if (result) {
            recursive(promise)
        }
    })
}

recursive(run)
// 400
// 600
// 800

可以看出,await 的执行次数由程序自动控制,而回退到 generator 模拟,需要根据条件判断是否已经将函数执行完毕。

7 Async Await 异常

不论是同步、异步的异常,await 都不会自动捕获,但好处是可以自动中断函数,我们大可放心编写业务逻辑,而不用担心异步异常后会被执行引发雪崩:

function fetch(callback) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject()
        })
    })
}

async function main() {
    const result = await fetch()
    console.log('请求处理', result) // 永远不会执行
}

main()

8 Async Await 捕获异常

我们使用 try catch 捕获异常。

认真阅读 Generator 番外篇的话,就会理解为什么此时异步的异常可以通过 try catch 来捕获。

因为此时的异步其实在一个作用域中,通过 generator 控制执行顺序,所以可以将异步看做同步的代码去编写,包括使用 try catch 捕获异常。

function fetch(callback) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject('no')
        })
    })
}

async function main() {
    try {
        const result = await fetch()
        console.log('请求处理', result) // 永远不会执行
    } catch (error) {
        console.log('异常', error) // 异常 no
    }
}

main()

9 Async Await 无法捕获的异常

和第五章 Promise 无法捕获的异常 一样,这也是 await 的软肋,不过任然可以通过第六章的方案解决:

function thirdFunction() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject('收敛一些')
        })
    })
}

async function main() {
    try {
        const result = await thirdFunction()
        console.log('请求处理', result) // 永远不会执行
    } catch (error) {
        console.log('异常', error) // 异常 收敛一些
    }
}

main()

现在解答第六章尾部的问题,为什么 await 是更加优雅的方案:

async function main() {
    try {
        const result1 = await secondFunction() // 如果不抛出异常,后续继续执行
        const result2 = await thirdFunction() // 抛出异常
        const result3 = await thirdFunction() // 永远不会执行
        console.log('请求处理', result) // 永远不会执行
    } catch (error) {
        console.log('异常', error) // 异常 收敛一些
    }
}

main()

10 业务场景

在如今 action 概念成为标配的时代,我们大可以将所有异常处理收敛到 action 中。

我们以如下业务代码为例,默认不捕获错误的话,错误会一直冒泡到顶层,最后抛出异常。

const successRequest = () => Promise.resolve('a')
const failRequest = () => Promise.reject('b')

class Action {
    async successReuqest() {
        const result = await successRequest()
        console.log('successReuqest', '处理返回值', result) // successReuqest 处理返回值 a
    }

    async failReuqest() {
        const result = await failRequest()
        console.log('failReuqest', '处理返回值', result) // 永远不会执行
    }

    async allReuqest() {
        const result1 = await successRequest()
        console.log('allReuqest', '处理返回值 success', result1) // allReuqest 处理返回值 success a
        const result2 = await failRequest()
        console.log('allReuqest', '处理返回值 success', result2) // 永远不会执行
    }
}

const action = new Action()
action.successReuqest()
action.failReuqest()
action.allReuqest()

// 程序崩溃
// Uncaught (in promise) b
// Uncaught (in promise) b

为了防止程序崩溃,需要业务线在所有 async 函数中包裹 try catch

我们需要一种机制捕获 action 最顶层的错误进行统一处理。

为了补充前置知识,我们再次进入番外话题。

番外 Decorator

Decorator 中文名是装饰器,核心功能是可以通过外部包装的方式,直接修改类的内部属性。

装饰器按照装饰的位置,分为 class decorator method decorator 以及 property decorator(目前标准尚未支持,通过 get set 模拟实现)。

Class Decorator

类级别装饰器,修饰整个类,可以读取、修改类中任何属性和方法。

const classDecorator = (target: any) => {
    const keys = Object.getOwnPropertyNames(target.prototype)
    console.log('classA keys,', keys) // classA keys ["constructor", "sayName"]
}

@classDecorator
class A {
    sayName() {
        console.log('classA ascoders')
    }
}
const a = new A()
a.sayName() // classA ascoders

Method Decorator

方法级别装饰器,修饰某个方法,和类装饰器功能相同,但是能额外获取当前修饰的方法名。

为了发挥这一特点,我们篡改一下修饰的函数。

const methodDecorator = (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
    return {
        get() {
            return () => {
                console.log('classC method override')
            }
        }
    }
}

class C {
    @methodDecorator
    sayName() {
        console.log('classC ascoders')
    }
}
const c = new C()
c.sayName() // classC method override

Property Decorator

属性级别装饰器,修饰某个属性,和类装饰器功能相同,但是能额外获取当前修饰的属性名。

为了发挥这一特点,我们篡改一下修饰的属性值。

const propertyDecorator = (target: any, propertyKey: string | symbol) => {
    Object.defineProperty(target, propertyKey, {
        get() {
            return 'github'
        },
        set(value: any) {
            return value
        }
    })
}

class B {
    @propertyDecorator
    private name = 'ascoders'

    sayName() {
        console.log(`classB ${this.name}`)
    }
}
const b = new B()
b.sayName() // classB github

11 业务场景 统一异常捕获

我们来编写类级别装饰器,专门捕获 async 函数抛出的异常:

const asyncClass = (errorHandler?: (error?: Error) => void) => (target: any) => {
    Object.getOwnPropertyNames(target.prototype).forEach(key => {
        const func = target.prototype[key]
        target.prototype[key] = async (...args: any[]) => {
            try {
                await func.apply(this, args)
            } catch (error) {
                errorHandler && errorHandler(error)
            }
        }
    })
    return target
}

将类所有方法都用 try catch 包裹住,将异常交给业务方统一的 errorHandler 处理:

const successRequest = () => Promise.resolve('a')
const failRequest = () => Promise.reject('b')

const iAsyncClass = asyncClass(error => {
    console.log('统一异常处理', error) // 统一异常处理 b
})

@iAsyncClass
class Action {
    async successReuqest() {
        const result = await successRequest()
        console.log('successReuqest', '处理返回值', result)
    }

    async failReuqest() {
        const result = await failRequest()
        console.log('failReuqest', '处理返回值', result) // 永远不会执行
    }

    async allReuqest() {
        const result1 = await successRequest()
        console.log('allReuqest', '处理返回值 success', result1)
        const result2 = await failRequest()
        console.log('allReuqest', '处理返回值 success', result2) // 永远不会执行
    }
}

const action = new Action()
action.successReuqest()
action.failReuqest()
action.allReuqest()

我们也可以编写方法级别的异常处理:

const asyncMethod = (errorHandler?: (error?: Error) => void) => (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
    const func = descriptor.value
    return {
        get() {
            return (...args: any[]) => {
                return Promise.resolve(func.apply(this, args)).catch(error => {
                    errorHandler && errorHandler(error)
                })
            }
        },
        set(newValue: any) {
            return newValue
        }
    }
}

业务方用法类似,只是装饰器需要放在函数上:

const successRequest = () => Promise.resolve('a')
const failRequest = () => Promise.reject('b')

const asyncAction = asyncMethod(error => {
    console.log('统一异常处理', error) // 统一异常处理 b
})

class Action {
    @asyncAction async successReuqest() {
        const result = await successRequest()
        console.log('successReuqest', '处理返回值', result)
    }

    @asyncAction async failReuqest() {
        const result = await failRequest()
        console.log('failReuqest', '处理返回值', result) // 永远不会执行
    }

    @asyncAction async allReuqest() {
        const result1 = await successRequest()
        console.log('allReuqest', '处理返回值 success', result1)
        const result2 = await failRequest()
        console.log('allReuqest', '处理返回值 success', result2) // 永远不会执行
    }
}

const action = new Action()
action.successReuqest()
action.failReuqest()
action.allReuqest()

12 业务场景 没有后顾之忧的主动权

我想描述的意思是,在第 11 章这种场景下,业务方是不用担心异常导致的 crash,因为所有异常都会在顶层统一捕获,可能表现为弹出一个提示框,告诉用户请求发送失败。

业务方也不需要判断程序中是否存在异常,而战战兢兢的到处 try catch,因为程序中任何异常都会立刻终止函数的后续执行,不会再引发更恶劣的结果。

像 golang 中异常处理方式,就存在这个问题
通过 err, result := func() 的方式,虽然固定了第一个参数是错误信息,但下一行代码免不了要以 if error {...} 开头,整个程序的业务代码充斥着巨量的不必要错误处理,而大部分时候,我们还要为如何处理这些错误想的焦头烂额。

而 js 异常冒泡的方式,在前端可以用提示框兜底,nodejs端可以返回 500 错误兜底,并立刻中断后续请求代码,等于在所有危险代码身后加了一层隐藏的 return

同时业务方也握有绝对的主动权,比如登录失败后,如果账户不存在,那么直接跳转到注册页,而不是傻瓜的提示用户帐号不存在,可以这样做:

async login(nickname, password) {
	try {
		const user = await userService.login(nickname, password)
		// 跳转到首页,登录失败后不会执行到这,所以不用担心用户看到奇怪的跳转
	} catch (error) {
		if (error.no === -1) {
			// 跳转到登录页
		} else {
			throw Error(error) // 其他错误不想管,把球继续踢走
		}
	}
}

补充

nodejs 端,记得监听全局错误,兜住落网之鱼:

process.on('uncaughtException', (error: any) => {
    logger.error('uncaughtException', error)
})

process.on('unhandledRejection', (error: any) => {
    logger.error('unhandledRejection', error)
})

在浏览器端,记得监听 window 全局错误,兜住漏网之鱼:

window.addEventListener('unhandledrejection', (event: any) => {
    logger.error('unhandledrejection', event)
})
window.addEventListener('onrejectionhandled', (event: any) => {
    logger.error('onrejectionhandled', event)
})

如有错误,欢迎斧正,本人 github 主页:https://github.com/ascoders 希望结交有识之士!

dob - 框架实现

本系列分三部曲:《框架实现》 《框架使用》 与 《跳出框架看哲学》,这三篇是我对数据流阶段性的总结,正好补充之前过时的文章。

本篇是 《框架实现》。

1 引言

我觉得数据流与框架的关系,有点像网络与人的关系。

在网络诞生前,人与人之间连接点较少,大部分消息都是通过人与人之间传递,虽然信息整体性不强,但信息在局部非常完备:当你想开一家门面,找到经验丰富的经理人,可以一手包办完。

网络诞生后,如果想通过纯网络的方式,学习如何开门面,如果不是对网络很熟悉,一时半会也难以学习到全套流程。

数据流对框架来说,就像网络对人一样,总是存在着模块功能的完备性与项目整体性的博弈。

全局性强了,对整体性强要求的项目(频繁交互数据)友好,顺便利于测试,因为不利于测试的 UI 与数据关系被抽离开了。

局部性强了,对弱关联的项目友好,这样任何模块都能不依赖全局数据,自己完成所有功能。

对数据流的研究,大多集中于 “优化在某些框架的用法” “基于场景改良” “优化全局与局部数据流间关系” “函数式与面向对象之争” “对输入抽象” “数据格式转换” 这几方面。这里面参杂着统一与分离,类比到网络与人,也许最终只有人脑搬到网络中,才可以达到最终状态。

虚的就说这么多,本篇讲的是 《框架实现》,我们先钻到细节里。

2 精读 dob 框架实现

dob 是个类似 mobx 的框架,实现思路都很类似,如果难以读懂 mobx 的源码,可以先参考 dob 的实现原理。

抽丝剥茧,实现依赖追踪

MVVM 思路中,依赖追踪是核心。

dob 中 observe 类似 mobx 的 autorun,是使用频率最高的依赖监听工具。

写作时,已经有许多文章将 vue 源码翻来覆去研究过了,因此这里就不长篇大论 MVVM 原理了。

依赖追踪分为两部分,分别是 依赖收集触发回调,如果把这两个功能合起来,就是 observe 函数,分开的话,就是较为底层的 Reaction

Reaction 双管齐下,一边监听用到了哪些变量,另一边在这些变量改变后,执行回调函数。Observe 利用 Reaction 实现(简化版):

image

function observe(callback) {
  const reaction = new Reaction(() => {
    reaction.track(callback)
  })

  reaction.run()
}

reaction.run() 在初始化就执行 new Reaction 的回调,而这个回调又恰好执行 reaction.track(callback)。所以 callback 函数中用到的变量被记录了下来,当变量更改时,会触发 new Reaction 的回调,又重新收集一轮依赖,同时执行了 callback

这样就实现了回调函数用到的变量被改变后,重新执行这个回调函数,这就是 observe

为什么依赖追踪只支持同步函数

依赖收集无法得到触发时的环境信息。

依赖收集由 getter、setter 完成,但触发时,却无法定位触发代码位于哪个函数中,所以为了依赖追踪(即变量与函数绑定),需要定义一个全局的变量标示当前执行函数,当各依赖收集函数执行没有交叉时,可以正常运作:

image

上图右侧白色方块是函数体,getter 表示其中访问到某个变量的 getter,经由依赖收集后,变量被修改时,左侧控制器会重新调用其所在的函数。

但是,当函数嵌套函数时,就会出现异常:

image

由于采用全局变量标记法,当回调函数嵌套起来时,当内层函数执行完后,实际作用域已回到了外层,但依赖收集无法获取这个堆栈改变事件,导致后续 getter 都会误绑定到内层函数。

异步(回调)也是同理,虽然写在一个函数体内,但执行的堆栈却不同,因此无法实现正确的依赖收集。

所以需要一些办法,将嵌套的函数放在外层函数执行完毕后,再执行:

image

换成代码描述如下:

observe(()=>{
  console.log(1)
  observe(()=>{
  	console.log(2)
  })
  console.log(3)
})
// 需要输出 1,3,2

当然这不是简单 setTimeout 异步控制就可以,因为依赖收集是同步的,我们要在同步基础上,实现函数执行顺序的变换。

我们可以逐层分解,在每一层执行时,子元素如果是 observe,就会临时放到队列里并跳过,在父 observe 执行完毕后,检查并执行队列,两层嵌套时执行逻辑如下图所示:

image

这些努力,就是为了保证在同步执行时,所有 getter 都能绑定到正确的回调函数。

如何结合 React

observe 如何到 render

observe 可以类比到 React 的 render,它们都具有相同的特征:是同步函数,同时 observe 的运行机制也符合了 render 函数的需求,不是吗?

如果将 observe 用到 react render 函数,当任何 render 函数使用到的变量发生改动,对应的 render 函数就会重新执行,实现 UI 刷新。

要实现结合,用到两个小技巧:聚合生命周期、替换 render 函数,用图才能解释清楚:

image

以上是简化版,正式版本使用 reaction 实现,可以更清晰的区分依赖收集与 rerender 阶段。

如何避免在 view 中随意修改变量

为了使用起来具有更好的可维护性,需要限制依赖追踪的功能,使值不能再随意的修改。可见,强大的功能,不代表在数据流场景的高可用性,恰当的约束反而会更好。

因此引入 Action 概念,在 Action 中执行的变量修改,不仅会将多次修改聚合成一次 render,而且不在 Action 中的变量修改会抛出异常。

Action 类似进栈出栈,当栈深度不为 0 时,进行的任何的变量修改,拦截到后就可以抛出异常了。

有层次的实现 Debug

一层一层功能逐渐冒泡。

调试功能,在依赖追踪、与 react 结合这一层都需要做,怎样分工配合才能保证功能不冗余,且具有良好的拓展性呢?

数据流框架的 Debug 分为数据层和 UI 层,顺序是 dob 核心记录 debug 信息 -> dob-devtools 读取再加工,强化 UI 信息

在 UI 层不止可以简单的将对象友好展示出来,更可以通过额外信息采集,将 Action 与 UI 元素绑定,让用户找到任意一次 Action 触发时,rerender 了哪些 UI 元素,以及每个 UI 元素分别与哪些 Action 绑定。

由于数据流需要一个 Provider 提供数据源,与 Connect 注入数据,所以可以将所有与数据流绑定的 UI 元素一一映射到 Debug UI,就像一面镜子一样映射:

image

通过 Debug UI,将 debug 信息与 UI 一一对应,实现 dob-react-devtools 的效果。

Debug 功能如何解耦

解耦还能方便许多功能拓展,比如支持 redux。

我得答案是事件。通过精心定义的一系列事件,制造出一个具有生命周期的工具库!

在所有 getter setter 节点抛出相关信息,Debug 端订阅这些事件,找到对自己有用的,记录下来。例如:

event.on("get", info => {
  // 不在调试模式
  if (!globalState.useDebug) {
    return
  }

  // 记录调用堆栈..
})

Dob 目前支持这几种事件钩子:

  • get: 任何数据发生了 getter。
  • set: 任何数据发生了 setter。
  • deleteProperty: 任何数据的 key 被移除时。
  • runInAction: 调用了 Action。
  • startBatch: 任意 Action 入栈。
  • endBatch: 任意 Action 出栈。

并且在关键生命周期节点,还要遵守调用顺序,比如以下是 Action 触发后,到触发 observe 的顺序:

startBatch -> debugInAction -> ...multiple nested startBatch and endBatch -> debugOutAction -> reaction -> observe

如果未开启 debug,执行顺序简化为:

startBatch -> ...multiple nested startBatch and endBatch -> reaction -> observe

订阅了这些事件,可以完成类似 redux-dev-tools 的功能。

3 总结

由于篇幅有限,本文介绍的《框架实现》均是一些上层设计,很少有代码讲解。因为我觉得一篇引发思考的文章不应该贴太多的代码,况且人脑处理图形的效率远远高于文字、略高于代码,所以通过一些图来展示。如果想看代码实现,可以读 dob 源码

如希望详细了解依赖注入实现流程,请看 从零开始用 proxy 实现 mobx

下一篇是 《框架使用》,会站在使用者的角度思考数据流。

精读 Immutable 结构共享

本期精读的文章是:Immutable 结构共享是如何实现的

鉴于 mobx-state-tree 的发布,实现了 mutable 到 immutable 数据的自由转换,将 mobx 写法的数据流,无缝接入 redux 生态,或继续使用 mobx 生态。

这是将事务性,可追溯性与依赖追踪特性的结合,同时解决开发体验与数据流可维护性。万一这种思路火了呢?我们先来预热下其重要特征,结构共享。

1 引言

image

结构共享不仅仅是 “结构共享” 那么简单,背后包含了 Hash maps tries 与 vector tries 结构的支持,如果让我们设计一个结构共享功能,需要考虑哪些点呢?本期精读的文章给了答案。

2 内容概要

使用 Object.assign 作用于大对象时,速度会成为瓶颈,比如拥有 100,000 个属性的对象,这个操作耗费了 134ms。性能损失主要原因是 “结构共享” 操作需要遍历近10万个属性,而这些引用操作耗费了100ms以上的时间。

解决办法就是减少引用指向的操作数量,而且由于引用指向到任何对象的损耗都几乎一致(无论目标对象极限小或者无穷大,引用消耗时间都几乎没有区别),我们需要一种精心设计的树状结构将打平的引用建立深度,以减少引用操作次数,vector tries 就是一种解决思路:

image

上图的 key: t0143c274,通过 hash 后得到的值为 621051904(与 md5 不同,比如 hash("a") == 0,hash("c") == 2),转化为二进制后,值是 10010 10000 01001 00000 00000 00000,这个路径是唯一的,同时,为了减少树的深度,按照 5bit 切分,切分后的路径也是唯一的。因此寻址路径就如上图所示。

因此结构共享的核心思路是以空间换时间

3 精读

本精读由 rccoder ascoders cisen BlackGanglion jasonslyvia TingGe twobin camsong 讨论而出,以及我个人的吐血阅读论文原文总结而成。

Immutable 树结构的特性

camsong 的动态图形象介绍一下共享的操作流程:

image

但是,当树越宽(子节点越多)时,相应树的高度会下降,随之查询效率会提高,但更新效率则会下降(试想一下极限情况,就相当于线性结构)。为寻求更新与查询的平衡,我们便选择了 5bit 一分割。

因此最终每个节点拥有 2^5=32 个子节点,同时通过 Vector trie 和 Hash maps trie 压缩空间结构,使其深度最小,性能最优。

Vector trie

通过这篇文章查看详细介绍

其原理是,使用二叉树,将所有值按照顺序,从左到右存放于叶子节点,当需要更新数据时,只将其更新路径上的节点生成新的对象,没有改变的节点继续共用。

image

Hash maps trie

Immutablejs 对于 Map,使用了这种方式优化,并且通过树宽与树高的压缩,形成了文中例图中的效果(10010 10000 聚合成了一个节点,并且移除了同级的空节点)。

树宽压缩:

image

树高压缩:

image

再结合 Vector trie,实现结构共享,保证其更新性能最优,同时查询路径相对较优。

Object.assign 是否可替代 Immutable?

结构共享指的是,根节点的引用改变,但对没修改的节点,引用依然指向旧节点。所以Object.assign 也能实现结构共享

见如下代码:

const objA = { a: 1, b: 2, c: 3 }
const objB = Object.assign({}, objA, { c: 4 })
objA === objB     // false
objA.a === objB.a // true
objA.b === objB.b // true

证明 Object.assign 完全可以胜任 Immutable 的场景。但正如文章所述,当对象属性庞大时, Object.assign 的效率较低,因此在特殊场景,不适合使用 Object.assign 生成 immutable 数据。但是大部分场景还是完全可以使用 Object.assign 的,因为性能不是瓶颈,唯一繁琐点在于深层次对象的赋值书写起来很麻烦。

Map 性能比 Object.assign 更好,是否可以替代 Immutable?

当一层节点达到 1000000 时,immutable.get 查询性能是 object.key 的 10 倍以上。

就性能而言可以替代 Immutable,但就结合 redux 使用而言,无法替代 Immutable。

redux 判断数据更新的条件是,对象引用是否变化,而且要满足,当修改对象子属性时,父级对象的引用也要一并修改。Map 跪在这个特性上,它无法使 set 后的 map 对象产生一份新的引用。

这样会导致,Connect 了 style 对象,其 backgroundColor 属性变化时,不会触发 reRender。因此虽然 Map 性能不错,但无法胜任 Object.assign 或 immutablejs 库对 redux 的支持。

3 总结

数据结构共享要达到真正可用,需要借助 Hash maps tries 和 vector tries 数据结构的帮助,在上文中已经详细阐述。既然清楚了结构共享怎么做,就更加想知道 mobx-state-tree 是如何做到 mutable 数据到 immutable 数据转换了,敬请期待下次的源码分析(不一定在下一期)。

如何你对原理不是很关心,那拿走这个结论也不错:在大部分情况可以使用 Object.assign 代替 Immutablejs,只要你不怕深度赋值的麻烦语法;其效果与 Immutablejs 一模一样,唯一,在数据量巨大的字段上,可以使用 Immutablejs 代替以提高性能。

讨论地址是:Immutable 结构共享是如何实现的? · Issue #14 · dt-fe/weekly

如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。

谈谈 Redux 与 Mobx **的适用场景

谈谈 Redux 与 Mobx **的适用场景

Redux 和 Mobx 都是当下比较火热的数据流模型,一个背靠函数式,似乎成为了开源界标配,一个基于面向对象,低调的前行。

函数式 vs 面向对象

首先任何避开业务场景的技术选型都是耍流氓,我先耍一下流氓,首先函数式的优势,比如:

  1. 无副作用,可时间回溯,适合并发。
  2. 数据流变换处理很拿手,比如 rxjs。
  3. 对于复杂数据逻辑、科学计算维的开发和维护效率更高。

当然,连原子都是由带正电的原子核,与带负电的电子组成的,几乎任何事务都没有绝对的好坏,面向对象也存在很多优势,比如:

  1. javascript 的鸭子类型,表明它基于对象,不适合完全函数式表达。
  2. 数学思维和数据处理适合用函数式,技术是为业务服务的,而业务模型适合用面向对象。
  3. 业务开发和做研究不同,逻辑严谨的函数式相当完美,但别指望每个程序员都愿意消耗大量脑细胞解决日常业务问题。

Redux vs Mobx

那么具体到这两种模型,又有一些特定的优缺点呈现出来,先谈谈 Redux 的优势:

  1. 数据流流动很自然,因为任何 dispatch 都会导致广播,需要依据对象引用是否变化来控制更新粒度。
  2. 如果充分利用时间回溯的特征,可以增强业务的可预测性与错误定位能力。
  3. 时间回溯代价很高,因为每次都要更新引用,除非增加代码复杂度,或使用 immutable。
  4. 时间回溯的另一个代价是 action 与 reducer 完全脱节,数据流过程需要自行脑补。原因是可回溯必然不能保证引用关系。
  5. 引入中间件,其实主要为了解决异步带来的副作用,业务逻辑或多或少参杂着 magic。
  6. 但是灵活利用中间件,可以通过约定完成许多复杂的工作。
  7. 对 typescript 支持困难。

Mobx:

  1. 数据流流动不自然,只有用到的数据才会引发绑定,局部精确更新,但免去了粒度控制烦恼。
  2. 没有时间回溯能力,因为数据只有一份引用。
  3. 自始至终一份引用,不需要 immutable,也没有复制对象的额外开销。
  4. 没有这样的烦恼,数据流动由函数调用一气呵成,便于调试。
  5. 业务开发不是脑力活,而是体力活,少一些 magic,多一些效率。
  6. 由于没有 magic,所以没有中间件机制,没法通过 magic 加快工作效率(这里 magic 是指 action 分发到 reducer 的过程)。
  7. 完美支持 typescript。

到底如何选择

从目前经验来看,我建议前端数据流不太复杂的情况,使用 Mobx,因为更加清晰,也便于维护;如果前端数据流极度复杂,建议谨慎使用 Redux,通过中间件减缓巨大业务复杂度,但还是要做到对开发人员尽量透明,如果可以建议使用 typescript 辅助。

Mobx **的实现原理

Mobx **的实现原理

Mobx 最关键的函数在于 autoRun,举个例子,它可以达到这样的效果:

const obj = observable({
    a: 1,
    b: 2
})

autoRun(() => {
    console.log(obj.a)
})

obj.b = 3 // 什么都没有发生
obj.a = 2 // observe 函数的回调触发了,控制台输出:2

我们发现这个函数非常智能,用到了什么属性,就会和这个属性挂上钩,从此一旦这个属性发生了改变,就会触发回调,通知你可以拿到新值了。没有用到的属性,无论你怎么修改,它都不会触发回调,这就是神奇的地方。

autoRun 的用途

使用 autoRun 实现 mobx-react 非常简单,核心**是将组件外面包上 autoRun,这样代码中用到的所有属性都会像上面 Demo 一样,与当前组件绑定,一旦任何值发生了修改,就直接 forceUpdate,而且精确命中,效率最高。

依赖收集

autoRun 的专业名词叫做依赖收集,也就是通过自然的使用,来收集依赖,当变量改变时,根据收集的依赖来判断是否需要更新。

实现步骤拆解

为了兼容,Mobx 使用了 Object.defineProperty 拦截 gettersetter,但是无法拦截未定义的变量,为了方便,我们使用 proxy 来讲解,而且可以监听未定义的变量哦。

步骤一 存储结构

众所周知,事件监听是需要预先存储的,autoRun 也一样,为了知道当变量修改后,哪些方法应该被触发,我们需要一个存储结构。

首先,我们需要存储所有的代理对象,让我们无论拿到原始对象,还是代理对象,都能快速的找出是否有对应的代理对象存在,这个功能用在判断代理是否存在,是否合法,以及同一个对象不会生成两个代理。

代码如下:

const proxies = new WeakMap()

function isObservable<T extends object>(obj: T) {
    return (proxies.get(obj) === obj)
}

重点来了,第二个要存储的是最重要的部分,也就是所有监听!当任何对象被改变的时候,我们需要知道它每一个 key 对应着哪些监听(这些监听由 autoRun 注册),也就是,最终会存在多个对象,每个对象的每个 key 都可能与多个 autoRun 绑定,这样在更新某个 key 时,直接触发与其绑定的所有 autoRun 即可。

代码如下:

const observers = new WeakMap<object, Map<PropertyKey, Set<Observer>>>()

第三个存储结构就是待观察队列,为了使同一个调用栈多次赋值仅执行一次 autoRun,所有待执行的都会放在这个队列中,在下一时刻统一执行队列并清空,执行的时候,当前所有 autoRun 都是在同一时刻触发的,所以让相同的 autoRun 不用触发多次即可实现性能优化。

const queuedObservers = new Set()

代码如下:

我们还要再存储两个全局变量,分别是是否在队列执行中,以及当前执行到的 autoRun

代码如下:

let queued = false
let currentObserver: Observer = null

步骤二 将对象加工可观察

这一步讲解的是 observable 做了哪些事,首先第一件就是,如果已经存在代理对象了,就直接返回。

代码如下:

function observable<T extends object>(obj: T = {} as T): T {
    return proxies.get(obj) || toObservable(obj)
}

我们继续看 toObservable 函数,它做的事情是,实例化代理,并拦截 get set 等方法。

我们先看拦截 get 的作用:先拿到当前要获取的值 result,如果这个值在代理中存在,优先返回代理对象,否则返回 result 本身(没有引用关系的基本类型)。

上面的逻辑只是简单返回取值,并没有注册这一步,我们在 currentObserver 存在时才会给对象当前 key 注册 autoRun,并且如果结果是对象,又不存在已有的代理,就调用自身 toObservable 再递归一遍,所以返回的对象一定是代理。

registerObserver 函数的作用是将 targetObj -> key -> autoRun 这个链路关系存到 observers 对象中,当对象修改的时候,可以直接找到对应 keyautoRun

那么 currentObserver 是什么时候赋值的呢?首先,并不是访问到 get 就要注册 registerObserver,必须在 autoRun 里面的才符合要求,所以执行 autoRun 的时候就会将当前回调函数赋值给 currentObserver,保证了在 autoRun 函数内部所有监听对象的 get 拦截器都能访问到 currentObserver。以此类推,其他 autoRun 函数回调函数内部变量 get 拦截器中,currentObserver 也是对应的回调函数。

代码如下:

const dynamicObject = new Proxy(obj, {
    // ...
    get(target, key, receiver) {
        const result = Reflect.get(target, key, receiver)

        // 如果取的值是对象,优先取代理对象
        const resultIsObject = typeof result === 'object' && result
        const existProxy = resultIsObject && proxies.get(result)

        // 将监听添加到这个 key 上
        if (currentObserver) {
            registerObserver(target, key)
            if (resultIsObject) {
                return existProxy || toObservable(result)
            }
        }

        return existProxy || result
    }),
    // ...
})

setter 过程中,如果对象产生了变动,就会触发 queueObservers 函数执行回调函数,这些回调都在 getter 中定义好了,只需要把当前对象,以及修改的 key 传过去,直接触发对应对象,当前 key 所注册的 autoRun 即可。

代码如下:

const dynamicObject = new Proxy(obj, {
    // ...
    set(target, key, value, receiver) {
        // 如果改动了 length 属性,或者新值与旧值不同,触发可观察队列任务
        if (key === 'length' || value !== Reflect.get(target, key, receiver)) {
            queueObservers<T>(target, key)
        }

        // 如果新值是对象,优先取原始对象
        if (typeof value === 'object' && value) {
            value = value.$raw || value
        }

        return Reflect.set(target, key, value, receiver)
    },
    // ...
})

没错,主要逻辑已经全部说完了,新对象之所以可以检测到,是因为 proxyget 会触发,这要多谢 proxy 的强大。

可能有人问 Object.defineProperty 为什么不行,原因很简单,因为这个函数只能设置某个 keygetter setter~。

symbol proxy reflect 这三剑客能做的事还有很多很多,这仅仅是实现 Object.observe 而已,还有更强大的功能可以挖掘。

总结

es6 真的非常强大,呼吁大家抛弃 ie11,拥抱美好的未来!

React Editor 应用编辑器(1) - 拖拽功能剖析

这是可视化编辑器 Gaea-Editor 的第一篇连载分析文章,希望我能在有限的篇幅讲清楚制作这个网页编辑器的动机,以及可能带来的美好使用前景(画大饼)。它会具有如下几个特征:

  1. 运行在网页
  2. 文档流布局,绝对定位同时支持
  3. 对插入的任何 React 组件都可以直接作为编辑元素拖拽到页面中
  4. 兼容 React-Native 的 web 组件可以让它生成 android 和 ios 原生页面
  5. 拥有 Gaea-Preview 套件,传入 Gaea-Editor 生成的 json,可以立刻生成页面
  6. 拥有 Gaea-web-components Gaea-native-components 分别提供网页、原生基础最小粒度的组件
  7. 可以定制任何 React 组件插入到编辑器中
  8. 像 chrome-devtools 一样灵活,可以跨层级排序拖拽任何编辑区的元素
  9. 可以自定义组合模板,三下五除二搞定相似的需求

当然看完这篇文章,不仅限于了解这个编辑器的功能,我会非常详细介绍其设计细节,只要你仔细读它,完全可以做出自己的网页编辑器 ^_^。

在说这个可视化编辑器之前,不得不提到 React,这是我创作它的动机。虽然不确定 React 能火多久,但它带来的组件化掀起了一场前端界的工业革命,当然,组件化这个理念也不是 React 首创,但 React 大大降低了组件化的成本,就像发明了活字印刷术,让只有贵族才买得起的书本普及到了千家万户。

在全民组件化的时代里,我写过几篇文章介绍如何应用和管理组件 :http://www.jianshu.com/p/aaca5047a149 以及组件库的维护经验:fex-team/fit#3 。现在组件化正在越来越普及,我们掌握了组件开发和管理的规律后,项目结构组织,团队间协作已经取得了飞速进步,组件化带来效率的提升也会日渐枯竭,但可视化编辑可能是一条突破瓶颈之路,第一,在有了现成组件的基础上,将其迁移到可视化编辑平台的成本非常小,第二,代码之外的页面开发更加直观,加上部分代码的辅佐会让结构组织更高效(类似 Unity 引擎)。

React 与原生拖拽结合

网页编辑器第一步,也是最重要的一步,就是拖拽功能了,我们希望最终效果如图所示:

drag

如图所示,支持随意拖拽、拖拽动画,跨父级拖拽。我们使用 sortablejs 可以达到此效果,这篇文章重点就是介绍如何结合到 React。

使用 sortable.js

为了支持嵌套拖拽,我们使用开发版地址安装 "sortablejs":"git://github.com/RubaXa/Sortable.git#dev"

将 sortable 与 react 结合我们首先会想到在拖拽结束后重新 render,但这样做有如下几个缺点:

  • sortable 因为拖拽过程中改变了 dom 结构,所以操作流畅,但因此生成的 dom 节点脱离了 react 的控制
  • 排序拖拽后会,sortable 会删除之前拖拽的节点,导致 react diff 算法删除元素时发现 dom 已经消失

总结来说就是既要让 sortable 操作 dom,又不能让 dom 操作导致脱离 react 的控制,我们采用操作回放的方式,将 sortable 操作结束后的 dom 修改回退,再将操作结果状态用 react 刷新

右侧菜单配置

对右侧菜单配置如下:

Sortable.create(ReactDOM.findDOMNode(this.dragContainerInstance), {
    // 放在一个组里,可以跨组拖拽
    group: {
        name: 'gaea-layout',
        pull: 'clone',
        put: false
    },
    sort: false,
    delay: 0,
    onStart: (event: any) => {
        // 存储开始拖拽的位置和拖拽结束的位置
        // ...
    },
    onEnd: (event: any) => {
        // 拖拽菜单时,真实元素会被拖拽走,拖拽成功的话元素会重复, 没成功拖拽会被添加到末尾
        // 所以先移除 clone 的元素(吐槽下, 拖走的才是真的, 留下的才是 clone 的)
        // 有 parentNode, 说明拖拽完毕还是没有被清除, 说明被拖走了, 因为如果没真正拖动成功, clone 元素会被删除
        if (event.clone.parentNode) {
            // 有 clone, 说明已经真正拖走了
            this.dragContainerDomInstance.removeChild(event.clone)
            // 再把真正移过去的弄回来
            if (this.lastDragStartIndex === this.dragContainerDomInstance.childNodes.length) {
                // 如果拖拽的是最后一个
                this.dragContainerDomInstance.appendChild(event.item)
            } else {
                // 拖拽的不是最后一个
                this.dragContainerDomInstance.insertBefore(event.item, this.dragContainerDomInstance.childNodes[this.lastDragStartIndex])
            }
        } else {
            // 没拖走, 只是晃了一下, 不用管了
        }
    }
})

如上代码注释写的很详尽,解释一下就是,从菜单拖拽的配置要用 pull:clone 的方式配置,这样同一个元素才可以拖拽多次。put:false 让菜单不能被其它元素拖入。

当开始拖拽时,保存拖拽后的位置,便于找到用户拖拽的元素,在页面生成实例,同时保存拖拽前的位置,便于拖拽结束后恢复元素。

所以拖拽结束后,先判断 event.clone.parentNode,如果是空,说明元素并没有被拖走,所以不需要处理,否则需要先删除原先位置留下的 clone dom,因为这个元素不受 react 控制,再将真实拖走的元素还原到之前的位置

视图区域配置

编辑器视图区域的 sortable 配置比较长,因此拆解分析。

group 配置:

group: {
    name: 'gaea-layout',
    pull: true,
    put: true
}

这个很容易理解,因为视图区域的元素可以被移走,也可以被其它元素移入,因此 pullput 都是 true

开始拖拽时

onStart: (event: any) => {
    // 保存拖拽前、后的位置
}

拖拽结束时

onEnd: (event: any) => {
    // 略
}

拖拽结束不需要做特殊处理,但可以做一些视觉设置,比如告诉用户拖拽结束了。

有元素新增时

onAdd: (event: any)=> {
    // 取消 srotable 对 dom 的修改
    // 删掉 dom 元素, 让 react 去生成 dom
    if (this.props.viewport.currentMovingComponent.isNew) {
        // 是新拖进来的, 不用管, 因为工具栏会把它收回去
        // 为什么不删掉? 因为这个元素不论是不是 clone, 都被移过来了, 不还回去 react 在更新 dom 时会无法找到
    } else {
        // 如果是从某个元素移过来的(新增的,而不是同一个父级改变排序)
        // 把这个元素还给之前拖拽的父级
        if (this.props.viewport.dragStartParentElement.childNodes.length === 0) {
            // 之前只有一个元素
            this.props.viewport.dragStartParentElement.appendChild(event.item)
        } else if (this.props.viewport.dragStartParentElement.childNodes.length === this.props.viewport.dragStartIndex) {
            // 是上一次位置是最后一个, 而且父元素有多个元素
            this.props.viewport.dragStartParentElement.appendChild(event.item)
        } else {
            // 不是最后一个, 而且有多个元素
            // 插入到它下一个元素的前一个
            this.props.viewport.dragStartParentElement.insertBefore(event.item, this.props.viewport.dragStartParentElement.childNodes[this.props.viewport.dragStartIndex])
        }
    }
}

有元素新增后,有两种情况:新增元素,或者从已有元素中拖拽进来新增。

如果是从工具栏拖拽进来新增的元素,只需要用 react 重新渲染一遍即可。

如果是从其它视图元素中移入进来的,需要把这个元素还原到之前拖拽的位置,这样就回退到 sortable 操作之前的状态,再用 react 渲染这两个父级组件。

同一父级内元素位置更新时

onUpdate: (event: any)=> {
    // 同一个父级下子元素交换父级
    // 取消 srotable 对 dom 的修改, 让元素回到最初的位置即可复原
    const oldIndex = event.oldIndex as number
    const newIndex = event.newIndex as number
    if (this.props.viewport.dragStartParentElement.childNodes.length === oldIndex + 1) {
        // 是从最后一个元素开始拖拽的
        this.props.viewport.dragStartParentElement.appendChild(event.item)
    } else {
        if (newIndex > oldIndex) {
            // 如果移到了后面
            this.props.viewport.dragStartParentElement.insertBefore(event.item, this.props.viewport.dragStartParentElement.childNodes[oldIndex])
        } else {
            // 如果移到了前面
            this.props.viewport.dragStartParentElement.insertBefore(event.item, this.props.viewport.dragStartParentElement.childNodes[oldIndex + 1])
        }
    }
    this.props.viewport.sortComponents(this.props.mapUniqueKey, event.oldIndex as number, event.newIndex as number)
}

我们只需要对元素位置进行还原,之后根据起点位置和终点位置模拟元素移动,再使用 react 渲染即可。这里需要注意, sortable 的拖拽不是简单的a b互换,而是 a -> b,下面用图简单描述一下:

image

如上图所示,同一个父级下有6个元素,当我们拖拽第一个元素到第5个元素时,排序不是变成了 5 2 3 4 1 6,而是如下图所示:

image

不可避免的产生了互换,我们逐一互换元素位置,然后更新父级元素子元素的位置。注意此时最佳状态是不触发 react 元素渲染,我们只要保证子元素的 key 不变, react diff 算法会自动移动 dom 节点,而不是重新渲染 1 2 3 4 5 这 5 个子节点。

当元素被移走时

onRemove: (event: any)=> {
    // 渲染父级元素,减少一个子元素在当前位置
}

当元素被移走时,不会触发 onUpdate 方法,而会触发 onAdd 方法,但是我们已经在 onAdd 方法中将移走的元素还原回去,因此这里不需要做任何处理,相当于没有改动,我们只需要更新 react 父级元素重新渲染,让 react 将元素移走即可。

总结

基于以上菜单区域和视图区域的博弈,终于将 sortable 与 react 渲染完美结合起来,然而不用担心有什么副作用,因为我们已经将所有 sortable 的操作还原,所以实际上只用了它的拖拽过程已经拖拽结果,忙到后来其实没有改变任何 dom 结构,最终 dom 元素的变化还是由 react 来控制。

后续系列我们会继续剖析实现部分,以及放上仓库地址。解析到底是如何将元素放在视图区域,并且并支持无限层级嵌套的,敬请期待!

Mvvm 前端数据流框架精讲

本次分享是带大家了解什么是 mvvm,mvvm 的原理,以及近几年产生了哪些演变。

同时借 mvvm 这个话题拓展到对各类前端数据流方案的思考,形成对前端数据流整体认知,帮助大家在团队中更好的做技术选型。

Mvvm 的概念与发展

Mvvm & 单向数据流

Mvvm 是指双向数据流,即 View-Model 之间的双向通信,由 ViewModel 作桥接。如下图所示:

image

而单向数据流则去除了 View -> Model 这一步,需要由用户手动绑定。

生态 - 内置 & 解耦

许多前端框架都内置了 Mvvm 功能,比如 Knockout、Angular、Ember、Avalon、Vue、San 等等。

而就像 Redux 一样,Mvvm 框架中也出现了许多与框架解耦的库,比如 Mobx、Immer、Dob 等,这些库需要一个中间层与框架衔接,比如 mobx-react、redux-box、dob-react。解耦让框架更专注 View 层,实现了库与框架灵活搭配的能力。

解耦的数据流框架也诠释了更高抽象级别的 Mvvm 架构,即:View - 前端框架,Model - (mobx, dob),ViewModel - (mobx-react, dob-react)。

同时也实现了数据与框架分离,便于测试与维护。比如下面的例子,左边是框架无关的纯数据/数据操作定义,右边是 View + ViewModel:

image

运行效率 - 脏检测 & getter/setter 劫持

Angluar 早期的脏检测机制虽然开创了 mvvm 先河,但监听效率比较低,需要 N + 1 次确认数据是否有联动变化,就像下图所示:

image

现在几乎所有框架都改为 getter/setter 劫持实现监听,任何数据的变化都可以在一个事件循环周期内完成:

image

语法 - 特殊语法 & 原生语法

早期一些 Mvvm 框架需要手动触发视图刷新,现在这种做法几乎都被原生赋值语句取代。

数据变更方式 - Mutable & Immutable

下图的代码语法虽为 mutable,但产生的结果可能是 mutable,也可能是 immutable,取决于 mvvm 框架内置实现机制:

image

Connect 的两种写法

由于 mvvm 支持了 mutable 与 immutable 两种写法,所以对于 mutable 的底层,我们使用左图的 connect 语法,对于 immutable 的底层,需要使用右图的 conenct 语法:

image

对左图而言,由于 mutable 驱动,所有数据改动会自动调用视图刷新,因此不但更新可以一步到位,而且可以数据全量注入,因为没用到的变量不会导致额外渲染。

对右图,由于 immutable 驱动,本身并没有主动驱动视图刷新能力,所以当右下角节点变更时,会在整条链路产生新的对象,通过 view 更新机制一层层传导到要更新的视图。

从 TFRP 到 mvvm

讲到 mvvm 的原理,先从 TFRP 说起,详细可以参考 dob-框架实现 这里以 dob 框架为例子,一步步介绍了如何实现 mvvm。本文简单做个介绍。

autorun & reaction

autorun 是 TFRP 的函数效果,即集成了依赖收集与监听,autorun 背后由 reaction 实现。

image

reaction 实现 autorun

如下图所示,autorun 是 subscription 套上 track 的 reaction,并且初始化时主动 dispatch,从入口(subscription)处激活循环,完成 subscription -> track -> 监听修改 -> subscription 完成闭环。

image

track 的实现

每个 track 在其执行期间会监听 callback 的 getter 事件,并将 target 与 properityKey 存储在二维 Map 中,当任何 getter 触发后,从这个二维表中查询依赖关系,即可找到对应的 callback 并执行。

image

View-Model 的实现

由于 autorun 与 view 的 render 函数很像,我们在 render 函数初始化执行时,使其包裹在 autorun 环境中,第 2 次 render 开始遍剥离外层的 autorun,保证只绑定一遍数据。

这样 view 层在原本 props 更新机制的基础上,增加了 autorun 的功能,实现修改任何数据自动更新对应 view 的效果。

image

Mvvm 的缺点与解法?

Mvvm 所有已知缺点几乎都有了解决方案。

无法监听新增属性

用过 Mobx 的同学都知道,给 store 添加一个不存在的属性,需要使用 extendObservable 这个方法。这个问题在 Dob 与 Mobx4.0 中都得到了解决,解决方法就是使用 proxy 替代 Object.defineProperty

image

异步问题

由于 getter/setter 无法获得当前执行函数,只能通过全局变量方式解决,因此 autorun 的 callback 函数不支持异步:

image

嵌套问题

由于 reaction 特性,只支持同步 callback 函数,因此 autorun 发生嵌套时,很可能会打乱依赖绑定的顺序。解决方案是将嵌套的 autorun 放到执行队列尾部,如下图所示:

image

无数据快照

mutable 最被人诟病的一点就是无法做数据快照,不能像 redux 一样做时间回溯。有问题自然有人会解决,Mobx 作者的 Immer 库完美的解决了问题。

image

原理是通过 proxy 返回代理对象,在内部通过浅拷贝替代对对象的 mutable 更改。具体原理可以参考我的这篇文章:精读 Immer.js 源码

image

无副作用隔离

mvvm 函数的 Action 由于支持异步,许多人会在 Action 中发请求,同时修改 store,这样就无法将请求副作用隔离到 store 之外。同时对 store 的 mutable 修改,本身也是一种副作用。

image

虽然可以将请求函数拆分到另一个 Action 中,但人为因素无法完全避免。

自从有了 Immer.js 之后,至少从支持元编程的角度来看,mutable 并不一定会产生副作用,它可以是零副作用的:

function inc(obj) {
  return produce(obj => obj.count++)
}

上面这种看似 mutable 的写法其实是零副作用的纯函数,和下面写法等价:

function inc(obj) {
  return {
    count: obj.count + 1,
    ...obj
  }
}

而对副作用的隔离,也可以做出类似 dva 的封装:

image

Mvvm store 组织形式

Mvvm 在项目中 stores 代码结构也千变万化,这里列出 4 种常见形式。

对象形式,代表框架 – mobx

mobx 开创了最基本的 mvvm store 组织形式,基本也是各内置 mvvm 框架的 store 组织形式。

image

Class + 注入,代表框架 – dob

dob 在 store 组织形式下了不少功夫,通过依赖注入增强了 store 之间的关联,实现 stores -> action 多对一的网状结构。

image

数据结构化,代表框架 – mobx-state-tree

mobx-state-tree 是典型结构化 store 组织的代表,这种组织形式适合一体化 app 开发,比如很多页面之间细粒度数据需要联动。

image

约定与集成,代表框架 – 类 dva

类 dva 是一种集成模式,是针对 redux 复杂的样板代码,思考形成的简化方案,自然集成与约定是简化的方向。

另外这种方案更像一层数据 dsl,得益于此,同一套代码可以拥有不同的底层实现。

image

Mvvm vs Reactive programming

Mvvm 与 Reactive programming 都拥有 observable 特性,通过下面两张图可以轻松区分:

image

上面红线是 mvvm 的 observable 部分,这里指的是数据变化的 autorun 动作。

image

上面红线是 Reactive programming 的 observable 部分,指的是数据源派发流的过程。

Mvvm 与 Reactive programming 的结合

既然 redux 可以与 rxjs 结合(redux-observable),那么 mvvm 应该也可以如此。

下面是这种方案的构想:

image

rxjs 仅用来隔离副作用与数据处理,mvvm 拥有修改 store 的能力,并且精准更新使用的 View。

总结

根据业务场景指定数据流方案,数据流方案没有银弹,只有贴着场景走,才能找到最合适的方案。

了解到 mvvm 的发展与演进,让不同数据流方案组合,你会发现,数据流方案还有很多。

一篇看懂 React Hooks

将之前对 React Hooks 的总结整理在一篇文章,带你从认识到使用 React Hooks。

什么是 React Hooks

React Hooks 是 React 16.7.0-alpha 版本推出的新特性,想尝试的同学安装此版本即可。

React Hooks 要解决的问题是状态共享,是继 render-propshigher-order components 之后的第三种状态共享方案,不会产生 JSX 嵌套地狱问题。

这个状态指的是状态逻辑,所以称为状态逻辑复用会更恰当,因为只共享数据处理逻辑,不会共享数据本身。

不久前精读分享过的一篇 Epitath 源码 - renderProps 新用法 就是解决 JSX 嵌套问题,有了 React Hooks 之后,这个问题就被官方正式解决了。

为了更快理解 React Hooks 是什么,先看笔者引用的下面一段 renderProps 代码:

function App() {
  return (
    <Toggle initial={false}>
      {({ on, toggle }) => (
        <Button type="primary" onClick={toggle}> Open Modal </Button>
        <Modal visible={on} onOk={toggle} onCancel={toggle} />
      )}
    </Toggle>
  )
}

恰巧,React Hooks 解决的也是这个问题:

function App() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <Button type="primary" onClick={() => setOpen(true)}>
        Open Modal
      </Button>
      <Modal
        visible={open}
        onOk={() => setOpen(false)}
        onCancel={() => setOpen(false)}
      />
    </>
  );
}

可以看到,React Hooks 就像一个内置的打平 renderProps 库,我们可以随时创建一个值,与修改这个值的方法。看上去像 function 形式的 setState,其实这等价于依赖注入,与使用 setState 相比,这个组件是没有状态的

React Hooks 的特点

React Hooks 带来的好处不仅是 “更 FP,更新粒度更细,代码更清晰”,还有如下三个特性:

  1. 多个状态不会产生嵌套,写法还是平铺的(renderProps 可以通过 compose 解决,可不但使用略为繁琐,而且因为强制封装一个新对象而增加了实体数量)。
  2. Hooks 可以引用其他 Hooks。
  3. 更容易将组件的 UI 与状态分离。

第二点展开说一下:Hooks 可以引用其他 Hooks,我们可以这么做:

import { useState, useEffect } from "react";

// 底层 Hooks, 返回布尔值:是否在线
function useFriendStatusBoolean(friendID) {
  const [isOnline, setIsOnline] = useState(null);

  function handleStatusChange(status) {
    setIsOnline(status.isOnline);
  }

  useEffect(() => {
    ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
    };
  });

  return isOnline;
}

// 上层 Hooks,根据在线状态返回字符串:Loading... or Online or Offline
function useFriendStatusString(props) {
  const isOnline = useFriendStatusBoolean(props.friend.id);

  if (isOnline === null) {
    return "Loading...";
  }
  return isOnline ? "Online" : "Offline";
}

// 使用了底层 Hooks 的 UI
function FriendListItem(props) {
  const isOnline = useFriendStatusBoolean(props.friend.id);

  return (
    <li style={{ color: isOnline ? "green" : "black" }}>{props.friend.name}</li>
  );
}

// 使用了上层 Hooks 的 UI
function FriendListStatus(props) {
  const statu = useFriendStatusString(props.friend.id);

  return <li>{statu}</li>;
}

这个例子中,有两个 Hooks:useFriendStatusBooleanuseFriendStatusString, useFriendStatusString 是利用 useFriendStatusBoolean 生成的新 Hook,这两个 Hook 可以给不同的 UI:FriendListItemFriendListStatus 使用,而因为两个 Hooks 数据是联动的,因此两个 UI 的状态也是联动的。

顺带一提,这个例子也可以用来理解 对 React Hooks 的一些思考 一文的那句话:“有状态的组件没有渲染,有渲染的组件没有状态”

  • useFriendStatusBooleanuseFriendStatusString 是有状态的组件(使用 useState),没有渲染(返回非 UI 的值),这样就可以作为 Custom Hooks 被任何 UI 组件调用。
  • FriendListItemFriendListStatus 是有渲染的组件(返回了 JSX),没有状态(没有使用 useState),这就是一个纯函数 UI 组件,

利用 useState 创建 Redux

Redux 的精髓就是 Reducer,而利用 React Hooks 可以轻松创建一个 Redux 机制:

// 这就是 Redux
function useReducer(reducer, initialState) {
  const [state, setState] = useState(initialState);

  function dispatch(action) {
    const nextState = reducer(state, action);
    setState(nextState);
  }

  return [state, dispatch];
}

这个自定义 Hook 的 value 部分当作 redux 的 state,setValue 部分当作 redux 的 dispatch,合起来就是一个 redux。而 react-redux 的 connect 部分做的事情与 Hook 调用一样:

// 一个 Action
function useTodos() {
  const [todos, dispatch] = useReducer(todosReducer, []);

  function handleAddClick(text) {
    dispatch({ type: "add", text });
  }

  return [todos, { handleAddClick }];
}

// 绑定 Todos 的 UI
function TodosUI() {
  const [todos, actions] = useTodos();
  return (
    <>
      {todos.map((todo, index) => (
        <div>{todo.text}</div>
      ))}
      <button onClick={actions.handleAddClick}>Add Todo</button>
    </>
  );
}

useReducer 已经作为一个内置 Hooks 了,在这里可以查阅所有 内置 Hooks

不过这里需要注意的是,每次 useReducer 或者自己的 Custom Hooks 都不会持久化数据,所以比如我们创建两个 App,App1 与 App2:

function App1() {
  const [todos, actions] = useTodos();

  return <span>todo count: {todos.length}</span>;
}

function App2() {
  const [todos, actions] = useTodos();

  return <span>todo count: {todos.length}</span>;
}

function All() {
  return (
    <>
      <App1 />
      <App2 />
    </>
  );
}

这两个实例同时渲染时,并不是共享一个 todos 列表,而是分别存在两个独立 todos 列表。也就是 React Hooks 只提供状态处理方法,不会持久化状态。

如果要真正实现一个 Redux 功能,也就是全局维持一个状态,任何组件 useReducer 都会访问到同一份数据,可以和 useContext 一起使用。

大体思路是利用 useContext 共享一份数据,作为 Custom Hooks 的数据源。具体实现可以参考 redux-react-hook

利用 useEffect 代替一些生命周期

在 useState 位置附近,可以使用 useEffect 处理副作用:

useEffect(() => {
  const subscription = props.source.subscribe();
  return () => {
    // Clean up the subscription
    subscription.unsubscribe();
  };
});

useEffect 的代码既会在初始化时候执行,也会在后续每次 rerender 时执行,而返回值在析构时执行。这个更多带来的是便利,对比一下 React 版 G2 调用流程:

class Component extends React.PureComponent<Props, State> {
  private chart: G2.Chart = null;
  private rootDomRef: React.ReactInstance = null;

  componentDidMount() {
    this.rootDom = ReactDOM.findDOMNode(this.rootDomRef) as HTMLDivElement;

    this.chart = new G2.Chart({
      container: document.getElementById("chart"),
      forceFit: true,
      height: 300
    });
    this.freshChart(this.props);
  }

  componentWillReceiveProps(nextProps: Props) {
    this.freshChart(nextProps);
  }

  componentWillUnmount() {
    this.chart.destroy();
  }

  freshChart(props: Props) {
    // do something
    this.chart.render();
  }

  render() {
    return <div ref={ref => (this.rootDomRef = ref)} />;
  }
}

用 React Hooks 可以这么做:

function App() {
  const ref = React.useRef(null);
  let chart: G2.Chart = null;

  React.useEffect(() => {
    if (!chart) {
      chart = new G2.Chart({
        container: ReactDOM.findDOMNode(ref.current) as HTMLDivElement,
        width: 500,
        height: 500
      });
    }

    // do something
    chart.render();

    return () => chart.destroy();
  });

  return <div ref={ref} />;
}

可以看到将细碎的代码片段结合成了一个完整的代码块,更维护。

现在介绍了 useState useContext useEffect useRef 等常用 hooks,更多可以查阅:内置 Hooks,相信不久的未来,这些 API 又会成为一套新的前端规范。

React Hooks 将带来什么变化

Hooks 带来的约定

Hook 函数必须以 "use" 命名开头,因为这样才方便 eslint 做检查,防止用 condition 判断包裹 useHook 语句。

为什么不能用 condition 包裹 useHook 语句,详情可以见 官方文档,这里简单介绍一下。

React Hooks 并不是通过 Proxy 或者 getters 实现的(具体可以看这篇文章 React hooks: not magic, just arrays),而是通过数组实现的,每次 useState 都会改变下标,如果 useState 被包裹在 condition 中,那每次执行的下标就可能对不上,导致 useState 导出的 setter 更新错数据。

虽然有 eslint-plugin-react-hooks 插件保驾护航,但这第一次将 “约定优先” 理念引入了 React 框架中,带来了前所未有的代码命名和顺序限制(函数命名遭到官方限制,JS 自由主义者也许会暴跳如雷),但带来的便利也是前所未有的(没有比 React Hooks 更好的状态共享方案了,约定带来提效,自由的代价就是回到 renderProps or HOC,各团队可以自行评估)。

笔者认为,React Hooks 的诞生,也许来自于这个灵感:“不如通过增加一些约定,彻底解决状态共享问题吧!”

React 约定大于配置脚手架 nextjs umi 以及笔者的 pri 都通过有 “约定路由” 的功能,大大降低了路由配置复杂度,那么 React Hooks 就像代码级别的约定,大大降低了代码复杂度。

状态与 UI 的界限会越来越清晰

因为 React Hooks 的特性,如果一个 Hook 不产生 UI,那么它可以永远被其他 Hook 封装,虽然允许有副作用,但是被包裹在 useEffect 里,总体来说还是挺函数式的。而 Hooks 要集中在 UI 函数顶部写,也很容易养成书写无状态 UI 组件的好习惯,践行 “状态与 UI 分开” 这个理念会更容易。

不过这个理念稍微有点蹩脚的地方,那就是 “状态” 到底是什么。

function App() {
  const [count, setCount] = useCount();
  return <span>{count}</span>;
}

我们知道 useCount 算是无状态的,因为 React Hooks 本质就是 renderProps 或者 HOC 的另一种写法,换成 renderProps 就好理解了:

<Count>{(count, setCount) => <App count={count} setCount={setCount} />}</Count>;

function App(props) {
  return <span>{props.count}</span>;
}

可以看到 App 组件是无状态的,输出完全由输入(Props)决定。

那么有状态无 UI 的组件就是 useCount 了:

function useCount() {
  const [count, setCount] = useState(0);
  return [count, setCount];
}

有状态的地方应该指 useState(0) 这句,不过这句和无状态 UI 组件 App 的 useCount() 很像,既然 React 把 useCount 成为自定义 Hook,那么 useState 就是官方 Hook,具有一样的定义,因此可以认为 useCount 是无状态的,useState 也是一层 renderProps,最终的状态其实是 useState 这个 React 内置的组件。

我们看 renderProps 嵌套的表达:

<UseState>
  {(count, setCount) => (
    <UseCount>
      {" "}
      {/**虽然是透传,但给 count 做了去重,不可谓没有作用 */}
      {(count, setCount) => <App count={count} setCount={setCount} />}
    </UseCount>
  )}
</UseState>

能确定的是,App 一定有 UI,而上面两层父级组件一定没有 UI。为了最佳实践,我们尽量避免 App 自己维护状态,而其父级的 RenderProps 组件可以维护状态(也可以不维护状态,做个二传手)。因此可以考虑在 “有状态的组件没有渲染,有渲染的组件没有状态” 这句话后面加一句:没渲染的组件也可以没状态。

React Hooks 实践

通过上面的理解,你已经对 React Hooks 有了基本理解,也许你也看了 React Hooks 基本实现剖析(就是数组),但理解实现原理就可以用好了吗?学的是知识,而用的是技能,看别人的用法就像刷抖音一样(哇,饭还可以这样吃?),你总会有新的收获。

首先,站在使用角度,要理解 React Hooks 的特点是 “非常方便的 Connect 一切”,所以无论是数据流、Network,或者是定时器都可以监听,有一点 RXJS 的意味,也就是你可以利用 React Hooks,将 React 组件打造成:任何事物的变化都是输入源,当这些源变化时会重新触发 React 组件的 render,你只需要挑选组件绑定哪些数据源(use 哪些 Hooks),然后只管写 render 函数就行了!

DOM 副作用修改 / 监听

做一个网页,总有一些看上去和组件关系不大的麻烦事,比如修改页面标题(切换页面记得改成默认标题)、监听页面大小变化(组件销毁记得取消监听)、断网时提示(一层层装饰器要堆成小山了)。而 React Hooks 特别擅长做这些事,造这种轮子,大小皆宜。

由于 React Hooks 降低了高阶组件使用成本,那么一套生命周期才能完成的 “杂耍” 将变得非常简单。

下面举几个例子:

修改页面 title

效果:在组件里调用 useDocumentTitle 函数即可设置页面标题,且切换页面时,页面标题重置为默认标题 “前端精读”。

useDocumentTitle("个人中心");

实现:直接用 document.title 赋值,不能再简单。在销毁时再次给一个默认标题即可,这个简单的函数可以抽象在项目工具函数里,每个页面组件都需要调用。

function useDocumentTitle(title) {
  useEffect(
    () => {
      document.title = title;
      return () => (document.title = "前端精读");
    },
    [title]
  );
}

在线 Demo

监听页面大小变化,网络是否断开

效果:在组件调用 useWindowSize 时,可以拿到页面大小,并且在浏览器缩放时自动触发组件更新。

const windowSize = useWindowSize();
return <div>页面高度:{windowSize.innerWidth}</div>;

实现:和标题思路基本一致,这次从 window.innerHeight 等 API 直接拿到页面宽高即可,注意此时可以用 window.addEventListener('resize') 监听页面大小变化,此时调用 setValue 将会触发调用自身的 UI 组件 rerender,就是这么简单!

最后注意在销毁时,removeEventListener 注销监听。

function getSize() {
  return {
    innerHeight: window.innerHeight,
    innerWidth: window.innerWidth,
    outerHeight: window.outerHeight,
    outerWidth: window.outerWidth
  };
}

function useWindowSize() {
  let [windowSize, setWindowSize] = useState(getSize());

  function handleResize() {
    setWindowSize(getSize());
  }

  useEffect(() => {
    window.addEventListener("resize", handleResize);
    return () => {
      window.removeEventListener("resize", handleResize);
    };
  }, []);

  return windowSize;
}

在线 Demo

动态注入 css

效果:在页面注入一段 class,并且当组件销毁时,移除这个 class。

const className = useCss({
  color: "red"
});

return <div className={className}>Text.</div>;

实现:可以看到,Hooks 方便的地方是在组件销毁时移除副作用,所以我们可以安心的利用 Hooks 做一些副作用。注入 css 自然不必说了,而销毁 css 只要找到注入的那段引用进行销毁即可,具体可以看这个 代码片段

DOM 副作用修改 / 监听场景有一些现成的库了,从名字上就能看出来用法:document-visibilitynetwork-statusonline-statuswindow-scroll-positionwindow-sizedocument-title

组件辅助

Hooks 还可以增强组件能力,比如拿到并监听组件运行时宽高等。

获取组件宽高

效果:通过调用 useComponentSize 拿到某个组件 ref 实例的宽高,并且在宽高变化时,rerender 并拿到最新的宽高。

const ref = useRef(null);
let componentSize = useComponentSize(ref);

return (
  <>
    {componentSize.width}
    <textArea ref={ref} />
  </>
);

实现:和 DOM 监听类似,这次换成了利用 ResizeObserver 对组件 ref 进行监听,同时在组件销毁时,销毁监听。

其本质还是监听一些副作用,但通过 ref 的传递,我们可以对组件粒度进行监听和操作了。

useLayoutEffect(() => {
  handleResize();

  let resizeObserver = new ResizeObserver(() => handleResize());
  resizeObserver.observe(ref.current);

  return () => {
    resizeObserver.disconnect(ref.current);
    resizeObserver = null;
  };
}, []);

在线 Demo,对应组件 component-size

拿到组件 onChange 抛出的值

效果:通过 useInputValue() 拿到 Input 框当前用户输入的值,而不是手动监听 onChange 再腾一个 otherInputValue 和一个回调函数把这一堆逻辑写在无关的地方。

let name = useInputValue("Jamie");
// name = { value: 'Jamie', onChange: [Function] }
return <input {...name} />;

可以看到,这样不仅没有占用组件自己的 state,也不需要手写 onChange 回调函数进行处理,这些处理都压缩成了一行 use hook。

实现:读到这里应该大致可以猜到了,利用 useState 存储组件的值,并抛出 valueonChange,监听 onChange 并通过 setValue 修改 value, 就可以在每次 onChange 时触发调用组件的 rerender 了。

function useInputValue(initialValue) {
  let [value, setValue] = useState(initialValue);
  let onChange = useCallback(function(event) {
    setValue(event.currentTarget.value);
  }, []);

  return {
    value,
    onChange
  };
}

这里要注意的是,我们对组件增强时,组件的回调一般不需要销毁监听,而且仅需监听一次,这与 DOM 监听不同,因此大部分场景,我们需要利用 useCallback 包裹,并传一个空数组,来保证永远只监听一次,而且不需要在组件销毁时注销这个 callback。

在线 Demo,对应组件 input-value

做动画

利用 React Hooks 做动画,一般是拿到一些具有弹性变化的值,我们可以将值赋给进度条之类的组件,这样其进度变化就符合某种动画曲线。

在某个时间段内获取 0-1 之间的值

这个是动画最基本的概念,某个时间内拿到一个线性增长的值。

效果:通过 useRaf(t) 拿到 t 毫秒内不断刷新的 0-1 之间的数字,期间组件会不断刷新,但刷新频率由 requestAnimationFrame 控制(不会卡顿 UI)。

const value = useRaf(1000);

实现:写起来比较冗长,这里简单描述一下。利用 requestAnimationFrame 在给定时间内给出 0-1 之间的值,那每次刷新时,只要判断当前刷新的时间点占总时间的比例是多少,然后做分母,分子是 1 即可。

在线 Demo,对应组件 use-raf

弹性动画

效果:通过 useSpring 拿到动画值,组件以固定频率刷新,而这个动画值以弹性函数进行增减。

实际调用方式一般是,先通过 useState 拿到一个值,再通过动画函数包住这个值,这样组件就会从原本的刷新一次,变成刷新 N 次,拿到的值也随着动画函数的规则变化,最后这个值会稳定到最终的输入值(如例子中的 50)。

const [target, setTarget] = useState(50);
const value = useSpring(target);

return <div onClick={() => setTarget(100)}>{value}</div>;

实现:为了实现动画效果,需要依赖 rebound 库,它可以实现将一个目标值拆解为符合弹性动画函数过程的功能,那我们需要利用 React Hooks 做的就是在第一次接收到目标值是,调用 spring.setEndValue 来触发动画事件,并在 useEffect 里做一次性监听,再值变时重新 setValue 即可。

最神奇的 setTarget 联动 useSpring 重新计算弹性动画部分,是通过 useEffect 第二个参数实现的:

useEffect(
  () => {
    if (spring) {
      spring.setEndValue(targetValue);
    }
  },
  [targetValue]
);

也就是当目标值变化后,才会进行新的一轮 rerender,所以 useSpring 并不需要监听调用处的 setTarget,它只需要监听 target 的变化即可,而巧妙利用 useEffect 的第二个参数可以事半功倍。

在线 Demo

Tween 动画

明白了弹性动画原理,Tween 动画就更简单了。

效果:通过 useTween 拿到一个从 0 变化到 1 的值,这个值的动画曲线是 tween。可以看到,由于取值范围是固定的,所以我们不需要给初始值了。

const value = useTween();

实现:通过 useRaf 拿到一个线性增长的值(区间也是 0 ~ 1),再通过 easing 库将其映射到 0 ~ 1 到值即可。这里用到了 hook 调用 hook 的联动(通过 useRaf 驱动 useTween),还可以在其他地方举一反三。

const fn: Easing = easing[easingName];
const t = useRaf(ms, delay);

return fn(t);

发请求

利用 Hooks,可以将任意请求 Promise 封装为带有标准状态的对象:loading、error、result。

通用 Http 封装

效果:通过 useAsync 将一个 Promise 拆解为 loading、error、result 三个对象。

const { loading, error, result } = useAsync(fetchUser, [id]);

实现:在 Promise 的初期设置 loading,结束后设置 result,如果出错则设置 error,这里可以将请求对象包装成 useAsyncState 来处理,这里就不放出来了。

export function useAsync(asyncFunction) {
  const asyncState = useAsyncState(options);

  useEffect(() => {
    const promise = asyncFunction();
    asyncState.setLoading();
    promise.then(
      result => asyncState.setResult(result);,
      error => asyncState.setError(error);
    );
  }, params);
}

具体代码可以参考 react-async-hook,这个功能建议仅了解原理,具体实现因为有一些边界情况需要考虑,比如组件 isMounted 后才能相应请求结果。

Request Service

业务层一般会抽象一个 request service 做统一取数的抽象(比如统一 url,或者可以统一换 socket 实现等等)。假如以前比较 low 的做法是:

async componentDidMount() {
  // setState: 改 isLoading state
  try {
    const data = await fetchUser()
    // setState: 改 isLoading、error、data
  } catch (error) {
    // setState: 改 isLoading、error
  }
}

后来把请求放在 redux 里,通过 connect 注入的方式会稍微有些改观:

@Connect(...)
class App extends React.PureComponent {
  public componentDidMount() {
    this.props.fetchUser()
  }

  public render() {
    // this.props.userData.isLoading | error | data
  }
}

最后会发现还是 Hooks 简洁明了:

function App() {
  const { isLoading, error, data } = useFetchUser();
}

useFetchUser 利用上面封装的 useAsync 可以很容易编写:

const fetchUser = id =>
  fetch(`xxx`).then(result => {
    if (result.status !== 200) {
      throw new Error("bad status = " + result.status);
    }
    return result.json();
  });

function useFetchUser(id) {
  const asyncFetchUser = useAsync(fetchUser, id);
  return asyncUser;
}

填表单

React Hooks 特别适合做表单,尤其是 antd form 如果支持 Hooks 版,那用起来会方便许多:

function App() {
  const { getFieldDecorator } = useAntdForm();

  return (
    <Form onSubmit={this.handleSubmit} className="login-form">
      <FormItem>
        {getFieldDecorator("userName", {
          rules: [{ required: true, message: "Please input your username!" }]
        })(
          <Input
            prefix={<Icon type="user" style={{ color: "rgba(0,0,0,.25)" }} />}
            placeholder="Username"
          />
        )}
      </FormItem>
      <FormItem>
        <Button type="primary" htmlType="submit" className="login-form-button">
          Log in
        </Button>
        Or <a href="">register now!</a>
      </FormItem>
    </Form>
  );
}

不过虽然如此,getFieldDecorator 还是基于 RenderProps 思路的,彻底的 Hooks 思路是利用之前说的 组件辅助方式,提供一个组件方法集,用解构方式传给组件

Hooks 思维的表单组件

效果:通过 useFormState 拿到表单值,并且提供一系列 组件辅助 方法控制组件状态。

const [formState, { text, password }] = useFormState();
return (
  <form>
    <input {...text("username")} required />
    <input {...password("password")} required minLength={8} />
  </form>
);

上面可以通过 formState 随时拿到表单值,和一些校验信息,通过 password("pwd") 传给 input 组件,让这个组件达到受控状态,且输入类型是 password 类型,表单 key 是 pwd。而且可以看到使用的 form 是原生标签,这种表单增强是相当解耦的。

实现:仔细观察一下结构,不难发现,我们只要结合 组件辅助 小节说的 “拿到组件 onChange 抛出的值” 一节的思路,就能轻松理解 textpassword 是如何作用于 input 组件,并拿到其输入状态

往简单的来说,只要把这些状态 Merge 起来,通过 useReducer 聚合到 formState 就可以实现了。

为了简化,我们只考虑对 input 的增强,源码仅需 30 几行:

export function useFormState(initialState) {
  const [state, setState] = useReducer(stateReducer, initialState || {});

  const createPropsGetter = type => (name, ownValue) => {
    const hasOwnValue = !!ownValue;
    const hasValueInState = state[name] !== undefined;

    function setInitialValue() {
      let value = "";
      setState({ [name]: value });
    }

    const inputProps = {
      name, // 给 input 添加 type: text or password
      get value() {
        if (!hasValueInState) {
          setInitialValue(); // 给初始化值
        }
        return hasValueInState ? state[name] : ""; // 赋值
      },
      onChange(e) {
        let { value } = e.target;
        setState({ [name]: value }); // 修改对应 Key 的值
      }
    };

    return inputProps;
  };

  const inputPropsCreators = ["text", "password"].reduce(
    (methods, type) => ({ ...methods, [type]: createPropsGetter(type) }),
    {}
  );

  return [
    { values: state }, // formState
    inputPropsCreators
  ];
}

上面 30 行代码实现了对 input 标签类型的设置,监听 value onChange,最终聚合到大的 values 作为 formState 返回。读到这里应该发现对 React Hooks 的应用都是万变不离其宗的,特别是对组件信息的获取,通过解构方式来做,Hooks 内部再做一下聚合,就完成表单组件基本功能了。

实际上一个完整的轮子还需要考虑 checkbox radio 的兼容,以及校验问题,这些思路大同小异,具体源码可以看 react-use-form-state

模拟生命周期

有的时候 React15 的 API 还是挺有用的,利用 React Hooks 几乎可以模拟出全套。

componentDidMount

效果:通过 useMount 拿到 mount 周期才执行的回调函数。

useMount(() => {
  // quite similar to `componentDidMount`
});

实现:componentDidMount 等价于 useEffect 的回调(仅执行一次时),因此直接把回调函数抛出来即可。

useEffect(() => void fn(), []);

componentWillUnmount

效果:通过 useUnmount 拿到 unmount 周期才执行的回调函数。

useUnmount(() => {
  // quite similar to `componentWillUnmount`
});

实现:componentWillUnmount 等价于 useEffect 的回调函数返回值(仅执行一次时),因此直接把回调函数返回值抛出来即可。

useEffect(() => fn, []);

componentDidUpdate

效果:通过 useUpdate 拿到 didUpdate 周期才执行的回调函数。

useUpdate(() => {
  // quite similar to `componentDidUpdate`
});

实现:componentDidUpdate 等价于 useMount 的逻辑每次执行,除了初始化第一次。因此采用 mouting flag(判断初始状态)+ 不加限制参数确保每次 rerender 都会执行即可。

const mounting = useRef(true);
useEffect(() => {
  if (mounting.current) {
    mounting.current = false;
  } else {
    fn();
  }
});

Force Update

效果:这个最有意思了,我希望拿到一个函数 update,每次调用就强制刷新当前组件。

const update = useUpdate();

实现:我们知道 useState 下标为 1 的项是用来更新数据的,而且就算数据没有变化,调用了也会刷新组件,所以我们可以把返回一个没有修改数值的 setValue,这样它的功能就仅剩下刷新组件了。

const useUpdate = () => useState(0)[1];

对于 getSnapshotBeforeUpdate, getDerivedStateFromError, componentDidCatch 目前 Hooks 是无法模拟的。

isMounted

很久以前 React 是提供过这个 API 的,后来移除了,原因是可以通过 componentWillMountcomponentWillUnmount 推导。自从有了 React Hooks,支持 isMount 简直是分分钟的事。

效果:通过 useIsMounted 拿到 isMounted 状态。

const isMounted = useIsMounted();

实现:看到这里的话,应该已经很熟悉这个套路了,useEffect 第一次调用时赋值为 true,组件销毁时返回 false,注意这里可以加第二个参数为空数组来优化性能。

const [isMount, setIsMount] = useState(false);
useEffect(() => {
  if (!isMount) {
    setIsMount(true);
  }
  return () => setIsMount(false);
}, []);
return isMount;

在线 Demo

存数据

上一篇提到过 React Hooks 内置的 useReducer 可以模拟 Redux 的 reducer 行为,那唯一需要补充的就是将数据持久化。我们考虑最小实现,也就是全局 Store + Provider 部分。

全局 Store

效果:通过 createStore 创建一个全局 Store,再通过 StoreProviderstore 注入到子组件的 context 中,最终通过两个 Hooks 进行获取与操作:useStoreuseAction

const store = createStore({
  user: {
    name: "小明",
    setName: (state, payload) => {
      state.name = payload;
    }
  }
});

const App = () => (
  <StoreProvider store={store}>
    <YourApp />
  </StoreProvider>
);

function YourApp() {
  const userName = useStore(state => state.user.name);
  const setName = userAction(dispatch => dispatch.user.setName);
}

实现:这个例子的实现可以单独拎出一篇文章了,所以笔者从存数据的角度剖析一下 StoreProvider 的实现。

对,Hooks 并不解决 Provider 的问题,所以全局状态必须有 Provider,但这个 Provider 可以利用 React 内置的 createContext 简单搞定:

const StoreContext = createContext();

const StoreProvider = ({ children, store }) => (
  <StoreContext.Provider value={store}>{children}</StoreContext.Provider>
);

剩下就是 useStore 怎么取到持久化 Store 的问题了,这里利用 useContext 和刚才创建的 Context 对象:

const store = useContext(StoreContext);
return store;

更多源码可以参考 easy-peasy,这个库基于 redux 编写,提供了一套 Hooks API。

封装原有库

是不是 React Hooks 出现后,所有的库都要重写一次?当然不是,我们看看其他库如何做改造。

RenderProps to Hooks

这里拿 react-powerplug 举例。

比如有一个 renderProps 库,希望改造成 Hooks 的用法:

import { Toggle } from 'react-powerplug'

function App() {
  return (
    <Toggle initial={true}>
      {({ on, toggle }) => (
        <Checkbox checked={on} onChange={toggle} />
      )}
    </Toggle>
  )
}
     
import { useToggle } from 'react-powerhooks'

function App() {
  const [on, toggle] = useToggle()
  return <Checkbox checked={on} onChange={toggle} />
}

效果:假如我是 react-powerplug 的维护者,怎么样最小成本支持 React Hook? 说实话这个没办法一步做到,但可以通过两步实现。

export function Toggle() {
  // 这是 Toggle 的源码
  // balabalabala..
}

const App = wrap(() => {
  // 第一步:包 wrap
  const [on, toggle] = useRenderProps(Toggle); // 第二步:包 useRenderProps
});

实现:首先解释一下为什么要包两层,首先 Hooks 必须遵循 React 的规范,我们必须写一个 useRenderProps 函数以符合 Hooks 的格式,**那问题是如何拿到 Toggle 给 render 的 ontoggle?**正常方式应该拿不到,所以退而求其次,将 useRenderProps 拿到的 Toggle 传给 wrapwrap 构造 RenderProps 执行环境拿到 ontoggle 后,调用 useRenderProps 内部的 setArgs 函数,让 const [on, toggle] = useRenderProps(Toggle) 实现曲线救国。

const wrappers = []; // 全局存储 wrappers

export const useRenderProps = (WrapperComponent, wrapperProps) => {
  const [args, setArgs] = useState([]);
  const ref = useRef({});
  if (!ref.current.initialized) {
    wrappers.push({
      WrapperComponent,
      wrapperProps,
      setArgs
    });
  }
  useEffect(() => {
    ref.current.initialized = true;
  }, []);
  return args; // 通过下面 wrap 调用 setArgs 获取值。
};

由于 useRenderProps 会先于 wrap 执行,所以 wrappers 会先拿到 Toggle,wrap 执行时直接调用 wrappers.pop() 即可拿到 Toggle 对象。然后构造出 RenderProps 的执行环境即可:

export const wrap = FunctionComponent => props => {
  const element = FunctionComponent(props);
  const ref = useRef({ wrapper: wrappers.pop() }); // 拿到 useRenderProps 提供的 Toggle
  const { WrapperComponent, wrapperProps } = ref.current.wrapper;
  return createElement(WrapperComponent, wrapperProps, (...args) => {
    // WrapperComponent => Toggle,这一步是在构造 RenderProps 执行环境
    if (!ref.current.processed) {
      ref.current.wrapper.setArgs(args); // 拿到 on、toggle 后,通过 setArgs 传给上面的 args。
      ref.current.processed = true;
    } else {
      ref.current.processed = false;
    }
    return element;
  });
};

以上实现方案参考 react-hooks-render-props,有需求要可以拿过来直接用,不过实现思路可以参考,作者的脑洞挺大。

Hooks to RenderProps

好吧,如果希望 Hooks 支持 RenderProps,那一定是希望同时支持这两套语法。

效果:一套代码同时支持 Hooks 和 RenderProps。

实现:其实 Hooks 封装为 RenderProps 最方便,因此我们使用 Hooks 写核心的代码,假设我们写一个最简单的 Toggle

const useToggle = initialValue => {
  const [on, setOn] = useState(initialValue);
  return {
    on,
    toggle: () => setOn(!on)
  };
};

在线 Demo

然后通过 render-props 这个库可以轻松封装出 RenderProps 组件:

const Toggle = ({ initialValue, children, render = children }) =>
  renderProps(render, useToggle(initialValue));

在线 Demo

其实 renderProps 这个组件的第二个参数,在 Class 形式 React 组件时,接收的是 this.state,现在我们改成 useToggle 返回的对象,也可以理解为 state,利用 Hooks 机制驱动 Toggle 组件 rerender,从而让子组件 rerender。

封装原本对 setState 增强的库

Hooks 也特别适合封装原本就作用于 setState 的库,比如 immer

useState 虽然不是 setState,但却可以理解为控制高阶组件的 setState,我们完全可以封装一个自定义的 useState,然后内置对 setState 的优化。

比如 immer 的语法是通过 produce 包装,将 mutable 代码通过 Proxy 代理为 immutable:

const nextState = produce(baseState, draftState => {
  draftState.push({ todo: "Tweet about it" });
  draftState[1].done = true;
});

那这个 produce 就可以通过封装一个 useImmer 来隐藏掉:

function useImmer(initialValue) {
  const [val, updateValue] = React.useState(initialValue);
  return [
    val,
    updater => {
      updateValue(produce(updater));
    }
  ];
}

使用方式:

const [value, setValue] = useImmer({ a: 1 });

value(obj => (obj.a = 2)); // immutable

总结

把 React Hooks 当作更便捷的 RenderProps 去用吧,虽然写法看上去是内部维护了一个状态,但其实等价于注入、Connect、HOC、或者 renderProps,那么如此一来,使用 renderProps 的门槛会大大降低,因为 Hooks 用起来实在是太方便了,我们可以抽象大量 Custom Hooks,让代码更加 FP,同时也不会增加嵌套层级。

那么看了这么多使用实例,你准备怎么用呢?

精读 js 模块化发展

1 引言

如今,Javascript 模块化规范非常方便、自然,但这个新规范仅执行了2年,就在 4 年前,js 的模块化还停留在运行时支持,10 年前,通过后端模版定义、注释定义模块依赖。对经历过来的人来说,历史的模块化方式还停留在脑海中,反而新上手的同学会更快接受现代的模块化规范。

但为什么要了解 Javascript 模块化发展的历史呢?因为凡事都有两面性,了解 Javascript 模块化规范,有利于我们思考出更好的模块化方案,纵观历史,从 1999 年开始,模块化方案最多维持两年,就出现了新的替代方案,比原有的模块化更清晰、强壮,我们不能被现代模块化方式限制住思维,因为现在的 ES2015 模块化方案距离发布也仅仅过了两年。

2 内容概要

直接定义依赖 (1999): 由于当时 js 文件非常简单,模块化方式非常简单粗暴 —— 通过全局方法定义、引用模块。这种定义方式与现在的 commonjs 非常神似,区别是 commonjs 以文件作为模块,而这种方法可以在任何文件中定义模块,模块不与文件关联。

闭包模块化模式 (2003): 用闭包方式解决了变量污染问题,闭包内返回模块对象,只需对外暴露一个全局变量。

模版依赖定义 (2006): 这时候开始流行后端模版语法,通过后端语法聚合 js 文件,从而实现依赖加载,说实话,现在 go 语言等模版语法也很流行这种方式,写后端代码的时候不觉得,回头看看,还是挂在可维护性上。

注释依赖定义 (2006): 几乎和模版依赖定义同时出现,与 1999 年方案不同的,不仅仅是模块定义方式,而是终于以文件为单位定义模块了,通过 lazyjs 加载文件,同时读取文件注释,继续递归加载剩下的文件。

外部依赖定义 (2007): 这种定义方式在 cocos2d-js 开发中普遍使用,其核心**是将依赖抽出单独文件定义,这种方式不利于项目管理,毕竟依赖抽到代码之外,我是不是得两头找呢?所以才有通过 webpack 打包为一个文件的方式暴力替换为 CSS CommonJS 的方式出现。

Sandbox模式 (2009): 这种模块化方式很简单,暴力,将所有模块塞到一个 sanbox 变量中,硬伤是无法解决明明冲突问题,毕竟都塞到一个 sandbox 对象里,而 Sandbox 对象也需要定义在全局,存在被覆盖的风险。模块化需要保证全局变量尽量干净,目前为止的模块化方案都没有很好的做到这一点。

依赖注入 (2009): 就是大家熟知的 angular1.0,依赖注入的**现在已广泛运用在 react、vue 等流行框架中。但依赖注入和解决模块化问题还差得远。

CommonJS (2009): 真正解决模块化问题,从 node 端逐渐发力到前端,前端需要使用构建工具模拟。

Amd (2009): 都是同一时期的产物,这个方案主要解决前端动态加载依赖,相比 CSS CommonJS,体积更小,按需加载。

Umd (2011): 兼容了 CommonJS 与 Amd,其核心**是,如果在 CommonJs 环境(存在 module.exports,不存在 define),将函数执行结果交给 module.exports 实现 CommonJs,否则用 Amd 环境的 define,实现 Amd。

Labeled Modules (2012): 和 CommonJs 很像了,没什么硬伤,但生不逢时,碰上 CommonJs 与 Amd,那只有被人遗忘的份了。

YModules (2013): 既然都出了 CommonJs Amd,文章还列出了此方案,一定有其独到之处。其核心**在于使用 provide 取代 return,可以控制模块结束时机,处理异步结果;拿到第二个参数 module,修改其他模块的定义(虽然很有拓展性,但用在项目里是个搅屎棍)。

ES2015 Modules (2015): 就是我们现在的模块化方案,还没有被浏览器实现,大部分项目已通过 babeltypescript 提前体验。

3 精读

本次提出独到观点的同学有:流形黄子毅苏里约camsong杨森淡苍留影,精读由此归纳。

从语言层面到文件层面的模块化

从 1999 年开始,模块化探索都是基于语言层面的优化,真正的革命从 2009 年 CommonJS 的引入开始,前端开始大量使用预编译。

这篇文章所提供的模块化历史的方案都是逻辑模块化,从 CommonJS 方案开始前端把服务端的解决方案搬过来之后,算是看到标准物理与逻辑统一的模块化。但之后前端工程不得不引入模块化构建这一步。正是这一步给前端开发无疑带来了诸多的不便,尤其是现在我们开发过程中经常为了优化这个工具带了很多额外的成本。

从 CommonJS 之前其实都只是封装,并没有一套模块化规范,这个就有些像类与包的概念。我在10年左右用的最多的还是 YUI2,YUI2 是用 namespace 来做模块化的,但有很多问题没有解决,比如多版本共存,因此后来 YUI3 出来了。

YUI().use('node', 'event', function (Y) {
    // The Node and Event modules are loaded and ready to use.
    // Your code goes here!
});

YUI3 的 sandbox 像极了差不多同时出现的 AMD 规范,但早期 yahoo 在前端圈的影响力还是很大的,而 requirejs 到 2011 年才诞生,因此圈子不是用着 YUI 要不就自己封装一套 sandbox,内部使用 jQuery。

为什么模块化方案这么晚才成型,可能早期应用的复杂度都在后端,前端都是非常简单逻辑。后来 Ajax 火了之后,web app 概念的开始流行,前端的复杂度也呈指数级上涨,到今天几乎和后端接近一个量级。工程发展到一定阶段,要出现的必然会出现。
 

前端三剑客的模块化展望

从 js 模块化发展史,我们还看到了 CSS HTML 模块化方面的严重落后,如今依赖编译工具的模块化增强在未来会被标准所替代。

原生支持的模块化,解决 HTML 与 CSS 模块化问题正是以后的方向。

再回到 JS 模块化这个主题,开头也说到是为了构建 scope,实则提供了业务规范标准的输入输出的方式。但文章中的 JS 的模块化还不等于前端工程的模块化,Web 界面是由 HTML、CSS 和 JS 三种语言实现,不论是 CommonJS 还是 AMD 包括之后的方案都无法解决 CSS 与 HTML 模块化的问题。

对于 CSS 本身它就是 global scope,因此开发样式可以说是喜忧参半。近几年也涌现把 HTML、CSS 和 JS 合并作模块化的方案,其中 react/css-modules 和 vue 都为人熟知。当然,这一点还是非常依赖于 webpack/rollup 等构建工具,让我们意识到在 browser 端还有很多本质的问题需要推进。

对于 CSS 模块化,目前不依赖预编译的方式是 styled-component,通过 js 动态创建 class。而目前 CSS 也引入了与 js 通信的机制 与 原生变量支持。未来 CSS 模块化也很可能是运行时的,所以目前比较看好 styled-component 的方向。

对于 HTML 模块化,小尤最近爆出与 chrome 小组调研 HTML Modules,如果 HTML 得到了浏览器,编辑器的模块化支持,未来可能会取代 jsx 成为最强大的模块化、模板语言。

对于 js 模块化,最近出现的 <script type="module"> 方式,虽然还没有得到浏览器原生支持,但也是我比较看好的未来趋势,这样就连 webpack 的拆包都不需要了,直接把源代码传到服务器,配合 http2.0 完美抛开预编译的枷锁。

上述三中方案都不依赖预编译,分别实现了 HTML、CSS、JS 模块化,相信这就是未来。

模块化标准推进速度仍然缓慢

2015 年提出的标准,在 17 年依然没有得到实现,即便在 nodejs 端。

这几年 TC39 对语言终于重视起来了,慢慢有动作了,但针对模块标准制定的速度,与落实都非常缓慢,与 javascript 越来越流行的趋势逐渐脱节。nodejs 至今也没有实现 ES2015 模块化规范,所有 jser 都处在构建工具的阴影下。

Http 2.0 对 js 模块化的推动

js 模块化定义的再美好,浏览器端的支持粒度永远是瓶颈,http 2.0 正是考虑到了这个因素,大力支持了 ES 2015 模块化规范。

幸运的是,模块化构建将来可能不再需要。随着 HTTP/2 流行起来,请求和响应可以并行,一次连接允许多个请求,对于前端来说宣告不再需要在开发和上线时再做编译这个动作。

几年前,模块化几乎是每个流行库必造的轮子(YUI、Dojo、Angular),大牛们自己爽的同时其实造成了社区的分裂,很难积累。有了 ES2015 Modules 之后,JS 开发者终于可以像 Java 开始者十年前一样使用一致的方式愉快的互相引用模块。

不过 ES2015 Modules 也只是解决了开发的问题,由于浏览器的特殊性,还是要经过繁琐打包的过程,等 Import,Export 和 HTTP 2.0 被主流浏览器支持,那时候才是彻底的模块化。

Http 2.0 后就不需要构建工具了吗?

看到大家基本都提到了 HTTP/2,对这项技术解决前端模块化及资源打包等工程问题抱有非常大的期待。很多人也认为 HTTP/2 普及后,基本就没有 Webpack 什么事情了。

不过 Webpack 作者 @sokra 在他的文章 webpack & HTTP/2 里提到了一个新的 Webpack 插件 AggressiveSplittingPlugin。简单的说,这款插件就是为了充分利用 HTTP/2 的文件缓存能力,将你的业务代码自动拆分成若干个数十 KB 的小文件。后续若其中任意一个文件发生变化,可以保证其他的小 chunck 不需要重新下载。

可见,即使不断的有新技术出现,也依然需要配套的工具来将前端工程问题解决方案推向极致。

模块化是大型项目的银弹吗?

只要遵循了最新模块化规范,就可以使项目具有最好的可维护性吗? Js 模块化的目的是支持前端日益上升的复杂度,但绝不是唯一的解决方案。

分析下 JavaScript 为什么没有模块化,为什么又需要模块化:这个 95 年被设计出来的时候,语言的开发者根本没有想到它会如此的大放异彩,也没有将它设计成一种模块化语言。按照文中的说法,99 年也就是 4 年后开始出现了模块化的需求。如果只有几行代码用模块化是扯,初始的 web 开发业务逻辑都写在 server 端,js 的作用小之又小。而现在 spa 都出现了,几乎所有的渲染逻辑都在前端,如果还是没有模块化的组织,开发过程会越来越难,维护也是更痛苦。

文中已经详细说明了模块化的发展和优劣,这里不准备做过多的讨论。我想说的是,在模块化之后还有一个模块间耦合的问题,如果模块间耦合度大也会降低代码的可重用性或者说复用性。所以也出现了降低耦合的观察者模式或者发布/订阅模式。这对于提升代码重用,复用性和避免单点故障等都很重要。说到这里,还想顺便提一下最近流行起来的响应式编程(RxJS),响应式编程中有一个很核心的概念就是 observable,也就是 Rx 中的流(stream)。它可以被 subscribe,其实也就是观察者设计模式。

补充阅读

总结

未来前端复杂度不断增加已成定论,随着后端成熟,自然会将焦点转移到前端领域,而且服务化、用户体验越来越重要,前端体验早不是当初能看就行,任何网页的异常、视觉的差异,或文案的模糊,都会导致用户流失,支付中断。前端对公司营收的影响,渐渐与后端服务宕机同等严重,所以前端会越来越重,异常监控,性能检测,工具链,可视化等等都是这几年大家逐渐重视起来的。

我们早已不能将 javascript 早期玩具性质的模块化方案用于现代越来越重要的系统中,前端界必然出现同等重量级的模块化管理方案,感谢 TC39 制定的 ES2015 模块化规范,我们已经离不开它,哪怕所有人必须使用 babel。

话说回来,标准推进的太慢,我们还是把编译工具当作常态,抱着哪怕支持了 ES2015 所有特性,babel 依然还有用的心态,将预编译进行到底。一句话,模块化仍在路上。js 模块化的矛头已经对准了 CSS 与 HTML,这两位元老也该向前卫的 js 学习学习了。

未来 CSS、HTML 的模块化会自立门户,还是赋予 js 更强的能力,让两者的模块化依附于 js 的能力呢?目前 HTML 有自立门户的苗头(HTMLModules),而 CSS 迟迟没有改变,社区出现的 styled-component 已经用 js 将 CSS 模块化得很好了,最新 CSS 规范也支持了与 js 的变量通信,难道希望依附于 js 吗?这里希望得到大家更广泛的讨论。

我也认同,毕竟压缩、混淆、md5、或者利用 nonce 属性对 script 标签加密,都离不开本地构建工具。

据说 http2 的优化中,有个最佳文件大小与数量的比例,那么还是脱离不了构建工具,前端未来会越来越复杂,同时也越来越美好。

至此,对于 javascript 模块化讨论已接近尾声,对其优缺点也基本达成了一致。前端复杂度不断提高,促使着模块化的改进,代理(浏览器、node) 的支持程度,与前端特殊性(流量、缓存)可能前端永远也离不开构建工具,新的标准会让这些工作做的更好,同时取代、增强部分特征,前端的未来是更加美好的,复杂度也更高。

如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。

w3c 规范原味解读 - 2 基本元素

这章罗列了基础的 number boolean float 等类型,并做了非常详尽的描述,不过实在太啰嗦了,所以只举了其中几个例子。

术语

Resources

只要资源满足最小功能就可以被实现,反之功能不全就无法被实现。

Image 的像素资源可以被编码和解码,就可以显示在页面上,哪怕这个图片还包含了不被支持的动画资源。

mp4 的长宽可以被读取,但是不支持压缩格式,也无法显示。

DOM trees

document 根元素是文档的第一个元素,如果文档没有元素,那就没有根元素。

Node 归属的 document 可以通过 NodeownerDocument 属性获取。

修改 attribute 的值,仅指与新值与原值不同时,如果相同,则没有发生修改。

empty 指在某个 attribute 或者 Text nodestring 时,表示其长度为空(空格也没有)。

Scripting

"a Foo object" 更多时候指 Foo 这个接口,而不是指实现了 Foo 接口的 object

当一个 IDL(interface definition language) 属性被访问时,是 getting 状态,被赋值时,是 setting 状态。

如果 dom 元素在文档中,操作其属性和方法访问的是底层数据,而不是瞬时值。

Plugins

浏览器可以拓展 user-agent 的解析行为,比如 pdf 类型实例化一个 pdf 阅读器。

Character encodings

字符编码,是将 byte streamsUnicode string 相互转换。

React 通用组件管理源码剖析

如何有效编译、发布组件,同时组织好组件之间依赖关联是这篇文章要解决的问题。

目标

比如现在有 navbar resource-card 这两个组件,并且 resource-card 依赖了 navbar,现在通过命令:

npm run manage -- --publish wefan/navbar#major

给 navbar 发布一个主要版本号,会提示下图确认窗口,check一遍发布级别、实际发布级别、当前版本号与发布版本号是否符合预期,当复合预期后,再正式发布组件。

78c063f0ab5dc73a6985aba1d

上图的发布级别,可以看到 resource-card 因为直接依赖了 navbar,而 navbar 发布了大版本号产生了 break change,因此依赖它的 resource-card 连带升级一个 minor 新版本号。

而依赖关系是通过脚本分析,实际开发中不需要关心组件之间的依赖关系,当发布时,程序自动整理出组件的依赖关系,并且根据发布的版本号判断哪些组件要连带更新。同时对直接更新的组件进行编译,对直接依赖,但非直接发布的组件只进行发布。

最后,为了保证组件发布的安全性,将依赖本次发布组件最少的组件优先发布,避免因为发布失败,而让线上组件引用了一个未发布的版本。

安装 commander

commander 可以让 nodejs 方便接收用户输入参数。现在一个项目下有N个组件,我们对这些组件的期望操作是——更新、提交、发布:

commander.version('1.0.0')
    .option('-u, --update', '更新')
    .option('-p, --push', '提交')
    .option('-pub, --publish', '发布')

定义子组件结构

组件可能是通用的、业务定制的,我们给组件定一个分类:

export interface Category {
    /**
     * 分类名称
     */
    name: string
    /**
     * 分类中文名
     */
    chinese: string
    /**
     * 发布时候的前缀
     */
    prefix: string
    /**
     * 是否隐私
     * private: 提交、发布到私有仓库
     * public: 提交、发布到公有仓库
     */
    isPrivate: boolean
    /**
     * 组件列表
     */
    components?: Array<ComponentConfig>
}

每个组件只需要一个组件名(对应仓库名)和中文名:

export interface ComponentConfig {
    /**
     * 组件名(不带前缀)
     */
    name: string
    /**
     * 中文名
     */
    chinese: string
}

更新组件

采用 subtree 管理子组件仓库,对不存在项目中的组件,从仓库中拖拽下来,对存在的组件,从远程仓库更新

node manage.js --update
components.forEach(category=> {
    category.components.forEach(component=> {
        // 组件根目录
        const componentRootPath = `${config.componentsPath}/${category.name}/${component.name}`

        if (!fs.existsSync(componentRootPath)) { 
            // 如果组件不存在, 添加
            execSync(`git subtree add -P ${componentRootPath} ${config.privateGit}/${category.name}-${component.name}.git master`)
        } else {
            // 组件存在, 更新
            execSync(`git subtree pull -P ${componentRootPath} ${config.privateGit}/${category.name}-${component.name}.git master`)
        }
    })
})

提交组件

采用 subtree 管理,在提交子组件之前在根目录统一提交, 再循环所有组件进行 subtree 提交

execSync(`git add -A`)
execSync(`git commit -m "${message}"`)

发布组件

首先遍历所有组件,将其依赖关系分析出来:

filesPath.forEach(filePath=> {
    const source = fs.readFileSync(filePath).toString()
    const regex = /import\s+[a-zA-Z{},\s\*]*(from)?\s?\'([^']+)\'/g

    let match: any
    while ((match = regex.exec(source)) != null) {
        // 引用的路径
        const importPath = match[2] as string
        importPaths.set(importPath, filePath)
    }
})

根据是否含有 ./ 或者 ../ 开头,判断这个依赖是 npm 的还是其它组件的:

if (importPath.startsWith('./') || importPath.startsWith('../')) {
    // 是个相对引用
    // 引用模块的完整路径
    const importFullPath = path.join(filePathDir, importPath)
    const importFullPathSplit = importFullPath.split('/')

    if (`${config.componentsPath}/${importFullPathSplit[1]}/${importFullPathSplit[2]}` !== componentPath) {
        // 保证引用一定是 components 下的
        deps.dependence.push({
            type: 'component',
            name: importFullPathSplit[2],
            category: importFullPathSplit[1]
        })
    }
} else {
    // 绝对引用, 暂时认为一定引用了 node_modules 库
    deps.dependence.push({
        type: 'npm',
        name: importPath
    })
}

接下来使用 ts 编译。因为 typescript 生成 d.ts 方式只能针对文件为入口,首先构造一个入口文件,引入全部组件,再执行 tsc -d 将所有组件编译到 built 目录下:

execSync(`tsc -m commonjs -t es6 -d --removeComments --outDir built-components --jsx react ${comboFilePath}`)

再遍历用户要发布的组件,编译其 lib 目录(将 typescript 编译后的文件使用 babel 编译,提高对浏览器兼容性),之后根据提交版本判断是否要将其依赖的组件提交到待发布列表:

if (componentInfo.publishLevel === 'major') {
    // 如果发布的是主版本, 所有对其直接依赖的组件都要更新 patch
    // 寻找依赖这个组件的组件
    allComponentsInfoWithDep.forEach(componentInfoWithDep=> {
        componentInfoWithDep.dependence.forEach(dep=> {
            if (dep.type === 'component' && dep.category === componentInfo.publishCategory.name && dep.name === componentInfo.publishComponent.name) {
                // 这个组件依赖了当前要发布的组件, 而且这个发布的还是主版本号, 因此给它发布一个 minor 版本
                // 不需要更新其它依赖, package.json 更新依赖只有要发布的组件才会享受, 其它的又不发布, 不需要更新依赖, 保持版本号更新发个新版本就行了, 他自己的依赖会在发布他的时候修正
                addComponentToPublishComponents(componentInfoWithDep.component, componentInfoWithDep.category, 'minor')
            }
        })
    })
}

现在我们需要将发布组件排序,依照其对这次发布组件的依赖数量,由小到大排序。我们先创建一个模拟发布的队列,每当认定一个组件需要发布,便将这个组件 push 到这个队列中,并且下次判断组件依赖时忽略掉模拟发布队列中的组件,直到到模拟发布组件长度为待发布组件总长度,这个模拟发布队列就是我们想要的发布排序:

// 添加未依赖的组件到模拟发布队列, 直到队列长度与发布组件长度相等
while (simulations.length !== allPublishComponents.length) {
    pushNoDepPublishComponents()
}
/**
 * 遍历要发布的组件, 将没有依赖的(或者依赖了组件,但是在模拟发布队列中)组件添加到模拟发布队列中
 */
const pushNoDepPublishComponents = ()=> {
    // 为了防止对模拟发布列表的修改影响本次判断, 做一份拷贝
    const simulationsCopy = simulations.concat()

    // 遍历要发布的组件
    allPublishComponents.forEach(publishComponent=> {
        // 过滤已经在发布队列中的组件
        // ...

        // 是否依赖了本次发布的组件
        let isRelyToPublishComponent = false

        publishComponent.componentInfoWithDep.dependence.forEach(dependence=> {
            if (dependence.type === 'npm') {
                // 不看 npm 依赖
                return
            }

            // 遍历要发布的组件
            for (let elPublishComponent of allPublishComponents) {
                // 是否在模拟发布列表中
                let isInSimulation = false
                // ..
                if (isInSimulation) {
                    // 如果这个发布的组件已经在模拟发布组件中, 跳过
                    continue
                }

                if (elPublishComponent.componentInfoWithDep.component.name === dependence.name && elPublishComponent.componentInfoWithDep.category.name === dependence.category) {
                    // 这个依赖在这次发布组件中
                    isRelyToPublishComponent = true
                    break
                }
            }
        })

        if (!isRelyToPublishComponent) {
            // 这个组件没有依赖本次要发布的组件, 把它添加到发布列表中
            simulations.push(publishComponent)
        }
    })
}

发布队列排好后,使用 tty-table 将模拟发布队列优雅的展示在控制台上,正是文章开头的组件发布确认图。再使用 prompt 这个包询问用户是否确认发布,因为目前位置,所有发布操作都是模拟的,如果用户发现了问题,可以随时取消这次发布,不会造成任何影响:

prompt.start()
prompt.get([{
    name: 'publish',
    description: '以上是最终发布信息, 确认发布吗? (true or false)',
    message: '选择必须是 true or false 中的任意一个',
    type: 'boolean',
    required: true
}], (err: Error, result: any) => {
    // ...
})

接下来我们将分析好的依赖数据写入每个组件的 package.json 中,在根目录提交(提交这次 package.json 的修改),遍历组件进行发布。对于内部模块,我们一般会提交到内部 git 仓库,使用 tag 进行版本管理,这样安装的时候便可以通过 xxx.git#0.0.1 按版本号进行控制:

// 打 tag
execSync(`cd ${publishPath}; git tag v${publishInfo.componentInfoWithDep.packageJson.version}`)

// push 分支
execSync(`git subtree push -P ${publishPath} ${config.privateGit}/${publishInfo.componentInfoWithDep.category.name}-${publishInfo.componentInfoWithDep.component.name}.git v${publishInfo.componentInfoWithDep.packageJson.version}`)

// push 到 master
execSync(`git subtree push -P ${publishPath} ${config.privateGit}/${publishInfo.componentInfoWithDep.category.name}-${publishInfo.componentInfoWithDep.component.name}.git master`)

// 因为这个 tag 也打到了根目录, 所以在根目录删除这个 tag
execSync(`git tag -d v${publishInfo.componentInfoWithDep.packageJson.version}`)

因为对于 subtree 打的 tag 会打在根目录上,因此打完 tag 并提交了 subtree 后,删除根目录的 tag。最后对根目录提交,因为对 subtree 打 tag 的行为虽然也认定为一次修改,即便没有源码的变更:

// 根目录提交
execSync(`git push`)

总结

目前通过 subtree 实现多 git 仓库管理,并且对组件依赖联动分析、版本发布和安全控制做了处理,欢迎拍砖。

面向未来的前端数据流框架 - dob

我们数据技术产品部有一部分只需要兼容最新版 chrome 对外产品,以及大部分对内产品,都广泛使用了 dob 管理前端数据流,下面隆重介绍一下。

dob 是利用 proxy 实现的数据依赖追踪工具,利用 dob-react 与 react 结合。

dob 的核心**大量借鉴了 mobx,但是从实现原理、使用便捷性,以及调试工具都做了大量优化。

特征

  • ✅ 支持
  • ❌ 不支持
  • 📦 生态支持
  • 🤷 不完全支持
功能 redux mobx dob
异步 📦redux-thunk
可回溯 📦 mst
分形 🤷 replaceReducer
代码精简 📦 dva
函数式 🤷 🤷
面向对象 🤷
Typescript 支持 🤷
调试工具
调试工具 action 与 UI 双向绑定 🤷
严格模式
支持原生 Map 等类型
observable 语法自然度
store 规范化 🤷

从依赖追踪开始

dob 自己只实现了依赖追踪功能,其特性非常简单,如下示意图+代码所示:

img

import { observable, observe } from "dob"

const obj = observable({ a: 1, b: 1 })

observe(() => {
    console.log(obj.a)
})

一句话描述就是:由 observable 产生的对象,在 observe 回调函数中使用,当这个对象被修改时,会重新执行这个回调函数。

与 react 优雅结合

那么利用这个特性,将 observe 换成 react 框架的 render 函数,就变成了下图:

img

import { observable, observe } from "dob"
import { Provider, Connect } from 'dob-react'

const obj = observable({ a: 1 })

@Connect
class App extends React.Component {
    render() {
        return (
            <span onClick={() => { this.props.store.a = 2 }}>
                {this.props.store.a}
            </span>
        )
    }
}

ReactDOM.render(
	<Provider store={obj}> <App/> </Provider>
, dom)

这正是 dob-react 做的工作。

上面这种结合随意性太强,不利于项目维护,真正的 dob-react 对 dob 的使用方式做了限制。

全局数据流

为了更好管理全局数据流,我们引入 action、store 的概念,组件只能触发 action,只有 action 内部才能修改 store:

img

由于聚合 store 注入到 react 非常简单,只需要 Provider @Connect 即可,所以组织好 store 与 action 的关系,也就组织好了整个应用结构。

那么如何组织 action、store、react 之间的关系呢?对全局数据流,dob 提供了一种成熟的模式:依赖注入。以下是可维护性良好模式

img

import { Action, observable, combineStores, inject } from 'dob'
import { Provider, Connect } from 'dob-react'

@observable
export class UserStore {
    name = 'bob'
}

export class UserAction {
    @inject(UserStore) private UserStore: UserStore;

    @Action setName () {
        this.store.name = 'lucy'
    }
}

@Connect
class App extends React.Component {
    render() {
        return (
            <span onClick={this.props.UserAction.setName}>
                {this.props.UserStore.name}
            </span>
        )
    }
}

ReactDOM.render(
    <Provider {
        ...combineStores({
            UserStore,
            UserAction
        })
    }>
        <App />
    </Provider>
, dom)

一句话描述就是:通过 combineStores 聚合 store 与 action,store 通过 inject 注入到 action 中被修改,react 组件通过 @Connect 自动注入聚合 store。

局部数据流

对于对全局状态不敏感的数据,可以作为局部数据流处理。

@Connect 装饰器如果不带参数,会给组件注入 Provider 所有参数,如果参数是一个对象,除了注入全局数据流,还会把这个对象注入到当前组件,由此实现了局部数据流。

PS: Connect 函数更多用法可以参考文档: dob-react #Connect

结构如下图所示:

img

import { Action, observable, combineStores, inject } from 'dob'
import { Provider, Connect } from 'dob-react'

@observable
export class UserStore {
    name = 'bob'
}

export class UserAction {
    @inject(UserStore) private UserStore: UserStore;

    @Action setName () {
        this.store.name = 'lucy'
    }
}

@Connect(combineStores(UserStore, UserAction))
class App extends React.Component {
    render() {
        return (
            <span onClick={this.props.UserAction.setName}>
                {this.props.UserStore.name}
            </span>
        )
    }
}

PS: 局部数据流可以替代 setState 管理组件自身状态,每当组件被实例化一次,就会创建一个与之绑定的局部数据流。如果不想使用 react 提供的 setState,可以使用局部数据流替代。

异步 & 副作用

redux 中需要将副作用代码从 reducer 抽离,而 dob 不需要,我们可以如下书写 action:

@Action async getUserInfo() {
	this.UserStore.loading = true
	this.UserStore.currentUser = await fetchUser()
	this.UserStore.loading = false
	
	try {
		this.UserStore.articles = await fetchArticle()
	} catch(error) {
		// 静默失败
	}
}

Devtools

借助 dob-react-devtools 开启调试模式,可以实现类似 redux-devtools 的效果,但,该调试工具具备 action 与 UI 双向可视化绑定 的功能等:

  • UI 与 action 绑定:ui 元素触发 rerender 时,自身会高亮,并在左上角显示渲染次数,以及导致其 render 的 action。
  • action 与 UI 绑定:展开右侧 action 列表后,通过 hover 可展示因此 action 触发而 rerender 的 UI 元素,高亮出来。
  • 搜索、清空等方式管理 action。
  • 点击灯泡 开启/关闭 debug 模式。

假设现在有一个文章列表需求,我们创建了 ArticleStoreArticleActionArticleAction 提供了 addArticle, removeArticle, changeArticleTitle 等基础方法。

现在我们开启了调试功能,获得如下 gif 图的效果:

dob-react-devtools

dob-react-devtools 主要提供了可视化界面展示每个 Action 触发列表,鼠标移动到每个 Action 会高亮对应 rerender 的 UI 元素,UI 元素 render 的时候,左上角工具条也列出了与这个 UI 元素相关的 Action 列表。

开启调试模式的方法:

import "dob-react-devtools"
import { startDebug } from "dob-react"

startDebug()

调试 UI 元素将自动附着在 Provider 元素上。

Devtools 双向绑定原理解读

一旦开启调试模式,在 dob 依赖追踪的 getter setter 处就会增加调用信息的存储(因此开启调试模式时性能会一定程度下降,且更加吃内存)。

由于 react render 函数是同步的(16 支持的异步渲染模式,在执行到 render 时也是同步的),只要包裹在 Action 中的变量存取,都可以在结束时打上唯一 id,当 react render 执行时,将当前 id 推送给调试工具,即可将 UI 与 Action 一一绑定。

action 与 debug 调用顺序:startBatch -> debugInAction -> ...multiple nested startBatch and endBatch -> debugOutAction -> reaction -> observe。

快速上手 Demo

git clone https://github.com/dobjs/dob-example.git

通过 npm i; npm start 快速运行起来,本项目包含了 dob 推荐的目录结构与 store 组织方式。本 demo 使用了 typescript、react@16 与 react-router@4。

生态

  • dob-react 让您在 react 中使用 dob!
  • dob-react-devtools - dob-react 的调试工具,具有 UI 元素与 Action 双向绑定特性。
  • dob-redux - 可以在 dob 中使用 redux 与 react-redux,同时具有 mutable 与 immutable 的优点!
  • dob-refect - 自动发请求,摆脱 componentDidUpdate 的困扰。

总结

mobx 即将到来的 4.0 版本也要支持 proxy 了:Road to 4.0 · Issue #1076 · mobxjs/mobx

届时其性能与实用度将与 dob 越来越接近,但现在 dob 已凭借大量使用经验进行优化,对 devtools 的支持也更为抢眼,UI 与 Action 双向绑定 debug 模式应该是首创。

所以至于选择 mobx 还是 dob,全凭个人喜好,也许等到 4.0,mobx 会变得和 dob 一样好用,现在,你可以通过 dob 预先尝试抛弃 IE 的爽快开发体验,以及独具特色的 devTools。

至于 redux/rxjs 与 mobx/dob 之间的选择,凭团队或个人爱好或许会更好抉择。

可视化在线编辑器架构设计

1 背景

本文开发框架基于 React,涉及 React 部分会对背景做简单铺垫。

前端开源江湖非常有意思,竞争是公平的,而且不需要成本,任何一个初入茅庐的学徒都可以找江湖高手过招,且迟早会自成门派,而今前端门派已经灿若繁星,知名的门派也不计其数,其『供需链』大致如下:

w3c规范 ==> 浏览器实现 ==> 开发引擎 ==> 数据框架 ==> UI框架 ==> 开发者 ==> 用户

『可视化在线编辑器』指的是引擎这一环,虽然开发引擎在前端并不常见,但看看游戏界就能知道,脱离游戏引擎编码是多么痛苦的一件事。前端和游戏共同点是都要考虑 UI 和 数据逻辑,其实微软在做界面开发时就有很多引擎出现,现在前端一点一点向全栈迈进,架构越来越重,分工越来越细,因为 node 让许多后端开发者接触前端,将后端沉淀的精髓带到了前端,而今前端又将触手延伸到客户端、PC端甚至硬件领域,逐渐吸收了开发引擎的**,促进前端进入工业时代。

在线编辑器是我在百度负责的主要项目之一,因为需要在 RN 的支持下兼容三端,因此就要设计得更加通用,为了循序渐进的讲解,我准备以 设计理念 功能实现 拓展架构设计 的顺序叙述。

2 设计理念

在头脑风暴之前,我们有几个目标需要提前明确,就像做游戏引擎一样,如果整体架构没有设计好,之后的开发将非常痛苦,以下是我重构两次后总结出的整体要领。

2.1 模块化

  1. 各司其责,组件化。编辑器只是引擎中的一环,还有负责部署在各端的展示器,提供最细粒度"积木"的基础组件,使用 typescript 的用户需要的类型库组件。
  2. 精简核心。编辑器 的核心功能是组件聚合,包括UI聚合与数据流聚合,以及提供依赖注入的功能,业务功能只要提供编辑区域渲染拖拽功能
  3. 插件是第一等公民。所有核心功能都通过插件提供,插件的UI、数据流都可以接入编辑器。

2.2 编辑器核心功能精简

所有编辑功能由插件提供,编辑器只需要实现"任何位置和功能都能由插件替代"的功能即可(拓展架构设计详细说明),这样编辑器可以理解为一块神奇磁铁,其特殊的引力将插件规律的吸附在四周。

2.3 展示器不关注平台细节

即不要对组件进行 dom 结构的包装,就可以适应任何平台(由组件内部实现决定)。

2.4 事件设计

事件可以让程序活起来,就像 Playmaker 可以不用写一行代码,在 Unity3d 做一款小游戏一样。事件分为 触发条件动作效果

  1. 触发条件的拓展点在于组件的生命周期,比如滚动条组件的 onScroll、按钮组件的 onClick 都可以作为触发条件。
  2. 动作效果的拓展点在于调用平台特征与修改自身属性。调用平台特征一大好处在于不关心组件实现细节,任何地方都可以调用,比如分享、调起相机等等。修改自身属性也是通用特征,可以用来显示模态框、修改数据源等。

2.5 数据流设计

Mobx 是一个双向绑定库,奇特之处在于自动绑定实例用到的属性,并且在数据变化时仅更新依赖于它的实例。Inversify 库实现了依赖注入。

React 本身只是 View 层,仅提供了组件内部状态 State 以及不建议使用的 Context 维护简单数据状态。编辑器复杂度较高,必须借助外部数据流管理,我们使用 Mobx 以及 Inversify 实现双向绑定和依赖注入,数据流向如下图所示:

1

React 触发刷新常见有三种,除了组件内部调用 setState 更新内部状态、或者 forceUpdate 强制刷新之外,父级传参 props 发生了变化一般也会触发刷新。

React 概念中 props 是传参,即父级 A 对子组件 B 传递了参数 x,那么 x 就是 B 组件的 props 属性,对 B 来说是 readyOnly 的。

从页面组件开始看,将 ActionStore 分别注入到页面中,由于希望数据变动后页面立刻刷新,我们用 mobxStore 注入到组件的 props 中,而 Action 则通过 Inversify 直接注入为实例的成员变量。

Action 之间也可以相互注入,同样 Store 也可以相互注入。只允许 Action 修改 Store,进而触发页面 props 变化,页面刷新。

3 功能实现

3.1 编辑器

需要实现两种状态:编辑态预览态

3.1.1 编辑状态

React dom 与 web dom node 不同,使用了虚拟 dom,而且组件不一定有实体 dom,就算最终挂在到了实体 dom 上,如果不将 dom 支持的基本手势事件暴露出来,组件外部将无法调用。

编辑状态需要捕获 click hover 等鼠标事件,由于组件不一定将回调透传,我们通过 ReactDOM.findDOMNode 拿到组件的 dom 节点直接监听。

再实现 实时编辑拖拽功能 ,编辑器的核心业务逻辑就完成了。

3.1.2 预览状态

为了方便将代码部署在三端,优先考虑的部署方式不是生成代码,而是生成配置,有一个专门的展示器负责解析配置,部署在不同平台,具体细节见 展示器

因为预览与实际部署效果一致,所以调用 展示器 传入当前页面编辑信息即可。

3.1.3 拖拽功能

由于支持了内部排序,与外部拖拽,社区的 SortableJs 非常合适担此重任。

sortablejs 嵌套拖拽 event.oldIndex 在其稳定版本(1.4.2)一直是 0,但这个 bug 在 dev 分支已修复。

我们将 SortablejsReact 结合即可完成拖拽功能,在结合前先介绍一下 Reactdom 方面的特征:

React 使用虚拟 dom 进行计算,将计算后 diff 结果同步在真实 dom 中,由此 React 对真实 dom 结构依赖非常强,其操作 dom 接口过于底层没有暴露出来,如果直接操作了 dom 会打乱 React 的算盘。

我们转换策略,仅仅将 Sortable 库作为中间动画使用,并依托其拖拽生命周期,在拖拽结束后获取用户拖拽意图,将 dom 的改动完全还原,再将意图交由 React 来实现。

伪代码如下:

Sortable.sort({
	onEnd: (event)=>{
		// 将移走的 dom 还原回去,目标元素自然会消失
		sourceParentElement.insertBefore(event.item, sourceIndex)
		// React 修改两个父级子元素状态
		action.moveComponent(sourceId, sourceIndex, targetId, targetIndex)
	}
})

3.1.4 实时编辑

将页面所有编辑元素打平,每个元素渲染时绑定其对应 id 的数据,修改属性时直接修改对应数据,mobx 会直接更新目标组件实例,如图所示:

2

Map 中有一个根节点,从根节点开始渲染,每个节点从数据库中取到自身数据,如果有子元素,则会递归渲染,子元素再从数据库获取子元素自身的数据,依次循环,当循环完毕后,我们会得到一颗与 Map 数据一一对应绑定的dom 树,Map 中任何一个元素发生改变,Mobx 会通过之前 getter 记录的关联关系,主动找到绑定的实例执行 forceUpdate 刷新。

mobx 接入组件的 props 数据不会触发 render,而是仅通过实例对应关系主动触发组件的 forceUpdateMobx 会在 shouldComponentUpdate 的生命周期中屏蔽掉 observe 类型数据的判断,因此 Mobx 的数据不会影响 React 的更新循环。

3.1.5 设置为组合

编辑器中,除了设定好在菜单中的组件,还可以让任意组合形成模板,将模板作为新组件放在组件菜单中。

关键点在于如何从打平的数据中获取组件间关联关系,并独立抽出来。

生成模板配置只需获取全量编辑信息,并进行瘦身即可,伪代码如下:

// 将当前编辑状态组件的 key、编辑信息和子元素信息一并获取
let componentFullInfo = action.getComponentFullInfoByKey(currentEditKey)
// 根据 defaultProps 去重,删除编辑时无用字段等
componentFullInfo = clean(componentFullInfo)

瘦身时使用 lz-string gzip 压缩,因为配置信息重复的字段很多,甚至大段可能都是复制粘贴的,因为 js 无法传输二进制文件,需要转化为 base64,体积增大了 66%,但还是将 971kb 的配置压缩到了 78kb。

将模板插入到页面中,首先将瘦身的信息补全,再给内部每个组件设置一个全新的 key, 但关联关系保持不变,最后将最外层组件挂载到拖拽到的父级上。伪代码如下:

// 补全组件信息
const componentFullInfoExpend = expend(componentFullInfo)
// 保持父子级关系不变,将所有 Key 全部换掉
const componentFullInfoCopy = copyWithNewKey(componentFullInfoExpend)
// 添加到页面
addToPage(componentFullInfoCopy)

关联关系不变,比如组合是 a 有一个子元素 bkey 分别是 keyA keyB,因为组件 map 需要保证 key 的唯一性,生成一对新的 key keyC keyD,但 keyB 父级关联的 keyA 同时也会改为 keyC

3.2 展示器

3

如图所示,展示器负责部署在各端,目前支持网页、安卓和苹果。核心**是利用 react-native 将组件直接渲染到端上,为了同时适配网页,使用 react-native-web 配合 webpack,将 react-native 代码在网页端编译时 aliasreact-native-web,用其提供的兼容样式展现。

展示器还负责将仅预览状态有效的 事件机制、变量配置、动作等激活,利用自身生命周期,以及子组件的回调函数挂上动作钩子。

3.3 动态拓展

如果说编辑与展示给了应用健壮的躯体,那动态拓展就让应用活了起来。

动态数据对编辑器来说,是一个拓展功能,分别可以拓展组件的 功能数据来源 以及 融入应用自身的数据流

3.3.1 功能注入

就是将平台特有的功能注入到编辑器生成的页面中,其实这是一种反向注入的过程,编辑器申明自己想要什么,具体功能是如何实现,效果如何,都完全交由各平台自己去实现。

更加自由的方式是申明回调函数,编辑器可以发出带有任意参数的回调,供部署到的平台任意拓展,平台部署的伪代码如下:

<GaeaPreview onCall={ (functionName, params)=>{ // .. do something } } />

3.3.2 传参注入

在网页显示一篇文章,一定是通过 url 获取 id,在端上也是通过页面传参拿到的,我们在部署端将可能拿到的参数全部注入到展示器中。

3.3.3 数据流接入

如果页面部署在普通网页上,比如做运营页,那就没有数据流概念一说。如果部署在端上,或者部署在一个网页平台上,那部署端自身一定有自己的数据流系统,可能是 redux mobx 等等 mvc mvp 的设计,我们需要考虑将数据流接入这些自有体系中。

  1. 端上将自身数据流抽取出来,端上实例化一份数据实例,每个组件根据数据接口进行数据注入,调用 Action 的方式展现与操作数据。也就是让每个组件都依赖数据接口,组件即便拆出来单独使用,但一旦部署到端上,将会自动接入端上数据流。
  2. 编辑器与展示器都不需要额外处理。

3.4 事件

高阶组件(HOC),原理类似高阶函数,即在原有组件基础之上包装一个组件,这个包装的就是高阶组件,好处是享有一套独立的生命周期,不对原组件产生影响,却又能拓展每个组件的功能。

事件只发生在展示器阶段,事件分为 触发条件动作效果,我们在展示器对每个组件包一层高阶组件,让其支持触发和响应事件。

3.4.1 触发条件

  1. 初始化。在高阶组件初始化的生命周期中触发。
  2. 监听事件。高阶组件初始化时监听事件。
  3. 生命周期。指的是组件自身生命周期也是触发条件的一部分,在调用子组件时,将子组件的回调函数指向动作效果函数即可,但要同一生命周期可以定义多个事件,但回调函数可不一定支持多个,我们需要做序列化处理,伪代码如下:
// 将事件数组按照触发条件聚合,转换成 map 类型
const functionMap = getSelfFunctionMap()
functionMap.forEach((value: Array<FitGaea.EventData>, key: string) => {
    props[key] = (...args: any[]) => {
        value.forEach(eachValue => {
	        // 执行动作效果,将参数打散传入
            runEvent.apply(this, [eachValue, ...args])
        })
    }
})

3.4.2 动作效果

  1. 触发事件。展示器实例维护了一个事件实例,通过这个事件系统派发事件。
  2. 修改属性。修改组件自身属性,对 propsmerge 即可。
  3. 调用注入方法。触发展示器的回调函数,调用部署平台的功能。

事件的整体流程如下图所示:

4

4 拓展架构设计

为了让编辑器拓展性更强,我们可以将编辑器所有功能以插件方式组装,插件可以插入到编辑器任何位置,也可以插件嵌套插件;插件可以使用编辑器数据流,也可以提供数据流供其它插件使用。

也就是拓展分为数据流拓展UI拓展

mobx-react 是适配 react 的库,将 MobxStore 注入到任意 React,为了保证操作的是同一份实例,初始化时先将所有 Store 实例化一份,并通过传参给根组件 Provider,分发到各个组件。

数据流设计 这一章提到了非常灵活的数据注入,首先 mobx-react 利用 context 实现了任意 Action Store 注入在任意 React 组件中,我们只需要实现在 ActionStore 中相互注入即可。

4.1 数据流拓展

我们希望任意 Action Store 之间都能随意注入,不会引发循环依赖,可以通过引入中间人的方式解决。我们有 A.ts B.ts 两个文件,分别在各自的类中引入对方实例,并期望所有对引用的操作都发生在同一实例下(如果组件被实例化多次,我们一定不希望多个实例共享数据),希望的结果伪代码如下:

A.ts

import {inject} from 'inject-instance'
import B from './B'

export default class A {
    @inject('B') private b: B
    public name = 'aaa'

    say() {
        console.log('A inject B instance', this.b.name)
    }
}

B.ts

import {inject} from 'inject-instance'
import A from './A'

export default  class B {
    @inject('A') private a: A
    public name = 'bbb'

    say() {
        console.log('B inject A instance', this.a.name)
    }
}

入口文件如下,期望输入注释中的结果:

import injectInstance from 'inject-instance'

const instances1 = injectInstance(A, B)
instances1.get('A').say()
instances1.get('B').say()
instances1.get('A').name = 'c'
instances1.get('B').say()
// A inject B instance bbb
// B inject A instance aaa
// B inject A instance c

const instances2 = injectInstance(A, B)
instances2.get('A').say()
instances2.get('B').say()
// A inject B instance bbb
// B inject A instance aaa

可以看出,如果实现了 inject-instance,就可以在 componentWillMount 的生命周期调用 injectInstance,并传入所有 Action Store不同实例之间数据流独立

不同实例间数据流独立的意思是,在 class A 中操作注入实例 b 的数据,只会操作当前 class A 归属组件实例的数据流中的 b。如果实例化了 N 份编辑器,比如显示模态框通过 storeshowModal 控制,不至于出现点击一个编辑器的按钮,所有模态框都弹出的结果。

4.1.1 inject-decorator 实现原理

inject-decorator 是装饰器,给字段打一个 tag,告诉之后要执行的 injectInstance 方法:"这个字段要注入 XXX Class,到时候帮我替换一下!"。

伪代码如下:

export default (injectName: string): any => (target: any, propertyKey: string, descriptor: PropertyDescriptor): any => {
	// 变量值替换为注入类名称
    target[propertyKey] = injectName
    // 加入一个标注变量
    target['injectArray'].push(propertyKey)
}

es6 箭头函数实现函数式非常方便,N 层嵌套可以用打平的 N 个 => 表示。
装饰器是个函数,如果装饰器本身带参数,则变成 2 层嵌套的函数。

将变量值替换成注入类名称,只是标记到时候替换成什么类的实例,而 injectArray 字段才是打 tag,执行 injectInstance 时会根据这个字段来替换对应成员变量。

4.1.2 injectInstance 实现原理

将传入的所有类根据类名放入 Map(仅加快查找用,用空间换时间),因为返回对应实例,所以先全部实例化,再遍历所有实例,根据 inject-decorator 打的 tag 变量 injectArray 将对应字段替换为实例。

最后,编辑器将得到的全部实例传入 mobx-reactprovider 中,实现了 UI 组件注入数据与数据流中注入的数据是统一份实例的效果。

更多注入细节,查看 inject-instance

4.2 UI拓展

就是允许插件插入到页面任何节点,与数据注入不同,数据注入是将所有插件数据流与编辑器自身数据流混在一起,其结构是打平的,像一个 Map。而UI注入,结构像 Tree 是层叠的,编辑器自身预留许多插槽,允许任何插件插入。

为了更好的拓展性,也允许插件留下插槽,让其它插件插入,而这样的好处不仅在于位置灵活,还可以优雅实现『自定义编辑功能』的能力,这个之后再说。

在编辑器或者插件中留一个插槽的伪代码如下:

// 在导航条左侧留一个插槽
ApplicationAction.loadingPluginByPosition('navbarLeft')

如果插件类中静态属性 Positon = 'navbarLeft',他就会插入在左侧导航条中。

别忘了,依赖与 inject-instance 的数据流注入功能,插件也可以随时调用这个方法,因此轻松实现插件预留插槽的功能。

4.2.1 利用 UI 注入实现自定义编辑类型

编辑器一般会提供基础编辑类型,比如纯文本的 text,下拉选择框 select 等等,如果用户希望自定义一种 array 编辑类型,实现对数组字段编辑功能,可以用 UI 注入的方式实现。

为了实现这种方式,编辑组件中,判断编辑类型的伪代码如下:

ApplicationAction.loadingPluginByPosition('editorAttribute' + editType)

注意,预留插槽的属性可以存在变量,而且以传入的编辑类型为结尾,就可以拓展编辑类型了,其它类型的拓展也不在话下。

那么希望支持 array 类型时,编辑器会试图加载 editorAttributeArray UI组件,那我们定义一个 Position = 'editorAttributeArray' 的组件就可以显示在这个位置,之后读取编辑器核心数据流的 currentEditComponent 对当前编辑组件进行操作即可。

4.3 拓展架构总结

用一张图总结插件拓展的全貌:

5

插件与编辑器的数据流是双向互通的,插件的UI可以插入编辑器UI,插件也可以插入插件的UI(不能循环引用)。

5 结语

看到这里,其实编辑器实现原理倒并不重要了,重要的是对数据流、拓展性的设计思路,这些**迁移到普通类型项目依然适用。当然,如果还有兴趣可以读读编辑器实现源码

w3c 规范原味解读 - 3 HTML 语义 结构 接口

写到第三集才发现,真的不能用太多中文描述 w3c 的规范,比如 Document文档,用 Document 表示时,我们会联想到一系列 api,但用 文档 描述时,下意识觉得"此文档非彼文档",因此但凡遇到定义性名词,在需要的时候都会以英文展示。

3.1 Documents

任何 XML 或者 HTML,在解析 HTML 的浏览器上都被称为 Documents

Document 的地址是绝对 url 地址,不过也可以在其生命周期,由脚本所改变(跳转,直接修改,pushState)。

当文档由脚本 createDocument() 或者 createHTMLDocument() 创建时,地址完全由脚本指定,并且会立即加载。

文档的地址来源可以在加载好之后被重新赋值,但没有加载完毕之前,这个值是空的。

3.1.1 Document Object

Document 对象的定义如下:

enum DocumentReadyState { "loading", "interactive", "complete" };

[OverrideBuiltins]
partial /*sealed*/ interface Document {
  // resource metadata management
  [PutForwards=href, Unforgeable] readonly attribute Location? location;
           attribute DOMString domain;
  readonly attribute DOMString referrer;
           attribute DOMString cookie;
  readonly attribute DOMString lastModified;
  readonly attribute DocumentReadyState readyState;

  // DOM tree accessors
  getter object (DOMString name);
           attribute DOMString title;
           attribute DOMString dir;
           attribute HTMLElement? body;
  readonly attribute HTMLHeadElement? head;
  readonly attribute HTMLCollection images;
  readonly attribute HTMLCollection embeds;
  readonly attribute HTMLCollection plugins;
  readonly attribute HTMLCollection links;
  readonly attribute HTMLCollection forms;
  readonly attribute HTMLCollection scripts;
  NodeList getElementsByName(DOMString elementName);

  // dynamic markup insertion
  Document open(optional DOMString type = "text/html", optional DOMString replace = "");
  WindowProxy open(DOMString url, DOMString name, DOMString features, optional boolean replace = false);
  void close();
  void write(DOMString... text);
  void writeln(DOMString... text);

  // user interaction
  readonly attribute WindowProxy? defaultView;
  readonly attribute Element? activeElement;
  boolean hasFocus();
           attribute DOMString designMode;
  boolean execCommand(DOMString commandId, optional boolean showUI = false, optional DOMString value = "");
  boolean queryCommandEnabled(DOMString commandId);
  boolean queryCommandIndeterm(DOMString commandId);
  boolean queryCommandState(DOMString commandId);
  boolean queryCommandSupported(DOMString commandId);
  DOMString queryCommandValue(DOMString commandId);

  // special event handler IDL attributes that only apply to Document objects
  [LenientThis] attribute EventHandler onreadystatechange;
};
Document implements GlobalEventHandlers;

3.1.2 资源元数据管理

document.referrer

返回文档地址,哪怕是失效地址也会返回空。

document.cookie

返回当前 cookies,如果没有则返回空字符串,可以被添加,修改和删除。

以下两种情况的 document 中没有 cookie:

  1. 没有浏览器执行环境
  2. 不是一个服务器地址

符合以上两种情况的,document.cookie 一定会返回空字符串。如果服务器地址格式错误,返回一个 SecurityError

由于 cookie 可以跨 frame 访问,因此 cookie 的路径path 仅仅是一种管理工具,而不是确保安全的途径。

document.lastModified

返回文档最后修改时间,格式为 MM/DD/YYYY hh:mm:ss,如果不确定则返回当前时间。

document.readyState

  • loading 文档正在加载中
  • interactive 解析结束但还在加载其它资源
  • complete 完全加载完毕

Document 会在 readyState 修改时触发 readystateChange 事件。

3.1.3 DOM 树访问器

document.head

headdocument 第一个节点(如果有的话)。

document.title

返回 title elementText Node,也可以直接被修改(如果没有 head element 修改被忽略)。

SVG 元素也有 title 属性,在 SVGDocument 中,优先修改 SVGtitle

注意,获取 title 最后一步会清除两边空格。
赋值时,如果有 head element 但是没有 title element 将会在 head 末尾 append 一个 title element

document.body

返回 body 节点,可以被修改。

document.images

返回文档中所有 img 标签。

document.embeds & document.plugins

返回文档中所有 embed 标签,两个方法作用完全一样。

document.links

返回所有含有 href 属性的 aarea 标签

document.forms

返回文档中所有 form 标签。

document.scripts

返回文档中所有 script 标签。

document.getElementsByName

返回一个 NodeList 对象,其 name 属性为指定属性。

3.2 Elements

3.2.1 Semantics

指某些标签比如 ol 表示一个有序列表。

再比如 h1 h2 标签,虽然会有样式差异,但绝不是为了呈现不同样式而区分,而是为了表意。因为同样标签在PC端和移动端可能展现形式都不同,但含义都是标题。

dob - 框架使用

本系列分三部曲:《框架实现》 《框架使用》 与 《跳出框架看哲学》,这三篇是我对数据流阶段性的总结,正好补充之前过时的文章。

本篇是 《框架使用》。

1 引言

现在我们团队也在重新思考数据流的价值,在业务不断发展,业务场景增多时,一个固定的数据流方案可能难以覆盖所有场景,在所有业务里都用得爽。特别在前端数据层很薄的场景下,在数据流治理上花功夫反倒是本末倒置。

业务场景通常很复杂,但是对技术的探索往往只追求理想情况下的效果,所以很多人草草阅读完别人的经验,给自己业务操刀时,会听到一些反对的声音,而实际效果也差强人意。

所以在阅读文章之前,应该先认识到数据流只是项目中非常微小的一环,而且每个具体方案都很看场景,就算用对了路子,带来的提效也不一定很明显。

2017 年 Redux 依然是主流,可能到 18 年还是。大家吐槽归吐槽,最终活还是得干,Redux 还是得用,就算分析出 js 天生不适合函数式,也依然一条路走到黑,因为谁也不知道未来会如何发展,redux 生态虽然用得繁琐,但普适性强,忍一忍,生活也能继续过。

Dob 和 Mobx 类似,也只是数据流中响应式方案的一个分支,思考也是比较理想化的,因此可能也摆脱不了中看不中用的命运,谁叫业务场景那么多呢。

不过相对而言,应该算是接地气一些,它既没有要求纯函数式和分离副作用,也没有 cyclejs 那么抽象,只要入门的面向对象,就可以用好。

2 精读 dob 框架使用

使用 redux 时,很多时候是傻傻分不清要不要将结构化数据拍平,再分别订阅,或者分不清订阅后数据处理应该放在组件上还是全局。这是因为 redux 破坏了 react 分形设计,在 最近的一次讨论记录 有说到。而许多基于 redux 的分形方案都是 “伪” 分形的,偷偷利用 replaceReducer 做一些动态 reducer 注册,再绑定到全局。

讨论理想数据流方案比较痛苦,而且引言里说到,很多业务场景下收益也不大,所以可以考虑结合工程化思维解决,将组件类型区分开,分为普通组件与业务组件,普通组件不使用数据流,业务组件绑定全局数据流,可以避免纠结。

Store 如何管理

使用 Mobx 时,文档告诉我们它具有依赖追踪、监听等许多能力,但没有好的实践例子做指导,看完了 todoMvc 觉得学完了 90%,在项目中实践后发现无从下手。

所谓最佳实践,是基于某种约定或约束,让代码可读性、可维护性更好的方案。约定是活的,不遵守也没事,约束是死的,不遵守就无法运行。约束大部分由框架提供,比如开启严格模式后,禁止在 Action 外修改变量。然而纠结最多的地方还是在约定上,我在写 dob 框架前后,总结出了一套使用约定,可能仅对这种响应式数据流管用。

使用数据流,第一要做的事情就是管理数据,要解决 Store 放在哪,怎么放的问题。其实还有个前置条件:要不要用 Store 的问题。

要不要用 store

首先,最简单的组件肯定不需要用数据流。那么组件复杂时,如果数据流本身具有分形功能,那么可用可不用。所谓具有分形功能的数据流,是贴着 react 分形功能,将其包装成任具有分形能力的组件:

import { combineStores, observable, inject, observe } from 'dob'
import { Connect } from 'dob-react'

@observable
class Store { name = 123 }

class Action {
  @inject(Store) store: Store

  changeName = () => { this.store.name = 456 }
}

const stores = combineStores({ Store, Action })

@Connect(stores)
class App extends React.Component<typeof stores, any> {
  render() {
    return <div onClick={this.props.Action.changeName}>{this.props.Store.name}</div>
  }
}

ReactDOM.render(<App /> , document.getElementById('react-dom'))

dob 就是这样的框架,上面例子中,点击文字可以触发刷新,即便根 dom 节点没有 Provider。这意味着这个组件不论放到任何环境,都可以独立运行,成为任何项目中的一部分。这种组件虽然用了数据流,但是和普通 React 组件完全无区别,可以放心使用。

如果是伪分形的数据流,可能在 ReactDOM.render 需要特定的 Provider 配合才可使用,那么这个组件就不具备可迁移能力。如果别人不幸安装了这种组件,就需要在项目根目录安装一个全家桶。

问:虽然数据流+组件具备完全分形能力,但若此组件对 props 有响应式要求,那还是有对该数据流框架的隐形依赖。

答:是的,如果组件要求接收的 props 是 observable 化的,以便在其变化时自动 rerender,那当某个环境传递了普通 props,这个组件的部分功能将失效。其实 props 属于 react 的通用连接桥梁,因此组件只应该依赖普通对象的 props,内部可以再对其 observable 化,以具备完备的可迁移能力。

怎么用 store

React 虽然可以完全模块化,但实际项目中模块一定分为通用组件与业务组件,页面模块也可以当作业务组件。复杂的网站由数据驱动比较好,既然是数据驱动,那么可以将业务组件与数据的连接移到顶层管理,一般通过页面顶层包裹 Provider 实现:

import { combineStores, observable, inject, observe } from 'dob'
import { Connect } from 'dob-react'

@observable
class Store { name = 123 }

class Action {
  @inject(Store) store: Store

  changeName = () => { this.store.name = 456 }
}

const stores = combineStores({ Store, Action })

ReactDOM.render(
  <Provider {...store}>
    <App />
  </Provider>  
, document.getElementById('react-dom'))

本质上只是改变了 Store 定义的位置,而组件使用方式依然不变:

@Connect
class App extends React.Component<typeof stores, any> {
  render() {
    return <div onClick={this.props.Action.changeName}>{this.props.Store.name}</div>
  }
}

有一个区别是 @Connect 不需要带参数了,因为如果全局注册了 Provider,会默认透传到 Connect 中。与分形相反,这种设计会导致组件无法迁移到其他项目单独运行,但好处是可以在本项目中任意移动。

分形的组件对结构强依赖,只要给定需要的 props 就可以完成功能,而全局数据流的组件几乎可以完全不依赖结构,所有 props 都从全局 store 获取。

其实说到这里,可以发现这两点是难以合二为一的,我们可以预先将组件分为业务耦合与非业务耦合两种,让业务耦合的组件依赖全局数据流,让非业务耦合组件保持分形能力。

如果有更好的 Store 管理方式,可以在我的 github知乎 深入聊聊。

每个组件都要 Connect 吗

对于 Mvvm **的库,Connect 概念不仅仅在于注入数据(与 redux 不同),还会监听数据的变化触发 rerender。那么每个组件需要 Connect 吗?

从数据流功能来说,没有用到数据流的组件当然不需要 Connect,但业务组件保持着未来不确定性(业务不确定),所以保持每个业务组件的 Connect 便于后期维护。

而且 Connect 可能还会做其他优化工作,比如 dob 的 Connect 不仅会注入数据,完成组件自动 render,还会保证组件的 PureRender,如果对 dob 原理感兴趣,可以阅读 精读《dob - 框架实现》

其实个议题只是非常微小的点,不过现实就是讽刺的,很多时候多会纠结在这种小点子上,所以单独花费篇幅说几句。

数据流是否要扁平化

Store 扁平化有很大原因是 js 对 immutable 支持力度不够,导致对深层数据修改非常麻烦导致的,虽然 immutable.js 这类库可以通过字符串快速操作,但这种使用方式必然会被不断发展的前端浪潮所淹没,我们不可能看到 js 标准推荐我们使用字符串访问对象属性。

通过字符串访问对象属性,和 lodash 的 _.get 类似,不过对于安全访问属性,也已经有 proposal-optional-chaining 的提案在语法层面解决,同样 immutable 的便捷操作也需要一种标准方式完成。实际上不用等待另一个提案,利用 js 现有能力就可以模拟原生 immutable 支持的效果。

dob-redux 可以通过类似 this.store.articles.push(article) 的 mutable 写法,实现与 react-redux 的对接,内部自然做掉了类似 immutable.set 的事情,感兴趣可以读读我的这篇文章:Redux 使用可变数据结构,介绍了这个黑魔法的实现原理。

有点扯远了,那么数据流扁平化本质解决的是数据格式规范问题。比如 normalizr 就是一种标准数据规范的推进,很多时候我们都将冗余、或者错误归类的数据存入 Store,那维护性自然比较差,Redux 推崇的应当是正确的数据格式化,而不是一昧追求扁平化。

对于前端数据流很薄的场景,也不是随便处理数据就完事了。还有许多事可做,比如使用 node 微服务对后端数据标准化、封装一些标准格式处理组件,把很薄的数据做成零厚度,业务代码可以对简单的数据流完全无感知等等。

异步与副作用

Redux 自然而然用 action 隔离了副作用与异步,那在只有 action 的 Mvvm 开发模式中,异步需要如何隔离?Mvvm 真的完美解决了 Redux 避而远之的异步问题吗?

在使用 dob 框架时,异步后赋值需要非常小心:

@Action async getUserInfo() {
  const userInfo = await fetchUser()
  this.store.user.data = userInfo // 严格模式下将会报错,因为脱离了 Action 作用域。
}

原因是 await 只是假装用同步写异步,当一个 await 开始时,当前函数的栈已经退出,因此后续代码都不在一个 Action 中,所以一般的解法是显示申明 Action 的显示申明大法:

@Action async getUserInfo() {
  const userInfo = await fetchUser()
  Action(() => {
    this.store.user.data = userInfo
  })
}

这说明了异步需要当心!Redux 将异步隔离到 Reducer 之外很正确,只要涉及到数据流变化的操作是同步的,外面 Action 怎么千奇百怪,Reducer 都可以高枕无忧。

其实 redux 的做法与下面代码类似:

@Action async getUserInfo() { // 类 redux action
  const userInfo = await fetchUser()
  this.setUserInfo(userInfo)
}

@Action async setUserInfo(userInfo) { // 类 redux reduer
  this.store.user.data = userInfo
}

所以这是 dob 中对异步的另一种处理方法,称作隔离大法吧。所以在响应式框架中,显示申明大法与隔离大法都可以解决异步问题,代码也显得更加灵活。

请求自动重发

响应式框架的另一个好处在于可以自动触发,比如自动触发请求、自动触发操作等等。

比如我们希望当请求参数改变时,可以自动重发,一般的,在 react 中需要这么申明:

componentWillMount() {
  this.fetch({ url: this.props.url, userName: this.props.userName })
}

componentWillReceiveProps(nextProps) {
  if (
    nextProps.url !== this.props.url ||
    nextProps.userName !== this.props.userName
  ) {
    this.fetch({ url: nextProps.url, userName: nextProps.userName })
  }
}

在 dob 这类框架中,以下代码的功能是等价的:

import { observe } from 'dob'

componentWillMount() {
  this.signal = observe(() => {
    this.fetch({ url: this.props.url, userName: this.props.userName })
  })
}

其神奇地方在于,observe 回调函数内用到的变量(observable 后的变量)改变时,会重新执行此回调函数。而 componentWillReceiveProps 内做的判断,其实是利用 react 的生命周期手工监听变量是否改变,如果改变了就触发请求函数,然而这一系列操作都可以让 observe 函数代劳。

observe 有点像更自动化的 addEventListener

document.addEventListener('someThingChanged', this.fetch)

所以组件销毁时不要忘了取消监听:

this.signal.unobserve()

最近我们团队也在探索如何更方便的利用这一特性,正在考虑实现一个自动请求库,如果有好的建议,也非常欢迎一起交流。

类型推导

如果你在使用 redux,可以参考 你所不知道的 Typescript 与 Redux 类型优化 优化 typescript 下 redux 类型的推导,如果使用 dob 或 mobx 之类的框架,类型推导就更简单了:

import { combineStores, Connect } from 'dob'

const stores = combineStores({ Store, Action })

@Connect
class Component extends React.PureComponent<typeof stores, any> {
  render() {
    this.props.Store // 几行代码便获得了完整类型支持
  }
}

这都得益于响应式数据流是基于面向对象方式操作,可以自然的推导出类型。

Store 之间如何引用

复杂的数据流必然存在 Store 与 Action 之间相互引用,比较推荐依赖注入的方式解决,这也是 dob 推崇的良好实践之一。

当然依赖注入不能滥用,比如不要存在循环依赖,虽然手握灵活的语法,但在下手写代码之前,需要对数据流有一套较为完整的规划,比如简单的用户、文章、评论场景,我们可以这么设计数据流:

分别建立 UserStore ArticleStore ReplyStore

import { inject } from 'dob'

class UserStore {
  users
}

class ReplyStore {
  @inject(UserStore) userStore: UserStore

  replys // each.user
}

class ArticleStore {
  @inject(UserStore) userStore: UserStore
  @inject(ReplyStore) replyStore: ReplyStore

  articles // each.replys each.user
}

每个评论都涉及到用户信息,所以 ReplyStore 注入了 UserStore,每个文章都包含作者与评论信息,所以 ArticleStore 注入了 UserStoreReplyStore,可以看出 Store 之间依赖关系应当是树形,而不是环形。

最终 Action 对 Store 的操作也是通过注入来完成,而由于 Store 之间已经注入完了,Action 可以只操作对应的 Store,必要的时候再注入额外 Store,而且也不会存在循环依赖:

class UserAction {
  @inject(UserStore) userStore: UserStore
}

class ReplyAction {
  @inject(ReplyStore) replyStore: ReplyStore
}

class ArticleAction {
  @inject(ArticleStore) articleStore: ArticleStore
}

最后,不建议在局部 Store 注入全局 Store,或者局部 Action 注入全局 Store,因为这会破坏局部数据流的分形特点,切记保证非业务组件的独立性,把全局绑定交给业务组件处理。

Action 的错误处理

比较优雅的方式,是编写类级别的装饰器,统一捕获 Action 的异常并抛出:

const errorCatch = (errorHandler?: (error?: Error) => void) => (target: any) => {
    Object.getOwnPropertyNames(target.prototype).forEach(key => {
        const func = target.prototype[key]
        target.prototype[key] = async (...args: any[]) => {
            try {
                await func.apply(this, args)
            } catch (error) {
                errorHandler && errorHandler(error)
            }
        }
    })
    return target
}

const myErrorCatch = errorCatch(error => {
    // 上报异常信息 error
})

@myErrorCatch
class ArticleAction {
  @inject(ArticleStore) articleStore: ArticleStore
}

当任意步骤触发异常,await 之后的代码将停止执行,并将异常上报到前端监控平台,比如我们内部的 clue 系统。关于异常处理更多信息,可以访问我较早的一篇文章:Callback Promise Generator Async-Await 和异常处理的演进

3 总结

准确区分出业务与非业务组件、写代码前先设计数据流的依赖关系、异步时注意分离,就可以解决绝大部分业务场景的问题,实在遇到特殊情况可以使用 observe 监听数据变化,由此可以拓展出比如请求自动重发的功能,运用得当可以解决余下比较棘手的特殊需求。

虽然数据流只是项目中非常微小的一环,但如果想让整个项目保持良好的可维护性,需要把各个环节做精致。

这篇文章写于 2017 年最后一天,祝大家元旦快乐!

w3c 规范原味解读 - 1 介绍

说明

解读不是翻译,因此不会逐句涵盖 w3c 的官方文档,我本意将站在一个初学者的角度,将需要注意的地方记录下来,同时站在一个实用主义者角度,将工作中不常用,但与标准差异较大的理解记录下来,主要意图是记录自己的理解,和帮助日后索引与查询,如果读者希望地毯式理解 w3c 标准,建议逐字阅读 w3c 官方英文文档。

HTML

the Hypertext Markup Language 超文本标记语言

HTML 乍一看可能给人感觉到一些荒谬,因为其规范是在几十年间,许多不同背景的开发者共同贡献的,很多地方可能无法很全局的把握。

为了简化难度,没有暴露多线程的特征给开发者,HTML 和 DOM API 也被设计为无法检测是否有其它脚本正在同时运行。就算是 webWorker,其实现原理可以认为是在同一个浏览器上下文序列化执行所有脚本。

Tag

< > / 构成,某些标签可以不闭合,比如 <br>

标签可以嵌套,比如:

<p>This <em>is <strong>correct</strong>.</em></p>

但不能交叉嵌套:

<p>This is <em>very <strong>wrong</em>!</strong></p>

Attribute

如果属性值不包含空格、" ' ` = < > ,就可以不用双引号,以下写法都是正确的:

<!-- empty attributes -->
<input disabled>
<input disabled="">
<input disabled=""/>

<!-- attributes with a value -->
<input name=address>
<input name='address'>
<input name="address">

拓展机制

HTML 提供了很多方法拓展语义,确保使用安全的方法拓展语义,保证不会产生副作用,例如:

  • class 可以被浏览器广泛支持
  • data - * 属性可以确保不被浏览器使用,安全的传递数据
  • 使用 描述页面数据
  • 通过 rel="" 定义链接类型
  • <script type =""> 可以定义自定义数据类型,通过本地或者服务器在页面嵌入原生数据
  • 使用 embed 嵌入插件,比如 flash
  • 使用 js 拓展功能

解析

HTML 标记在浏览器被解析成 DOM 树,存储在内存中。包括的节点类型:DocumentType,Element,Text,Comment,以及不常见的 ProcessingInstruction。

例如:

<!DOCTYPE html>
<html>
 <head>
  <title>Sample page</title>
 </head>
 <body>
  <h1>Sample page</h1>
  <p>This is a <a href="demo.html">simple</a> sample.</p>
  <!-- this is a comment -->
 </body>
</html>

会被解析为:

image

可以看到文字虽然没有被标签包裹,但在 DOM 树中与标签一样会生成 #text 节点。不过 #text 节点比预期的要多,因为代码中包含很多空格与换行,不过,所有 <head> 之前的空白会被忽略,所有 </body> 之后的空白都会被提前到 </body> 的结尾处。

任何 DOM 节点都可以被嵌入的 script 脚本操控。

事件触发

dom的回调,比如 img 标签的 onLoad,虽然是异步事件,但可能在 dom 渲染过程中触发,因此如下的 js 监听可能不会生效:

 <img id="games" src="games.png" alt="Games">
 <!-- the 'load' event might fire here while the parser is taking a
      break, in which case you will not see it! -->
 <script>
  var img = document.getElementById('games');
  img.onload = gamesLogoHasLoaded; // might never fire!
 </script>

XHTML

是 HTML 的变体,因为使用了 XML 语法。XHTML 是 XML 的应用程序。

如果文档以 text / html MIME 类型传输,浏览器会作为 HTML 类型处理,目前使用 html 5.0 版本的规范,也就是 "HTML 5"。

如果使用 XML MIME 类型,例如 application / xhtml + xml 时,会被浏览器视为 XML 文档,使用 XHTML 5.0 版本的规范,称为 "XHTML 5"。与 HTML 5 的区别是,XHTML 5 对 HTML 标签语法检查的更严格,细小的语法错误都会阻止文档渲染,比如 XHTML 中的 DOM 语法不允许 noscript 标签,也不允许 --> 的注释。

CSS

Cascading Style Sheets 层叠样式表
让呈现样式与结构分离。

HTML 内联的样式属性因为不利于维护,增大文件体积,已经逐渐废弃,例如 <font color=""> ,仅保留了 style 属性。

WebFonts

在网页使用字体,无需在系统安装,正在打造的网页字体通用标准是: WOFF

容错

在结束标签书写属性应该被忽略,但却是合法的,应为以后可能会作为一种拓展能力 (<p></p attr="">)。

对属性设置 disable=false 是不允许的,尽管看起来设置了 enable,但实际上和 disable=true 效果一样,因为这个效果看的是属性,而不是值。

该闭合的不闭合,会根据不同情况容错,比如 form 不会放在段落元素中,那么下面这段标签会这么解析:

<p>Welcome. <form><label>Name:</label> <input></form>

自动在 form 标签前把 p 标签结束:

<p>Welcome. </p><form><label>Name:</label> <input></form>

script 标签不支持 src 与内容同时存在,为了防止减少不必要的开发错误,因为有了 src 属性的 script 标签内容将不被执行。

React & Npm 组件库维护经验

我们先来回顾一下 React ,Facebook 是这么描述的:

A JavaScript library for building user interfaces

官方定义其为 UI 库,这名字似乎太低调了些。从 React-Native 的发展就能看出来其野心勃勃,但官方的定义反而使其成为了开发者的宠儿 —— "为什么要用React?" "它只是个UI库"。

从 jQuery 开始,前端组件遍地花开,有jQuery官方提供的成套组件,也有活跃社区提供的第三方组件,从最简单的文本截断功能,到复杂的拖拽排序都应有尽有。业务使用的时候,经常会给 window 挂上 $,代码中组织也非常灵活,在需要复杂 dom 操作时,jQuery 总能帮忙轻松完成。

React 这个 UI 库进入大家的视野后,我们猛然发现『万物皆组件』,就连最不加修饰的业务代码也可以作为组件被其它模块所引用,这极大的激发了大家的热情。写代码的时候感觉在造轮子,在写导航栏、稍微通用点儿的功能时都自觉的将其拆了出来,刚要把标题写死,猛然想到 "如果这里用传参变量,UI加个参数配置,这不就成通用组件了吗!"。最早、最彻底的把后端模块思维引入到前端,所以 React 组件生态迅速壮大。

应该说 React 的出现加快了前端发展的进程,拉近了前端与后端开发的距离,之后各个框架便纷纷效仿,逐渐青睐对 Commonjs 规范的支持。业务开发中,将组件化**彻底贯彻其中,许多人都迫不及待的希望发布自己平时积累的组件,下面就来谈谈如何从零开始构建组件库。

如何从零构建组件库

组件库的教程不只对 React 适用,其中提到的**,对大多数通用组件编写都有效。

本篇介绍的全部构建脚本代码都可以在 https://github.com/fex-team/fit/blob/master/scripts 找到。

分散维护 VS 集中维护

准备搭建组件库之初,这估计是大家第一个会考虑到的问题:到底把组件库的代码放在一起,还是分散在各个仓库?

调查发现 Antd 是将所有组件都写入一个项目中,这样方便组件统一管理,开发时不需要在多个仓库之间切换,而且预览效果只需运行跟项目,而不是为每个组件开启一个端口进行预览。其依赖的 react-components 组件库中的组件以 rc 开头,不过这个项目没有进行集中管理。

Material-UI、 React-UI 采用集中式管理等等。

但是集中管理有一些弊端。

  • 引用默认是载入全部,虽然可以通过配置方式避免,(Antd 还提供了 webpack 插件做这个事情),但安装时必须全量。
  • 无法对每个组件做更细粒度的版本控制。
  • 协作开发困难,每个人都要搭建一套全环境,提 pr 也具有不少难度。

分散维护的弊端更明显,无法在同一个项目中观察全局,修改组件后引发的连带风险无法观察,组件之间引用需要发布或者 mock,不直观,甚至组件之间的版本关联、依赖分析都没法有效进行管理。

因此 Fit 组件库在设计时,也经历了一番酝酿,最后采用了两者结合的方案,分散部署+集中维护的折中方式,而且竟能结合了两者各自的优点:

  • 建立根项目 Root,用来做整体容器,顺便还可以当对外网站
  • 建立 Group,并在其中建立多个组件仓库
  • 开发时只要用到项目 Root,根据依赖文件编写脚本自动拉取每个仓库中的内容
  • 主要负责人拉取全部子项目仓库,子组件维护者只需要下载对应组件
  • 发布时独立发布每个组件
  • 管理时,统一管理所有组件

package 版本统一

组件的依赖版本号需要统一,比如 fit-input ,fit-checkbox,fit-auto-complete 都依赖了 lodash,但因为先后开发时隔久远,安装时分别依赖了 2.x 3.x 4.x,当别人一起使用你最新版的时候,就会无辜的额外增加了两个 lodash 文件大小。

更可怕的是,连 React 的版本都不可靠,之前就遇到过一半组件留在 0.14.x ,一半新组件装了 15.x 的情况,直接导致了线上编译后项目出错,因为多个 React 组件不能同时兼容,这只是不能并存的其中一个例子。

因为项目开发时组件在一起,使统一版本号成为可能。我们将所有依赖到的组件都安装在 Root 项目中,每个组件的 package.json 由脚本自动生成,这个脚本需要静态扫描每个组件的 Import 或 require 语法,分析到依赖的模块后,使用根目录的版本号,填写在组件的 package.json 中,核心代码如下:

ca505894-6bf9-47bd-8c5d-3a5fe39c3144

先收集每个组件中的依赖, 如果在根目录的 package.json 中找到了,就使用根目录的版本号。

完整代码仓库:https://github.com/fex-team/fit/blob/master/scripts/module-manage/utils/upgrade-dependencies.js

依赖联动

依赖联动是指,fit-button 更新了代码,如果 fit-table 依赖了 fit-button,那么其也要发布一个版本,更新 fit-button 依赖的版本号。

除了依赖第三方模块,组件之间可能也有依赖,如果将模块分散维护,想更新一下依赖模块都需要发布+下载,非常消耗时间,而且依赖联动根本没法做。集中维护使用 webpack 的 alias 方案,在 typescript 找不到引用,总之不想找麻烦就不能写 hack 的代码。

回到 Fit 组件库结构,因为所有组件都被下载到了 Root 仓库下,因此组件之间的引用也自然而然的使用了相对路径,这样组件更新麻烦的问题迎刃而解,唯一需要注意的是,发布后,将所有引入非本组件目录的引用,替换成为 npm 名称,例如:

// 源码的内容
import Button from '../../../button'
// 发布时,通过编译脚本替换为
import Button from 'fit-button'

依赖联动,需要在发布时,扫描所有组件,找出所有有更新的组件,并生成一项依赖配置,最后将所有更新、或者被依赖的组件统一升级版本号加入发布队列。

完整代码仓库:https://github.com/fex-team/fit/blob/master/scripts/module-manage/utils/version.js

inline Or ClassName?

React 组件使用 inline-style 还是 className 是个一直有争论的话题,在此我把自己的观点摆出:className 比 inline-style 更具有拓展性。

首先 className 更符合 css 使用习惯,inline-style 无疑是一种退步,既抛弃了 sass less post-css 等强大预编译工具的支持,也大大减弱了对内部样式的控制能力,它让 css 退化到了没有优先级,没有强大选择器的荒蛮时代。

其次没有预编译工具的支持,别忘了许多 css 的实验属性都需要加上浏览器前缀,除非用库把强大的 autoprefixer 再实现一遍。

使用 className 可以很好的加上前缀,在追查文件时能得到清晰的定位,下面是我们对 CSS 命名空间的一种实现 ——html-path-loader css-path-loader 插件 配合 webpack 后得到的调试效果:

文件结构

023fa032-ee32-4295-8d58-a5b29580eec3

DOM结构对应 className

(https://cloud.githubusercontent.com/assets/7970947/17137106/edee7cc8-536b-11e6-9e5f-3dda7a87cf39.png)

直接从 dom 结构就能顺藤摸瓜找到文件,上线时再将路径 md5 处理。

这个插件会自动对当前目录下的 scss或less 文件包一层目录名,在 jsx 中,使用 className="_namespace" ,html-path-loader 会自动将 _namespace 替换为与 css 一致的目录名称。

typescript 支持

既然前端模块化向后端看齐,强类型也成为了无可阻挡的未来趋势,我们需要让开发出的组件原生支持 typescript 的项目,得到更好的开发体验,同时对 js 项目也能优雅降级。

由于现在 typescript 已原生支持 npm 生态,如果组件本身使用 typescript 开发,我们只需要使用 tsc -d 命令在目录下生成对应的 d.ts 定义文件,当业务项目使用 typescript 的时候,会自动解析 d.ts 作为组件的定义。

再给 package.json 再上 typings 定义指向入口文件的 d.ts ,那么整体工作基本就完成了。

最后,对于某些没有定义文件的第三方模块,我们在根项目 Root 中写上定义文件后, 导入时将文件拷贝一份到组件目录内,并修正相对引用的位置,保证组件独立发布后还可以找到依赖文件。

完整代码仓库:https://github.com/fex-team/fit/blob/master/scripts/module-manage/push.js

更强的拓展性

React 组件的拓展性似乎永远也争论不休,无论你怎样做组件,都会有人给你抱怨:要是这里支持 xxx 参数就好了。

毕竟使用了组件,就一定不如自己定制的拓展性更强,节省了劳动力,就要付出被约束的代价,Fit 作为一个大量被业务线使用的组件库,使用了透传方式尽可能的增强组件拓展性。

我们写了一个很简单的透传组件:fit-transmit-transparently,使用方法如下:

import {others} from 'fit-transmit-transparently'
const _others = others(new Component.defaultProps, this.props)
// ... <div {..._others}/>

它会将 this.props 中,除了 defaultProps 定义了的字段抽到 _others 中,直接透传给外围组件,因为 defaultProps 中定义了的字段默认是有含义的,因此不会对其进行操作,避免多次定义产生的风险。

现在 fit-input 就将 props 透传到了原生 Input 组件上,因此虽然我没有处理各类事件,但依然可以响应任意的 onKeyDown onKeyUp onChange onClick 等事件,也可以定义 style 来覆盖样式等等。

fit-number 继承了 fit-input,因此依然支持所有原生事件,fit-auto-complete 也继承了 fit-input,对其添加的例如 onBlur 等事件依然会被透传到 input 框中。

组件的 dom 结构要尽量精简,透传属性一般放置在最外层,但对于 input 这种重要标签,透传属性最好放置与其之上,因为用户的第一印象是 onChange 应该被 input 触发。

同构模块引用技巧

当依赖的模块不支持 node 环境,但还必须加载它的时候,我们希望在后端忽略掉它,而在前端加载它;当依赖模块只处理了后端逻辑,在前端没必要加载时,我们希望前端忽略它,后端加载它,下面是实现的例子:

// 前端加载 & 后端不加载
if (process.browser) {
    require ('module-only-support-in-browser');
}
// 后端加载 & 前端不加载
require ('module-only' + '-support-in-node')

前端加载&后端不加载的原理是,前端静态扫描到了这个模块,因此无条件加载了它(前端引用是静态扫描),后端会因为判断语句而忽略掉这个引用(后端引用是运行时)。

后端加载&前端不加载的原理是,将模块引用拆成非字面量,前端静态扫描发现,这是什么鬼?忽略掉吧,而 node 会老老实实的把模块拼凑起来,发现还真有 module-only-support-in-node 这个模块,因此引用了它。

一份代码 Demo & 源码显示

webpack 提供了如下 api 拓展 require 行为:

![0cb8955e-8050-4c56-a5ee-00e09c920336](https://cloud.gi
thubusercontent.com/assets/7970947/17137141/1f00657e-536c-11e6-8c5e-8c023c796da1.png)
7ab38b0f-2d22-45b2-be5f-6a86d2e25665

  • ! 打头的,忽略配置文件的 preLoaders 设置
  • !!打头的,忽略所有配置文件的设置
  • -! 打头的,忽略 preLoaders 和 loaders ,但 postLoaders 依然有效

一般来说,我们都在配置文件设置了对 js 文件的 loader,如果想引用源码,正好可以用 !! 打头把所有 loaders 都干掉,然后直接用 text-loader 引用,这样我们就得到了一份纯源码以供展示。

组件编写一些注意点

理解 value 与 defaultValue

defaultValue 属性用于设置组件初始值,之后组件内部触发的值的改变,不会受到这个属性的影响,当父级组件触发 render 后,组件的值应当重新被赋予 defaultValue。

value 是受控属性,也用来设置值,但除了可以设置初始值(优先级比 defaultValue 高)之外,还应满足只要设置了 value,组件内部就无法修改状态的要求,这个组件的状态只能由父级授予并控制,所以叫受控属性。

value 与 defaultValue 不应该同时存在,最好做一下检查。

render 函数中最小化代码逻辑

React 的宗旨是希望通过修改状态来修改渲染内容,尽量不要在 render 函数中编写过多的业务逻辑和判断语句,最好将能抽离成状态的放在 state 中,在 componentWillReceiveProps 中改变它

使用 auto-bind

如果你也使用 ES6 写法,那么最好注意使用 auto-bind 插件,将所有成员函数自动绑定 this,否则 .bind(this) 会返回一个新的函数,一来损耗性能,二来非常影响子组件的 shouldComponentUpdate 判断!

慎用 componentWillMount

对于同构模块,React 组件的生命周期 componentWillMount 会在 node 环境中执行,而 componentDidMount 不会。

要避免在 willMount 中操作浏览器的 api,也要避免将可有可无的逻辑写在其中,导致后端服务器渲染吃力(目前 React 渲染是同步的),无关初始化逻辑应当放在 didMount 中,由客户端均摊计算压力。对于影响到页面渲染的逻辑还是要放在 willMount 中,不然后端渲染就没有意义。

巧用 key 做性能优化

React 组件生命周期中 shouldComponentUpdate 方法是控制组件状态改变时是否要触发渲染的,但当同级组件量非常庞大时,即便在每个组件做是否渲染的判断都会花费几百毫秒,这时我们就要选择更好的优化方式了。

新的优化方式还是基于 shouldComponentUpdate ,只不过判断条件非常苛刻,我们设定为只有 state 发生变化才会触发 render,其它任何情况都不会触发。这种方式排除了对复杂 props 条件的判断,当 props 结构非常复杂时,对没有使用 immutable 的代码简直是一场灾难,我们现在完全忽略 props 的影响,组件变成为了完完全全封闭的王国,不会听从任何人的指挥。

当我们实在需要更新它时,所有的 props 都不起作用,但是可以通过 key 的改变来绕过 shouldComponentUpdate 进行强制刷新,这样组件的一举一动完全被我们控制在手,最大化提升了渲染效率。

组件级 Redux 如何应用

组件级 Redux 使用场景主要在于组件逻辑非常复杂、或使用时,父子 dom 强依赖,但可能不会被用于直接父子级的场景,例如 fit-scroll-listen 组件,用来做滚动监听:

import { ScrollListenBox, ScrollListenNail , ScrollListen, createStore } from 'fit-scroll-listen'
const store = createStore()

export default class Demo extends React.Component {
    render() {
        return (
            <div>
                <ScrollListenBox store={store}>
                    <ScrollListenNail store={store} title="第一位置">第一个位置</ScrollListenNail>
                    内容
                </ScrollListenBox>
                <ScrollListen store={store}/>
            </div>
        )
    }
}

ScrollListenBox 是需要监听滚动的区域,ScrollListenNail 是滚动区域中需要被标记的节点,ScrollListen 是显示滚动监听状态的 dom 结构。

由于业务需求,这三个节点很可能无法满足直接父级子关系,而且上图应用中,ScrollListen 就与 ScrollListenBox 是同级关系,两者也无办法通信,因此需要使用 Redux 作数据通信。

我们从 createStore 实例化了一个 store,并传递给每一个 fit-scroll-listen,这样他们即便隔着千山万水,也能畅快无阻的通信了。

npm 资源加载简析

webpack&fis 最核心的功能可以说就是对 npm 生态的支持了,社区是编译工具的衣食父母,支持了生态才会有未来。

为了解决业务线可能遇到的各种 npm 环境问题,我们要有刨根问底的精神,了解 npm 包加载原理。下面会一步一步介绍一个 npm 模块是如何被解析加载的。

文件查找

无论是 webpack、fis,还是其它构建工具,都有文件查找的钩子,当解析了类似 import '../index.js' 时,会优先查找相对路径,但解析到了 import 'react' 便无从下手,因为这时构建工具还不知道这种模块应该从哪查找,我们就从这里开始截断,当出现无法找到的模块时,就优先从 node_modules 文件夹下进行查找(node_modules 下查找模块放到后面讲)。

由于 npm 模块打平&嵌套两种方案可能并存,每次都递归查找的效率太低,因此我们首先会把 node_modules 下所有模块缓存起来,这里分为两种方案:

  1. 根据node_modules 下文件夹遍历读取,优点是扫描全面,缺点是效率低。
  2. 根据 package.json 中 deps(可以设置忽略devDeps)进行扫描,优先是效率高,缺点是忘记 --save 模块会被忽略。

将所有模块存到 map 后,我们直接就能 get 到想要的模块,但是要注意版本问题:如果这个模块是打平安装的,那毫无疑问不会存在同模块多版本号问题,[email protected] 后即便是打平安装,但遇到依赖模块已经在根目录存在,但版本号不一致,还是会采用嵌套方式,而 [email protected] 无论如何都会用嵌套的方式。

因此我们的目的就明确了,不用区分 npm 的版本,如果这个当前文件位于非 node_modules 文件夹中,直接从根目录引用它需要的模块,如果这个当前位于 node_modules 中,优先从当前文件夹中的 node_modules 获取,如果当前文件夹的 node_modules 不存在依赖文件,就从根目录取。

解读 package.json

找到了依赖在 node_modules 里的根目录,我们就要解析 package.json 进行引用了,main 这个属性是我们的指明灯,告诉我们在复杂的包结构中,哪个文件才是真正的入口文件。

我们还要注意 package.json 里设置了 browser 属性的模块,由于我们做的是前端文件加载,所以这个属性对我们有效,将依赖模块的路径用 browser 做修正即可,一般都是同构模块使用它,特意将前端实现重写了一遍。所以当 browser 属性为字符串时我们就放弃对 main 信任,转而使用 browser 属性来代替入口路径。

当 browser 属性为对象时,情况复杂一些,因为此时 browser 指代的含义不是入口文件的相对路径,而是对这个模块内部使用的包引用的重定向,此时我们还不能信任 main 对入口的引导,初始化时将 browser 对象保存,整体查找顺序是:优先查找当前模块的 browser 设置,替换 require 路径,找到模块后,如果 browser 是字符串,优先用其路径,否则使用 main 的路径。

环境变量

npm 生态非常惯着用户,我们希望直接在模块中使用 Buffer process.env.NODE_ENV 等变量,而且通常会根据当前传入的变量环境做判断,可能开发过程中载入了不少影响性能,但方便调试的插件,当NODE_ENVproduction 时会自动干掉,如果我们不对这种情况做处理,上线后无法达到模块的最佳性能(甚至报错,因为 process 没有定义)。

编译脚本要根据用户的设置,比如 CLI 使用了 NODE_ENV=production ,或者在插件中申明,就将代码中 process.env.NODE_ENV 替换为对应的字符串,对与 Buffer 这类模块也要单独拎出来替换成 require。

模块加载

为了让浏览器识别 module.exports (es6 的 export 语法交给 babel 或者 typescript 转换为 module.exports)、define、require,需要给模块包一层 Define,同时把模块名缓存到 map 中,可以根据文件路径起名字,也可以使用 hash,最后 require 就从这里取即可。

由于是简析,不做更深入的分析,剩下的工作基本上是优化缓存、对更多功能语法的支持。

同构方案

为了保证传统的首屏体验,同时维持单页应用的优势,替代方案走了不少弯路。从单独写一份给爬虫看的页面,到使用 phantomjs 抓取静态页面信息,现在已经步入了后端渲染阶段,由于其可维护性与用户体验两者兼顾,所以才快速壮大起来。

后端渲染

无论何种后端渲染方案,其本质都是在后端使用 nodejs 运行前端的 js 代码,有的库使用同步渲染,也有异步,React 目前官方实现属于同步渲染,关于同步渲染遇到的问题与解决方案,会在 "同构请求" 这一节说明。

使用 React 进行后端渲染代码如下:

import {renderToString} from 'react-dom/server'
const componentHTML = renderToString(React.createElement('div'))

稍稍改造,将其与 Redux 结合,只需要将 Provider 作为组件传入,并传入 store 来存储页面数据,最后获得的 initialState 就是页面的初始数据:

import {Provider} from 'react-redux'
import configureStore from '../client/store'
const store = configureStore()
const InitialView = React.createElement(Provider, {store}, React.createElement('div'))
const componentHTML = renderToString(InitialView)
// Redux 后端渲染后的数据初始状态
const initialState = store.getState()

这样,将页面初始数据打在 window 全局变量中,前端 Redux 初始化直接用后端传来的初始数据,就可以将页面状态与后端渲染衔接上。

对于 Redux,是项目数据结构的抽象,最好按照 state 树结构拆分文件夹,将 Redux 数据流与页面、组件完全解耦。

同构请求

同构请求是对后端渲染的进一步处理,使后端渲染不仅仅能生成静态页面数据,还可以首屏展现依赖网络请求数据所渲染出的 dom 结构。

同构请求的优化主要体现在后端处理,因为前端没有选择,只能体现在 Http 请求。现在有两种比较理想的方案:

http 请求

这种方案依赖同构的请求库,例如 axios,在后端渲染时,能和前端一样发出请求并获取数据。主要注意一下,如果使用的是同步渲染的框架,例如 React,我们需要将请求写在生命周期之外,在其运行之前抽出来使用 Promise 调用,待请求 Ready 之后再执行一遍渲染即可。

这种方案修改成本中等,需要把所有同构请求从组件实例中抽离出来,可能获取某些依赖组件实例的数据源比较困难,不过可以满足大部分简单数据请求。

这种方案稍加改造,可以产生一套修改成本几乎为零的方案,缺点是需要渲染两遍。第一遍渲染,将所有组件实例中的请求实例抽取出来,第二步类似使用 Promise.all 等数据获取完毕,最后再执行一遍渲染即可,缺点是渲染两遍,而且网络请求耗费 IO,访问外网数据速度很慢,和直接调用函数的速度完全不在一个数量级。所以我们在想,能不能将前端的 http 请求在后端转换为直接调用函数?

直接命中函数

这个方案基于上一套方案优化而来,唯一的缺点是渲染了两遍,对项目改动极小,后端请求效率最大化。

希望后端直接命中函数,需要对整体项目框架进行改造,因为我们要提前收集全部的后端方法存储在 Map 中,当后端请求执行时,改为从 Map 中抽取方法并直接调用。

后端响应请求的方法,我们采用装饰器定义路由与收集到 Map:

import {initService, routerDecorator} from 'fit-isomorphic-redux-tools/lib/service'
class Service {
    @routerDecorator('/api/simple-get-function', 'get')
    simpleGet(options:any) {
        return `got get: ${options.name}`
    }
}
new Service()

fit-isomorphic-redux-tools 组件导出的 routerDecorator 方法做了两件事,第一件是绑定路由,第二件是将收集到的函数塞到 Map 中,key 就是 url,用于同构请求在后端定位查找。

前端代码中,action 中调用 fit-isomorphic-redux-tools 提供的 fetch 方法,这个方法也做了两件事,第一件是前端模块根据配置发请求,第二件在后端环境下,通过 url 查找上一段代码在 routerDecorator 注册的函数,如果命中了,会直接执行该函数。

import fetch from 'fit-isomorphic-redux-tools/lib/fetch'
export const SIMPLE_GET_FUNCTION = 'SIMPLE_GET_FUNCTION'
export const simpleGet = ()=> {
    return fetch({
        type: SIMPLE_GET_FUNCTION,
        url: '/api/simple-get-function',
        params: {
            name: 'huangziyi'
        },
        method: 'get'
    })
}

上面的 fetch 方法内部封装了对浏览器与node环境的判断,如果是浏览器环境则直接发送请求,node环境则直接调用 promise。在前后端都经过 redux 处理,为了让 reducer 拿到 promise 后的数据,我们封装一个 redux 中间件:

export default (store:any) => (next:any) => (action:any) => {
    const {promise, type} = action
    // 没有 promise 字段不处理
    if (!promise) return next(action)

    const BEFORE = type + '_PROMISE_BEFORE'
    const DONE = type + '_PROMISE_DONE'
    next({type: BEFORE, ...action})
    if (process.browser) {
        // 前端一定是 promise
        return promise.then(req => {
            next({type: DONE, req, ...action})
        })
        //.catch...
    } else {
        const result = promise(action.data, action.req)
        if (typeof result.then === 'function') {
            // 处理 promise 情况 (比如 async)
            return result.then((data: any) => {
                next({data, ...action})
                return true
            })
            //.catch
        } else {
            // 处理非 promise 情况
            return next({type: DONE, ...action})
        }
    }
}

上述代码对所有包含 promise 的 action 起作用,在前端会在 promise 执行完毕后触发 [actionName]_PROMISE_DONE ,在 reducer 里监听这个字符串即可。后端会直接调用方法,因为方法可能是同步也可能是异步的,比如下面就是异步的:

export const test = async (req:any, res:any) => {
    return 'test';
}

所以做了两套处理,async 最终返回一个 promise,如果不用 async 包裹住则没有,因此 result.then === 'function' 便是判断这个方法是否是 async 的。

给出一套上述理论的完整实现,有兴趣的同学可以安装体验下:https://github.com/ascoders/isomorphic-react-redux-app

编译优化

为了避免模块太大导致的加载变慢问题,我们通过 require.ensure 动态加载模块,这也对 HTTP2.0 并发请求相当友好。

webpack&Fis 按需加载

使用了 require.ensure 的模块,webpack&fis会将其拆分后单独打包,并在引用时转换为 amd 方式加载,下面是与 react-router 结合的例子:

<IndexRoute getComponent={getHome}/>
const getHome = (nextState: any, callback: any)=> {
    require.ensure([], function (require: any) {
        callback(null, require('./routes/home').default)
    })
}

这样遍做到了对业务模块的按需加载,而且业务模块代码不多,可以忽略编译时对性能的影响:

7ab38b0f-2d22-45b2-be5f-6a86d2e25665

如果是同构的模块,需要在 node 端对 require.ensure 做 mock 处理,因为 nodejs 可不知道 require.ensure 是什么!

if (typeof(require.ensure) !== 'function') {
    require.ensure = function (modules: Array<string>, callback: Function) {
        callback(require)
    }
}

现在访问 /home 这个 url,前端模块会先加载基础库文件,再动态请求 Home 这个组件,获取到组件后再执行其中代码,渲染到页面,但对于后端渲染,希望直接获取到动态加载的组件,并根据组件设置页面标题就变得困难,因此上面代码中, callback(require) 将 require.ensure 在后端改为的同步加载,因此可以直接获取到组件中静态成员变量,我们可以将例如页面标题写在页面级组件的静态成员变量中,例如:

export default class Home extends React.Component <Props, States> {
    public static title: string = 'auth by ascoders'
}

在 node 端这样处理:

// 找到最深层组件的 title
const title = renderProps.components[renderProps.components.length-1].title

并将获取到的 title 插入到模板的 title 中,让页面初始化时标题就是动态组件加载后就要设置的,而且更利于搜索引擎对页面初始状态的抓取,实现了前端对后端的控制反转。

相比业务代码,npm 生态的模块比起来真是庞然大物,动辄 2000+ 细文件的引用,虽然开启了增量 build,但文件的整合打包依然非常影响开发体验,因此有必要在开发时忽略 npm 模块。

webpack 的编译优化

编译优化的最终目的是将大型第三方模块拆开,在编译时直接跳过对其的编译,并直接在页面中引用编译好的脚本,因此第一步需要将所有不顺眼的模块全部打包到 vendor.js 文件中:

// webpack 配置截取
entry: {
    react: ['react', 'react-dom', 'react-router'],
    fit: ['fit-input', 'fit-isomorphic-redux-tools']
},

output: {
    filename: '[name].dll.js',
    path    : path.join(process.cwd(), 'output/dll'),
    library : '[name]'
},

plugins: [
    new webpack.optimize.CommonsChunkPlugin('common.js'),
    new webpack.DllPlugin({
        path: path.join(process.cwd(), 'output/dll', '[name]-mainfest.json'),
        name: '[name]'
    })
]

entry 定义了哪些文件需要抽出,output 中,library 定义了暴露在 window 的 namespace, plugins 注意将 name 设置为与 library 相同,因为引用时参考的是这个名字。

我们执行 webpack,执行结果如下:

b983a356-0415-4785-8191-e48d94a3ac71

产出了 dll 与 mainfest 两种文件,dll 是打包后的文件,mainfest 是配置文件

c95659f8-7758-47b1-926e-724f19a71241

发现配置了两个重要属性,一个是暴露在 window 的 namespace ,另一个是所有相对路径引用的模块名,webpack 打包后会转化为数字进行查找,防止路径过长在 windows 下报错。

下面开始配置开发配置 webpack.config.js:

plugins: [
    new webpack.DllReferencePlugin({
        context : path.join(__dirname, '../output/dll'),
        manifest: require(path.join(process.cwd(), 'output/dll/react-mainfest.json'))
    }),
    new webpack.DllReferencePlugin({
        scope   : 'fit',
        manifest: require(path.join(process.cwd(), 'output/dll/fit-mainfest.json'))
    }),
    new webpack.DllReferencePlugin({
        scope   : 'common',
        manifest: require(path.join(process.cwd(), 'output/dll/common-mainfest.json'))
    })
],

执行结果只有很小的大小:

b3250046-4bf8-4730-820a-70f996357226

再将所有文件引用到页面中,这样初始化构建时先执行 dll 脚本,生成打包文件后再仅对当前项目打包&监听,这就解决了开发时体验问题。

可视化拖拽平台组件

最后分享一下我们的终极解决方案 fit-gaea,它是一个组件,是可视化拖拽平台,安装方式如下:

npm install fit-gaea
import {Gaea, Preview} from 'fit-gaea'

Gaea 是编辑器本身,它主要负责拖拽视图,并生成对应 json 配置。 Preview 是部署组件,将 Gaea 生成的 json 配置传入,可以自动生成与拖拽编辑时一模一样的页面。

最大特色在于组件自定义,右侧菜单栏罗列了可供拖拽的组件,我们也可以自己编写 React 组件在 Gaea 初始化时传入自定义组件,自由设置这个组件可以编辑的字段,并且在组件中使用它。

对于粗粒度的运营招聘页,甚至可以将整个页面作为一个自定义组件传入,因为每个页面非常雷同,只需要定义几处文字修改即可,生成一个新页面,只需要将自定义组件拖拽出来实例化,并且简单修改自己字段即可。

同时 fit-gaea 也提供了很多细粒度的通用组件,例如 按钮、段落、输入框、布局组件 等等,我们也可以自己编写一些细粒度组件,通过任意嵌套组合的方式,生成更加复杂的组合,平台也支持将任意组合成组,打成一个组件保存在工具栏,我们可以通过嵌套组合的方式生成新的组件。

这个平台本质就是一个组件,业务线不需要花费大量精力重复编写非常复杂的拖拽平台,只需要将精力关注在编写与业务紧密结合的定制组件,再传入 fit-gaea,就可以让写的组件变得可以拖拽编辑。

fit-gaea api 文档地址:http://fit.baidu.com/components/pc/gaea
fit-gaea demo 体验地址: http://fit.baidu.com/designer

总结

分享进入了尾声,对以上经验做一个总结。通过对组件库灵活分散的管理,同时透传暴露更多 api 提高组件可用性,提供从组件,到同构方案,最后到开发体验优化与打包性能优化,可以说提供了一套完整的开发方案。

同时通过 React-Native 方案提高三端开发的效率,开发出 web、native 通用的组件,通过 fit-gaea 可视化编辑组件的支持,让编辑器生成横跨三端的页面,并且不受发版、前端人力资源限制,运营&产品都可以快速创建任何定制化页面。

最后,Fit 组件我们一直在努力维护中,我也希望将编写组件的经验分享给更多人,让更多人参与到构建组件生态的队伍中,愿组件社区这棵大树枝繁叶茂。

ReactNative 大图手势浏览技术分析

支持通用的手势缩放,手势跟随,多图翻页

手势系统

1

通过 PanResponder.create 创建手势响应者,分别在 onPanResponderMoveonPanResponderRelease 阶段进行处理实现上述功能。

手势阶段

大体介绍整体设计,在每个手势阶段需要做哪些事。

开始

onPanResponderGrant

// 开始手势操作
this.lastPositionX = null
this.lastPositionY = null
this.zoomLastDistance = null
this.lastTouchStartTime = new Date().getTime()

开始时非常简单,初始化上一次的唯一、缩放距离、触摸时间,这些中间量分别会在计算增量位移、增量缩放、用户松手意图时使用。

移动

onPanResponderMove

if (evt.nativeEvent.changedTouches.length <= 1) {
    // 单指移动 or 翻页
} else {
    // 双指缩放
}

在移动中,先根据手指数量区分用户操作意图。

当单个手指时,可能是移动或者翻页

先记录增量位移:

// x 位移
let diffX = gestureState.dx - this.lastPositionX
if (this.lastPositionX === null) {
    diffX = 0
}
// y 位移
let diffY = gestureState.dy - this.lastPositionY
if (this.lastPositionY === null) {
    diffY = 0
}

// 保留这一次位移作为下次的上一次位移
this.lastPositionX = gestureState.dx
this.lastPositionY = gestureState.dy

获得了位移距离后,我们先不要移动图片,因为横向操作如果溢出了屏幕边界,我们要触发图片切换(如果滑动方向还有图),此时不能再增加图片的偏移量,而是要将其偏移量记录下来存储到溢出量,当这个溢出量没有用完时,只滑动整体容器,不移动图片,用完时再移动图片,就可以将移动图片与整体滑动连贯起来了。

// diffX > 0 表示手往右滑,图往左移动,反之同理
// horizontalWholeOuterCounter > 0 表示溢出在左侧,反之在右侧,绝对值越大溢出越多
if (this.props.imageWidth * this.scale > this.props.cropWidth) { // 如果图片宽度大图盒子宽度, 可以横向拖拽
    // 没有溢出偏移量或者这次位移完全收回了偏移量才能拖拽
    if (this.horizontalWholeOuterCounter > 0) { // 溢出在右侧
        if (diffX < 0) { // 从右侧收紧
            if (this.horizontalWholeOuterCounter > Math.abs(diffX)) {
                // 偏移量还没有用完
                this.horizontalWholeOuterCounter += diffX
                diffX = 0
            } else {
                // 溢出量置为0,偏移量减去剩余溢出量,并且可以被拖动
                diffX += this.horizontalWholeOuterCounter
                this.horizontalWholeOuterCounter = 0
                this.props.horizontalOuterRangeOffset(0)
            }
        } else { // 向右侧扩增
            this.horizontalWholeOuterCounter += diffX
        }

    } else if (this.horizontalWholeOuterCounter < 0) { // 溢出在左侧
        if (diffX > 0) { // 从左侧收紧
            if (Math.abs(this.horizontalWholeOuterCounter) > diffX) {
                // 偏移量还没有用完
                this.horizontalWholeOuterCounter += diffX
                diffX = 0
            } else {
                // 溢出量置为0,偏移量减去剩余溢出量,并且可以被拖动
                diffX += this.horizontalWholeOuterCounter
                this.horizontalWholeOuterCounter = 0
                this.props.horizontalOuterRangeOffset(0)
            }
        } else { // 向左侧扩增
            this.horizontalWholeOuterCounter += diffX
        }
    } else {
        // 溢出偏移量为0,正常移动
    }

上述代码表示在溢出时,优先计算溢出量,并且当收缩时,用增量位移抵消溢出量,最后如果还有增量位移,就可以移动图片了:

3

// 产生位移
this.positionX += diffX / this.scale

还有横向不能出现黑边,因此移动到边界时会把位移全部转换为偏移量:

// 但是横向不能出现黑边
// 横向能容忍的绝对值
const horizontalMax = (this.props.imageWidth * this.scale - this.props.cropWidth) / 2 / this.scale
if (this.positionX < -horizontalMax) { // 超越了左边临界点,还在继续向左移动
    this.positionX = -horizontalMax
    this.horizontalWholeOuterCounter += diffX
} else if (this.positionX > horizontalMax) { // 超越了右侧临界点,还在继续向右移动
    this.positionX = horizontalMax
    this.horizontalWholeOuterCounter += diffX
}
this.animatedPositionX.setValue(this.positionX)

PS:如果图片长宽没有超过外部容器大小,那么所有位移都算做溢出量,也就是图片不能被移动,所有移动都会当做在切换图片:

// 不能横向拖拽,全部算做溢出偏移量
this.horizontalWholeOuterCounter += diffX

我们在溢出量不为0的时候,执行切换图片的逻辑即可,由于本文主要介绍手势操作,切换图片的逻辑不再细说。最后再给Y轴限定低于盒子高度不能纵向移动:

if (this.props.imageHeight * this.scale > this.props.cropHeight) {
    // 如果图片高度大图盒子高度, 可以纵向拖拽
    this.positionY += diffY / this.scale
    this.animatedPositionY.setValue(this.positionY)
}

当两个手指时,希望缩放

2

先找到两手位置中 minX minY maxX maxY,由此计算缩放距离:

const widthDistance = maxX - minX
const heightDistance = maxY - minY
const diagonalDistance = Math.sqrt(widthDistance * widthDistance + heightDistance * heightDistance)
this.zoomCurrentDistance = Number(diagonalDistance.toFixed(1))

开始缩放:

let distanceDiff = (this.zoomCurrentDistance - this.zoomLastDistance) / 400
let zoom = this.scale + distanceDiff

if (zoom < 0.6) {
    zoom = 0.6
}
if (zoom > 10) {
    zoom = 10
}

// 记录之前缩放比例
const beforeScale = this.scale

// 开始缩放
this.scale = zoom
this.animatedScale.setValue(this.scale)

此时需要注意的时,我们还要以双手中心点为固定点,保持这个点在屏幕的相对位置不变,这样才能放大到用户想看的部分,我们需要对图片进行位移:

// 图片要慢慢往两个手指的中心点移动
// 缩放 diff
const diffScale = this.scale - beforeScale
// 找到两手中心点距离页面中心的位移
const centerDiffX = (evt.nativeEvent.changedTouches[0].pageX + evt.nativeEvent.changedTouches[1].pageX) / 2 - this.props.cropWidth / 2
const centerDiffY = (evt.nativeEvent.changedTouches[0].pageY + evt.nativeEvent.changedTouches[1].pageY) / 2 - this.props.cropHeight / 2
// 移动位置
this.positionX -= centerDiffX * diffScale
this.positionY -= centerDiffY * diffScale
this.animatedPositionX.setValue(this.positionX)
this.animatedPositionY.setValue(this.positionY)

其实是计算了这次的缩放增量,再计算出双手中心点距离屏幕正中心的距离,用这个距离乘以缩放增量就是这次缩放造成的中心点位移值,我们再反向移动这个位移抵消掉,就会产生这个点的相对位置不变的效果。

结束

结束时主要做一些重置操作,和判断是否翻到下一页或者关闭看大图。比如图片被移出边界需要弹回来,缩放的过小需要恢复原大小。

// 手势完成,如果是单个手指、距离上次按住只有预设秒、滑动距离小于预设值,认为是退出
const stayTime = new Date().getTime() - this.lastTouchStartTime
const moveDistance = Math.sqrt(gestureState.dx * gestureState.dx + gestureState.dy * gestureState.dy)
if (evt.nativeEvent.changedTouches.length <= 1 && stayTime < this.props.leaveStayTime && moveDistance < this.props.leaveDistance) {
    this.props.onCancle()
    return
} else {
    this.props.responderRelease(gestureState.vx)
}

当单手指结束,并且移动距离小于某个值,并且移动时间过短,就会认为是退出,否则手势结束,再判断是否要切换图片,切换图片部分不再展开说明,下面罗列出结束时需要注意重置的要点,粗看即可:

if (this.scale < 1) {
    // 如果缩放小于1,强制重置为 1
    this.scale = 1
    Animated.timing(this.animatedScale, {
        toValue: this.scale,
        duration: 100,
    }).start()
}

if (this.props.imageWidth * this.scale <= this.props.cropWidth) {
    // 如果图片宽度小于盒子宽度,横向位置重置
    this.positionX = 0
    Animated.timing(this.animatedPositionX, {
        toValue: this.positionX,
        duration: 100,
    }).start()
}

if (this.props.imageHeight * this.scale <= this.props.cropHeight) {
    // 如果图片高度小于盒子高度,纵向位置重置
    this.positionY = 0
    Animated.timing(this.animatedPositionY, {
        toValue: this.positionY,
        duration: 100,
    }).start()
}

// 横向肯定不会超出范围,由拖拽时控制
// 如果图片高度大于盒子高度,纵向不能出现黑边
if (this.props.imageHeight * this.scale > this.props.cropHeight) {
    // 纵向能容忍的绝对值
    const verticalMax = (this.props.imageHeight * this.scale - this.props.cropHeight) / 2 / this.scale
    if (this.positionY < -verticalMax) {
        this.positionY = -verticalMax
    } else if (this.positionY > verticalMax) {
        this.positionY = verticalMax
    }
    Animated.timing(this.animatedPositionY, {
        toValue: this.positionY,
        duration: 100,
    }).start()
}

// 拖拽正常结束后,如果没有缩放,直接回到0,0点
if (this.scale === 1) {
    this.positionX = 0
    this.positionY = 0
    Animated.timing(this.animatedPositionX, {
        toValue: this.positionX,
        duration: 100,
    }).start()
    Animated.timing(this.animatedPositionY, {
        toValue: this.positionY,
        duration: 100,
    }).start()
}

如果结束时速度超过某个阈值,也要切换图片,这个判断就很方便了:

if (gestureState.vx > 0.7) {
    // 上一张
    this.goBack.call(this)
} else if (gestureState.vx < -0.7) {
    // 下一张
    this.goNext.call(this)
}

// 水平溢出量置空
this.horizontalWholeOuterCounter = 0

最后重置水平溢出量,完成整套手势操作,可以进行周而复始的循环了。

从零开始用 proxy 实现 mobx

dynamic-object 只对外暴露了三个 api:observable observe Action,分别是 动态化对象变化监听懒追踪辅助函数

下面以开发角度描述实现思路,同时作为反思,如果有更优的思路,我会随时更新。

1. 术语解释

本库包含许多抽象概念,为了简化描述,使用固定单词指代,约定如下:

单词 含义
observable dynamic-object 提供的最重要功能,将对象动态化的函数
observe 监听其回调函数中当前访问到的 observable 化的对象的修改,并在值变化时重新出发执行
observer 指代 observe 中的回调函数

2. 总体思路

如果单纯的实现 observable,使用 proxy 很简单,可以完全监听对象的变化,难点在于如何在 observe 中执行依赖追踪,并当 observable 对象触发 set 时,触发对应 observe 中的 observer

每个 observable 对象触发 get 时,都将当前所在的 object + key 与当前 observer 对应关系存储起来,当其被 set 时,拿到对应的 observer 执行即可。

我们必须依赖持久化变量才能做到这一点,因为 observableset 过程,与 observerget 的过程是分开的。

3. 定义持久化变量

变量名 类型 含义
proxies WeakMap 所有代理对象都存储于此,当重复执行 observable 或访问对象子属性时,如果已经是 proxy 就从 proxies 中取出返回
observers WeakMap<object, Map<PropertyKey, Set>> 任何对象的 key 只要被 get,就会被记录在这里,同时记录当前的 observer,当任意对象被 set 时,根据此 map 查询所有绑定的 observer 并执行,就达到 observe 的效果了
currentObserver Observer 当前的 observer。当执行 observe 时,当前 observer 指向其第一个回调函数,这样当代理被访问时,保证其绑定的 observer 是其当前所在的回调函数。

4. 从 observable 函数开始

对于 observable(obj),按照以下步骤分析:

4.1. 去重

如果传入的 obj 本身已是 proxy,也就是存在于 proxies,直接返回 proxies.get(obj)。这种情况考虑到可能将对象 observable 执行了多次。(proxies 保存原对象与代理各一份,保证传入的是已代理的原对象,还是代理本身,都可以被查找到)

4.2. new Proxy

如果没有重复,new Proxy 生成代理返作为返回值。代理涉及到三处监听处理:get set deleteProperty

4.3. get 处理

get(target, key, receiver)

先判断 currentObserver 是否为空,如果为空,说明是在 observer 之外访问了对象,此时不做理会。

如果 currentObserver 不为空,将 object + key -> currentObserver 的映射记录到 observers 对象中。同时为 currentObserver.observedKeys 添加当前的映射引用,当 unobserve 时,需要读取 observer.observedKeys 属性,将 observers 中所有此 observer 的依赖关系删除。

最后,如果 get 取的值不是对象(typeof obj !== "object"),那么是基本类型,直接返回即可。如果是对象,那么:

  1. 如果在 proxies 存在,直接返回 proxy 引用。eg: const name = obj.name,这时 name 变量也是一个代理,其依赖也可追踪。
  2. 如果在 proxies 不存在,将这个对象重新按照如上流程处理一遍,这就是惰性代理,比如访问到 a.b.c,那么会分别将 a b c 各走一遍 get 处理,这样无论其中哪一环,都是代理对象,可追踪,相反,如果 a 对象还存在其他字段,因为没有被访问到,所以不会进行处理,其值也不是代理,因为没有访问的对象也没必要追踪。

4.4. set 处理

set(target, key, value, receiver)

如果新值与旧值不同,或 key === "length" 时,就认为产生了变化,找到当前 object + key 对应的 observers 队列依次执行即可。有两个注意点:

  1. 执行前先将当前执行的 observer 绑定关系清空:因为 observer 时会触发新一轮绑定,这样实现了条件的动态绑定。
  2. 执行前设置 currentObserver 为当前 observer,再执行 observer 时就可以将 set 正确绑定上。

4.5 deleteProperty

删除属性时,直接触发对应 observer

4.6 Map WeakMap Set WeakSet 的情况

这些类型的特点是有明确封装方法,其实更容易设置追踪,这次不使用 proxy,而是复写这些对象的方法,在 get set 中加上钩子。

5. observe 函数

立刻执行当前回调 observer,执行规则与 4.4 小节的 observers 队列执行机制相同。

有人会有疑惑,为什么 observe 要立即执行内部回调呢?如果初始化不不输出,结果可能会好看一些:

import { observable, observe } from "dynamic-object"

const dynamicObj = observable({
    a: 1
})

observe(() => {
    console.log('a:', dynamicObj.a)
})

dynamicObj.a = 2

以上会输出两次,分别是 a: 1a: 2。另外,可能会觉得这样与 react 结合,会不会导致初始化时增加不必要的渲染?

这两个都是很好的问题,但结论是:初始化执行是必要的:

  1. 如果初始化不执行,就没有办法执行初始数据绑定,那么后续的赋值完全找不到对应的 observer 是什么(除非做静态分析,但稍稍复杂些就不可能了)。
  2. 结合 react 时,通过生命周期 mixins 来覆写 render 函数,将初始化的 observe 绑定与后续 render 函数分离,达到首次 render 是 observe 初始化触发,后续 render 依靠依赖追踪自动触发 的效果,在 dynamic-react 章节会有深入介绍。

6. Action

Action 是用于写标准 action 的装饰器,有以下两种写法:

@Action setUserName() {..}
Action(setUserName)

起作用是将回调函数中发生的变更临时存储起来,当执行完时统一触发,并且同一个 observer 的多次 set 行为只会触发一次,并且执行时,获取到的是最终值,所有值的中间变化过程都会被忽略。

比如: 当 dynamicObj.a 初始值为 1 时,下面的代码不会触发 observer 执行:

Action(()=> {
  dynamicObj.a = 2
  dynamicObj.a = 1
})

7. 调用栈深度统计

要达到上面效果,需要额外定义一个持久化变量 trackingDeep,每次 Action 执行时,这个变量先自增 1,执行 observer 时,如果 trackingDeep 不为 0,就把 observer 存储在队列中,当回调函数执行完后,深度减 1,开始执行存储的队列,同样,如果深度不为 1 就跳过,深度为 0 就执行。

我们假象这种场景:

class Test {
  @Action setUser(info) {
    this.userStore.account = info.account
    this.setName(info.name)
  }

  @Action setName(name) {
    this.userStore.name = name
  }
}

当调用 setUser 时,其内部又调用了 setName,那么执行 setUser 时,trackingDeep 为 1,之后又执行到 setName 使得 trackingDeep 变成 2,内层 Action 执行完毕,trackingDeep 变回 1,此时队列不会执行,调用栈回退到 setName 后,trackingDeep 终于变成 0,队列执行,此时observer 仅触发了一次。

Tips: 这里有个优化点,当 trackingDeep 不为 0 时,终止 dynamic-object 的依赖收集行为。这么做的好处是,当 react render 函数中,同步调用 action 时,不会绑定到这个 action 用到的变量。

7.1 缺点

Action 的概念存在一个严重的缺点(但不致命),同时也是 mobx 库一直没有解决的问题,那就是对于异步 action 无可奈何(除非为异步 action 分段使用 Action,这也是 mobx 官方推荐的方式,也有 babel 插件来解决,但这样很 hack)。

我们思考如下代码:

class Test {
  @Action async getUser() {
    this.isLoading = true
    const result = await fetch()
    this.isLoading = false
    this.user = result 
  }
}

首先我们不希望它是忽略中间态的,否则初始将 isLoading 设置为 true 就没有意义了。

比较好的途径是,将这个异步 action 触发的 observer 塞入到队列中,每当遇到 await 就执行并清空队列,同时还可以支持 timeout 设定,比如设置为 100ms 时,如果 fetch 函数在 100ms 内执行完毕,就不会执行之前的队列,达到肉眼无法识别的间隔内不触发 loading 的效果。

理想很美好,可惜难点不在如何实现如上的设定,而是我们没办法将队列分隔开,考虑如下代码:

handleClick() {
  this.props.Test.getUser()
  this.props.Test.getArticle()
}

getUsergetArticle 都是异步的,如果我们将缓存队列共用一个,那么 getArticle 执行到 await 时,顺便会邪恶的把 getUser 队列中 observer 给执行了,纵使 getUserawait 还没有结束(可能出现 loading 在数据还没加载完成就消失)。

有人说,将 getUsergetArticle 队列分开不就行了吗?是的,但目前 javascript 还做不到这一点,见此处讨论。无论是 defineProperty 还是 proxy,都无法在 set 触发时,知道自己是从哪个闭包中被触发的。只知道触发的对象,以及被访问的 key,是没办法将 getUser getArticle
放在不同队列执行 observer 的。

目前我的做法与 mobx 一样,async 函数会打破 Action 的庇护,失去了收集后统一执行的特性,但保证了程序的正确运行。目前的解决方法是,为同步区域再套一层 Action,或者干脆将异步与同步分开写!

说实话,这个问题被 redux 用概念巧妙规避了,我们必须将这个函数拆成两个 dispatch。回头想想,如果我们也这么做,也完全可以规避这个问题,拆成两个 action 即可!但我希望有一天,能找到完美的解决方法。
另外希望表达一点,redux 的成功在于定义了许多概念与规则,只要我们遵守,就能写出维护性很棒的代码,其实 oo **也是一样!我们在使用 oo 时,将对 fp 的耐心拿出来,一样能写出维护性很棒的代码。

8. dynamic-react

dynamic-react 是 dynamic-object 在 react 上的应用,类似于 mobx-react 相比于 mobx。实现思路与 mobx-react 很接近,但是简化了许多。

dynamic-react 只暴露了两个接口 ProviderConnect,分别用于 数据初始化绑定更新与依赖注入

8.1 从 Provider 开始

Provider 将接收到的所有参数全局透传到组件,因此实现很简单,将接收到的所有字段存在 context 中即可。

8.2 Connect 的依赖注入

这个装饰器用于 react 组件,分别提供了绑定更新与依赖注入的功能。

由于 dynamic-react 是与 dynamic-object 结合使用的,因此会将全量 store 数据注入到 react 组件中,由于依赖追踪的特性,不会造成不必要的渲染。

注入通过高阶组件方式,从 context 中取出 Provider 阶段注入的值,直接灌给自组件即可,注意组件自身的 props 需要覆盖注入数据:

export default function Connect(componentClass: any): any {
    return class InjectWrapper extends React.Component<any, any>{
        // 取 context
        static contextTypes = {
            dyStores: React.PropTypes.object
        }

        render() {
            return React.createElement(componentClass, {
                ...this.context.dyStores,
                ...this.props,
            })
        }
    }
}

8.3 Connect 的绑定更新

见如上代码,我们通过拿到当前子组件的实例:componentClass.prototype || componentClass 将其生命周期函数重写为,先执行自定义函数钩子,再执行其自身,而且自定义函数钩子绑定上当前 this,可以在自定义勾子修改当前实例的任意字段,后续重写 render 也是依赖此实现的。

8.3.1 willMount 生命周期钩子

最重要阶段是在 willMount 生命周期完成的,因为对于 observer 来说,只要在初始化时绑定了引用,之后更新都是从 observe 中自动触发的。

整体思路是复写 render 方法:

  1. 在第一次执行时,通过 observe 包裹住原始 render 方法执行,因此绑定了依赖,将此时 render 结果直接返回即可。
  2. 非第一次执行,是由第一次执行时 observe 自动触发的(或者 state、props 传参变化,这些不管),此时可以确定是由数据流变动导致的刷新,因此可以调用 componentWillReact 生命周期。然后调用 forceUpdate 生命周期,因为重写了 render 的缘故,视图不会自动刷新。
  3. 由 state、props 变化导致的刷新,只要返回原始 render 即可。

注意第一次调用时,无论如何会触发一次 observer,为了忽略此次渲染,我们设置一个是否渲染的 flag,当 observer 渲染了,普通 render 就不再执行,由此避免 observe 初始化必定执行一次带来初始渲染两次的问题。

8.3.2 其他生命周期钩子

componentWillUnmountunobserve 掉当前组件的依赖追踪,给 shouldComponentUpdate 加上 pureRender,以及在 componentDidMountcomponentDidUpdate 时通知 devTools 刷新,这里与 mobx-react 实现思路完全一致。

9. 写在最后

最后给出 dynamic-object 的项目地址,欢迎提出建议和把玩。

React Editor 应用编辑器(2) - 编辑区基本设计

React Editor 应用编辑器(2) - 编辑区基本设计

上一篇说了如何实现灵活的拖拽,那么加上编辑功能,拖拽编辑器的两大核心功能就集齐了,剩下就是组件树、版本管理、模板、预览、快捷键、事件、动画、在线编辑代码以及部署方式这些边角功能,当然这些边角功能都不影响大局,这次我们来谈谈如何设计编辑区,类似下图的结构:

editor

  1. 从图中可以看出,编辑区涉及很多数据同步操作,我们使用了 mobx 很好的解决了这个问题,本篇文章因为重点描述编辑器设计,因此数据设计部分不会过多涉及。
  2. 除了基本属性设置,还应该有脚本设置、事件设置、动画设置,这些后续文章再讨论。

通用属性编辑

我们发现,样式才是最通用的属性,无论何种组件都逃离不了样式的设置,除此以外的属性都是自定义的,我们无法抽象出共性加以定制,但是样式是固定的,所以编辑区先要支持通用样式的编辑。

通用样式:背景 边框 字体 边距 布局 溢出处理 宽高 透明度

我们提供了对应的 13 余中定制编辑类型,比如像上图的边距调节器,专门针对边距进行修改,只要将编辑类型设置为 marginPadding ,编辑框中就会出现非常方便的边距调节器。

还有一种通用属性处理,比如有一个图标组件,实现以下效果:

icon

如果单独为图标类型设置一种编辑状态很不划算,这种分类可以划为 实例类型每一个图标其实是这个组件接收了某种参数后的状态,我们预先提供这些状态,编辑器将这些状态的组件分别实例化显示出来,每当鼠标点击时,就将当前状态覆盖到页面中。编辑配置入下:

const instances = [{
    name: 'icnMineSettingB'
}, {
    name: 'iconFindSearch'
}, {
    name: 'minus'
}]

const editOption = {
    field: null as string,
    label: '',
    editor: 'instance',
    editable: true,
    instance: instances
}

每一种图标样式其实就是 name 属性的不同,将这些 name 分别填充给实例化出来的组件,就能看到上图的效果,每次点击都会将 instances 中当前项作为 props 覆盖到页面组件中,便实现了预期效果,并且类似需求都具有很强的通用性。

通用属性如何设置在组件上

每个组件都是一个 React Class ,其 defaultProps 属性只要包含了 gaeaName gaeaIcon gaeaUniqueKeygaeaEdit 属性,就拥有编辑功能。

gaeaNamegaeaIcon 分别是显示在编辑器上的组件名和图标。

gaeaUniqueKey 是给每个组件起的唯一 key,所有类的寻找都以此为依据。

gaeaEdit 是数组,存放了编辑类型。

一个基本的 gaeaEdit 对象如下:

gaeaEdit = [{
    field: 'name',
    label: '名称',
    editor: 'text',
    editable: true
}]

editor 表示了当前属性用什么类型编辑器编辑,通用编辑类型有文本框,选择框,开关等等,除此之外还有定制编辑类型,比如 background

field 表示了编辑后对应改变哪个字段的值。

label 表示在编辑器上显示的提示文案。

editor 还有许多类型,比如 editor: number 类型的配置如下(透明度就是封装了 number 的编辑类型):

export const opacityEditor = {
    field: 'style.opacity',
    label: '透明度',
    editor: 'number',
    number: {
        units: [{
            key: '',
            value: '%'
        }],
        currentUnit: '',
        max: 100,
        min: 0,
        step: 1,
        inputRange: [0, 100],
        outputRange: [0, 1],
        slider: true
    },
    editable: true
}

使用时我们直接放入 gaeaEdit 数组中:

gaeaEdit = [
    opacityEditor
]

其中 utils 表示数字类型框可选的单位,inputRange outputRange 如上设置,那么编辑器中输入框填入80,实际会转换成 0.8 赋值到 opacity 属性上。

因为通用属性是固定的,所以我们提供了 gaeaHelper ,提供许多常用编辑类型:

import gaeaHelper from 'gaea-helper'

export class PropsGaea {
    gaeaName = '图标'
    gaeaIcon = 'square-o'
    gaeaUniqueKey = 'wefan-icon'
    gaeaEdit = [
        '图标',
        {
            field: null as string,
            label: '',
            editor: 'instance',
            editable: true,
            instance: instances
        },
        '布局',
        gaeaHelper.marginPaddingEditor,
        gaeaHelper.widthHeightEditor,
        '特效',
        gaeaHelper.opacityEditor
    ]
}

最后我们写自定义的 props 类集成描述编辑状态的 PropsGaea

export class Props extends PropsGaea {
    name = '名称'
}

将其实例化后赋值在 defaultProps 即可:

static defaultProps = new Props()

自定义属性编辑

值得寻味的是,通用属性看起来其实更像定制属性,而自定义属性其实更需要通用设计。

许多时候编辑器需要修改的属性都是某些字段,而这些字段都其对应的类型和通用编辑规则,所以我们提供了基础的 text number selector switch array object 等通用编辑类型,并且通过额外配置来适配简单需求。

比如 number 类型的编辑配置:

{
    field: 'style.opacity',
    label: '透明度',
    editor: 'number',
    number: {
        units: [{
            key: '',
            value: '%'
        }],
        currentUnit: '',
        max: 100,
        min: 0,
        step: 1,
        inputRange: [0, 100],
        outputRange: [0, 1],
        slider: true
    }
}

field 属性支持 . 的方式访问深层对象,比如 style 属性的 opacity 字段就是这次要修改的字段。number 类型的编辑类型,通过 number 字段描述其详细设置。比如最大最小值、单位、输出转换、按钮调解速度、步长、是否拥有 Slider 做滑动调节。

自定义与通用属性混合编辑

编辑器混合了通用属性与自定义属性,完全通过 gaeaEditor 这个字段来描述:

gaeaEdit = [
    '图标',
    {
        field: null as string,
        label: '',
        editor: 'instance',
        editable: true,
        instance: instances
    },
    '布局',
    gaeaHelper.marginPaddingEditor,
    gaeaHelper.widthHeightEditor,
    '特效',
    gaeaHelper.opacityEditor
]

只要将两者混合写入数组即可,同时如果传入的是字符串,会作为标题分割,方便区分功能区域。

记录编辑历史

本来支持 undo redo 快捷键是个边角功能,但是由于需要编辑区的支持,所以也放在这一节说。

Undo Redo

就像编辑 word 一样,我们需要记录每一次用户操作,以便回退或者重做,记录历史有以下三种方案:

每次操作记录全量编辑 json,撤销的时候刷新整体视图区域

这种方式太原始了,虽然操作方便不容易出错,但弊端也非常明显,就是占用内存过大,每次记录了全量数据肯定不是一件好事。

每次操作记录增量编辑 json, 撤销的时候根据每一步骤做 merge ,再刷新整体视图区域

这种方式改进了一下内存占用,但缺点是刷新整体视图区域的操作太笨重,如果视图区域有 1000 个组件实例,全量刷新就是一件很痛苦的事,我们操作时明明是局部刷新,为什么回退历史要全量呢?

记录每一步的操作类型、操作数据,回退时根据操作类型模拟人工操作

一个好的系统架构,是会将 action store 分离出来的,我们手动拖拽、编辑组件的时候,都会触发对应 action,进而修改 store,自动触发视图区域刷新(利用了mobx),在回退历史记录的时候,我们只需要逆向调用对应的 action 就能够模拟出高性能人工操作,付出的代价是需要记录不同操作类型,并记录不同的数据格式。

分类记录操作历史

值得记录的操作种类有 添加 移动 删除 排序 更新组件属性 粘贴 等,我们的 editor 还有 属性重置 新增模板 这两种操作属性,下面是对这几种操作类型的描述:

export interface Diff {
    // 操作类型
    type: 'add' | 'move' | 'remove' | 'exchange' | 'update' | 'paste' | 'reset' | 'addCombo' | 'addSource'
    // 操作组件的 mapUniqueKey
    mapUniqueKey: string
    // 新增操作
    add?: {
        // 新增组件的唯一标识 id
        uniqueId: string
        // 父级 mapKey
        parentMapUniqueKey: string
        // 插入的位置
        index: number
    }
    // 移动到另一个父元素
    move?: {
        // 移动到的父级 mapKey
        targetParentMapUniqueKey: string
        // 移动前父级 mapKey
        sourceParentMapUniqueKey: string
        // 插入的位置
        targetIndex: number
        // 移除的位置
        sourceIndex: number
    }
    // 删除组件
    remove?: DiffRemove
    // 内部交换顺序
    exchange?: {
        oldIndex: number
        newIndex: number
    }
    // 更新操作
    update?: {
        oldValue: ComponentProps
        newValue: ComponentProps
    }
    // 粘贴操作
    paste?: DiffRemove
    // 重置组件
    reset?: {
        // 重置前的信息
        beforeProps: ComponentProps
        beforeName: string
    }
    // 新增组合
    addCombo?: {
        // 父级 mapKey
        parentMapUniqueKey: string
        // 父级的 index
        index: number
        // 组合的完整信息(不是 copy 的, 是真正对应的 mapUniqueKey)
        componentInfo: ViewportComponentFullInfo
    }
    // 新增模板
    addSource?: {
        // 父级 mapKey
        parentMapUniqueKey: string
        // 父级的 index
        index: number
        // 组合的完整信息(不是 copy 的, 是真正对应的 mapUniqueKey)
        componentInfo: ViewportComponentFullInfo
    }
}

在 undo,redo时,根据不同编辑类型还原操作,就可以高效模拟操作了。

https://github.com/ascoders/gaea-editor/blob/master/gaea-editor/store/viewport.tsx#L768

上述仓库地址中可以看到每一步历史只存了还原它需要的最小字段,因此大大降低了内存占用。顺带一提,因为使用了 Mobx 打平 map 存储视图中的所有组件,因此每个组件都会保存对应 mapUniqueKey 来找到对应实例。

undo redo 操作效果如图所示:

undo-redo

Javascript 框架设计精简索引

种子模块

命名空间

var _$ = window.$ // 把非 jquery 产生的 $ 存起来

jQuery.extend({
    noConflict: function () {
        window.$ = _$ // 用非 jquery 的 $ 覆盖自己的 $
        return jQuery // 返回自身,接住的就是 jquery,例如 var my_$ = $.noConflict()
    }
})

贴吧 React 最佳实践

前端是个比较苦逼的工种,面临着一年一变的开发框架,一季一变的脚手架,一月一变工具库,这几年现已经发展到整个开发生态圈一年一变。

然而对于新技术的追求是一定要有的,毕竟唯一不变的东西就是变化,在互联网行业跟不上变化就等于淘汰。对于比较有开发经验的前端同学们来说,学习一项新的框架是非常轻松的,积极订阅技术周刊、看文档、逛github都可以使你迅速跟上前端变化的节奏。

回到现实,在大公司的大业务线,比如我所负责的百度贴吧,情况没有那么乐观。一个十多年的业务线所积累的业务代码是每一个个体无法想象,也无法掌控的,贴吧的前端代码几乎反应了整个前端历史的发展轨迹:在体系复杂的基础项目、林林种种的创新项目、变化多样的运营项目中,几乎所有博文中介绍过的优雅,神奇,黑科技的方法毫无例外都被使用过,框架集中在了jquery生态,是jquery时代混合php编程的经典范例。然而随着前端的发展,产品迭代的加速,旧的前端开发架构已经越来越无力。

在前后端分离开发方式早就被实践的今天,想在贴吧做一点点改变也会受到编译脚本、模块耦合,php全环境问题的困扰,任何小小的优化都会牵一发而动全身,于是我们开始了漫长的改造,从制作新的编译脚本,使用新开发流程,对fis通用化定制,以及后端UI层改为nodejs全方位辅助前端模块化开发,框架选用了React。

写到这里,应该总结一些为什么要使用React理由,毕竟前端变化那么快,为什么这么看好React呢?React不仅仅有非常优秀的模块化机制,普通的业务模块也能拆出来拥抱npm,更重要的是推出了虚拟dom**,提高dom渲染效率,使得跨平台开发成为可能。也许在未来web app会替代native app(假设),可是虚拟dom更使后端渲染成为了可能,web app也需要借助虚拟dom的优势优化首屏用户体验。

Fis3 vs Webpack

fis3是完整的前端构建工具,webpack是前端打包工具,现在fis3也拥有了webpack对npm生态打包的能力,详情参考这篇文章:如何用 fis3 来开发 React?

让 fis3 拥有 webpack 的打包能力,只需要 fis-conf.js 添加如下配置:

// fis3 中预设的是 fis-components,这里不需要,所以先关了。
fis.unhook('components')

// 使用 fis3-hook-node_modules 插件。
fis.hook('node_modules', {
    ignoreDevDependencies: true // 忽略 devDep 文件
})

假设我们将项目分为 clientserver ,可以按需加载前端用到的文件:

fis.set('project.files', [
    // client 按需加载
    '/client/index.html',
    // server 全部加载
    '/server/**'
])

再将前端文件使用 typescript 编译:

fis.match('/client/**.{jsx,tsx}', {
    rExt  : 'js',
    parser: fis.plugin('typescript', {
        module: 1,
        target: 0
    }),
})

如果上线后需要将文件发布到 cdn 域名下,可以动态替换,同时开启压缩等操作:

const production = fis.media('production')
production.match('*.{css,less,scss,sass,js}', {
    domain: 'http://cdn.example.com'
})

// 压缩 css
production.match('*.{css,scss}', {
    optimizer: fis.plugin('clean-css')
})

// 针对以下下文件,开启文件 hash
production.match('*.{ts,tsx,js,jsx,css,scss,png,jpg,jpeg,gif,bmp,eot,svg,ttf,woff,woff2}', {
    useHash: true
})

// png 压缩
production.match('*.png', {
    optimizer: fis.plugin('png-compressor')
})

// 压缩 js 文件
production.match('*.{js,tsx}', {
    optimizer: fis.plugin('uglify-js')
})

生产环境需要压缩前端文件:

const pack = {
    '/client/pkg/bundle.js': [
        '/client/index.tsx',
        '/client/index.tsx:deps'
    ],
    '/client/pkg/bundle.css': [
        '*.scss',
        '*.css'
    ]
}

// 依赖打包
production.match('::package', {
    packager: fis.plugin('deps-pack', pack)
})

这样就将所有 js 依赖文件都打包到 /client/pkg/bundle.jscss 文件都打包到 /client/pkg/bundle.css,同时fis3会自动替换html中的引用。

Yog2 vs express

yog2是基于express封装的nodejs UI层解决方案,文档地址。主要特点使用了app拆分,使得协同开发变得方便。业务项目与node根项目分离,本地开发时,使用fis3的http-push能力提交到node根项目子项目文件夹中,保证不同业务项目的分离。

先安装yog2:

npm install yog2 -g

运行:

yog2 run -e dev

让项目上传到 yog2 根项目中,需要修改 fis-confg.js

production.match('*', {
    charset: 'utf8',
    deploy : [
        fis.plugin('http-push', {
            receiver: 'http://127.0.0.1:8080/yog/upload',
            to      : '/'
        })
    ]
})

支持 bigpipe、quickling,以及后端渲染,默认支持mvc模式,自动路由:/server/api/user.tsdefault export 默认监听 /[project-name]/api/user 这个url。

开发中支持热更新,只要添加 --watch 参数,无需重启 node 就可以更新代码逻辑:

yog2 release --watch --fis3

Fit vs Antd

Fit和Antd类似,是一款基于commonjs规范的React组件库,同时提供了对公司内部业务线的定制组件,不同的是,Fit组件源码使用typescript编写,使得可维护性较强,由FEX团队负责维护(现在还未对外开放)。

除了提供通用的业务组件以外,还提供了同构插件 fit-isomorphic-redux-tools,这个组件提供了基于redux的同构渲染方法支持。

React 后端渲染企业级实践

先从业务角度解析一遍后端渲染准备工作,之后再解析内部原理。

后端模板的准备工作

对纯前端页面来说,后端模板只需要提供基础模板,以及各种 api 接口。为了实现后端渲染,需要根据当前(html5)路由动态添加内容放到模版中去,因此 fit-isomorphic-redux-tools 提供了封装好的 serverRender 函数:

server/action/index.ts

import * as React from 'react'
import routes from '../../client/routes'
import {basename} from '../../client/config'
import rootReducer from '../../client/reducer'
import serverRender from 'fit-isomorphic-redux-tools/lib/server-render'
import * as fs from 'fs'
import * as path from 'path'

// 读取前端 html 文件内容
const htmlText = fs.readFileSync(path.join(__dirname, '../../client/index.html'), 'utf-8')

export default async(req:any, res:any) => {
    serverRender({
        req,
        res,
        routes,
        basename,
        rootReducer,
        htmlText,
        enableServerRender: true
    })
}

server/router.ts

import initService from './service'

export default (router:any)=> {
    router.use(function (req:any, res:any, next:any) {
        /^\/api\//.test(req.path) ? next() : router.action('index')(req, res, next)
    })

    initService(router)
}

server/router.ts 说起,引入了 service(下一节介绍),对非 /api 开头的 url 路径返回 server/action/index.ts 文件中的内容。

server/action/index.ts 这个文件引用了三个 client 目录下文件,分别是 routes 路由定义、basename 此模块的命名空间、rootReducer redux 聚合后的 reducer。读取了 client/index.html 中内容,最后将参数全部传入 serverRender 函数中,通过 enableServerRender 设置是否开启后端渲染。如果开启了后端渲染,访问页面时,会根据当前路由渲染出对应的 html 片段插入到模板文件中返回给客户端。

在后端抽象出统一的 service 接口

server/service/index.ts

import {initService, routerDecorator} from 'fit-isomorphic-redux-tools/lib/service'
export default initService

class Service {
    @routerDecorator('/api/simple-get-function', 'get')
    simpleGet(options:any) {
        return `got get: ${options.name}`
    }

    @routerDecorator('/api/simple-post-function', 'post')
    simplePost(options:any) {
        return `got post: ${options.name}`
    }

    @routerDecorator('/api/current-user', 'get')
    async currentUser(options:any, req:any) {
        return await setTimeout(() => {
            return 'my name is huangziyi'
        }, 1000)
    }
}

new Service()

fit-isomorphic-redux-tools 还提供了两个工具 initService export 出去供 router 绑定路由,routerDecorator 是个装饰器,第一个参数设置 url 地址,第二个参数设置 httpMethod。定义一个 Service 类,每一个成员函数都是对应的后端 api 函数,支持同步和异步方法。最后创建一个 Service 的实例。

当通过 http 请求访问时,同步和异步方法是没有任何区别的,当请求从后端执行时,不会发起新的 http 请求 ,而是直接访问到这个函数,对异步函数进行异步处理,使得与同步函数效果统一。

自此后端模块介绍完毕了,可以对 service 进行自由拆分,例如分成多个文件继承等等。

前端模板文件处理

client/index.html

<!DOCTYPE html>
<html lang="zh-cn">
    <body>
        <div id="react-dom"></div>
    </body>

    <script>
        window.__INITIAL_STATE__ = __serverData('__INITIAL_STATE__');
    </script>
    <script type="text/javascript" src="./static/mod.js"></script>
    <script type="text/javascript" src="./index.tsx"></script>
</html>

引入 mod.js 是为了支持 fis 的模块化寻找(webpack将类似逻辑预制到打包文件中,所以不需要手动引用),index.tsx 是入口文件,需要通过 fis-conf.js 设置其为非模块化(仅入口非模块化),之后都是模块化引用:

fis.match('/client/index.tsx', {
    isMod: false
})

window.__INITIAL_STATE__ = __serverData('__INITIAL_STATE__'); 这段代码存在的意义是,后端渲染开启时,会替换 __serverData('__INITIAL_STATE__') 为后端渲染后的内容,在 redux 初始化时传入 window.__INITIAL_STATE__ 参数,让前端继承了后端渲染后的 store 状态,之后页面完全交给前端接手。

前端入口文件处理

client/index.tsx

import * as React from 'react'
import * as ReactDOM from 'react-dom'
import routerFactory from 'fit-isomorphic-redux-tools/lib/router'
import routes from './routes'
import {basename} from './config'
import reducer from './reducer'
import './index.scss'

const router = routerFactory(routes, basename, reducer)

ReactDOM.render(router, document.getElementById('react-dom'))

fit-isomorphic-redux-tools 提供了方法 routerFactory 返回最终渲染到页面上的 React 组件,第一个参数是路由设置,第二个参数是项目命名空间(字符串,作为路由的第一层路径,区分子项目),第三个参数是 redux 的聚合 reducer。

routes 是非常单一的 react-router 路由定义文件:

client/routes.tsx

import * as React from 'react'
import {Route, IndexRoute} from 'react-router'
import Layout from './routes/layout/index'
import Home from './routes/home/index'
import PageA from './routes/page-a/index'
import PageB from './routes/page-b/index'

export default (
    <Route path="/"
           component={Layout}>
        <IndexRoute component={Home}/>
        <Route path="page-a"
               component={PageA}/>
        <Route path="page-b"
               component={PageB}/>
    </Route>
)

reducer也是基本的 redux 使用方法:

client/reducer.tsx

import {combineReducers} from 'redux'
import {routerReducer} from 'react-router-redux'

// 引用各模块 reducer
import layout from './routes/layout/reducer'

// 聚合各 reducer
// 将路由也加入 reducer
const rootReducer = combineReducers({
    routing: routerReducer,
    layout: layout
})

export default rootReducer

config 文件是定义文件,将静态定义内容存放于此:

client/config.tsx

export const basename:string = '/my-app-prefix'

action、reducer

存放在 stores 文件夹下. actions 可共用,但对于复杂项目,最好按照 state 树结构拆分文件夹,每个文件夹下对应 action.tsxreducer.tsx。将 Redux 数据流与组件完全解耦。

特别对于可能在后端发送的请求,可以使用 fit-isormophic-redux-tools 提供的 fetch 方法:

client/stores/user/action.tsx

import fetch from 'fit-isomorphic-redux-tools/lib/fetch'

export const SIMPLE_GET_FUNCTION = 'SIMPLE_GET_FUNCTION'

export const simpleGet = ()=> {
    return fetch({
        type: SIMPLE_GET_FUNCTION,
        url: '/api/simple-get-function',
        params: {
            name: 'huangziyi'
        },
        method: 'get'
    })
}

然后在前端任何地方执行,它都只是一个普通的请求,如果这个 action 在后端被触发(比如被放置在 componentWillMount生命周期中),还记得 service 中这段代码吗?

@routerDecorator('/api/simple-get-function', 'get')
simpleGet(options:any) {
    return `got get: ${options.name}`
}

会直接调用此方法。第一个参数是 params(get) 与 data(post) 数据的 merge,第二个参数是 req,如果在后端执行此方法,则这个 req 是获取页面模板时的。

组件

使用 connect 将 redux 的 state 注入到组件的 props,还不熟悉的同学可以搜一搜 react-redux 教程。

组件的介绍为什么这么简单?因为有了 fit-isormophic-redux-tools 插件的帮助,组件中抹平了同构请求的差异。再次强调一遍,在任何地方调用 action ,如果这段逻辑在后端被触发,它会自动向 service 取数据。

fit-isomorphic-redux-tools 剖析

核心函数 serverRender 代码片段

// 后端渲染
export default(option:Option)=> {
    // 如果不启动后端渲染,直接返回未加工的模板
    if (!option.enableServerRender) {
        return option.res.status(200).send(renderFullPage(option.htmlText, '', {}))
    }

    match({
        routes: option.routes,
        location: option.req.url,
        basename: option.basename
    }, (error:any, redirectLocation:any, renderProps:any) => {
        if (error) {
            option.res.status(500).send(error.message)
        } else if (redirectLocation) {
            option.res.redirect(302, redirectLocation.pathname + redirectLocation.search)
        } else if (renderProps) {
            const serverRequestHelper = new ServerRequestHelper(service, option.req)

            // 初始化 fetch
            setServerRender(serverRequestHelper.Request as Function)

            // 初始化 redux
            const store = configureStore({}, option.rootReducer)
            const InitialView = React.createElement(Provider, {store: store}, React.createElement(RouterContext, renderProps))
            try {
                // 初次渲染触发所有需要的网络请求
                renderToString(InitialView)

                // 拿到这些请求的action
                const actions = serverRequestHelper.getActions()
                Promise.all(actions.map((action:any)=> {
                    return store.dispatch(action)
                })).then(()=> {
                    const componentHTML = renderToString(InitialView)
                    const initialState = store.getState()
                    // 将初始状态输出到 html
                    option.res.status(200).send(renderFullPage(option.htmlText, componentHTML, initialState))
                })
            } catch (err) {
                console.log('Server Render Error', err)
                yog.log.fatal(err)
                option.res.status(404).send('Server Render Error')
            }
        } else {
            option.res.status(404).send('Not Found')
        }
    })
}

renderFullPage 方法,返回页面模板,可接收参数将后端渲染的内容填入其中,如果不开启后端渲染,无参调用此方法即可。

// 初始化 fetch
setServerRender(serverRequestHelper.Request as Function)

// 初次渲染触发所有需要的网络请求
renderToString(InitialView)

为了抹平前端请求在后端处理的差异,需要触发两次 renderToString 方法,上述代码是第一次。因为 fetch 方法在前后端都会调用,我们将 serverRequestHelper.Request 传入其中,当 action 在后端执行时,不会返回数据,而是将此 action 存放在 Map 对象中,渲染完毕后再将 action 提取出来单独执行:

const actions = serverRequestHelper.getActions()
Promise.all(actions.map((action:any)=> {
    return store.dispatch(action)
}))

因为 react 渲染是同步的(vue2.0 对此做了改进,可谓抓住了 react 的痛点),对异步操作无法处理,因此需要多渲染一次。这时,redux 的 store 中已经有了动态请求所需的数据,我们只需要再次渲染,就可以获取所有完整数据了:

const componentHTML = renderToString(InitialView)
const initialState = store.getState()
// 将初始状态输出到 html
option.res.status(200).send(renderFullPage(option.htmlText, componentHTML, initialState))

核心函数 promise-moddleware 代码片段

export default (store:any) => (next:any) => (action:any) => {
    const {promise, type, ...rest} = action

    // 没有 promise 字段不处理
    if (!promise) return next(action)

    const REQUEST = type + '_REQUEST'
    const SUCCESS = type + '_SUCCESS'
    const FAILURE = type + '_FAILURE'

    if (process.browser) {
        next({type: REQUEST, ...rest})

        return promise.then((req:any) => {
            next({data: req.data, type: SUCCESS, ...rest})
            return true
        }).catch((error:any) => {
            next({error, type: FAILURE, ...rest})
            console.log('FrontEnd PromiseMiddleware Error:', error)
            return false
        })
    } else {
        const result = promise(action.data, action.req)
        if (typeof result.then === 'function') {
            return result.then((data:any) => {
                next({data: data, type: SUCCESS, ...rest})
                return true
            }).catch((error:any) => {
                next({error, type: FAILURE, ...rest})
                console.log('ServerEnd PromiseMiddleware Error:', error)
                return false
            })
        } else {
            return next({type: SUCCESS, data: result, ...rest})
        }
    }
}

篇幅原因,默认大家了解 redux 中间件的工作原理。这里有个约定,action 所有异步请求都放在 promise 字段上,dispatch 分为三个状态 (_REQUEST,_SUCCESS,_FAILURE)。前端请求都是异步的,因此使用 promise.then 统一处理,后端请求因为直接访问 model ,异步时,与前端同样处理,同步时,直接调用 promise 函数获取结果。还记得 server/service/index.ts 文件中为何能支持普通方法,与 async 方法吗?因为这里分开处理了。

核心函数 service 代码片段

const services = new Map()
export default services

export const routerDecorator = (url:string, method:string) =>(target:any, key:string, descriptor:any)=> {
    services.set(url, {
        value: descriptor.value,
        method: method
    })
    return descriptor
}

export const initService = (router:any)=> {
    for (let key of services.keys()) {
        const target = services.get(key)
        router[target.method](key, async(req:any, res:any)=> {
            let params:any = {}
            if (target.method === 'get') {
                params = req.query
            } else {
                params =  _.assign(req.body || {}, req.query || {})
            }
            const result = await target.value(params, req)
            res.json(result)
        })
    }
}

这里有两个函数,将 service 层抽象出来。routerDecorator 装饰器用于定义函数的路由信息,initService 将 service 信息初始化到路由中,如果是 GET 请求,将 query 参数注入到 service 中,其它请求会对 query 与 body 参数做 merge 后再传给 service。

总结

React 组件生态降低了团队维护成本,提高开发效率,同时督促我们开发时模块解耦,配合 redux 将数据层与模版层分离,拓展了仅支持 view 层的 React。后端渲染大大提高了首屏效率,大家可以自己规划后端渲染架构,也可以直接使用 fit-isormophic-redux-tools

目前来看,React 后端渲染的短板在于 RenderToString 是同步的,必须依赖两次渲染才能方便获取异步数据(也可以放在静态变量中实现一次渲染),对于两层以上的异步依赖关系处理起来更加复杂,这需要 React 自身后续继续优化。当然,任何技术都是为了满足项目需求为前提,简单的异步数据获取已经可以满足大部分业务需求。

webpack只是个打包工具,我们不要过分放大它的优势,一个成熟的业务线需要 gulp 或者 fis3 这种重量级构建工具完成一系列的流程,如今 fis3 已经支持 npm 生态,正在不断改造与进步。对 express 熟悉的同学,转到企业开发时不妨考虑一下 yog2,提供了一套完整的企业开发流程。

如有遗误,感谢指正。

线上项目

贴吧一起嗨,由 FEX 团队助力打造,下面提供了开启后端渲染/关闭后端渲染的链接地址,方便大家调试比对性能。

http://tieba.baidu.com/n/tbhighh5/album/456900832689737361 ,开启后端渲染
http://tieba.baidu.com/n/tbhighh5/album/456900832689737361?nsr=1 ,关闭后端渲染

六十行代码完成 四则运算 语法解析器

syntax-parser 是完全利用 JS 编写的词法解析+语法解析引擎,所以完全支持在浏览器、NodeJS 环境执行。

它可以帮助你快速生成 词法解析器,亦或进一步生成 语法解析器,将字符串解析成语法树,语法解析器还支持下一步智能提示功能,输入光标位置,给出输入推荐。

目前 syntax-parser 功能逐渐稳定,内核性能还在逐步优化中,我们会利用 syntax-parser 引擎的能力,完成一些令人惊喜的小 DEMO,如果与你的业务场景恰好契合,欢迎使用!

这次的 DEMO 是:利用 syntax-parser 快速完成四则运算语法解析器!

1. 生成词法解析器

通过下面 20 行配置,生成一个能解析英文、数字、加减乘除、左右括号的词法解析器,so easy!

import { createLexer } from 'syntax-parser'

const myLexer = createLexer([
  {
    type: 'whitespace',
    regexes: [/^(\s+)/],
    ignore: true
  },
  {
    type: 'word',
    regexes: [/^([a-zA-Z0-9]+)/] // 解析数字
  },
  {
    type: 'operator',
    regexes: [
      /^(\(|\))/, // 解析 ( )
      /^(\+|\-|\*|\/)/ // 解析 + - * /
    ]
  }
]);

我们可以使用 myLexer 将字符串解析为一个个 Token:

myLexer('1 + 2 - 3 * b / (x + y)')

不过这次的目的是生成语法树,所以我们会把 myLexer 作为参数传给语法解析器。

2. 生成语法解析器

五个文法,20 行代码搞定,表示四则运算的文法,可以参考 此文

利用 chain ,可以高效表示每一个文法表达式要匹配的字符串、表示匹配次数,还支持嵌入新的文法函数。这些相互依赖的文法组成了一个文法链条,完整表达了四则运算的逻辑:

import { chain, createParser, many, matchTokenType } from 'syntax-parser'

const root = () => chain(term, many(addOp, root))(parseTermAst);

const term = () => chain(factor, many(mulOp, root))(parseTermAst);

const mulOp = () => chain(['*', '/'])(ast => ast[0].value);

const addOp = () => chain(['+', '-'])(ast => ast[0].value);

const factor = () => chain([
    chain('(', root, ')')(ast => ast[1]),
    chain(matchTokenType('word'))(ast => ast[0].value)
])(ast => ast[0]);

const myParser = createParser(
  root, // Root grammar.
  myLexer // Created in lexer example.
);

createParser 函数第一个参数接收根文法表达式,第二个参数是词法解析器,我们将上面创建的 myLexer 传入。 parseTermAst 函数单独提出来,目的是辅助生成语法树,一共 20 行代码:

const parseTermAst = (ast: any) =>
  ast[1]
    ? ast[1].reduce(
        (obj: any, next: any) =>
          next[0]
            ? {
                operator: next[0],
                left: obj || ast[0],
                right: next[1]
              }
            : {
                operator: next[1] && next[1].operator,
                left: obj || ast[0],
                right: next[1] && next[1].right
              },
        null
      )
    : ast[0];

这个函数是为了将语法树变得更规整,否则得到的 AST 解析将会是数组,而不是像 left right operator 这么有含义的对象。

PS:本文的 DEMO 没有考虑乘除高优先级问题。

3. 运行词法解析器

最后得到的 myParser 就是语法解析器了!直接执行就能拿到语法树结果!

const result = myParser('1 + 2 - (3 - 4 + 5) * 6 / 7');
console.log(result.ast)

我们打印出语法树,运行结果如下:

{
  "operator": "/",
  "left": {
    "operator": "-",
    "left": {
      "operator": "+",
      "left": "1",
      "right": "2"
    },
    "right": {
      "operator": "*",
      "left": {
        "operator": "+",
        "left": {
          "operator": "-",
          "left": "3",
          "right": "4"
        },
        "right": "5"
      },
      "right": "6"
    }
  },
  "right": "7"
}

4. 错误提示

不仅语法树,我们构造一个错误的输入试试!

const result = myParser('1 + 2 - (3 - 4 + 5) * 6 / ');
console.log(result.error)

这次我们打印错误信息:

{
  "suggestions": [
    {
      "type": "string",
      "value": "("
    },
    {
      "type": "special",
      "value": "word"
    }
  ],
  "token": {
    "type": "operator",
    "value": "/",
    "position": [
      24,
      25
    ]
  },
  "reason": "incomplete"
}

精准的提示了最后一个 / 位置不完整,建议是填写 ( 或者一个单词。这都是根据文法自动生成的建议,提示不多一个,不少一个!

5. 任意位置输入提示

最精髓的功能到了,这个语法解析器就是为了做自动提示,所以支持多传一个参数,告诉我当前你光标的位置:

const result = myParser('1 + 1', 5)
console.log(result.nextMatchings)

假设语句写到这里,我们光标位置定位到 5 的位置,也就是最后一个 1 后面,nextMatchings 属性会告诉我们后面的可能情况:

[
  {
    "type": "string",
    "value": "-"
  },
  {
    "type": "string",
    "value": "+"
  },
  {
    "type": "string",
    "value": "/"
  },
  {
    "type": "string",
    "value": "*"
  }
]

6. 结合业务,用在文本编辑器

笔者拿 monaco-editor 举例,利用上面的语法树解析,我们可以轻松完成下面的效果:

光标智能补全

image.png | left | 747x124

错误提示

image.png | left | 747x182

无论是智能补全,还是错误提示都是 100% 精准无误的(根据上面编写的文法表达式)。

相比普通的语法解析器在解析错误时直接抛出错误,syntax-parser 不仅提供语法树,还能根据文法智能提示光标位置的输入推荐,哪怕是输入错误的情况下,是不是解决了大家的痛点呢?如果觉得好用,欢迎给 syntax-parser 提 建议 或者 pr

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.