52lidan

52lidan 查看完整档案

北京编辑  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑

写代码要有追求

个人动态

52lidan 关注了用户 · 10月29日

王下邀月熊_Chevalier @wx_chevalier

爱代码 爱生活 希望成为全栈整合师
微信公众号:某熊的技术之路

关注 3420

52lidan 收藏了文章 · 10月29日

GUI应用程序架构的十年变迁:MVC,MVP,MVVM,Unidirectional,Clean

[toc]

笔者的编程基础与软件工程相关文章索引
十年前,Martin Fowler撰写了GUI Architectures一文,至今被奉为经典。本文所谈的所谓架构二字,核心即是对于对于富客户端的代码组织/职责划分。纵览这十年内的架构模式变迁,大概可以分为MV*与Unidirectional两大类,而Clean Architecture则是以严格的层次划分独辟蹊径。从笔者的认知来看,从MVC到MVP的变迁完成了对于View与Model的解耦合,改进了职责分配与可测试性。而从MVP到MVVM,添加了View与ViewModel之间的数据绑定,使得View完全的无状态化。最后,整个从MV*到Unidirectional的变迁即是采用了消息队列式的数据流驱动的架构,并且以Redux为代表的方案将原本MV*中碎片化的状态管理变为了统一的状态管理,保证了状态的有序性与可回溯性。

笔者在撰写本文的时候也不可避免的带了很多自己的观点,在漫长的GUI架构模式变迁过程中,很多概念其实是交错复杂,典型的譬如MVP与MVVM的区别,笔者按照自己的理解强行定义了二者的区分边界,不可避免的带着自己的主观想法。另外,鉴于笔者目前主要进行的是Web方面的开发,因此在整体倾向上是支持Unidirectional Architecture并且认为集中式的状态管理是正确的方向。但是必须要强调,GUI架构本身是无法脱离其所依托的平台,下文笔者也会浅述由于Android与iOS本身SDK API的特殊性,生搬硬套其他平台的架构模式也是邯郸学步,沐猴而冠。不过总结而言,它山之石,可以攻玉,本身我们所处的开发环境一直在不断变化,对于过去的精华自当应该保留,并且与新的环境相互印证,触类旁通。

Introduction

Make everything as simple as possible, but not simpler — Albert Einstein

Graphical User Interfaces一直是软件开发领域的重要组成部分,从当年的MFC,到WinForm/Java Swing,再到WebAPP/Android/iOS引领的智能设备潮流,以及未来可能的AR/VR,GUI应用开发中所面临的问题一直在不断演变,但是从各种具体问题中抽象而出的可以复用的模式恒久存在。而这些模式也就是所谓应用架构的核心与基础。对于所谓应用架构,空谈误事,不谈误己,笔者相信不仅仅只有自己想把那一团糟的代码给彻底抛弃。往往对于架构的认知需要一定的大局观与格局眼光,每个有一定经验的客户端程序开发者,无论是Web、iOS还是Android,都会有自己熟悉的开发流程习惯,但是笔者认为架构认知更多的是道,而非术。当你能够以一种指导思想在不同的平台上能够进行高效地开发时,你才能真正理解架构。这个有点像张三丰学武,心中无招,方才达成。笔者这么说只是为了强调,尽量地可以不拘泥于某个平台的具体实现去审视GUI应用程序架构模式,会让你有不一样的体验。譬如下面这个组装Android机器人的图:

怎么去焊接两个组件,属于具体的术实现,而应该焊接哪两个组件就是术,作为合格的架构师总不能把脚和头直接焊接在一起,而忽略中间的连接模块。对于软件开发中任何一个方面,我们都希望能够寻找到一个抽象程度适中,能够在接下来的4,5年内正常运行与方便维护扩展的开发模式。引申下笔者在我的编程之路中的论述,目前在GUI架构模式中,无论是Android、iOS还是Web,都在经历着从命令式编程到声明式/响应式编程,从Passive Components到Reactive Components,从以元素操作为核心到以数据流驱动为核心的变迁(关于这几句话的解释可以参阅下文的Declarative vs. Imperative这一小节)。

Terminology:名词解释

正文之前,我们先对一些概念进行阐述:

  • User Events/用户事件:即是来自于可输入设备上的用户操作产生的数据,譬如鼠标点击、滚动、键盘输入、触摸等等。

  • User Interface Rendering/用户界面渲染:View这个名词在前后端开发中都被广泛使用,为了明晰该词的含义,我们在这里使用用户渲染这个概念,来描述View,即是以HTML或者JSX或者XAML等等方式在屏幕上产生的图形化输出内容。

  • UI Application:允许接收用户输入,并且将输出渲染到屏幕上的应用程序,该程序能够长期运行而不只是渲染一次即结束

Passive Module & Reactive Module

箭头表示的归属权实际上也是Passive Programming与Reactive Programming的区别,譬如我们的系统中有Foo与Bar两个模块,可以把它们当做OOP中的两个类。如果我们在Foo与Bar之间建立一个箭头,也就意味着Foo能够影响Bar中的状态:

譬如Foo在进行一次网络请求之后将Bar内部的计数器加一操作:


// This is inside the Foo module

function onNetworkRequest() {
  // ...
  Bar.incrementCounter();
  // ...
}

在这里将这种逻辑关系可以描述为Foo拥有着网络请求完成之后将Bar内的计数器加一这个关系的控制权,也就是Foo占有主导性,而Bar相对而言是Passive被动的:

Bar是Passive的,它允许其他模块改变其内部状态。而Foo是主动地,它需要保证能够正确地更新Bar的内部状态,Passive模块并不知道谁会更新到它。而另一种方案就是类似于控制反转,由Bar完成对于自己内部状态的更新:

在这种模式下,Bar监听来自于Foo中的事件,并且在某些事件发生之后进行内部状态更新:


// This is inside the Bar module

Foo.addOnNetworkRequestListener(() => {

  self.incrementCounter(); // self is Bar

});

此时Bar就变成了Reactive Module,它负责自己的内部的状态更新以响应外部的事件,而Foo并不知道它发出的事件会被谁监听。

Declarative vs. Imperative:命令式编程与声明式编程

three-ds-of-web-development

前端攻略-从路人甲到英雄无敌二:JavaScript 与不断演化的框架

形象地来描述命令式编程与声明式编程的区别,就好像C#/JavaScript与类似于XML或者HTML这样的标记语言之间的区别。命令式编程关注于how to do what you want done,即事必躬亲,需要安排好每个要做的细节。而声明式编程关注于what you want done without worrying about how,即只需要声明要做的事情而不用将具体的过程再耦合进来。对于开发者而言,声明式编程将很多底层的实现细节向开发者隐藏,而使得开发者可以专注于具体的业务逻辑,同时也保证了代码的解耦与单一职责。譬如在Web开发中,如果你要基于jQuery将数据填充到页面上,那么大概按照命令式编程的模式你需要这么做:


var options = $("#options");
$.each(result, function() {
    options.append($("<option />").val(this.id).text(this.name));
});

而以Angular 1声明式的方式进行编写,那么是如下的标记模样:


<div ng-repeat="item in items" ng-click="select(item)">{{item.name}}
</div>

而在iOS和Android开发中,近年来函数响应式编程(Functional Reactive Programming)也非常流行,参阅笔者关于响应式编程的介绍可以了解,响应式编程本身是基于流的方式对于异步操作的一种编程优化,其在整个应用架构的角度看更多的是细节点的优化。以RxSwift为例,通过响应式编程可以编写出非常优雅的用户交互代码:


let searchResults = searchBar.rx_text
    .throttle(0.3, scheduler: MainScheduler.instance)
    .distinctUntilChanged()
    .flatMapLatest { query -> Observable<[Repository]> in
        if query.isEmpty {
            return Observable.just([])
        }

        return searchGitHub(query)
            .catchErrorJustReturn([])
    }
    .observeOn(MainScheduler.instance)
searchResults
    .bindTo(tableView.rx_itemsWithCellIdentifier("Cell")) {
        (index, repository: Repository, cell) in
        cell.textLabel?.text = repository.name
        cell.detailTextLabel?.text = repository.url
    }
    .addDisposableTo(disposeBag)

其直观的效果大概如下图所示:

到这里可以看出,无论是从命令式编程与声明式编程的对比还是响应式编程的使用,我们开发时的关注点都慢慢转向了所谓的数据流。便如MVVM,虽然它还是双向数据流,但是其使用的Data-Binding也意味着开发人员不需要再去以命令地方式寻找元素,而更多地关注于应该给绑定的对象赋予何值,这也是数据流驱动的一个重要体现。而Unidirectional Architecture采用了类似于Event Source的方式,更是彻底地将组件之间、组件与功能模块之间的关联交于数据流操控。

谈到架构,我们关心哪些方面?

当我们谈论所谓客户端开发的时候,我们首先会想到怎么保证向后兼容、怎么使用本地存储、怎么调用远程接口、如何有效地利用内存/带宽/CPU等资源,不过最核心的还是怎么绘制界面并且与用户进行交互,关于这部分详细的知识点纲要推荐参考笔者的我的编程之路——知识管理与知识体系这篇文章或者这张知识点列表思维脑图

而当我们提纲挈领、高屋建瓴地以一个较高的抽象的视角来审视总结这个知识点的时候会发现,我们希望的好的架构,便如在引言中所说,即是有好的代码组织方式/合理的职责划分粒度。笔者脑中会出现如下这样的一个层次结构,可以看出,最核心的即为View与ViewLogic这两部分:

实际上,对于富客户端的代码组织/职责划分,从具体的代码分割的角度,即是功能的模块化界面的组件化状态管理这三个方面。最终呈献给用户的界面,笔者认为可以抽象为如下等式:View = f(State,Template)。而ViewLogic中对于类/模块之间的依赖关系,即属于代码组织,譬如MVC中的View与Controller之间的从属关系。而对于动态数据,即所谓应用数据的管理,属于状态管理这一部分,譬如APP从后来获取了一系列的数据,如何将这些数据渲染到用户界面上使得用户可见,这样的不同部分之间的协同关系、整个数据流的流动,即属于状态管理。

分久必合,合久必分

实际上从MVC、MVP到MVVM,一直围绕的核心问题就是如何分割ViewLogic与View,即如何将负责界面展示的代码与负责业务逻辑的代码进行分割。所谓分久必合,合久必分,从笔者自我审视的角度,发现很有趣的一点。Android与iOS中都是从早期的用代码进行组件添加与布局到专门的XML/Nib/StoryBoard文件进行布局,Android中的Annotation/DataBinding、iOS中的IBOutlet更加地保证了View与ViewLogic的分割(这一点也是从元素操作到以数据流驱动的变迁,我们不需要再去编写大量的findViewById)。而Web的趋势正好有点相反,无论是WebComponent还是ReactiveComponent都是将ViewLogic与View置于一起,特别是JSX的语法将JavaScript与HTML混搭,很像当年的PHP/JSP与HTML混搭。这一点也是由笔者在上文提及的Android/iOS本身封装程度较高的、规范的API决定的。对于Android/iOS与Web之间开发体验的差异,笔者感觉很类似于静态类型语言与动态类型语言之间的差异。

功能的模块化

老实说在AMD/CMD规范之前,或者说在ES6的模块引入与Webpack的模块打包出来之前,功能的模块化依赖一直也是个很头疼的问题。

SOLID中的接口隔离原则,大量的IOC或者DI工具可以帮我们完成这一点,就好像Spring中的@Autowire或者Angular 1中的@Injection,都给笔者很好地代码体验。

在这里笔者首先要强调下,从代码组织的角度来看,项目的构建工具与依赖管理工具会深刻地影响到代码组织,这一点在功能的模块化中尤其显著。譬如笔者对于Android/Java构建工具的使用变迁经历了从Eclipse到Maven再到Gradle,笔者会将不同功能逻辑的代码封装到不同的相对独立的子项目中,这样就保证了子项目与主项目之间的一定隔离,方便了测试与代码维护。同样的,在Web开发中从AMD/CMD规范到标准的ES6模块与Webpack编译打包,也使得代码能够按照功能尽可能地解耦分割与避免冗余编码。而另一方面,依赖管理工具也极大地方便我们使用第三方的代码与发布自定义的依赖项,譬如Web中的NPM与Bower,iOS中的CocoaPods都是十分优秀的依赖发布与管理工具,使我们不需要去关心第三方依赖的具体实现细节即能够透明地引入使用。因此选择合适的项目构建工具与依赖管理工具也是好的GUI架构模式的重要因素之一。不过从应用程序架构的角度看,无论我们使用怎样的构建工具,都可以实现或者遵循某种架构模式,笔者认为二者之间也并没有必然的因果关系。

界面的组件化

A component is a small piece of the user interface of our application, a view, that can be composed with other components to make more advanced components.

何谓组件?一个组件即是应用中用户交互界面的部分组成,组件可以通过组合封装成更高级的组件。组件可以被放入层次化的结构中,即可以是其他组件的父组件也可以是其他组件的子组件。根据上述的组件定义,笔者认为像Activity或者UIViewController都不能算是组件,而像ListView或者UITableView可以看做典型的组件。

我们强调的是界面组件的Composable&Reusable,即可组合性与可重用性。当我们一开始接触到Android或者iOS时,因为本身SDK的完善度与规范度较高,我们能够很多使用封装程度较高的组件。譬如ListView,无论是Android中的RecycleView还是iOS中的UITableView或者UICollectionView,都为我们提供了。凡事都有双面性,这种较高程度的封装与规范统一的API方便了我们的开发,但是也限制了我们自定义的能力。同样的,因为SDK的限制,真正意义上可复用/组合的组件也是不多,譬如你不能将两个ListView再组合成一个新的ListView。在React中有所谓的controller-view的概念,即意味着某个React组件同时担负起MVC中Controller与View的责任,也就是JSX这种将负责ViewLogic的JavaScript代码与负责模板的HTML混编的方式。

界面的组件化还包括一个重要的点就是路由,譬如Android中的AndRouter、iOS中的JLRoutes都是集中式路由的解决方案,不过集中式路由在Android或者iOS中并没有大规模推广。iOS中的StoryBoard倒是类似于一种集中式路由的方案,不过更偏向于以UI设计为核心。笔者认为这一点可能是因为Android或者iOS本身所有的代码都是存放于客户端本身,而Web中较传统的多页应用方式还需要用户跳转页面重新加载,而后在单页流行之后即不存在页面级别的跳转,因此在Web单页应用中集中式路由较为流行而Android、iOS中反而不流行。

无状态的组件

无状态的组件的构建函数是纯函数(pure function)并且引用透明的(refferentially transparent),在相同输入的情况下一定会产生相同的组件输出,即符合View = f(State,Template)公式。笔者觉得Android中的ListView/RecycleView,或者iOS中的UITableView,也是无状态组件的典型。譬如在Android中,可以通过动态设置Adapter实例来为RecycleView进行源数据的设置,而作为View层以IoC的方式与具体的数据逻辑解耦。

组件的可组合性与可重用性往往最大的阻碍就是状态,一般来说,我们希望能够重用或者组合的组件都是

Generalization,而状态往往是Specification,即领域特定的。同时,状态也会使得代码的可读性与可测试性降低,在有状态的组件中,我们并不能通过简单地阅读代码就知道其功能。如果借用函数式编程的概念,就是因为副作用的引入使得函数每次回产生不同的结果。函数式编程中存在着所谓Pure Function,即纯函数的概念,函数的返回值永远只受到输入参数的影响。譬如(x)=>x*2这个函数,输入的x值永远不会被改变,并且返回值只是依赖于输入的参数。而Web开发中我们也经常会处于带有状态与副作用的环境,典型的就是Browser中的DOM,之前在jQuery时代我们会经常将一些数据信息缓存在DOM树上,也是典型的将状态与模板混合的用法。这就导致了我们并不能控制到底应该何时去进行重新渲染以及哪些状态变更的操作才是必须的,


var Header = component(function (data) {
  // First argument is h1 metadata
  return h1(null, data.text);
});

// Render the component to our DOM
render(Header({text: 'Hello'}), document.body);

// Some time later, we change it, by calling the
// component once more.
setTimeout(function () {
  render(Header({text: 'Changed'}), document.body);
}, 1000);

var hello = Header({ text: 'Hello' }); var bye   = Header({ text: 'Good Bye' });

状态管理

可变的与不可预测的状态是软件开发中的万恶之源

上文提及,我们尽可能地希望组件的无状态性,那么整个应用中的状态管理应该尽量地放置在所谓High-Order Component或者Smart Component中。在React以及Flux的概念流行之后,Stateless Component的概念深入人心,不过其实对于MVVM中的View,也是无状态的View。通过双向数据绑定将界面上的某个元素与ViewModel中的变量相关联,笔者认为很类似于HOC模式中的Container与Component之间的关联。随着应用的界面与功能的扩展,状态管理会变得愈发混乱。这一点,无论前后端都有异曲同工之难,笔者在基于Redux思想与RxJava的SpringMVC中Controller的代码风格实践一文中对于服务端应用程序开发中的状态管理有过些许讨论。

Features of Good Architectural Pattern:何为好的架构模式

Balanced Distribution of Responsibilities:合理的职责划分

合理的职责划分即是保证系统中的不同组件能够被分配合理的职责,也就是在复杂度之间达成一个平衡,职责划分最权威的原则就是所谓Single Responsibility Principle,单一职责原则。

Testability:可测试性

可测试性是保证软件工程质量的重要手段之一,也是保证产品可用性的重要途径。在传统的GUI程序开发中,特别是对于界面的测试常常设置于状态或者运行环境,并且很多与用户交互相关的测试很难进行场景重现,或者需要大量的人工操作去模拟真实环境。

Ease of Use:易用性

代码的易用性保证了程序架构的简洁与可维护性,所谓最好的代码就是永远不需要重写的代码,而程序开发中尽量避免的代码复用方法就是复制粘贴。

Fractal:碎片化,易于封装与分发

In fractal architectures, the whole can be naively packaged as a component to be used in some larger application.In non-fractal architectures, the non-repeatable parts are said to be orchestrators over the parts that have hierarchical composition.

  • By André Staltz

所谓的Fractal Architectures,即你的应用整体都可以像单个组件一样可以方便地进行打包然后应用到其他项目中。而在Non-Fractal Architectures中,不可以被重复使用的部分被称为层次化组合中的Orchestrators。譬如你在Web中编写了一个登录表单,其中的布局、样式等部分可以被直接复用,而提交表单这个操作,因为具有应用特定性,因此需要在不同的应用中具有不同的实现。譬如下面有一个简单的表单:


<form action="form_action.asp" method="get">
  <p>First name: <input type="text" name="fname" /></p>
  <p>Last name: <input type="text" name="lname" /></p>
  <input type="submit" value="Submit" />
</form>

因为不同的应用中,form的提交地址可能不一致,那么整个form组件是不可直接重用的,即Non-Fractal Architectures。而form中的input组件是可以进行直接复用的,如果将input看做一个单独的GUI架构,即是所谓的Fractal Architectures,form就是所谓的Orchestrators,将可重用的组件编排组合,并且设置应用特定的一些信息。

Reference

Overview

MV*

MVC

MVP

MVVM

Unidirectional Architecture

Viper/Clean Architecture

MV*:Fragmentary State 碎片化的状态与双向数据流

MVC模式将有关于渲染、控制与数据存储的概念有机分割,是GUI应用架构模式的一个巨大成就。但是,MVC模式在构建能够长期运行、维护、有效扩展的应用程序时遇到了极大的问题。MVC模式在一些小型项目或者简单的界面上仍旧有极大的可用性,但是在现代富客户端开发中导致职责分割不明确、功能模块重用性、View的组合性较差。作为继任者MVP模式分割了View与Model之间的直接关联,MVP模式中也将更多的ViewLogic转移到Presenter中进行实现,从而保证了View的可测试性。而最年轻的MVVM将ViewLogic与View剥离开来,保证了View的无状态性、可重用性、可组合性以及可测试性。总结而言,MV*模型都包含了以下几个方面:

  • Models:负责存储领域/业务逻辑相关的数据与构建数据访问层,典型的就是譬如PersonPersonDataProvider

  • Views:负责将数据渲染展示给用户,并且响应用户输入

  • Controller/Presenter/ViewModel:往往作为Model与View之间的中间人出现,接收View传来的用户事件并且传递给Model,同时利用从Model传来的最新模型控制更新View

MVC:Monolithic Controller

相信每一个程序猿都会宣称自己掌握MVC,这个概念浅显易懂,并且贯穿了从GUI应用到服务端应用程序。MVC的概念源自Gamma, Helm, Johnson 以及Vlissidis这四人帮在讨论设计模式中的Observer模式时的想法,不过在那本经典的设计模式中并没有显式地提出这个概念。我们通常认为的MVC名词的正式提出是在1979年5月Trygve Reenskaug发表的Thing-Model-View-Editor这篇论文,这篇论文虽然并没有提及Controller,但是Editor已经是一个很接近的概念。大概7个月之后,Trygve Reenskaug在他的文章Models-Views-Controllers中正式提出了MVC这个三元组。上面两篇论文中对于Model的定义都非常清晰,Model代表着an abstraction in the form of data in a computing system.,即为计算系统中数据的抽象表述,而View代表着capable of showing one or more pictorial representations of the Model on screen and on hardcopy.,即能够将模型中的数据以某种方式表现在屏幕上的组件。而Editor被定义为某个用户与多个View之间的交互接口,在后一篇文章中Controller则被定义为了a special controller ... that permits the user to modify the information that is presented by the view.,即主要负责对模型进行修改并且最终呈现在界面上。从我的个人理解来看,Controller负责控制整个界面,而Editor只负责界面中的某个部分。Controller协调菜单、面板以及像鼠标点击、移动、手势等等很多的不同功能的模块,而Editor更多的只是负责某个特定的任务。后来,Martin Fowler在2003开始编写的著作Patterns of Enterprise Application Architecture中重申了MVC的意义:Model View Controller (MVC) is one of the most quoted (and most misquoted) patterns around.,将Controller的功能正式定义为:响应用户操作,控制模型进行相应更新,并且操作页面进行合适的重渲染。这是非常经典、狭义的MVC定义,后来在iOS以及其他很多领域实际上运用的MVC都已经被扩展或者赋予了新的功能,不过笔者为了区分架构演化之间的区别,在本文中仅会以这种最朴素的定义方式来描述MVC。

根据上述定义,我们可以看到MVC模式中典型的用户场景为:

  • 用户交互输入了某些内容

  • Controller将用户输入转化为Model所需要进行的更改

  • Model中的更改结束之后,Controller通知View进行更新以表现出当前Model的状态

根据上述流程,我们可知经典的MVC模式的特性为:

  • View、Controller、Model中皆有ViewLogic的部分实现

  • Controller负责控制View与Model,需要了解View与Model的细节。

  • View需要了解Controller与Model的细节,需要在侦测用户行为之后调用Controller,并且在收到通知后调用Model以获取最新数据

  • Model并不需要了解Controller与View的细节,相对独立的模块

Observer Pattern:自带观察者模式的MVC

上文中也已提及,MVC滥觞于Observer模式,经典的MVC模式也可以与Observer模式相结合,其典型的用户流程为:

  • 用户交互输入了某些内容

  • Controller将用户输入转化为Model所需要进行的更改

  • View作为Observer会监听Model中的任意更新,一旦有更新事件发出,View会自动触发更新以展示最新的Model状态

可知其与经典的MVC模式区别在于不需要Controller通知View进行更新,而是由Model主动调用View进行更新。这种改变提升了整体效率,简化了Controller的功能,不过也导致了View与Model之间的紧耦合。

MVP:Decoupling View and Model 将视图与模型解耦, View<->Presenter

维基百科将MVP称为MVC的一个推导扩展,观其渊源而知其所以然。对于MVP概念的定义,Microsoft较为明晰,而Martin Fowler的定义最为广泛接受。MVP模式在WinForm系列以Visual-XXX命名的编程语言与Java Swing等系列应用中最早流传开来,不过后来ASP.NET以及JFaces也广泛地使用了该模式。在MVP中用户不再与Presenter进行直接交互,而是由View完全接管了用户交互,譬如窗口上的每个控件都知道如何响应用户输入并且合适地渲染来自于Model的数据。而所有的事件会被传输给Presenter,Presenter在这里就是View与Model之间的中间人,负责控制Model进行修改以及将最新的Model状态传递给View。这里描述的就是典型的所谓Passive View版本的MVP,其典型的用户场景为:

  • 用户交互输入了某些内容

  • View将用户输入转化为发送给Presenter

  • Presenter控制Model接收需要改变的点

  • Model将更新之后的值返回给Presenter

  • Presenter将更新之后的模型返回给View

根据上述流程,我们可知Passive View版本的MVP模式的特性为:

  • View、Presenter、Model中皆有ViewLogic的部分实现

  • Presenter负责连接View与Model,需要了解View与Model的细节。

  • View需要了解Presenter的细节,将用户输入转化为事件传递给Presenter

  • Model需要了解Presenter的细节,在完成更新之后将最新的模型传递给Presenter

  • View与Model之间相互解耦合

Supervising Controller MVP

简化Presenter的部分功能,使得Presenter只起到需要复杂控制或者调解的操作,而简单的Model展示转化直接由View与Model进行交互:

MVVM:Data Binding & Stateless View 数据绑定与无状态的View,View<->ViewModels

Model View View-Model模型是MV*家族中最年轻的一位,也是由Microsoft提出,并经由Martin Fowler布道传播。MVVM源于Martin Fowler的Presentation Model,Presentation Model的核心在于接管了View所有的行为响应,View的所有响应与状态都定义在了Presentation Model中。也就是说,View不会包含任意的状态。举个典型的使用场景,当用户点击某个按钮之后,状态信息是从Presentation Model传递给Model,而不是从View传递给Presentation Model。任何控制组件间的逻辑操作,即上文所述的ViewLogic,都应该放置在Presentation Model中进行处理,而不是在View层,这一点也是MVP模式与Presentation Model最大的区别。

MVVM模式进一步深化了Presentation Model的思想,利用Data Binding等技术保证了View中不会存储任何的状态或者逻辑操作。在WPF中,UI主要是利用XAML或者XML创建,而这些标记类型的语言是无法存储任何状态的,就像HTML一样(因此JSX语法其实是将View又有状态化了),只是允许UI与某个ViewModel中的类建立映射关系。渲染引擎根据XAML中的声明以及来自于ViewModel的数据最终生成呈现的页面。因为数据绑定的特性,有时候MVVM也会被称作MVB:Model View Binder。总结一下,MVVM利用数据绑定彻底完成了从命令式编程到声明式编程的转化,使得View逐步无状态化。一个典型的MVVM的使用场景为:

  • 用户交互输入

  • View将数据直接传送给ViewModel,ViewModel保存这些状态数据

  • 在有需要的情况下,ViewModel会将数据传送给Model

  • Model在更新完成之后通知ViewModel

  • ViewModel从Model中获取最新的模型,并且更新自己的数据状态

  • View根据最新的ViewModel的数据进行重新渲染

根据上述流程,我们可知MVVM模式的特性为:

  • ViewModel、Model中存在ViewLogic实现,View则不保存任何状态信息

  • View不需要了解ViewModel的实现细节,但是会声明自己所需要的数据类型,并且能够知道如何重新渲染

  • ViewModel不需要了解View的实现细节(非命令式编程),但是需要根据View声明的数据类型传入对应的数据。ViewModel需要了解Model的实现细节。

  • Model不需要了解View的实现细节,需要了解ViewModel的实现细节

MV* in iOS

MVC

Cocoa MVC中往往会将大量的逻辑代码放入ViewController中,这就导致了所谓的Massive ViewController,而且很多的逻辑操作都嵌入到了View的生命周期中,很难剥离开来。或许你可以将一些业务逻辑或者数据转换之类的事情放到Model中完成,不过对于View而言绝大部分时间仅起到发送Action给Controller的作用。ViewController逐渐变成了几乎所有其他组件的Delegate与DataSource,还经常会负责派发或者取消网络请求等等职责。你的代码大概是这样的:


var userCell = tableView.dequeueReusableCellWithIdentifier("identifier") as UserCell

userCell.configureWithUser(user)

上面这种写法直接将View于Model关联起来,其实算是打破了Cocoa MVC的规范的,不过这样也是能够减少些Controller中的中转代码呢。这样一个架构模式在进行单元测试的时候就显得麻烦了,因为你的ViewController与View紧密关联,使得其很难去进行测试,因为你必须为每一个View创建Mock对象并且管理其生命周期。另外因为整个代码都混杂在一起,即破坏了职责分离原则,导致了系统的可变性与可维护性也很差。经典的MVC的示例程序如下:


import UIKit



struct Person { // Model

    let firstName: String

    let lastName: String

}



class GreetingViewController : UIViewController { // View + Controller

    var person: Person!

    let showGreetingButton = UIButton()

    let greetingLabel = UILabel()

    

    override func viewDidLoad() {

        super.viewDidLoad()

        self.showGreetingButton.addTarget(self, action: "didTapButton:", forControlEvents: .TouchUpInside)

    }

    

    func didTapButton(button: UIButton) {

        let greeting = "Hello" + " " + self.person.firstName + " " + self.person.lastName

        self.greetingLabel.text = greeting

        

    }

    // layout code goes here

}

// Assembling of MVC

let model = Person(firstName: "David", lastName: "Blaine")

let view = GreetingViewController()

view.person = model;

上面这种代码一看就很难测试,我们可以将生成greeting的代码移到GreetingModel这个单独的类中,从而进行单独的测试。不过我们还是很难去在GreetingViewController中测试显示逻辑而不调用UIView相关的譬如viewDidLoaddidTapButton等等较为费时的操作。再按照我们上文提及的优秀的架构的几个方面来看:

  • Distribution:View与Model是分割开来了,不过View与Controller是紧耦合的

  • Testability:因为较差的职责分割导致貌似只有Model部分方便测试

  • 易用性:因为程序比较直观,可能容易理解。

MVP

Cocoa中MVP模式是将ViewController当做纯粹的View进行处理,而将很多的ViewLogic与模型操作移动到Presenter中进行,代码如下:


import UIKit



struct Person { // Model

    let firstName: String

    let lastName: String

}



protocol GreetingView: class {

    func setGreeting(greeting: String)

}



protocol GreetingViewPresenter {

    init(view: GreetingView, person: Person)

    func showGreeting()

}



class GreetingPresenter : GreetingViewPresenter {

    unowned let view: GreetingView

    let person: Person

    required init(view: GreetingView, person: Person) {

        self.view = view

        self.person = person

    }

    func showGreeting() {

        let greeting = "Hello" + " " + self.person.firstName + " " + self.person.lastName

        self.view.setGreeting(greeting)

    }

}



class GreetingViewController : UIViewController, GreetingView {

    var presenter: GreetingViewPresenter!

    let showGreetingButton = UIButton()

    let greetingLabel = UILabel()

    

    override func viewDidLoad() {

        super.viewDidLoad()

        self.showGreetingButton.addTarget(self, action: "didTapButton:", forControlEvents: .TouchUpInside)

    }

    

    func didTapButton(button: UIButton) {

        self.presenter.showGreeting()

    }

    

    func setGreeting(greeting: String) {

        self.greetingLabel.text = greeting

    }

    

    // layout code goes here

}

// Assembling of MVP

let model = Person(firstName: "David", lastName: "Blaine")

let view = GreetingViewController()

let presenter = GreetingPresenter(view: view, person: model)

view.presenter = presenter
  • Distribution:主要的业务逻辑分割在了Presenter与Model中,View相对呆板一点

  • Testability:较为方便地测试

  • 易用性:代码职责分割的更为明显,不过不像MVC那样直观易懂了

MVVM


import UIKit



struct Person { // Model

    let firstName: String

    let lastName: String

}



protocol GreetingViewModelProtocol: class {

    var greeting: String? { get }

    var greetingDidChange: ((GreetingViewModelProtocol) -> ())? { get set } // function to call when greeting did change

    init(person: Person)

    func showGreeting()

}



class GreetingViewModel : GreetingViewModelProtocol {

    let person: Person

    var greeting: String? {

        didSet {

            self.greetingDidChange?(self)

        }

    }

    var greetingDidChange: ((GreetingViewModelProtocol) -> ())?

    required init(person: Person) {

        self.person = person

    }

    func showGreeting() {

        self.greeting = "Hello" + " " + self.person.firstName + " " + self.person.lastName

    }

}



class GreetingViewController : UIViewController {

    var viewModel: GreetingViewModelProtocol! {

        didSet {

            self.viewModel.greetingDidChange = { [unowned self] viewModel in

                self.greetingLabel.text = viewModel.greeting

            }

        }

    }

    let showGreetingButton = UIButton()

    let greetingLabel = UILabel()

    

    override func viewDidLoad() {

        super.viewDidLoad()

        self.showGreetingButton.addTarget(self.viewModel, action: "showGreeting", forControlEvents: .TouchUpInside)

    }

    // layout code goes here

}

// Assembling of MVVM

let model = Person(firstName: "David", lastName: "Blaine")

let viewModel = GreetingViewModel(person: model)

let view = GreetingViewController()

view.viewModel = viewModel
  • Distribution:在Cocoa MVVM中,View相对于MVP中的View担负了更多的功能,譬如需要构建数据绑定等等

  • Testability:ViewModel拥有View中的所有数据结构,因此很容易就可以进行测试

  • 易用性:相对而言有很多的冗余代码

MV* in Android

此部分完整代码在这里,笔者在这里节选出部分代码方便对照演示。Android中的Activity的功能很类似于iOS中的UIViewController,都可以看做MVC中的Controller。在2010年左右经典的Android程序大概是这样的:


TextView mCounterText;

Button mCounterIncrementButton;



int mClicks = 0;



public void onCreate(Bundle b) {

  super.onCreate(b);



  mCounterText = (TextView) findViewById(R.id.tv_clicks);

  mCounterIncrementButton = (Button) findViewById(R.id.btn_increment);



  mCounterIncrementButton.setOnClickListener(new View.OnClickListener() {

    public void onClick(View v) {

      mClicks++;

      mCounterText.setText(""+mClicks);

    }

  });

}

后来2013年左右出现了ButterKnife这样的基于注解的控件绑定框架,此时的代码看上去是这样的:



@Bind(R.id.tv_clicks) mCounterText;

@OnClick(R.id.btn_increment)

public void onSubmitClicked(View v) {

    mClicks++;

    mCounterText.setText("" + mClicks);

}

后来Google官方也推出了数据绑定的框架,从此MVVM模式在Android中也愈发流行:


<layout xmlns:android="http://schemas.android.com/apk/res/android">

   <data>

       <variable name="counter" type="com.example.Counter"/>

       <variable name="counter" type="com.example.ClickHandler"/>

   </data>

   <LinearLayout

       android:orientation="vertical"

       android:layout_width="match_parent"

       android:layout_height="match_parent">

       <TextView android:layout_width="wrap_content"

           android:layout_height="wrap_content"

           android:text="@{counter.value}"/>

       <Buttonandroid:layout_width="wrap_content"

           android:layout_height="wrap_content"

           android:text="@{handlers.clickHandle}"/>

   </LinearLayout>

</layout>

后来Anvil这样的受React启发的组件式框架以及Jedux这样借鉴了Redux全局状态管理的框架也将Unidirectional 架构引入了Android开发的世界。

MVC

  • 声明View中的组件对象或者Model对象


    private Subscription subscription;

    private RecyclerView reposRecycleView;

    private Toolbar toolbar;

    private EditText editTextUsername;

    private ProgressBar progressBar;

    private TextView infoTextView;

    private ImageButton searchButton;
  • 将组件与Activity中对象绑定,并且声明用户响应处理函数




        super.onCreate(savedInstanceState);

        setContentView(R.layout.activity_main);

        progressBar = (ProgressBar) findViewById(R.id.progress);

        infoTextView = (TextView) findViewById(R.id.text_info);

        //Set up ToolBar

        toolbar = (Toolbar) findViewById(R.id.toolbar);

        setSupportActionBar(toolbar);

        //Set up RecyclerView

        reposRecycleView = (RecyclerView) findViewById(R.id.repos_recycler_view);

        setupRecyclerView(reposRecycleView);

        // Set up search button

        searchButton = (ImageButton) findViewById(R.id.button_search);

        searchButton.setOnClickListener(new View.OnClickListener() {

            @Override

            public void onClick(View v) {

                loadGithubRepos(editTextUsername.getText().toString());

            }

        });

        //Set up username EditText

        editTextUsername = (EditText) findViewById(R.id.edit_text_username);

        editTextUsername.addTextChangedListener(mHideShowButtonTextWatcher);

        editTextUsername.setOnEditorActionListener(new TextView.OnEditorActionListener() {

            @Override

            public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {

                if (actionId == EditorInfo.IME_ACTION_SEARCH) {

                    String username = editTextUsername.getText().toString();

                    if (username.length() > 0) loadGithubRepos(username);

                    return true;

                }

                return false;

            }

});
  • 用户输入之后的更新流程




        progressBar.setVisibility(View.VISIBLE);

        reposRecycleView.setVisibility(View.GONE);

        infoTextView.setVisibility(View.GONE);

        ArchiApplication application = ArchiApplication.get(this);

        GithubService githubService = application.getGithubService();

        subscription = githubService.publicRepositories(username)

                .observeOn(AndroidSchedulers.mainThread())

                .subscribeOn(application.defaultSubscribeScheduler())

                .subscribe(new Subscriber<List<Repository>>() {

                    @Override

                    public void onCompleted() {

                        progressBar.setVisibility(View.GONE);

                        if (reposRecycleView.getAdapter().getItemCount() > 0) {

                            reposRecycleView.requestFocus();

                            hideSoftKeyboard();

                            reposRecycleView.setVisibility(View.VISIBLE);

                        } else {

                            infoTextView.setText(R.string.text_empty_repos);

                            infoTextView.setVisibility(View.VISIBLE);

                        }

                    }



                    @Override

                    public void onError(Throwable error) {

                        Log.e(TAG, "Error loading GitHub repos ", error);

                        progressBar.setVisibility(View.GONE);

                        if (error instanceof HttpException

                                && ((HttpException) error).code() == 404) {

                            infoTextView.setText(R.string.error_username_not_found);

                        } else {

                            infoTextView.setText(R.string.error_loading_repos);

                        }

                        infoTextView.setVisibility(View.VISIBLE);

                    }



                    @Override

                    public void onNext(List<Repository> repositories) {

                        Log.i(TAG, "Repos loaded " + repositories);

                        RepositoryAdapter adapter =

                                (RepositoryAdapter) reposRecycleView.getAdapter();

                        adapter.setRepositories(repositories);

                        adapter.notifyDataSetChanged();

                    }

});

MVP

  • 将Presenter与View绑定,并且将用户响应事件绑定到Presenter中


        //Set up presenter

        presenter = new MainPresenter();

        presenter.attachView(this);

        ...

        

        // Set up search button

        searchButton = (ImageButton) findViewById(R.id.button_search);

        searchButton.setOnClickListener(new View.OnClickListener() {

            @Override

            public void onClick(View v) {

                presenter.loadRepositories(editTextUsername.getText().toString());

            }

        });
  • Presenter中调用Model更新数据,并且调用View中进行重新渲染


    public void loadRepositories(String usernameEntered) {

        String username = usernameEntered.trim();

        if (username.isEmpty()) return;



        mainMvpView.showProgressIndicator();

        if (subscription != null) subscription.unsubscribe();

        ArchiApplication application = ArchiApplication.get(mainMvpView.getContext());

        GithubService githubService = application.getGithubService();

        subscription = githubService.publicRepositories(username)

                .observeOn(AndroidSchedulers.mainThread())

                .subscribeOn(application.defaultSubscribeScheduler())

                .subscribe(new Subscriber<List<Repository>>() {

                    @Override

                    public void onCompleted() {

                        Log.i(TAG, "Repos loaded " + repositories);

                        if (!repositories.isEmpty()) {

                            mainMvpView.showRepositories(repositories);

                        } else {

                            mainMvpView.showMessage(R.string.text_empty_repos);

                        }

                    }



                    @Override

                    public void onError(Throwable error) {

                        Log.e(TAG, "Error loading GitHub repos ", error);

                        if (isHttp404(error)) {

                            mainMvpView.showMessage(R.string.error_username_not_found);

                        } else {

                            mainMvpView.showMessage(R.string.error_loading_repos);

                        }

                    }



                    @Override

                    public void onNext(List<Repository> repositories) {

                        MainPresenter.this.repositories = repositories;

                    }

                });

        }


MVVM

  • XML中声明数据绑定


<data>

        <variable

            name="viewModel"

            type="uk.ivanc.archimvvm.viewmodel.MainViewModel"/>

</data>

...

            <EditText

                android:id="@+id/edit_text_username"

                android:layout_width="match_parent"

                android:layout_height="wrap_content"

                android:layout_toLeftOf="@id/button_search"

                android:hint="@string/hit_username"

                android:imeOptions="actionSearch"

                android:inputType="text"

                android:onEditorAction="@{viewModel.onSearchAction}"

                android:textColor="@color/white"

                android:theme="@style/LightEditText"

                app:addTextChangedListener="@{viewModel.usernameEditTextWatcher}"/>



  • View中绑定ViewModel


        super.onCreate(savedInstanceState);

        binding = DataBindingUtil.setContentView(this, R.layout.main_activity);

        mainViewModel = new MainViewModel(this, this);

        binding.setViewModel(mainViewModel);

        setSupportActionBar(binding.toolbar);

        setupRecyclerView(binding.reposRecyclerView);
  • ViewModel中进行数据操作


public boolean onSearchAction(TextView view, int actionId, KeyEvent event) {

        if (actionId == EditorInfo.IME_ACTION_SEARCH) {

            String username = view.getText().toString();

            if (username.length() > 0) loadGithubRepos(username);

            return true;

        }

        return false;

    }



    public void onClickSearch(View view) {

        loadGithubRepos(editTextUsernameValue);

    }



    public TextWatcher getUsernameEditTextWatcher() {

        return new TextWatcher() {

            @Override

            public void beforeTextChanged(CharSequence charSequence, int start, int count, int after) {



            }



            @Override

            public void onTextChanged(CharSequence charSequence, int start, int before, int count) {

                editTextUsernameValue = charSequence.toString();

                searchButtonVisibility.set(charSequence.length() > 0 ? View.VISIBLE : View.GONE);

            }



            @Override

            public void afterTextChanged(Editable editable) {



            }

        };

}

Unidirectional User Interface Architecture:单向数据流

Unidirectional User Interface Architecture架构的概念源于后端常见的CROS/Event Sourcing模式,其核心思想即是将应用状态被统一存放在一个或多个的Store中,并且所有的数据更新都是通过可观测的Actions触发,而所有的View都是基于Store中的状态渲染而来。该架构的最大优势在于整个应用中的数据流以单向流动的方式从而使得有用更好地可预测性与可控性,这样可以保证你的应用各个模块之间的松耦合性。与MVVM模式相比,其解决了以下两个问题:

  • 避免了数据在多个ViewModel中的冗余与不一致问题

  • 分割了ViewModel的职责,使得ViewModel变得更加Clean

Why not Bidirectional(Two-way DataBinding)?

This means that one change (a user input or API response) can affect the state of an application in many places in the code — for example, two-way data binding. That can be hard to maintain and debug.

Facebook强调,双向数据绑定极不利于代码的扩展与维护。

从具体的代码实现角度来看,双向数据绑定会导致更改的不可预期性(UnPredictable),就好像Angular利用Dirty Checking来进行是否需要重新渲染的检测,这导致了应用的缓慢,简直就是来砸场子的。而在采用了单向数据流之后,整个应用状态会变得可预测(Predictable),也能很好地了解当状态发生变化时到底会有多少的组件发生变化。另一方面,相对集中地状态管理,也有助于你不同的组件之间进行信息交互或者状态共享,特别是像Redux这种强调Single Store与SIngle State Tree的状态管理模式,能够保证以统一的方式对于应用的状态进行修改,并且Immutable的概念引入使得状态变得可回溯。

譬如Facebook在Flux Overview中举的例子,当我们希望在一个界面上同时展示未读信息列表与未读信息的总数目的时候,对于MV*就有点恶心了,特别是当这两个组件不在同一个ViewModel/Controller中的时候。一旦我们将某个未读信息标识为已读,会引起控制已读信息、未读信息、未读信息总数目等等一系列模型的更新。特别是很多时候为了方便我们可能在每个ViewModel/Controller都会设置一个数据副本,这会导致依赖连锁更新,最终导致不可预测的结果与性能损耗。而在Flux中这种依赖是反转的,Store接收到更新的Action请求之后对数据进行统一的更新并且通知各个View,而不是依赖于各个独立的ViewModel/Controller所谓的一致性更新。从职责划分的角度来看,除了Store之外的任何模块其实都不知道应该如何处理数据,这就保证了合理的职责分割。这种模式下,当我们创建新项目时,项目复杂度的增长瓶颈也就会更高,不同于传统的View与ViewLogic之间的绑定,控制流被独立处理,当我们添加新的特性,新的数据,新的界面,新的逻辑处理模块时,并不会导致原有模块的复杂度增加,从而使得整个逻辑更加清晰可控。

这里还需要提及一下,很多人应该是从React开始认知到单向数据流这种架构模式的,而当时Angular 1的缓慢与性能之差令人发指,但是譬如Vue与Angular 2的性能就非常优秀。借用Vue.js官方的说法,

The virtual-DOM approach provides a functional way to describe your view at any point of time, which is really nice. Because it doesn’t use observables and re-renders the entire app on every update, the view is by definition guaranteed to be in sync with the data. It also opens up possibilities to isomorphic JavaScript applications.

Instead of a Virtual DOM, Vue.js uses the actual DOM as the template and keeps references to actual nodes for data bindings. This limits Vue.js to environments where DOM is present. However, contrary to the common misconception that Virtual-DOM makes React faster than anything else, Vue.js actually out-performs React when it comes to hot updates, and requires almost no hand-tuned optimization. With React, you need to implementshouldComponentUpdate everywhere and use immutable data structures to achieve fully optimized re-renders.

总而言之,笔者认为双向数据流与单向数据流相比,性能上孰优孰劣尚无定论,最大的区别在于单向数据流与双向数据流相比有更好地可控性,这一点在上文提及的函数响应式编程中也有体现。若论快速开发,笔者感觉双向数据绑定略胜一筹,毕竟这种View与ViewModel/ViewLogic之间的直接绑定直观便捷。而如果是注重于全局的状态管理,希望维护耦合程度较低、可测试性/可扩展性较高的代码,那么还是单向数据流,即Unidirectional Architecture较为合适。一家之言,欢迎讨论。

Flux:数据流驱动的页面

Flux不能算是绝对的先行者,但是在Unidirectional Architecture中却是最富盛名的一个,也是很多人接触到的第一个Unidirectional Architecture。Flux主要由以下几个部分构成:

  • Stores:存放业务数据和应用状态,一个Flux中可能存在多个Stores

  • View:层次化组合的React组件

  • Actions:用户输入之后触发View发出的事件

  • Dispatcher:负责分发Actions

根据上述流程,我们可知Flux模式的特性为:

  • Dispatcher:Event Bus中设置有一个单例的Dispatcher,很多Flux的变种都移除了Dispatcher依赖。

  • 只有View使用可组合的组件:在Flux中只有React的组件可以进行层次化组合,而Stores与Actions都不可以进行层次化组合。React组件与Flux一般是松耦合的,因此Flux并不是Fractal,Dispatcher与Stores可以被看做Orchestrator。

  • 用户事件响应在渲染时声明:在React的render()函数中,即负责响应用户交互,也负责注册用户事件的处理器

下面我们来看一个具体的代码对比,首先是以经典的Cocoa风格编写一个简单的计数器按钮:


class ModelCounter



    constructor: (@value=1) ->



    increaseValue: (delta) =>

        @value += delta



class ControllerCounter



    constructor: (opts) ->

        @model_counter = opts.model_counter

        @observers = []



    getValue: => @model_counter.value



    increaseValue: (delta) =>

        @model_counter.increaseValue(delta)

        @notifyObservers()



    notifyObservers: =>

        obj.notify(this) for obj in @observers



    registerObserver: (observer) =>

        @observers.push(observer)



class ViewCounterButton



    constructor: (opts) ->

        @controller_counter = opts.controller_counter

        @button_class = opts.button_class or 'button_counter'

        @controller_counter.registerObserver(this)



    render: =>

        elm = $("<button class=\"#{@button_class}\">

                #{@controller_counter.getValue()}</button>")

        elm.click =>

            @controller_counter.increaseValue(1)

        return elm



    notify: =>

        $("button.#{@button_class}").replaceWith(=> @render())

上述代码逻辑用上文提及的MVC模式图演示就是:

而如果用Flux模式实现,会是下面这个样子:


# Store

class CounterStore extends EventEmitter



    constructor: ->

        @count = 0

        @dispatchToken = @registerToDispatcher()



    increaseValue: (delta) ->

        @count += 1



    getCount: ->

        return @count



    registerToDispatcher: ->

        CounterDispatcher.register((payload) =>

            switch payload.type

                when ActionTypes.INCREASE_COUNT

                    @increaseValue(payload.delta)

        )



# Action

class CounterActions



    @increaseCount: (delta) ->

        CounterDispatcher.handleViewAction({

            'type': ActionTypes.INCREASE_COUNT

            'delta': delta

        })



# View

CounterButton = React.createClass(



    getInitialState: ->

        return {'count': 0}



    _onChange: ->

        @setState({

            count: CounterStore.getCount()

        })



    componentDidMount: ->

        CounterStore.addListener('CHANGE', @_onChange)



    componentWillUnmount: ->

        CounterStore.removeListener('CHANGE', @_onChange)



    render: ->

        return React.DOM.button({'className': @prop.class}, @state.value)



)

其数据流图为:

Redux:集中式的状态管理

Redux是Flux的所有变种中最为出色的一个,并且也是当前Web领域主流的状态管理工具,其独创的理念与功能深刻影响了GUI应用程序架构中的状态管理的思想。Redux将Flux中单例的Dispatcher替换为了单例的Store,即也是其最大的特性,集中式的状态管理。并且Store的定义也不是从零开始单独定义,而是基于多个Reducer的组合,可以把Reducer看做Store Factory。Redux的重要组成部分包括:

  • Singleton Store:管理应用中的状态,并且提供了一个dispatch(action)函数。

  • Provider:用于监听Store的变化并且连接像React、Angular这样的UI框架

  • Actions:基于用户输入创建的分发给Reducer的事件

  • Reducers:用于响应Actions并且更新全局状态树的纯函数

根据上述流程,我们可知Redux模式的特性为:

  • 以工厂模式组装Stores:Redux允许我以createStore()函数加上一系列组合好的Reducer函数来创建Store实例,还有另一个applyMiddleware()函数可以允许在dispatch()函数执行前后链式调用一系列中间件。

  • Providers:Redux并不特定地需要何种UI框架,可以与Angular、React等等很多UI框架协同工作。Redux并不是Fractal,一般来说Store被视作Orchestrator。

  • User Event处理器即可以选择在渲染函数中声明,也可以在其他地方进行声明。

Model-View-Update

又被称作Elm Architecture,上面所讲的Redux就是受到Elm的启发演化而来,因此MVU与Redux之间有很多的相通之处。MVU使用函数式编程语言Elm作为其底层开发语言,因此该架构可以被看做更纯粹的函数式架构。MVU中的基本组成部分有:

  • Model:定义状态数据结构的类型

  • View:纯函数,将状态渲染为界面

  • Actions:以Mailbox的方式传递用户事件的载体

  • Update:用于更新状态的纯函数

根据上述流程,我们可知Elm模式的特性为:

  • 到处可见的层次化组合:Redux只是在View层允许将组件进行层次化组合,而MVU中在Model与Update函数中也允许进行层次化组合,甚至Actions都可以包含内嵌的子Action

  • Elm属于Fractal架构:因为Elm中所有的模块组件都支持层次化组合,即都可以被单独地导出使用

Model-View-Intent

MVI是一个基于RxJS的响应式单向数据流架构。MVI也是Cycle.js的首选架构,主要由Observable事件流对象与处理函数组成。其主要的组成部分包括:

  • Intent:Observable提供的将用户事件转化为Action的函数

  • Model:Observable提供的将Action转化为可观测的State的函数

  • View:将状态渲染为用户界面的函数

  • Custom Element:类似于React Component那样的界面组件

根据上述流程,我们可知MVI模式的特性为:

  • 重度依赖于Observables:架构中的每个部分都会被转化为Observable事件流

  • Intent:不同于Flux或者Redux,MVI中的Actions并没有直接传送给Dispatcher或者Store,而是交于正在监听的Model

  • 彻底的响应式,并且只要所有的组件都遵循MVI模式就能保证整体架构的fractal特性

查看原文

52lidan 收藏了文章 · 10月29日

GUI应用程序架构的十年变迁:MVC,MVP,MVVM,Unidirectional,Clean

[toc]

笔者的编程基础与软件工程相关文章索引
十年前,Martin Fowler撰写了GUI Architectures一文,至今被奉为经典。本文所谈的所谓架构二字,核心即是对于对于富客户端的代码组织/职责划分。纵览这十年内的架构模式变迁,大概可以分为MV*与Unidirectional两大类,而Clean Architecture则是以严格的层次划分独辟蹊径。从笔者的认知来看,从MVC到MVP的变迁完成了对于View与Model的解耦合,改进了职责分配与可测试性。而从MVP到MVVM,添加了View与ViewModel之间的数据绑定,使得View完全的无状态化。最后,整个从MV*到Unidirectional的变迁即是采用了消息队列式的数据流驱动的架构,并且以Redux为代表的方案将原本MV*中碎片化的状态管理变为了统一的状态管理,保证了状态的有序性与可回溯性。

笔者在撰写本文的时候也不可避免的带了很多自己的观点,在漫长的GUI架构模式变迁过程中,很多概念其实是交错复杂,典型的譬如MVP与MVVM的区别,笔者按照自己的理解强行定义了二者的区分边界,不可避免的带着自己的主观想法。另外,鉴于笔者目前主要进行的是Web方面的开发,因此在整体倾向上是支持Unidirectional Architecture并且认为集中式的状态管理是正确的方向。但是必须要强调,GUI架构本身是无法脱离其所依托的平台,下文笔者也会浅述由于Android与iOS本身SDK API的特殊性,生搬硬套其他平台的架构模式也是邯郸学步,沐猴而冠。不过总结而言,它山之石,可以攻玉,本身我们所处的开发环境一直在不断变化,对于过去的精华自当应该保留,并且与新的环境相互印证,触类旁通。

Introduction

Make everything as simple as possible, but not simpler — Albert Einstein

Graphical User Interfaces一直是软件开发领域的重要组成部分,从当年的MFC,到WinForm/Java Swing,再到WebAPP/Android/iOS引领的智能设备潮流,以及未来可能的AR/VR,GUI应用开发中所面临的问题一直在不断演变,但是从各种具体问题中抽象而出的可以复用的模式恒久存在。而这些模式也就是所谓应用架构的核心与基础。对于所谓应用架构,空谈误事,不谈误己,笔者相信不仅仅只有自己想把那一团糟的代码给彻底抛弃。往往对于架构的认知需要一定的大局观与格局眼光,每个有一定经验的客户端程序开发者,无论是Web、iOS还是Android,都会有自己熟悉的开发流程习惯,但是笔者认为架构认知更多的是道,而非术。当你能够以一种指导思想在不同的平台上能够进行高效地开发时,你才能真正理解架构。这个有点像张三丰学武,心中无招,方才达成。笔者这么说只是为了强调,尽量地可以不拘泥于某个平台的具体实现去审视GUI应用程序架构模式,会让你有不一样的体验。譬如下面这个组装Android机器人的图:

怎么去焊接两个组件,属于具体的术实现,而应该焊接哪两个组件就是术,作为合格的架构师总不能把脚和头直接焊接在一起,而忽略中间的连接模块。对于软件开发中任何一个方面,我们都希望能够寻找到一个抽象程度适中,能够在接下来的4,5年内正常运行与方便维护扩展的开发模式。引申下笔者在我的编程之路中的论述,目前在GUI架构模式中,无论是Android、iOS还是Web,都在经历着从命令式编程到声明式/响应式编程,从Passive Components到Reactive Components,从以元素操作为核心到以数据流驱动为核心的变迁(关于这几句话的解释可以参阅下文的Declarative vs. Imperative这一小节)。

Terminology:名词解释

正文之前,我们先对一些概念进行阐述:

  • User Events/用户事件:即是来自于可输入设备上的用户操作产生的数据,譬如鼠标点击、滚动、键盘输入、触摸等等。

  • User Interface Rendering/用户界面渲染:View这个名词在前后端开发中都被广泛使用,为了明晰该词的含义,我们在这里使用用户渲染这个概念,来描述View,即是以HTML或者JSX或者XAML等等方式在屏幕上产生的图形化输出内容。

  • UI Application:允许接收用户输入,并且将输出渲染到屏幕上的应用程序,该程序能够长期运行而不只是渲染一次即结束

Passive Module & Reactive Module

箭头表示的归属权实际上也是Passive Programming与Reactive Programming的区别,譬如我们的系统中有Foo与Bar两个模块,可以把它们当做OOP中的两个类。如果我们在Foo与Bar之间建立一个箭头,也就意味着Foo能够影响Bar中的状态:

譬如Foo在进行一次网络请求之后将Bar内部的计数器加一操作:


// This is inside the Foo module

function onNetworkRequest() {
  // ...
  Bar.incrementCounter();
  // ...
}

在这里将这种逻辑关系可以描述为Foo拥有着网络请求完成之后将Bar内的计数器加一这个关系的控制权,也就是Foo占有主导性,而Bar相对而言是Passive被动的:

Bar是Passive的,它允许其他模块改变其内部状态。而Foo是主动地,它需要保证能够正确地更新Bar的内部状态,Passive模块并不知道谁会更新到它。而另一种方案就是类似于控制反转,由Bar完成对于自己内部状态的更新:

在这种模式下,Bar监听来自于Foo中的事件,并且在某些事件发生之后进行内部状态更新:


// This is inside the Bar module

Foo.addOnNetworkRequestListener(() => {

  self.incrementCounter(); // self is Bar

});

此时Bar就变成了Reactive Module,它负责自己的内部的状态更新以响应外部的事件,而Foo并不知道它发出的事件会被谁监听。

Declarative vs. Imperative:命令式编程与声明式编程

three-ds-of-web-development

前端攻略-从路人甲到英雄无敌二:JavaScript 与不断演化的框架

形象地来描述命令式编程与声明式编程的区别,就好像C#/JavaScript与类似于XML或者HTML这样的标记语言之间的区别。命令式编程关注于how to do what you want done,即事必躬亲,需要安排好每个要做的细节。而声明式编程关注于what you want done without worrying about how,即只需要声明要做的事情而不用将具体的过程再耦合进来。对于开发者而言,声明式编程将很多底层的实现细节向开发者隐藏,而使得开发者可以专注于具体的业务逻辑,同时也保证了代码的解耦与单一职责。譬如在Web开发中,如果你要基于jQuery将数据填充到页面上,那么大概按照命令式编程的模式你需要这么做:


var options = $("#options");
$.each(result, function() {
    options.append($("<option />").val(this.id).text(this.name));
});

而以Angular 1声明式的方式进行编写,那么是如下的标记模样:


<div ng-repeat="item in items" ng-click="select(item)">{{item.name}}
</div>

而在iOS和Android开发中,近年来函数响应式编程(Functional Reactive Programming)也非常流行,参阅笔者关于响应式编程的介绍可以了解,响应式编程本身是基于流的方式对于异步操作的一种编程优化,其在整个应用架构的角度看更多的是细节点的优化。以RxSwift为例,通过响应式编程可以编写出非常优雅的用户交互代码:


let searchResults = searchBar.rx_text
    .throttle(0.3, scheduler: MainScheduler.instance)
    .distinctUntilChanged()
    .flatMapLatest { query -> Observable<[Repository]> in
        if query.isEmpty {
            return Observable.just([])
        }

        return searchGitHub(query)
            .catchErrorJustReturn([])
    }
    .observeOn(MainScheduler.instance)
searchResults
    .bindTo(tableView.rx_itemsWithCellIdentifier("Cell")) {
        (index, repository: Repository, cell) in
        cell.textLabel?.text = repository.name
        cell.detailTextLabel?.text = repository.url
    }
    .addDisposableTo(disposeBag)

其直观的效果大概如下图所示:

到这里可以看出,无论是从命令式编程与声明式编程的对比还是响应式编程的使用,我们开发时的关注点都慢慢转向了所谓的数据流。便如MVVM,虽然它还是双向数据流,但是其使用的Data-Binding也意味着开发人员不需要再去以命令地方式寻找元素,而更多地关注于应该给绑定的对象赋予何值,这也是数据流驱动的一个重要体现。而Unidirectional Architecture采用了类似于Event Source的方式,更是彻底地将组件之间、组件与功能模块之间的关联交于数据流操控。

谈到架构,我们关心哪些方面?

当我们谈论所谓客户端开发的时候,我们首先会想到怎么保证向后兼容、怎么使用本地存储、怎么调用远程接口、如何有效地利用内存/带宽/CPU等资源,不过最核心的还是怎么绘制界面并且与用户进行交互,关于这部分详细的知识点纲要推荐参考笔者的我的编程之路——知识管理与知识体系这篇文章或者这张知识点列表思维脑图

而当我们提纲挈领、高屋建瓴地以一个较高的抽象的视角来审视总结这个知识点的时候会发现,我们希望的好的架构,便如在引言中所说,即是有好的代码组织方式/合理的职责划分粒度。笔者脑中会出现如下这样的一个层次结构,可以看出,最核心的即为View与ViewLogic这两部分:

实际上,对于富客户端的代码组织/职责划分,从具体的代码分割的角度,即是功能的模块化界面的组件化状态管理这三个方面。最终呈献给用户的界面,笔者认为可以抽象为如下等式:View = f(State,Template)。而ViewLogic中对于类/模块之间的依赖关系,即属于代码组织,譬如MVC中的View与Controller之间的从属关系。而对于动态数据,即所谓应用数据的管理,属于状态管理这一部分,譬如APP从后来获取了一系列的数据,如何将这些数据渲染到用户界面上使得用户可见,这样的不同部分之间的协同关系、整个数据流的流动,即属于状态管理。

分久必合,合久必分

实际上从MVC、MVP到MVVM,一直围绕的核心问题就是如何分割ViewLogic与View,即如何将负责界面展示的代码与负责业务逻辑的代码进行分割。所谓分久必合,合久必分,从笔者自我审视的角度,发现很有趣的一点。Android与iOS中都是从早期的用代码进行组件添加与布局到专门的XML/Nib/StoryBoard文件进行布局,Android中的Annotation/DataBinding、iOS中的IBOutlet更加地保证了View与ViewLogic的分割(这一点也是从元素操作到以数据流驱动的变迁,我们不需要再去编写大量的findViewById)。而Web的趋势正好有点相反,无论是WebComponent还是ReactiveComponent都是将ViewLogic与View置于一起,特别是JSX的语法将JavaScript与HTML混搭,很像当年的PHP/JSP与HTML混搭。这一点也是由笔者在上文提及的Android/iOS本身封装程度较高的、规范的API决定的。对于Android/iOS与Web之间开发体验的差异,笔者感觉很类似于静态类型语言与动态类型语言之间的差异。

功能的模块化

老实说在AMD/CMD规范之前,或者说在ES6的模块引入与Webpack的模块打包出来之前,功能的模块化依赖一直也是个很头疼的问题。

SOLID中的接口隔离原则,大量的IOC或者DI工具可以帮我们完成这一点,就好像Spring中的@Autowire或者Angular 1中的@Injection,都给笔者很好地代码体验。

在这里笔者首先要强调下,从代码组织的角度来看,项目的构建工具与依赖管理工具会深刻地影响到代码组织,这一点在功能的模块化中尤其显著。譬如笔者对于Android/Java构建工具的使用变迁经历了从Eclipse到Maven再到Gradle,笔者会将不同功能逻辑的代码封装到不同的相对独立的子项目中,这样就保证了子项目与主项目之间的一定隔离,方便了测试与代码维护。同样的,在Web开发中从AMD/CMD规范到标准的ES6模块与Webpack编译打包,也使得代码能够按照功能尽可能地解耦分割与避免冗余编码。而另一方面,依赖管理工具也极大地方便我们使用第三方的代码与发布自定义的依赖项,譬如Web中的NPM与Bower,iOS中的CocoaPods都是十分优秀的依赖发布与管理工具,使我们不需要去关心第三方依赖的具体实现细节即能够透明地引入使用。因此选择合适的项目构建工具与依赖管理工具也是好的GUI架构模式的重要因素之一。不过从应用程序架构的角度看,无论我们使用怎样的构建工具,都可以实现或者遵循某种架构模式,笔者认为二者之间也并没有必然的因果关系。

界面的组件化

A component is a small piece of the user interface of our application, a view, that can be composed with other components to make more advanced components.

何谓组件?一个组件即是应用中用户交互界面的部分组成,组件可以通过组合封装成更高级的组件。组件可以被放入层次化的结构中,即可以是其他组件的父组件也可以是其他组件的子组件。根据上述的组件定义,笔者认为像Activity或者UIViewController都不能算是组件,而像ListView或者UITableView可以看做典型的组件。

我们强调的是界面组件的Composable&Reusable,即可组合性与可重用性。当我们一开始接触到Android或者iOS时,因为本身SDK的完善度与规范度较高,我们能够很多使用封装程度较高的组件。譬如ListView,无论是Android中的RecycleView还是iOS中的UITableView或者UICollectionView,都为我们提供了。凡事都有双面性,这种较高程度的封装与规范统一的API方便了我们的开发,但是也限制了我们自定义的能力。同样的,因为SDK的限制,真正意义上可复用/组合的组件也是不多,譬如你不能将两个ListView再组合成一个新的ListView。在React中有所谓的controller-view的概念,即意味着某个React组件同时担负起MVC中Controller与View的责任,也就是JSX这种将负责ViewLogic的JavaScript代码与负责模板的HTML混编的方式。

界面的组件化还包括一个重要的点就是路由,譬如Android中的AndRouter、iOS中的JLRoutes都是集中式路由的解决方案,不过集中式路由在Android或者iOS中并没有大规模推广。iOS中的StoryBoard倒是类似于一种集中式路由的方案,不过更偏向于以UI设计为核心。笔者认为这一点可能是因为Android或者iOS本身所有的代码都是存放于客户端本身,而Web中较传统的多页应用方式还需要用户跳转页面重新加载,而后在单页流行之后即不存在页面级别的跳转,因此在Web单页应用中集中式路由较为流行而Android、iOS中反而不流行。

无状态的组件

无状态的组件的构建函数是纯函数(pure function)并且引用透明的(refferentially transparent),在相同输入的情况下一定会产生相同的组件输出,即符合View = f(State,Template)公式。笔者觉得Android中的ListView/RecycleView,或者iOS中的UITableView,也是无状态组件的典型。譬如在Android中,可以通过动态设置Adapter实例来为RecycleView进行源数据的设置,而作为View层以IoC的方式与具体的数据逻辑解耦。

组件的可组合性与可重用性往往最大的阻碍就是状态,一般来说,我们希望能够重用或者组合的组件都是

Generalization,而状态往往是Specification,即领域特定的。同时,状态也会使得代码的可读性与可测试性降低,在有状态的组件中,我们并不能通过简单地阅读代码就知道其功能。如果借用函数式编程的概念,就是因为副作用的引入使得函数每次回产生不同的结果。函数式编程中存在着所谓Pure Function,即纯函数的概念,函数的返回值永远只受到输入参数的影响。譬如(x)=>x*2这个函数,输入的x值永远不会被改变,并且返回值只是依赖于输入的参数。而Web开发中我们也经常会处于带有状态与副作用的环境,典型的就是Browser中的DOM,之前在jQuery时代我们会经常将一些数据信息缓存在DOM树上,也是典型的将状态与模板混合的用法。这就导致了我们并不能控制到底应该何时去进行重新渲染以及哪些状态变更的操作才是必须的,


var Header = component(function (data) {
  // First argument is h1 metadata
  return h1(null, data.text);
});

// Render the component to our DOM
render(Header({text: 'Hello'}), document.body);

// Some time later, we change it, by calling the
// component once more.
setTimeout(function () {
  render(Header({text: 'Changed'}), document.body);
}, 1000);

var hello = Header({ text: 'Hello' }); var bye   = Header({ text: 'Good Bye' });

状态管理

可变的与不可预测的状态是软件开发中的万恶之源

上文提及,我们尽可能地希望组件的无状态性,那么整个应用中的状态管理应该尽量地放置在所谓High-Order Component或者Smart Component中。在React以及Flux的概念流行之后,Stateless Component的概念深入人心,不过其实对于MVVM中的View,也是无状态的View。通过双向数据绑定将界面上的某个元素与ViewModel中的变量相关联,笔者认为很类似于HOC模式中的Container与Component之间的关联。随着应用的界面与功能的扩展,状态管理会变得愈发混乱。这一点,无论前后端都有异曲同工之难,笔者在基于Redux思想与RxJava的SpringMVC中Controller的代码风格实践一文中对于服务端应用程序开发中的状态管理有过些许讨论。

Features of Good Architectural Pattern:何为好的架构模式

Balanced Distribution of Responsibilities:合理的职责划分

合理的职责划分即是保证系统中的不同组件能够被分配合理的职责,也就是在复杂度之间达成一个平衡,职责划分最权威的原则就是所谓Single Responsibility Principle,单一职责原则。

Testability:可测试性

可测试性是保证软件工程质量的重要手段之一,也是保证产品可用性的重要途径。在传统的GUI程序开发中,特别是对于界面的测试常常设置于状态或者运行环境,并且很多与用户交互相关的测试很难进行场景重现,或者需要大量的人工操作去模拟真实环境。

Ease of Use:易用性

代码的易用性保证了程序架构的简洁与可维护性,所谓最好的代码就是永远不需要重写的代码,而程序开发中尽量避免的代码复用方法就是复制粘贴。

Fractal:碎片化,易于封装与分发

In fractal architectures, the whole can be naively packaged as a component to be used in some larger application.In non-fractal architectures, the non-repeatable parts are said to be orchestrators over the parts that have hierarchical composition.

  • By André Staltz

所谓的Fractal Architectures,即你的应用整体都可以像单个组件一样可以方便地进行打包然后应用到其他项目中。而在Non-Fractal Architectures中,不可以被重复使用的部分被称为层次化组合中的Orchestrators。譬如你在Web中编写了一个登录表单,其中的布局、样式等部分可以被直接复用,而提交表单这个操作,因为具有应用特定性,因此需要在不同的应用中具有不同的实现。譬如下面有一个简单的表单:


<form action="form_action.asp" method="get">
  <p>First name: <input type="text" name="fname" /></p>
  <p>Last name: <input type="text" name="lname" /></p>
  <input type="submit" value="Submit" />
</form>

因为不同的应用中,form的提交地址可能不一致,那么整个form组件是不可直接重用的,即Non-Fractal Architectures。而form中的input组件是可以进行直接复用的,如果将input看做一个单独的GUI架构,即是所谓的Fractal Architectures,form就是所谓的Orchestrators,将可重用的组件编排组合,并且设置应用特定的一些信息。

Reference

Overview

MV*

MVC

MVP

MVVM

Unidirectional Architecture

Viper/Clean Architecture

MV*:Fragmentary State 碎片化的状态与双向数据流

MVC模式将有关于渲染、控制与数据存储的概念有机分割,是GUI应用架构模式的一个巨大成就。但是,MVC模式在构建能够长期运行、维护、有效扩展的应用程序时遇到了极大的问题。MVC模式在一些小型项目或者简单的界面上仍旧有极大的可用性,但是在现代富客户端开发中导致职责分割不明确、功能模块重用性、View的组合性较差。作为继任者MVP模式分割了View与Model之间的直接关联,MVP模式中也将更多的ViewLogic转移到Presenter中进行实现,从而保证了View的可测试性。而最年轻的MVVM将ViewLogic与View剥离开来,保证了View的无状态性、可重用性、可组合性以及可测试性。总结而言,MV*模型都包含了以下几个方面:

  • Models:负责存储领域/业务逻辑相关的数据与构建数据访问层,典型的就是譬如PersonPersonDataProvider

  • Views:负责将数据渲染展示给用户,并且响应用户输入

  • Controller/Presenter/ViewModel:往往作为Model与View之间的中间人出现,接收View传来的用户事件并且传递给Model,同时利用从Model传来的最新模型控制更新View

MVC:Monolithic Controller

相信每一个程序猿都会宣称自己掌握MVC,这个概念浅显易懂,并且贯穿了从GUI应用到服务端应用程序。MVC的概念源自Gamma, Helm, Johnson 以及Vlissidis这四人帮在讨论设计模式中的Observer模式时的想法,不过在那本经典的设计模式中并没有显式地提出这个概念。我们通常认为的MVC名词的正式提出是在1979年5月Trygve Reenskaug发表的Thing-Model-View-Editor这篇论文,这篇论文虽然并没有提及Controller,但是Editor已经是一个很接近的概念。大概7个月之后,Trygve Reenskaug在他的文章Models-Views-Controllers中正式提出了MVC这个三元组。上面两篇论文中对于Model的定义都非常清晰,Model代表着an abstraction in the form of data in a computing system.,即为计算系统中数据的抽象表述,而View代表着capable of showing one or more pictorial representations of the Model on screen and on hardcopy.,即能够将模型中的数据以某种方式表现在屏幕上的组件。而Editor被定义为某个用户与多个View之间的交互接口,在后一篇文章中Controller则被定义为了a special controller ... that permits the user to modify the information that is presented by the view.,即主要负责对模型进行修改并且最终呈现在界面上。从我的个人理解来看,Controller负责控制整个界面,而Editor只负责界面中的某个部分。Controller协调菜单、面板以及像鼠标点击、移动、手势等等很多的不同功能的模块,而Editor更多的只是负责某个特定的任务。后来,Martin Fowler在2003开始编写的著作Patterns of Enterprise Application Architecture中重申了MVC的意义:Model View Controller (MVC) is one of the most quoted (and most misquoted) patterns around.,将Controller的功能正式定义为:响应用户操作,控制模型进行相应更新,并且操作页面进行合适的重渲染。这是非常经典、狭义的MVC定义,后来在iOS以及其他很多领域实际上运用的MVC都已经被扩展或者赋予了新的功能,不过笔者为了区分架构演化之间的区别,在本文中仅会以这种最朴素的定义方式来描述MVC。

根据上述定义,我们可以看到MVC模式中典型的用户场景为:

  • 用户交互输入了某些内容

  • Controller将用户输入转化为Model所需要进行的更改

  • Model中的更改结束之后,Controller通知View进行更新以表现出当前Model的状态

根据上述流程,我们可知经典的MVC模式的特性为:

  • View、Controller、Model中皆有ViewLogic的部分实现

  • Controller负责控制View与Model,需要了解View与Model的细节。

  • View需要了解Controller与Model的细节,需要在侦测用户行为之后调用Controller,并且在收到通知后调用Model以获取最新数据

  • Model并不需要了解Controller与View的细节,相对独立的模块

Observer Pattern:自带观察者模式的MVC

上文中也已提及,MVC滥觞于Observer模式,经典的MVC模式也可以与Observer模式相结合,其典型的用户流程为:

  • 用户交互输入了某些内容

  • Controller将用户输入转化为Model所需要进行的更改

  • View作为Observer会监听Model中的任意更新,一旦有更新事件发出,View会自动触发更新以展示最新的Model状态

可知其与经典的MVC模式区别在于不需要Controller通知View进行更新,而是由Model主动调用View进行更新。这种改变提升了整体效率,简化了Controller的功能,不过也导致了View与Model之间的紧耦合。

MVP:Decoupling View and Model 将视图与模型解耦, View<->Presenter

维基百科将MVP称为MVC的一个推导扩展,观其渊源而知其所以然。对于MVP概念的定义,Microsoft较为明晰,而Martin Fowler的定义最为广泛接受。MVP模式在WinForm系列以Visual-XXX命名的编程语言与Java Swing等系列应用中最早流传开来,不过后来ASP.NET以及JFaces也广泛地使用了该模式。在MVP中用户不再与Presenter进行直接交互,而是由View完全接管了用户交互,譬如窗口上的每个控件都知道如何响应用户输入并且合适地渲染来自于Model的数据。而所有的事件会被传输给Presenter,Presenter在这里就是View与Model之间的中间人,负责控制Model进行修改以及将最新的Model状态传递给View。这里描述的就是典型的所谓Passive View版本的MVP,其典型的用户场景为:

  • 用户交互输入了某些内容

  • View将用户输入转化为发送给Presenter

  • Presenter控制Model接收需要改变的点

  • Model将更新之后的值返回给Presenter

  • Presenter将更新之后的模型返回给View

根据上述流程,我们可知Passive View版本的MVP模式的特性为:

  • View、Presenter、Model中皆有ViewLogic的部分实现

  • Presenter负责连接View与Model,需要了解View与Model的细节。

  • View需要了解Presenter的细节,将用户输入转化为事件传递给Presenter

  • Model需要了解Presenter的细节,在完成更新之后将最新的模型传递给Presenter

  • View与Model之间相互解耦合

Supervising Controller MVP

简化Presenter的部分功能,使得Presenter只起到需要复杂控制或者调解的操作,而简单的Model展示转化直接由View与Model进行交互:

MVVM:Data Binding & Stateless View 数据绑定与无状态的View,View<->ViewModels

Model View View-Model模型是MV*家族中最年轻的一位,也是由Microsoft提出,并经由Martin Fowler布道传播。MVVM源于Martin Fowler的Presentation Model,Presentation Model的核心在于接管了View所有的行为响应,View的所有响应与状态都定义在了Presentation Model中。也就是说,View不会包含任意的状态。举个典型的使用场景,当用户点击某个按钮之后,状态信息是从Presentation Model传递给Model,而不是从View传递给Presentation Model。任何控制组件间的逻辑操作,即上文所述的ViewLogic,都应该放置在Presentation Model中进行处理,而不是在View层,这一点也是MVP模式与Presentation Model最大的区别。

MVVM模式进一步深化了Presentation Model的思想,利用Data Binding等技术保证了View中不会存储任何的状态或者逻辑操作。在WPF中,UI主要是利用XAML或者XML创建,而这些标记类型的语言是无法存储任何状态的,就像HTML一样(因此JSX语法其实是将View又有状态化了),只是允许UI与某个ViewModel中的类建立映射关系。渲染引擎根据XAML中的声明以及来自于ViewModel的数据最终生成呈现的页面。因为数据绑定的特性,有时候MVVM也会被称作MVB:Model View Binder。总结一下,MVVM利用数据绑定彻底完成了从命令式编程到声明式编程的转化,使得View逐步无状态化。一个典型的MVVM的使用场景为:

  • 用户交互输入

  • View将数据直接传送给ViewModel,ViewModel保存这些状态数据

  • 在有需要的情况下,ViewModel会将数据传送给Model

  • Model在更新完成之后通知ViewModel

  • ViewModel从Model中获取最新的模型,并且更新自己的数据状态

  • View根据最新的ViewModel的数据进行重新渲染

根据上述流程,我们可知MVVM模式的特性为:

  • ViewModel、Model中存在ViewLogic实现,View则不保存任何状态信息

  • View不需要了解ViewModel的实现细节,但是会声明自己所需要的数据类型,并且能够知道如何重新渲染

  • ViewModel不需要了解View的实现细节(非命令式编程),但是需要根据View声明的数据类型传入对应的数据。ViewModel需要了解Model的实现细节。

  • Model不需要了解View的实现细节,需要了解ViewModel的实现细节

MV* in iOS

MVC

Cocoa MVC中往往会将大量的逻辑代码放入ViewController中,这就导致了所谓的Massive ViewController,而且很多的逻辑操作都嵌入到了View的生命周期中,很难剥离开来。或许你可以将一些业务逻辑或者数据转换之类的事情放到Model中完成,不过对于View而言绝大部分时间仅起到发送Action给Controller的作用。ViewController逐渐变成了几乎所有其他组件的Delegate与DataSource,还经常会负责派发或者取消网络请求等等职责。你的代码大概是这样的:


var userCell = tableView.dequeueReusableCellWithIdentifier("identifier") as UserCell

userCell.configureWithUser(user)

上面这种写法直接将View于Model关联起来,其实算是打破了Cocoa MVC的规范的,不过这样也是能够减少些Controller中的中转代码呢。这样一个架构模式在进行单元测试的时候就显得麻烦了,因为你的ViewController与View紧密关联,使得其很难去进行测试,因为你必须为每一个View创建Mock对象并且管理其生命周期。另外因为整个代码都混杂在一起,即破坏了职责分离原则,导致了系统的可变性与可维护性也很差。经典的MVC的示例程序如下:


import UIKit



struct Person { // Model

    let firstName: String

    let lastName: String

}



class GreetingViewController : UIViewController { // View + Controller

    var person: Person!

    let showGreetingButton = UIButton()

    let greetingLabel = UILabel()

    

    override func viewDidLoad() {

        super.viewDidLoad()

        self.showGreetingButton.addTarget(self, action: "didTapButton:", forControlEvents: .TouchUpInside)

    }

    

    func didTapButton(button: UIButton) {

        let greeting = "Hello" + " " + self.person.firstName + " " + self.person.lastName

        self.greetingLabel.text = greeting

        

    }

    // layout code goes here

}

// Assembling of MVC

let model = Person(firstName: "David", lastName: "Blaine")

let view = GreetingViewController()

view.person = model;

上面这种代码一看就很难测试,我们可以将生成greeting的代码移到GreetingModel这个单独的类中,从而进行单独的测试。不过我们还是很难去在GreetingViewController中测试显示逻辑而不调用UIView相关的譬如viewDidLoaddidTapButton等等较为费时的操作。再按照我们上文提及的优秀的架构的几个方面来看:

  • Distribution:View与Model是分割开来了,不过View与Controller是紧耦合的

  • Testability:因为较差的职责分割导致貌似只有Model部分方便测试

  • 易用性:因为程序比较直观,可能容易理解。

MVP

Cocoa中MVP模式是将ViewController当做纯粹的View进行处理,而将很多的ViewLogic与模型操作移动到Presenter中进行,代码如下:


import UIKit



struct Person { // Model

    let firstName: String

    let lastName: String

}



protocol GreetingView: class {

    func setGreeting(greeting: String)

}



protocol GreetingViewPresenter {

    init(view: GreetingView, person: Person)

    func showGreeting()

}



class GreetingPresenter : GreetingViewPresenter {

    unowned let view: GreetingView

    let person: Person

    required init(view: GreetingView, person: Person) {

        self.view = view

        self.person = person

    }

    func showGreeting() {

        let greeting = "Hello" + " " + self.person.firstName + " " + self.person.lastName

        self.view.setGreeting(greeting)

    }

}



class GreetingViewController : UIViewController, GreetingView {

    var presenter: GreetingViewPresenter!

    let showGreetingButton = UIButton()

    let greetingLabel = UILabel()

    

    override func viewDidLoad() {

        super.viewDidLoad()

        self.showGreetingButton.addTarget(self, action: "didTapButton:", forControlEvents: .TouchUpInside)

    }

    

    func didTapButton(button: UIButton) {

        self.presenter.showGreeting()

    }

    

    func setGreeting(greeting: String) {

        self.greetingLabel.text = greeting

    }

    

    // layout code goes here

}

// Assembling of MVP

let model = Person(firstName: "David", lastName: "Blaine")

let view = GreetingViewController()

let presenter = GreetingPresenter(view: view, person: model)

view.presenter = presenter
  • Distribution:主要的业务逻辑分割在了Presenter与Model中,View相对呆板一点

  • Testability:较为方便地测试

  • 易用性:代码职责分割的更为明显,不过不像MVC那样直观易懂了

MVVM


import UIKit



struct Person { // Model

    let firstName: String

    let lastName: String

}



protocol GreetingViewModelProtocol: class {

    var greeting: String? { get }

    var greetingDidChange: ((GreetingViewModelProtocol) -> ())? { get set } // function to call when greeting did change

    init(person: Person)

    func showGreeting()

}



class GreetingViewModel : GreetingViewModelProtocol {

    let person: Person

    var greeting: String? {

        didSet {

            self.greetingDidChange?(self)

        }

    }

    var greetingDidChange: ((GreetingViewModelProtocol) -> ())?

    required init(person: Person) {

        self.person = person

    }

    func showGreeting() {

        self.greeting = "Hello" + " " + self.person.firstName + " " + self.person.lastName

    }

}



class GreetingViewController : UIViewController {

    var viewModel: GreetingViewModelProtocol! {

        didSet {

            self.viewModel.greetingDidChange = { [unowned self] viewModel in

                self.greetingLabel.text = viewModel.greeting

            }

        }

    }

    let showGreetingButton = UIButton()

    let greetingLabel = UILabel()

    

    override func viewDidLoad() {

        super.viewDidLoad()

        self.showGreetingButton.addTarget(self.viewModel, action: "showGreeting", forControlEvents: .TouchUpInside)

    }

    // layout code goes here

}

// Assembling of MVVM

let model = Person(firstName: "David", lastName: "Blaine")

let viewModel = GreetingViewModel(person: model)

let view = GreetingViewController()

view.viewModel = viewModel
  • Distribution:在Cocoa MVVM中,View相对于MVP中的View担负了更多的功能,譬如需要构建数据绑定等等

  • Testability:ViewModel拥有View中的所有数据结构,因此很容易就可以进行测试

  • 易用性:相对而言有很多的冗余代码

MV* in Android

此部分完整代码在这里,笔者在这里节选出部分代码方便对照演示。Android中的Activity的功能很类似于iOS中的UIViewController,都可以看做MVC中的Controller。在2010年左右经典的Android程序大概是这样的:


TextView mCounterText;

Button mCounterIncrementButton;



int mClicks = 0;



public void onCreate(Bundle b) {

  super.onCreate(b);



  mCounterText = (TextView) findViewById(R.id.tv_clicks);

  mCounterIncrementButton = (Button) findViewById(R.id.btn_increment);



  mCounterIncrementButton.setOnClickListener(new View.OnClickListener() {

    public void onClick(View v) {

      mClicks++;

      mCounterText.setText(""+mClicks);

    }

  });

}

后来2013年左右出现了ButterKnife这样的基于注解的控件绑定框架,此时的代码看上去是这样的:



@Bind(R.id.tv_clicks) mCounterText;

@OnClick(R.id.btn_increment)

public void onSubmitClicked(View v) {

    mClicks++;

    mCounterText.setText("" + mClicks);

}

后来Google官方也推出了数据绑定的框架,从此MVVM模式在Android中也愈发流行:


<layout xmlns:android="http://schemas.android.com/apk/res/android">

   <data>

       <variable name="counter" type="com.example.Counter"/>

       <variable name="counter" type="com.example.ClickHandler"/>

   </data>

   <LinearLayout

       android:orientation="vertical"

       android:layout_width="match_parent"

       android:layout_height="match_parent">

       <TextView android:layout_width="wrap_content"

           android:layout_height="wrap_content"

           android:text="@{counter.value}"/>

       <Buttonandroid:layout_width="wrap_content"

           android:layout_height="wrap_content"

           android:text="@{handlers.clickHandle}"/>

   </LinearLayout>

</layout>

后来Anvil这样的受React启发的组件式框架以及Jedux这样借鉴了Redux全局状态管理的框架也将Unidirectional 架构引入了Android开发的世界。

MVC

  • 声明View中的组件对象或者Model对象


    private Subscription subscription;

    private RecyclerView reposRecycleView;

    private Toolbar toolbar;

    private EditText editTextUsername;

    private ProgressBar progressBar;

    private TextView infoTextView;

    private ImageButton searchButton;
  • 将组件与Activity中对象绑定,并且声明用户响应处理函数




        super.onCreate(savedInstanceState);

        setContentView(R.layout.activity_main);

        progressBar = (ProgressBar) findViewById(R.id.progress);

        infoTextView = (TextView) findViewById(R.id.text_info);

        //Set up ToolBar

        toolbar = (Toolbar) findViewById(R.id.toolbar);

        setSupportActionBar(toolbar);

        //Set up RecyclerView

        reposRecycleView = (RecyclerView) findViewById(R.id.repos_recycler_view);

        setupRecyclerView(reposRecycleView);

        // Set up search button

        searchButton = (ImageButton) findViewById(R.id.button_search);

        searchButton.setOnClickListener(new View.OnClickListener() {

            @Override

            public void onClick(View v) {

                loadGithubRepos(editTextUsername.getText().toString());

            }

        });

        //Set up username EditText

        editTextUsername = (EditText) findViewById(R.id.edit_text_username);

        editTextUsername.addTextChangedListener(mHideShowButtonTextWatcher);

        editTextUsername.setOnEditorActionListener(new TextView.OnEditorActionListener() {

            @Override

            public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {

                if (actionId == EditorInfo.IME_ACTION_SEARCH) {

                    String username = editTextUsername.getText().toString();

                    if (username.length() > 0) loadGithubRepos(username);

                    return true;

                }

                return false;

            }

});
  • 用户输入之后的更新流程




        progressBar.setVisibility(View.VISIBLE);

        reposRecycleView.setVisibility(View.GONE);

        infoTextView.setVisibility(View.GONE);

        ArchiApplication application = ArchiApplication.get(this);

        GithubService githubService = application.getGithubService();

        subscription = githubService.publicRepositories(username)

                .observeOn(AndroidSchedulers.mainThread())

                .subscribeOn(application.defaultSubscribeScheduler())

                .subscribe(new Subscriber<List<Repository>>() {

                    @Override

                    public void onCompleted() {

                        progressBar.setVisibility(View.GONE);

                        if (reposRecycleView.getAdapter().getItemCount() > 0) {

                            reposRecycleView.requestFocus();

                            hideSoftKeyboard();

                            reposRecycleView.setVisibility(View.VISIBLE);

                        } else {

                            infoTextView.setText(R.string.text_empty_repos);

                            infoTextView.setVisibility(View.VISIBLE);

                        }

                    }



                    @Override

                    public void onError(Throwable error) {

                        Log.e(TAG, "Error loading GitHub repos ", error);

                        progressBar.setVisibility(View.GONE);

                        if (error instanceof HttpException

                                && ((HttpException) error).code() == 404) {

                            infoTextView.setText(R.string.error_username_not_found);

                        } else {

                            infoTextView.setText(R.string.error_loading_repos);

                        }

                        infoTextView.setVisibility(View.VISIBLE);

                    }



                    @Override

                    public void onNext(List<Repository> repositories) {

                        Log.i(TAG, "Repos loaded " + repositories);

                        RepositoryAdapter adapter =

                                (RepositoryAdapter) reposRecycleView.getAdapter();

                        adapter.setRepositories(repositories);

                        adapter.notifyDataSetChanged();

                    }

});

MVP

  • 将Presenter与View绑定,并且将用户响应事件绑定到Presenter中


        //Set up presenter

        presenter = new MainPresenter();

        presenter.attachView(this);

        ...

        

        // Set up search button

        searchButton = (ImageButton) findViewById(R.id.button_search);

        searchButton.setOnClickListener(new View.OnClickListener() {

            @Override

            public void onClick(View v) {

                presenter.loadRepositories(editTextUsername.getText().toString());

            }

        });
  • Presenter中调用Model更新数据,并且调用View中进行重新渲染


    public void loadRepositories(String usernameEntered) {

        String username = usernameEntered.trim();

        if (username.isEmpty()) return;



        mainMvpView.showProgressIndicator();

        if (subscription != null) subscription.unsubscribe();

        ArchiApplication application = ArchiApplication.get(mainMvpView.getContext());

        GithubService githubService = application.getGithubService();

        subscription = githubService.publicRepositories(username)

                .observeOn(AndroidSchedulers.mainThread())

                .subscribeOn(application.defaultSubscribeScheduler())

                .subscribe(new Subscriber<List<Repository>>() {

                    @Override

                    public void onCompleted() {

                        Log.i(TAG, "Repos loaded " + repositories);

                        if (!repositories.isEmpty()) {

                            mainMvpView.showRepositories(repositories);

                        } else {

                            mainMvpView.showMessage(R.string.text_empty_repos);

                        }

                    }



                    @Override

                    public void onError(Throwable error) {

                        Log.e(TAG, "Error loading GitHub repos ", error);

                        if (isHttp404(error)) {

                            mainMvpView.showMessage(R.string.error_username_not_found);

                        } else {

                            mainMvpView.showMessage(R.string.error_loading_repos);

                        }

                    }



                    @Override

                    public void onNext(List<Repository> repositories) {

                        MainPresenter.this.repositories = repositories;

                    }

                });

        }


MVVM

  • XML中声明数据绑定


<data>

        <variable

            name="viewModel"

            type="uk.ivanc.archimvvm.viewmodel.MainViewModel"/>

</data>

...

            <EditText

                android:id="@+id/edit_text_username"

                android:layout_width="match_parent"

                android:layout_height="wrap_content"

                android:layout_toLeftOf="@id/button_search"

                android:hint="@string/hit_username"

                android:imeOptions="actionSearch"

                android:inputType="text"

                android:onEditorAction="@{viewModel.onSearchAction}"

                android:textColor="@color/white"

                android:theme="@style/LightEditText"

                app:addTextChangedListener="@{viewModel.usernameEditTextWatcher}"/>



  • View中绑定ViewModel


        super.onCreate(savedInstanceState);

        binding = DataBindingUtil.setContentView(this, R.layout.main_activity);

        mainViewModel = new MainViewModel(this, this);

        binding.setViewModel(mainViewModel);

        setSupportActionBar(binding.toolbar);

        setupRecyclerView(binding.reposRecyclerView);
  • ViewModel中进行数据操作


public boolean onSearchAction(TextView view, int actionId, KeyEvent event) {

        if (actionId == EditorInfo.IME_ACTION_SEARCH) {

            String username = view.getText().toString();

            if (username.length() > 0) loadGithubRepos(username);

            return true;

        }

        return false;

    }



    public void onClickSearch(View view) {

        loadGithubRepos(editTextUsernameValue);

    }



    public TextWatcher getUsernameEditTextWatcher() {

        return new TextWatcher() {

            @Override

            public void beforeTextChanged(CharSequence charSequence, int start, int count, int after) {



            }



            @Override

            public void onTextChanged(CharSequence charSequence, int start, int before, int count) {

                editTextUsernameValue = charSequence.toString();

                searchButtonVisibility.set(charSequence.length() > 0 ? View.VISIBLE : View.GONE);

            }



            @Override

            public void afterTextChanged(Editable editable) {



            }

        };

}

Unidirectional User Interface Architecture:单向数据流

Unidirectional User Interface Architecture架构的概念源于后端常见的CROS/Event Sourcing模式,其核心思想即是将应用状态被统一存放在一个或多个的Store中,并且所有的数据更新都是通过可观测的Actions触发,而所有的View都是基于Store中的状态渲染而来。该架构的最大优势在于整个应用中的数据流以单向流动的方式从而使得有用更好地可预测性与可控性,这样可以保证你的应用各个模块之间的松耦合性。与MVVM模式相比,其解决了以下两个问题:

  • 避免了数据在多个ViewModel中的冗余与不一致问题

  • 分割了ViewModel的职责,使得ViewModel变得更加Clean

Why not Bidirectional(Two-way DataBinding)?

This means that one change (a user input or API response) can affect the state of an application in many places in the code — for example, two-way data binding. That can be hard to maintain and debug.

Facebook强调,双向数据绑定极不利于代码的扩展与维护。

从具体的代码实现角度来看,双向数据绑定会导致更改的不可预期性(UnPredictable),就好像Angular利用Dirty Checking来进行是否需要重新渲染的检测,这导致了应用的缓慢,简直就是来砸场子的。而在采用了单向数据流之后,整个应用状态会变得可预测(Predictable),也能很好地了解当状态发生变化时到底会有多少的组件发生变化。另一方面,相对集中地状态管理,也有助于你不同的组件之间进行信息交互或者状态共享,特别是像Redux这种强调Single Store与SIngle State Tree的状态管理模式,能够保证以统一的方式对于应用的状态进行修改,并且Immutable的概念引入使得状态变得可回溯。

譬如Facebook在Flux Overview中举的例子,当我们希望在一个界面上同时展示未读信息列表与未读信息的总数目的时候,对于MV*就有点恶心了,特别是当这两个组件不在同一个ViewModel/Controller中的时候。一旦我们将某个未读信息标识为已读,会引起控制已读信息、未读信息、未读信息总数目等等一系列模型的更新。特别是很多时候为了方便我们可能在每个ViewModel/Controller都会设置一个数据副本,这会导致依赖连锁更新,最终导致不可预测的结果与性能损耗。而在Flux中这种依赖是反转的,Store接收到更新的Action请求之后对数据进行统一的更新并且通知各个View,而不是依赖于各个独立的ViewModel/Controller所谓的一致性更新。从职责划分的角度来看,除了Store之外的任何模块其实都不知道应该如何处理数据,这就保证了合理的职责分割。这种模式下,当我们创建新项目时,项目复杂度的增长瓶颈也就会更高,不同于传统的View与ViewLogic之间的绑定,控制流被独立处理,当我们添加新的特性,新的数据,新的界面,新的逻辑处理模块时,并不会导致原有模块的复杂度增加,从而使得整个逻辑更加清晰可控。

这里还需要提及一下,很多人应该是从React开始认知到单向数据流这种架构模式的,而当时Angular 1的缓慢与性能之差令人发指,但是譬如Vue与Angular 2的性能就非常优秀。借用Vue.js官方的说法,

The virtual-DOM approach provides a functional way to describe your view at any point of time, which is really nice. Because it doesn’t use observables and re-renders the entire app on every update, the view is by definition guaranteed to be in sync with the data. It also opens up possibilities to isomorphic JavaScript applications.

Instead of a Virtual DOM, Vue.js uses the actual DOM as the template and keeps references to actual nodes for data bindings. This limits Vue.js to environments where DOM is present. However, contrary to the common misconception that Virtual-DOM makes React faster than anything else, Vue.js actually out-performs React when it comes to hot updates, and requires almost no hand-tuned optimization. With React, you need to implementshouldComponentUpdate everywhere and use immutable data structures to achieve fully optimized re-renders.

总而言之,笔者认为双向数据流与单向数据流相比,性能上孰优孰劣尚无定论,最大的区别在于单向数据流与双向数据流相比有更好地可控性,这一点在上文提及的函数响应式编程中也有体现。若论快速开发,笔者感觉双向数据绑定略胜一筹,毕竟这种View与ViewModel/ViewLogic之间的直接绑定直观便捷。而如果是注重于全局的状态管理,希望维护耦合程度较低、可测试性/可扩展性较高的代码,那么还是单向数据流,即Unidirectional Architecture较为合适。一家之言,欢迎讨论。

Flux:数据流驱动的页面

Flux不能算是绝对的先行者,但是在Unidirectional Architecture中却是最富盛名的一个,也是很多人接触到的第一个Unidirectional Architecture。Flux主要由以下几个部分构成:

  • Stores:存放业务数据和应用状态,一个Flux中可能存在多个Stores

  • View:层次化组合的React组件

  • Actions:用户输入之后触发View发出的事件

  • Dispatcher:负责分发Actions

根据上述流程,我们可知Flux模式的特性为:

  • Dispatcher:Event Bus中设置有一个单例的Dispatcher,很多Flux的变种都移除了Dispatcher依赖。

  • 只有View使用可组合的组件:在Flux中只有React的组件可以进行层次化组合,而Stores与Actions都不可以进行层次化组合。React组件与Flux一般是松耦合的,因此Flux并不是Fractal,Dispatcher与Stores可以被看做Orchestrator。

  • 用户事件响应在渲染时声明:在React的render()函数中,即负责响应用户交互,也负责注册用户事件的处理器

下面我们来看一个具体的代码对比,首先是以经典的Cocoa风格编写一个简单的计数器按钮:


class ModelCounter



    constructor: (@value=1) ->



    increaseValue: (delta) =>

        @value += delta



class ControllerCounter



    constructor: (opts) ->

        @model_counter = opts.model_counter

        @observers = []



    getValue: => @model_counter.value



    increaseValue: (delta) =>

        @model_counter.increaseValue(delta)

        @notifyObservers()



    notifyObservers: =>

        obj.notify(this) for obj in @observers



    registerObserver: (observer) =>

        @observers.push(observer)



class ViewCounterButton



    constructor: (opts) ->

        @controller_counter = opts.controller_counter

        @button_class = opts.button_class or 'button_counter'

        @controller_counter.registerObserver(this)



    render: =>

        elm = $("<button class=\"#{@button_class}\">

                #{@controller_counter.getValue()}</button>")

        elm.click =>

            @controller_counter.increaseValue(1)

        return elm



    notify: =>

        $("button.#{@button_class}").replaceWith(=> @render())

上述代码逻辑用上文提及的MVC模式图演示就是:

而如果用Flux模式实现,会是下面这个样子:


# Store

class CounterStore extends EventEmitter



    constructor: ->

        @count = 0

        @dispatchToken = @registerToDispatcher()



    increaseValue: (delta) ->

        @count += 1



    getCount: ->

        return @count



    registerToDispatcher: ->

        CounterDispatcher.register((payload) =>

            switch payload.type

                when ActionTypes.INCREASE_COUNT

                    @increaseValue(payload.delta)

        )



# Action

class CounterActions



    @increaseCount: (delta) ->

        CounterDispatcher.handleViewAction({

            'type': ActionTypes.INCREASE_COUNT

            'delta': delta

        })



# View

CounterButton = React.createClass(



    getInitialState: ->

        return {'count': 0}



    _onChange: ->

        @setState({

            count: CounterStore.getCount()

        })



    componentDidMount: ->

        CounterStore.addListener('CHANGE', @_onChange)



    componentWillUnmount: ->

        CounterStore.removeListener('CHANGE', @_onChange)



    render: ->

        return React.DOM.button({'className': @prop.class}, @state.value)



)

其数据流图为:

Redux:集中式的状态管理

Redux是Flux的所有变种中最为出色的一个,并且也是当前Web领域主流的状态管理工具,其独创的理念与功能深刻影响了GUI应用程序架构中的状态管理的思想。Redux将Flux中单例的Dispatcher替换为了单例的Store,即也是其最大的特性,集中式的状态管理。并且Store的定义也不是从零开始单独定义,而是基于多个Reducer的组合,可以把Reducer看做Store Factory。Redux的重要组成部分包括:

  • Singleton Store:管理应用中的状态,并且提供了一个dispatch(action)函数。

  • Provider:用于监听Store的变化并且连接像React、Angular这样的UI框架

  • Actions:基于用户输入创建的分发给Reducer的事件

  • Reducers:用于响应Actions并且更新全局状态树的纯函数

根据上述流程,我们可知Redux模式的特性为:

  • 以工厂模式组装Stores:Redux允许我以createStore()函数加上一系列组合好的Reducer函数来创建Store实例,还有另一个applyMiddleware()函数可以允许在dispatch()函数执行前后链式调用一系列中间件。

  • Providers:Redux并不特定地需要何种UI框架,可以与Angular、React等等很多UI框架协同工作。Redux并不是Fractal,一般来说Store被视作Orchestrator。

  • User Event处理器即可以选择在渲染函数中声明,也可以在其他地方进行声明。

Model-View-Update

又被称作Elm Architecture,上面所讲的Redux就是受到Elm的启发演化而来,因此MVU与Redux之间有很多的相通之处。MVU使用函数式编程语言Elm作为其底层开发语言,因此该架构可以被看做更纯粹的函数式架构。MVU中的基本组成部分有:

  • Model:定义状态数据结构的类型

  • View:纯函数,将状态渲染为界面

  • Actions:以Mailbox的方式传递用户事件的载体

  • Update:用于更新状态的纯函数

根据上述流程,我们可知Elm模式的特性为:

  • 到处可见的层次化组合:Redux只是在View层允许将组件进行层次化组合,而MVU中在Model与Update函数中也允许进行层次化组合,甚至Actions都可以包含内嵌的子Action

  • Elm属于Fractal架构:因为Elm中所有的模块组件都支持层次化组合,即都可以被单独地导出使用

Model-View-Intent

MVI是一个基于RxJS的响应式单向数据流架构。MVI也是Cycle.js的首选架构,主要由Observable事件流对象与处理函数组成。其主要的组成部分包括:

  • Intent:Observable提供的将用户事件转化为Action的函数

  • Model:Observable提供的将Action转化为可观测的State的函数

  • View:将状态渲染为用户界面的函数

  • Custom Element:类似于React Component那样的界面组件

根据上述流程,我们可知MVI模式的特性为:

  • 重度依赖于Observables:架构中的每个部分都会被转化为Observable事件流

  • Intent:不同于Flux或者Redux,MVI中的Actions并没有直接传送给Dispatcher或者Store,而是交于正在监听的Model

  • 彻底的响应式,并且只要所有的组件都遵循MVI模式就能保证整体架构的fractal特性

查看原文

52lidan 收藏了文章 · 5月9日

CSS中的间距,前端开发中各种设置间距的优点缺点及实例

默认文件1588564373403.png

来源:https://ishadeed.com,作者:Ahmad Shadeed
翻译:公众号《前端外文精选》

如果两个或多个元素很接近,那么用户就会认为它们以某种方式属于彼此。当对多个设计元素进行分组时,用户可以根据它们之间的空间大小来决定它们之间的关系。没有间距,用户将很难浏览页面并知道哪些内容相关而哪些内容无关。

在本文中,我将介绍有关CSS中的间距,实现此间距的不同方法以及何时使用 padding 或 margin 所需的所有知识。

间距类型

CSS中的间距有两种类型,一种在元素外部,另一种在元素内部。对于本文,我将其称为outerinner。假设我们有一个元素,它内部的间距是inner,外部的间距是outer

在CSS中,间距可以如下:

.element {
  padding: 1rem;
  margin-bottom: 1rem;
}

我使用 padding 来填充内部间距,使用 margin 来填充外部间距。很简单,不是吗?但是,当处理具有许多细节和子元素的组件时,这会变得越来越复杂。

margin 外部间距

它用于增加元素之间的间距。例如,在上一个示例中,我添加了 margin-bottom:1rem 在两个堆叠的元素之间添加垂直间距。

由于可以沿四个不同的方向(top、right、 bottom、left)添加margin,因此在深入研究示例和用例之前,一定要阐明一些基本概念,这一点很重要。

margin 折叠

简而言之,当两个垂直元素具有margin,并且其中一个元素的margin大于另一个元素时,发生边距折叠。在这种情况下,将使用更大的margin,而另一个将被忽略。

在上面的模型中,一个元素有 margin-bottom,另一个元素有 margin-top,边距较大的元素获胜。

为避免此类问题,建议按照本文使用单向边距。此外,CSS Tricks还在页边距底部和页边距顶部之间进行了投票。61%的开发者更喜欢 margin-bottom 而不是 margin-top

请在下面查看如何解决此问题:

.element:not(:last-child) {
  margin-bottom: 1rem;
}

使用 :not CSS选择器,您可以轻松地删除最后一个子元素的边距,以避免不必要的间距。

另一个与边距折叠相关的例子是子节点和父节点。让我们假设如下:

<div class="parent">
  <div class="child">I'm the child element</div>
</div>
.parent {
  margin: 50px auto 0 auto;
  width: 400px;
  height: 120px;
}

.child {
  margin: 50px 0;
}

请注意,子元素固定在其父元素的顶部。那是因为它的边距折叠了。根据W3C,以下是针对该问题的一些解决方案:

  • 在父元素上添加 border
  • 将子元素显示更改为 inline-block

一个更直接的解决方案是将 padding-top 添加到父元素。

负margin

它可以与四个方向一起使用以留出余量,在某些用例中非常有用。让我们假设以下内容:

父节点具有 padding:1rem,这导致子节点从顶部、左侧和右侧偏移。但是,子元素应该紧贴其父元素的边缘。负margin可以助你一臂之力。

.parent {
  padding: 1rem
}

.child {
  margin-left: -1rem;
  margin-right: -1rem;
  margin-top: -1rem;
}

如果您想更多地挖负margin,建议阅读这篇文章。

padding 内部间距

如前所述,padding在元素内部增加了一个内间距。它的目标可以根据使用的情况而变化。

例如,它可以用于增加链接之间的间距,这将导致链接的可点击区域更大。

必须提出的是,垂直方向的padding对于那些具有 display:inline 的元素不适用,比如 <span><a>。如果添加了内边距,它不会影响元素,内边距将覆盖其他内联元素。

这只是一个友好的提醒,应该更改内联元素的 display 属性。

.element span {
  display: inline-block;
  padding-top: 1rem;
  padding-bottom: 1rem;
}

CSS Grid 间隙

在CSS网格中,可以使用 grid-gap 属性轻松在列和行之间添加间距。这是行和列间距的简写。

.element {
  display: grid;
  grid-template-columns: 1fr 1fr;
  grid-gap: 16px; /* 为行和列都增加了16px的间隙。 */
}

gap属性可以使用如下:

.element {
  display: grid;
  grid-template-columns: 1fr 1fr;
  grid-row-gap: 24px;
  grid-column-gap: 16px;
}

CSS Flexbox 间隙

gap 是一个提议的属性,将用于CSS Grid和flexbox,撰写本文时,它仅在Firefox中受支持。

.element {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
}

CSS 定位

它可能不是直接的元素间距方式,但在一些设计案例中却起到了一定的作用。例如,一个绝对定位的元素需要从其父元素的左边缘和上边缘定位 16px

考虑以下示例,带有图标的卡片,其图标应与其父对象的左上边缘隔开。在这种情况下,将使用以下CSS:

.category {
  position: absolute;
  left: 16px;
  top: 16px;
}

用例和实际示例

在这一节中,你将回顾一下在日常工作中,你在处理CSS项目时,会遇到的不同用例。

header 组件

在这种情况下,标题具有logo,导航和用户个人资料。你能猜出CSS中的间距应该如何设置吗?好吧,让我为你添加一个骨架模型。

<header class="c-header">
  <h1 class="c-logo"><a href="#">Logo</a></h1>
  <div class="c-header__nav">
    <nav class="c-nav">
      <ul>
        <li><a href="#">...</a></li>
      </ul>
    </nav>
    <a href="#" class="c-user">
      <span>Ahmad</span>
      <img class="c-avatar" data-original="shadeed.jpg" alt="">
    </a>
  </div>
</header>

Header的左侧和右侧都有padding,这样做的目的是防止内容物紧贴在边缘上。

.c-header {
  padding-left: 16px;
  padding-right: 16px;
}

对于导航,每个链接在垂直和水平侧均应具有足够的填充,因此其可单击区域可以很大,这将增强可访问性。

.c-nav a {
  display: block;
  padding: 16px 8px;
}

对于每个项目之间的间距,您可以使用 margin 或将 <li>display 更改为 inline-block。内联块元素在它的兄弟元素之间添加了一点空间,因为它将元素视为字符。

.c-nav li {
  /* 这将创建你在骨架中看到的间距 */
  display: inline-block;
}

最后,头像(avatar)和用户名的左侧有一个空白。

.c-user img,
.c-user span {
  margin-left: 10px;
}

请注意,如果你要构建多语言网站,建议使用如下所示的CSS逻辑属性。

.c-user img,
.c-user span {
  margin-inline-start: 1rem;
}

请注意,分隔符周围的间距现在相等,原因是导航项没有特定的宽度,而是具有padding。结果,导航项目的宽度基于其内容。以下是解决方案:

  • 设置导航项目的最小宽度
  • 增加水平padding
  • 在分隔符的左侧添加一个额外的margin

最简单,更好的解决方案是第三个解决方案,即添加 margin-left

.c-user {
  margin-left: 8px;
}

网格系统中的间距:Flexbox

网格是间隔最常用的情况之一。考虑以下示例:

间距应在列和行之间。考虑以下HTML标记:

<div class="wrapper">
  <div class="grid grid--4">
    <div class="grid__item">
      <article class="card"><!-- Card content --></article>
    </div>
    <div class="grid__item">
      <article class="card"><!-- Card content --></article>
    </div>
    <!-- And so on.. -->
  </div>
</div>

通常,我更喜欢将组件封装起来,并避免给它们增加边距。由于这个原因,我有 grid__item元素,我的card组件将位于其中。

.grid--4 {
  display: flex;
  flex-wrap: wrap;
}

.grid__item {
  flex-basis: 25%;
  margin-bottom: 16px;
}

使用上述CSS,每行将有四张卡片。这是在它们之间添加空格的一种可能的解决方案:

.grid__item {
  flex-basis: calc(25% - 10px);
  margin-left: 10px;
  margin-bottom: 16px;
}

通过使用CSS calc() 函数,可以从 flex-basis 中扣除边距。如你所见,这个方案并不是那么简单。我比较喜欢的是下面这个办法。

  • 向网格项目添加 padding-left
  • 在网格父节点上增加一个负值 margin-left,其 padding-left 值相同。

几年前,我从CSS Wizardy那里学到了上述解决方案(我忘记了文章标题,如果您知道,请告诉我)。

.grid--4 {
  display: flex;
  flex-wrap: wrap;
  margin-left: -10px;
}

.grid__item {
  flex-basis: 25%;
  padding-left: 10px;
  margin-bottom: 16px;
}

我之所以用了负 margin-left,是因为第一张卡有 padding-left,而实际上不需要。所以,它将把 .wrapper 元素推到左边,取消那个不需要的空间。

另一个类似的概念是在两边都添加填充,然后边距为负。这是Facebook故事的一个示例:

.wrapper {
  margin-left: -4px;
  margin-right: -4px;
}

.story {
  padding-left: 4px;
  padding-right: 4px;
}

网格系统中的间距:CSS Grid

现在,到了激动人心的部分!使用CSS Grid,你可以很容易地使用 grid-gap 添加间距。此外,你不需要关心网格项的宽度或底部空白,CSS Grid 为你做者一切!

.grid--4 {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
  grid-gap: 1rem;
}

就是这样!难道不是那么容易和直接吗?

按需定制

我真正喜欢CSS Grid 的地方是 grid-gap 只在需要的时候才会被应用。考虑下面的模型。

没有CSS网格,就不可能拥有这种灵活性。首先,请参见以下内容:

.card:not(:last-child) {
  margin-bottom: 16px;
}

@media (min-width: 700px) {
  .card:not(:last-child) {
    margin-bottom: 0;
    margin-left: 1rem;
  }
}

不舒服吧?这个如何?

.card-wrapper {
  display: grid;
  grid-template-columns: 1fr;
  grid-gap: 1rem;
}

@media (min-width: 700px) {
  .card-wrapper {
    grid-template-columns: 1fr 1fr;
  }
}

完成了!容易得多。

处理底部margin

假设以下组件堆叠在一起,每个组件都有底边距。

注意最后一个元素有一个空白,这是不正确的,因为边距只能在元素之间。

可以使用以下解决方案之一解决此问题:

解决方案1-CSS :not 选择器

.element:not(:last-child) {
  margin-bottom: 16px;
}

解决方案2:相邻兄弟组合器

.element + .element {
  margin-top: 16px;
}

虽然解决方案1具有吸引力,但它具有以下缺点:

  • 它会导致CSS的特异性问题。在使用 :not 选择器之前不可能覆盖它。
  • 万一设计中有不止一列,它将无法正常工作。参见下图。

关于解决方案2,它没有CSS特异性问题。但是,它只能处理一个列栈。

更好的解决方案是通过向父元素添加负边距来取消不需要的间距。

.wrapper {
  margin-bottom: -16px;
}

它用一个等于底部间距的值将元素推到底部。注意不要超过边距值,因为它会与同级元素重叠。

Card组件

Oh,如果我想把所有细节的Card组件间距都写进去的话,最后可能会出现书本上的内容。我就突出一个大概的模式,看看间距应该如何应用。

你能想到此卡片在哪里使用间距吗?参见下图。

<article class="card">
  <a href="#">
    <div class="card__thumb"><img data-original="food.jpg" alt=""></div>
    <div class="card__content">
      <h3 class="card__title">Cinnamon Rolls</h3>
      <p class="card__author">Chef Ahmad</p>
      <div class="card__rating"><span>4.9</span></div>
      <div class="card__meta"><!-- --></div>
    </div>
  </a>
</article>
.card__content {
  padding: 10px;
}

上面的 padding 将向其中的所有子元素添加一个偏移量。然后,我将添加所有边距。

.card__title,
.card__author,
.card__rating {
  margin-bottom: 10px;
}

对于评分和 .car__meta 元素之间的分隔线,我将添加它作为边框。

.card__meta {
  padding-top: 10px;
  border-top: 1px solid #e9e9e9;
}

糟糕!由于对父元素 .card__content 进行了填充,因此边框没有粘在边缘上。

是的,你猜对了!负边距是解决办法。

.card__meta {
  padding-top: 10px;
  border-top: 1px solid #e9e9e9;
  margin: 0 -10px;
}

糟糕,再次!出了点问题。内容粘在边缘!

为了解决这个问题,内容应该从左右两边加垫(呵呵,看来加垫是个新词)。

.card__meta {
  padding: 10px 10px 0 10px;
  border-top: 1px solid #e9e9e9;
  margin: 0 -10px;
}

文章内容

我相信这是一个非常非常普遍的用例。由于文章内容来自CMS(内容管理系统),或者是由Markdown文件自动生成的,因此无法为元素添加类。

考虑下面的示例,其中包含标题,段落和图像。

<div class="wrapper">
  <h1>Spacing Elements in CSS</h1>
  <p><!-- content --></p>
  <h2>Types of Spacing</h2>
  <img data-original="spacing-1.png" alt="">
  <p><!-- content --></p> 
  <p><!-- content --></p> 
  <h2>Use Cases</h2>
  <p><!-- content --></p> 
  <h3>Card Component</h3> 
  <img data-original="use-case-card-2.png" alt="">
</div>

为了使它们看起来不错,间距应保持一致并谨慎使用。我从type-scale.com借了一些样式。

h1, h2, h3, h4, h5 {
  margin: 2.75rem 0 1.05rem;
}

h1 {
  margin-top: 0;
}

img {
  margin-bottom: 0.5rem;
}

如果一个 <p> 后面有一个标题,例如“Types of Spacing”,那么 <p>margin-bottom 将被忽略。你猜到了,那是因为页边距折叠。

Just In Case Margin

我喜欢把这个叫做 "Just in case" margin,因为这就是字面意思。考虑一下下面的模型图。

当元素靠近的时候,它们看起来并不好看。我是用flexbox搭建的。这项技术称为“对齐移位包装”,我从CSS Tricks中学到了它的名称。

.element {
  display: flex;
  flex-wrap: wrap;
}

当视口尺寸较小时,它们的确以新行结尾。见下文:

需要解决的是中间设计状态,即两件物品仍然相邻,但两件物品之间的间距为零的设计状态。在这种情况下,我倾向于向元素添加一个 margin-right,这样可以防止它们相互接触,从而加快 flex-wrap 的工作速度。

CSS 书写模式

根据MDN:

writing-mode CSS属性设置了文本行是水平还是垂直排列,以及块的前进方向。

你是否曾经考虑过将边距与具有不同 writing-mode 的元素一起使用时应如何表现?考虑以下示例。

.wrapper {
  /* 使标题和食谱在同一行 */
  display: flex;
}

.title {
  writing-mode: vertical-lr;
  margin-right: 16px;
}

标题被旋转了90度,在它和图像之间应该有一个空白区。结果表明,基于 writing-mode 的页边距工作得非常好。

我认为这些用例就足够了。让我们继续一些有趣的概念!

组件封装

大型设计系统包含许多组件。向其直接添加边距是否合乎逻辑?

考虑以下示例。

<button class="button">Save Changes</button>
<button class="button button-outline">Discard</button>

按钮之间的间距应在哪里添加?是否应将其添加到左侧或右侧按钮?也许你可以如下使用相邻同级选择器:

.button + .button {
  margin-left: 1rem;
}

这是不好的。如果只有一个按钮的情况怎么办?或者,当它垂直堆叠时在移动设备上将如何工作?很多很多的复杂性。

使用抽象组件

解决上述问题的一种方法是使用抽象的组件,其目标是托管其他组件,就像Max Stoiber所说的那样,这是将管理边距的责任移到了父元素上,让我们以这种思维方式重新思考以前的用例。

<div class="list">
  <div class="list__item">
    <button class="button">Save Changes</button>
  </div>
  <div class="list__item">
    <button class="button button-outline">Discard</button>
  </div>
</div>

注意,我添加了一个包装器,并且每个按钮现在都包装在其自己的元素中。

.list {
  display: flex;
  align-items: center;
  margin-left: -1rem; /* 取消第一个元素的左空白 */
}

.list__item {
  margin-left: 1rem;
}

就是这样!而且,将这些概念应用到任何JavaScript框架中都相当容易。例如:

<List>
  <Button>Save Changes</Button>
  <Button outline>Discard</Button>
</List>

你使用的JavaScript工具应该将每个项包装在自己的元素中。

间隔组件

是的,你没看错。我在这篇文章中讨论了避免margin的概念,并使用间隔组件来代替它们。

让我们假设一个区域需要从左到右24px的空白,并记住这些限制:

  • margin不能直接用于组件,因为它是一个已经构建的设计系统。
  • 它应该是灵活的。间距可能在X页上,但不在Y页上。

我在检查Facebook的新设计CSS时首先注意到了这一点。

那是一个 <div>,内联样式宽度:16px,它唯一的作用是在左边缘和包装器之间增加一个空白空间。

引述这本React游戏手册中的内容。

但在现实世界中,我们确实需要组件之外的间距来合成页面和场景,这就是margin渗入组件代码的地方:用于组件的间距组合。

我同意。对于大型设计系统,不断向组件添加margin是不可伸缩的。这将最终导致一个令人毛骨悚然的代码。

间隔组件的挑战

现在你了解了间隔组件的概念,让我们深入研究使用它们时遇到的一些挑战。这是我想到的一些问题:

  • 间隔组件如何在父级内部取其宽度或高度?在水平布局和垂直布局中,它将如何工作?
  • 我们是否应该根据其父项的显示类型(Flex,Grid)对它们进行样式设置

让我们一一解决上述问题。

调整间隔组件的大小

可以创建一个接受不同变化和设置的间隔。我不是JavaScript开发人员,但我认为他们将其称为Props。考虑来自styled-system.com的以下内容:

我们在一个header和一个 section之间有一个隔板。

<Header />
    <Spacer mb={4} />
<Section />

虽然这个有点不一样,一个间隔器在logo和导航之间建立一个自动间隔。

<Flex>
  <Logo />
  <Spacer m="auto" />
  <Link>Beep</Link>
  <Link>Boop</Link>
</Flex>

你可能会认为,通过添加 justify-content:space-between,使用CSS做到这一点相当容易。

如果设计上需要改一下怎么办?那么,如果是这样的话,样式就应该改了。

见下文,你看到那里的灵活性了吗?

<Flex>
  <Logo />
  <Link>Beep</Link>
  <Link>Boop</Link>
  <Spacer m="auto" />
  <Link>Boop</Link>
</Flex>

那么,如果是这样的话,就应该改变样式。你看出来有什么灵活性了吗?对于尺寸调整部分,可以根据其母体的尺寸调整间隔的尺寸。

对于上面的内容,也许你可以做一个叫 grow 的prop,可以计算成 flex-grow:1 在CSS中。

<Flex>
  <Spacer grow="1" />
</Flex>

使用伪元素

我考虑过的另一个想法是使用伪元素创建间隔符。

.element:after {
    content: "";
    display: block;
    height: 32px;
}

也许我们可以选择通过一个伪元素而不是一个单独的元素来添加间隔器?例如:

<Header spacer="below" type="pseudo" length="32">
  <Logo />
  <Link>Home</Link>
  <Link>About</Link>
  <Link>Contact</Link>
</Header>

直到今天,我还没有在项目中使用间隔组件,但是我期待可以使用它们的用例。

CSS数学函数:Min(),Max(),Clamp()

有可能有动态的边距吗?例如,根据视口宽度设置具有最小值和最大值的空白。答案是肯定的!我们可以。最近,Firefox 75支持CSS数学函数,这意味着根据CanIUse在所有主流浏览器中都支持CSS数学函数。

让我们回想一下Grid用例,以了解如何在其中使用动态间距。

.wrapper {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-gap: min(2vmax, 32px);
}

下面是 min(2vmax,32px) 的意思:使用一个等于 2vmax 的间隙,但不能超过 32px

拥有这样的灵活性确实令人惊讶,并且为我们提供了构建更多动态和灵活布局的许多可能性。


文章首发《前端外文精选》微信公众号

subscribe2.png

继续阅读其他高赞文章


查看原文

52lidan 赞了文章 · 5月9日

CSS中的间距,前端开发中各种设置间距的优点缺点及实例

默认文件1588564373403.png

来源:https://ishadeed.com,作者:Ahmad Shadeed
翻译:公众号《前端外文精选》

如果两个或多个元素很接近,那么用户就会认为它们以某种方式属于彼此。当对多个设计元素进行分组时,用户可以根据它们之间的空间大小来决定它们之间的关系。没有间距,用户将很难浏览页面并知道哪些内容相关而哪些内容无关。

在本文中,我将介绍有关CSS中的间距,实现此间距的不同方法以及何时使用 padding 或 margin 所需的所有知识。

间距类型

CSS中的间距有两种类型,一种在元素外部,另一种在元素内部。对于本文,我将其称为outerinner。假设我们有一个元素,它内部的间距是inner,外部的间距是outer

在CSS中,间距可以如下:

.element {
  padding: 1rem;
  margin-bottom: 1rem;
}

我使用 padding 来填充内部间距,使用 margin 来填充外部间距。很简单,不是吗?但是,当处理具有许多细节和子元素的组件时,这会变得越来越复杂。

margin 外部间距

它用于增加元素之间的间距。例如,在上一个示例中,我添加了 margin-bottom:1rem 在两个堆叠的元素之间添加垂直间距。

由于可以沿四个不同的方向(top、right、 bottom、left)添加margin,因此在深入研究示例和用例之前,一定要阐明一些基本概念,这一点很重要。

margin 折叠

简而言之,当两个垂直元素具有margin,并且其中一个元素的margin大于另一个元素时,发生边距折叠。在这种情况下,将使用更大的margin,而另一个将被忽略。

在上面的模型中,一个元素有 margin-bottom,另一个元素有 margin-top,边距较大的元素获胜。

为避免此类问题,建议按照本文使用单向边距。此外,CSS Tricks还在页边距底部和页边距顶部之间进行了投票。61%的开发者更喜欢 margin-bottom 而不是 margin-top

请在下面查看如何解决此问题:

.element:not(:last-child) {
  margin-bottom: 1rem;
}

使用 :not CSS选择器,您可以轻松地删除最后一个子元素的边距,以避免不必要的间距。

另一个与边距折叠相关的例子是子节点和父节点。让我们假设如下:

<div class="parent">
  <div class="child">I'm the child element</div>
</div>
.parent {
  margin: 50px auto 0 auto;
  width: 400px;
  height: 120px;
}

.child {
  margin: 50px 0;
}

请注意,子元素固定在其父元素的顶部。那是因为它的边距折叠了。根据W3C,以下是针对该问题的一些解决方案:

  • 在父元素上添加 border
  • 将子元素显示更改为 inline-block

一个更直接的解决方案是将 padding-top 添加到父元素。

负margin

它可以与四个方向一起使用以留出余量,在某些用例中非常有用。让我们假设以下内容:

父节点具有 padding:1rem,这导致子节点从顶部、左侧和右侧偏移。但是,子元素应该紧贴其父元素的边缘。负margin可以助你一臂之力。

.parent {
  padding: 1rem
}

.child {
  margin-left: -1rem;
  margin-right: -1rem;
  margin-top: -1rem;
}

如果您想更多地挖负margin,建议阅读这篇文章。

padding 内部间距

如前所述,padding在元素内部增加了一个内间距。它的目标可以根据使用的情况而变化。

例如,它可以用于增加链接之间的间距,这将导致链接的可点击区域更大。

必须提出的是,垂直方向的padding对于那些具有 display:inline 的元素不适用,比如 <span><a>。如果添加了内边距,它不会影响元素,内边距将覆盖其他内联元素。

这只是一个友好的提醒,应该更改内联元素的 display 属性。

.element span {
  display: inline-block;
  padding-top: 1rem;
  padding-bottom: 1rem;
}

CSS Grid 间隙

在CSS网格中,可以使用 grid-gap 属性轻松在列和行之间添加间距。这是行和列间距的简写。

.element {
  display: grid;
  grid-template-columns: 1fr 1fr;
  grid-gap: 16px; /* 为行和列都增加了16px的间隙。 */
}

gap属性可以使用如下:

.element {
  display: grid;
  grid-template-columns: 1fr 1fr;
  grid-row-gap: 24px;
  grid-column-gap: 16px;
}

CSS Flexbox 间隙

gap 是一个提议的属性,将用于CSS Grid和flexbox,撰写本文时,它仅在Firefox中受支持。

.element {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
}

CSS 定位

它可能不是直接的元素间距方式,但在一些设计案例中却起到了一定的作用。例如,一个绝对定位的元素需要从其父元素的左边缘和上边缘定位 16px

考虑以下示例,带有图标的卡片,其图标应与其父对象的左上边缘隔开。在这种情况下,将使用以下CSS:

.category {
  position: absolute;
  left: 16px;
  top: 16px;
}

用例和实际示例

在这一节中,你将回顾一下在日常工作中,你在处理CSS项目时,会遇到的不同用例。

header 组件

在这种情况下,标题具有logo,导航和用户个人资料。你能猜出CSS中的间距应该如何设置吗?好吧,让我为你添加一个骨架模型。

<header class="c-header">
  <h1 class="c-logo"><a href="#">Logo</a></h1>
  <div class="c-header__nav">
    <nav class="c-nav">
      <ul>
        <li><a href="#">...</a></li>
      </ul>
    </nav>
    <a href="#" class="c-user">
      <span>Ahmad</span>
      <img class="c-avatar" data-original="shadeed.jpg" alt="">
    </a>
  </div>
</header>

Header的左侧和右侧都有padding,这样做的目的是防止内容物紧贴在边缘上。

.c-header {
  padding-left: 16px;
  padding-right: 16px;
}

对于导航,每个链接在垂直和水平侧均应具有足够的填充,因此其可单击区域可以很大,这将增强可访问性。

.c-nav a {
  display: block;
  padding: 16px 8px;
}

对于每个项目之间的间距,您可以使用 margin 或将 <li>display 更改为 inline-block。内联块元素在它的兄弟元素之间添加了一点空间,因为它将元素视为字符。

.c-nav li {
  /* 这将创建你在骨架中看到的间距 */
  display: inline-block;
}

最后,头像(avatar)和用户名的左侧有一个空白。

.c-user img,
.c-user span {
  margin-left: 10px;
}

请注意,如果你要构建多语言网站,建议使用如下所示的CSS逻辑属性。

.c-user img,
.c-user span {
  margin-inline-start: 1rem;
}

请注意,分隔符周围的间距现在相等,原因是导航项没有特定的宽度,而是具有padding。结果,导航项目的宽度基于其内容。以下是解决方案:

  • 设置导航项目的最小宽度
  • 增加水平padding
  • 在分隔符的左侧添加一个额外的margin

最简单,更好的解决方案是第三个解决方案,即添加 margin-left

.c-user {
  margin-left: 8px;
}

网格系统中的间距:Flexbox

网格是间隔最常用的情况之一。考虑以下示例:

间距应在列和行之间。考虑以下HTML标记:

<div class="wrapper">
  <div class="grid grid--4">
    <div class="grid__item">
      <article class="card"><!-- Card content --></article>
    </div>
    <div class="grid__item">
      <article class="card"><!-- Card content --></article>
    </div>
    <!-- And so on.. -->
  </div>
</div>

通常,我更喜欢将组件封装起来,并避免给它们增加边距。由于这个原因,我有 grid__item元素,我的card组件将位于其中。

.grid--4 {
  display: flex;
  flex-wrap: wrap;
}

.grid__item {
  flex-basis: 25%;
  margin-bottom: 16px;
}

使用上述CSS,每行将有四张卡片。这是在它们之间添加空格的一种可能的解决方案:

.grid__item {
  flex-basis: calc(25% - 10px);
  margin-left: 10px;
  margin-bottom: 16px;
}

通过使用CSS calc() 函数,可以从 flex-basis 中扣除边距。如你所见,这个方案并不是那么简单。我比较喜欢的是下面这个办法。

  • 向网格项目添加 padding-left
  • 在网格父节点上增加一个负值 margin-left,其 padding-left 值相同。

几年前,我从CSS Wizardy那里学到了上述解决方案(我忘记了文章标题,如果您知道,请告诉我)。

.grid--4 {
  display: flex;
  flex-wrap: wrap;
  margin-left: -10px;
}

.grid__item {
  flex-basis: 25%;
  padding-left: 10px;
  margin-bottom: 16px;
}

我之所以用了负 margin-left,是因为第一张卡有 padding-left,而实际上不需要。所以,它将把 .wrapper 元素推到左边,取消那个不需要的空间。

另一个类似的概念是在两边都添加填充,然后边距为负。这是Facebook故事的一个示例:

.wrapper {
  margin-left: -4px;
  margin-right: -4px;
}

.story {
  padding-left: 4px;
  padding-right: 4px;
}

网格系统中的间距:CSS Grid

现在,到了激动人心的部分!使用CSS Grid,你可以很容易地使用 grid-gap 添加间距。此外,你不需要关心网格项的宽度或底部空白,CSS Grid 为你做者一切!

.grid--4 {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
  grid-gap: 1rem;
}

就是这样!难道不是那么容易和直接吗?

按需定制

我真正喜欢CSS Grid 的地方是 grid-gap 只在需要的时候才会被应用。考虑下面的模型。

没有CSS网格,就不可能拥有这种灵活性。首先,请参见以下内容:

.card:not(:last-child) {
  margin-bottom: 16px;
}

@media (min-width: 700px) {
  .card:not(:last-child) {
    margin-bottom: 0;
    margin-left: 1rem;
  }
}

不舒服吧?这个如何?

.card-wrapper {
  display: grid;
  grid-template-columns: 1fr;
  grid-gap: 1rem;
}

@media (min-width: 700px) {
  .card-wrapper {
    grid-template-columns: 1fr 1fr;
  }
}

完成了!容易得多。

处理底部margin

假设以下组件堆叠在一起,每个组件都有底边距。

注意最后一个元素有一个空白,这是不正确的,因为边距只能在元素之间。

可以使用以下解决方案之一解决此问题:

解决方案1-CSS :not 选择器

.element:not(:last-child) {
  margin-bottom: 16px;
}

解决方案2:相邻兄弟组合器

.element + .element {
  margin-top: 16px;
}

虽然解决方案1具有吸引力,但它具有以下缺点:

  • 它会导致CSS的特异性问题。在使用 :not 选择器之前不可能覆盖它。
  • 万一设计中有不止一列,它将无法正常工作。参见下图。

关于解决方案2,它没有CSS特异性问题。但是,它只能处理一个列栈。

更好的解决方案是通过向父元素添加负边距来取消不需要的间距。

.wrapper {
  margin-bottom: -16px;
}

它用一个等于底部间距的值将元素推到底部。注意不要超过边距值,因为它会与同级元素重叠。

Card组件

Oh,如果我想把所有细节的Card组件间距都写进去的话,最后可能会出现书本上的内容。我就突出一个大概的模式,看看间距应该如何应用。

你能想到此卡片在哪里使用间距吗?参见下图。

<article class="card">
  <a href="#">
    <div class="card__thumb"><img data-original="food.jpg" alt=""></div>
    <div class="card__content">
      <h3 class="card__title">Cinnamon Rolls</h3>
      <p class="card__author">Chef Ahmad</p>
      <div class="card__rating"><span>4.9</span></div>
      <div class="card__meta"><!-- --></div>
    </div>
  </a>
</article>
.card__content {
  padding: 10px;
}

上面的 padding 将向其中的所有子元素添加一个偏移量。然后,我将添加所有边距。

.card__title,
.card__author,
.card__rating {
  margin-bottom: 10px;
}

对于评分和 .car__meta 元素之间的分隔线,我将添加它作为边框。

.card__meta {
  padding-top: 10px;
  border-top: 1px solid #e9e9e9;
}

糟糕!由于对父元素 .card__content 进行了填充,因此边框没有粘在边缘上。

是的,你猜对了!负边距是解决办法。

.card__meta {
  padding-top: 10px;
  border-top: 1px solid #e9e9e9;
  margin: 0 -10px;
}

糟糕,再次!出了点问题。内容粘在边缘!

为了解决这个问题,内容应该从左右两边加垫(呵呵,看来加垫是个新词)。

.card__meta {
  padding: 10px 10px 0 10px;
  border-top: 1px solid #e9e9e9;
  margin: 0 -10px;
}

文章内容

我相信这是一个非常非常普遍的用例。由于文章内容来自CMS(内容管理系统),或者是由Markdown文件自动生成的,因此无法为元素添加类。

考虑下面的示例,其中包含标题,段落和图像。

<div class="wrapper">
  <h1>Spacing Elements in CSS</h1>
  <p><!-- content --></p>
  <h2>Types of Spacing</h2>
  <img data-original="spacing-1.png" alt="">
  <p><!-- content --></p> 
  <p><!-- content --></p> 
  <h2>Use Cases</h2>
  <p><!-- content --></p> 
  <h3>Card Component</h3> 
  <img data-original="use-case-card-2.png" alt="">
</div>

为了使它们看起来不错,间距应保持一致并谨慎使用。我从type-scale.com借了一些样式。

h1, h2, h3, h4, h5 {
  margin: 2.75rem 0 1.05rem;
}

h1 {
  margin-top: 0;
}

img {
  margin-bottom: 0.5rem;
}

如果一个 <p> 后面有一个标题,例如“Types of Spacing”,那么 <p>margin-bottom 将被忽略。你猜到了,那是因为页边距折叠。

Just In Case Margin

我喜欢把这个叫做 "Just in case" margin,因为这就是字面意思。考虑一下下面的模型图。

当元素靠近的时候,它们看起来并不好看。我是用flexbox搭建的。这项技术称为“对齐移位包装”,我从CSS Tricks中学到了它的名称。

.element {
  display: flex;
  flex-wrap: wrap;
}

当视口尺寸较小时,它们的确以新行结尾。见下文:

需要解决的是中间设计状态,即两件物品仍然相邻,但两件物品之间的间距为零的设计状态。在这种情况下,我倾向于向元素添加一个 margin-right,这样可以防止它们相互接触,从而加快 flex-wrap 的工作速度。

CSS 书写模式

根据MDN:

writing-mode CSS属性设置了文本行是水平还是垂直排列,以及块的前进方向。

你是否曾经考虑过将边距与具有不同 writing-mode 的元素一起使用时应如何表现?考虑以下示例。

.wrapper {
  /* 使标题和食谱在同一行 */
  display: flex;
}

.title {
  writing-mode: vertical-lr;
  margin-right: 16px;
}

标题被旋转了90度,在它和图像之间应该有一个空白区。结果表明,基于 writing-mode 的页边距工作得非常好。

我认为这些用例就足够了。让我们继续一些有趣的概念!

组件封装

大型设计系统包含许多组件。向其直接添加边距是否合乎逻辑?

考虑以下示例。

<button class="button">Save Changes</button>
<button class="button button-outline">Discard</button>

按钮之间的间距应在哪里添加?是否应将其添加到左侧或右侧按钮?也许你可以如下使用相邻同级选择器:

.button + .button {
  margin-left: 1rem;
}

这是不好的。如果只有一个按钮的情况怎么办?或者,当它垂直堆叠时在移动设备上将如何工作?很多很多的复杂性。

使用抽象组件

解决上述问题的一种方法是使用抽象的组件,其目标是托管其他组件,就像Max Stoiber所说的那样,这是将管理边距的责任移到了父元素上,让我们以这种思维方式重新思考以前的用例。

<div class="list">
  <div class="list__item">
    <button class="button">Save Changes</button>
  </div>
  <div class="list__item">
    <button class="button button-outline">Discard</button>
  </div>
</div>

注意,我添加了一个包装器,并且每个按钮现在都包装在其自己的元素中。

.list {
  display: flex;
  align-items: center;
  margin-left: -1rem; /* 取消第一个元素的左空白 */
}

.list__item {
  margin-left: 1rem;
}

就是这样!而且,将这些概念应用到任何JavaScript框架中都相当容易。例如:

<List>
  <Button>Save Changes</Button>
  <Button outline>Discard</Button>
</List>

你使用的JavaScript工具应该将每个项包装在自己的元素中。

间隔组件

是的,你没看错。我在这篇文章中讨论了避免margin的概念,并使用间隔组件来代替它们。

让我们假设一个区域需要从左到右24px的空白,并记住这些限制:

  • margin不能直接用于组件,因为它是一个已经构建的设计系统。
  • 它应该是灵活的。间距可能在X页上,但不在Y页上。

我在检查Facebook的新设计CSS时首先注意到了这一点。

那是一个 <div>,内联样式宽度:16px,它唯一的作用是在左边缘和包装器之间增加一个空白空间。

引述这本React游戏手册中的内容。

但在现实世界中,我们确实需要组件之外的间距来合成页面和场景,这就是margin渗入组件代码的地方:用于组件的间距组合。

我同意。对于大型设计系统,不断向组件添加margin是不可伸缩的。这将最终导致一个令人毛骨悚然的代码。

间隔组件的挑战

现在你了解了间隔组件的概念,让我们深入研究使用它们时遇到的一些挑战。这是我想到的一些问题:

  • 间隔组件如何在父级内部取其宽度或高度?在水平布局和垂直布局中,它将如何工作?
  • 我们是否应该根据其父项的显示类型(Flex,Grid)对它们进行样式设置

让我们一一解决上述问题。

调整间隔组件的大小

可以创建一个接受不同变化和设置的间隔。我不是JavaScript开发人员,但我认为他们将其称为Props。考虑来自styled-system.com的以下内容:

我们在一个header和一个 section之间有一个隔板。

<Header />
    <Spacer mb={4} />
<Section />

虽然这个有点不一样,一个间隔器在logo和导航之间建立一个自动间隔。

<Flex>
  <Logo />
  <Spacer m="auto" />
  <Link>Beep</Link>
  <Link>Boop</Link>
</Flex>

你可能会认为,通过添加 justify-content:space-between,使用CSS做到这一点相当容易。

如果设计上需要改一下怎么办?那么,如果是这样的话,样式就应该改了。

见下文,你看到那里的灵活性了吗?

<Flex>
  <Logo />
  <Link>Beep</Link>
  <Link>Boop</Link>
  <Spacer m="auto" />
  <Link>Boop</Link>
</Flex>

那么,如果是这样的话,就应该改变样式。你看出来有什么灵活性了吗?对于尺寸调整部分,可以根据其母体的尺寸调整间隔的尺寸。

对于上面的内容,也许你可以做一个叫 grow 的prop,可以计算成 flex-grow:1 在CSS中。

<Flex>
  <Spacer grow="1" />
</Flex>

使用伪元素

我考虑过的另一个想法是使用伪元素创建间隔符。

.element:after {
    content: "";
    display: block;
    height: 32px;
}

也许我们可以选择通过一个伪元素而不是一个单独的元素来添加间隔器?例如:

<Header spacer="below" type="pseudo" length="32">
  <Logo />
  <Link>Home</Link>
  <Link>About</Link>
  <Link>Contact</Link>
</Header>

直到今天,我还没有在项目中使用间隔组件,但是我期待可以使用它们的用例。

CSS数学函数:Min(),Max(),Clamp()

有可能有动态的边距吗?例如,根据视口宽度设置具有最小值和最大值的空白。答案是肯定的!我们可以。最近,Firefox 75支持CSS数学函数,这意味着根据CanIUse在所有主流浏览器中都支持CSS数学函数。

让我们回想一下Grid用例,以了解如何在其中使用动态间距。

.wrapper {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-gap: min(2vmax, 32px);
}

下面是 min(2vmax,32px) 的意思:使用一个等于 2vmax 的间隙,但不能超过 32px

拥有这样的灵活性确实令人惊讶,并且为我们提供了构建更多动态和灵活布局的许多可能性。


文章首发《前端外文精选》微信公众号

subscribe2.png

继续阅读其他高赞文章


查看原文

赞 24 收藏 18 评论 4

52lidan 赞了文章 · 4月22日

react-router-dom@5.x官方文档翻译

简介

这是我学习react-router-dom@5.1.2时,为了加深自己对react-router-dom的理解和帮助一些英文不好的同学,对官方文档进行了翻译,本人水平有限,如有理解和翻译错误,欢迎大家指正。官网地址

快速入门

要在web应用中开始使用React Router,您将需要一个React Web应用程序.如果您需要创建一个,我们建议您尝试Create React App。这是一个非常流行的工具,可与React Router很好地配合使用。

首先,安装create-react-app并使用它创建一个新项目。

安装

您可以使用npm或yarn从公共npm注册表中安装React Router。由于我们构建的是web app,因此在本指南中将使用react-router-dom。

npm install -g create-react-app       // 全局安装 create-react-app
create-react-app demo-app             // 创建一个react项目
cd demo-app                           // 进入react项目目录
npm install react-router-dom          // 安装react-router-dom

接下来,将以下两个示例之一复制/粘贴到src/App.js中。

第一个示例:基本路由

在此示例中,路由器处理了3个“页面”:主页、关于页面和用户页面。当您点击不同的<Link>时,这个路由会渲染匹配的<Route>。

注意:其实<Link>最后渲染出来是一个有真实href的标签,因此使用键盘导航或屏幕阅读器的人也可以使用react-router-dom。

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Switch, Route, Link } from 'react-router-dom';

function Home(props) {
    console.log('Home=>', props);
    return <h2>Home</h2>
}

function About(props) {
    console.log('About=>', props);
    return <h2>About</h2>;
}

function Users(props) {
    console.log('Users=>', props);
    return <h2>Users</h2>;
}

function App() {
    return <BrowserRouter>
        <div>
            <nav>
                <ul>
                    <li>
                        <Link to={'/'}>Home</Link>
                    </li>
                    <li>
                        <Link to={'/about'}>About</Link>
                    </li>
                    <li>
                        <Link to={'/users'}>Users</Link>
                    </li>
                </ul>
            </nav>
            {/* <Switch>通过查找所有的子<Route>并渲染与当前URL匹配的第一个<Route>的内容 */}
            <Switch>
                <Route path={'/about'}>
                    <About />
                </Route>
                <Route path={'/users'} children={<Users />}/>
                <Route path={'/'}>
                    <Home />
                </Route>
            </Switch>
        </div>
    </BrowserRouter>
}

ReactDOM.render(<App />, document.querySelector('#root'));

第二个示例:嵌套路由

此示例显示了嵌套路由的工作方式。路线/topics会加载Topics组件,在这个组件上的path:id值上有条件地渲染任何其他<Route>。

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Switch, Route, Link, useRouteMatch, useParams } from 'react-router-dom';

function Home(props) {
    console.log('Home=>', props);
    return <h2>Home</h2>
}

function About(props) {
    console.log('About=>', props);
    return <h2>About</h2>;
}

function Topic() {
    let { topicId } = useParams();
    return <h3>Requested topic ID: {topicId}</h3>
}

function Topics() {
    const match = useRouteMatch();
    console.log('match=>', match);
    return (
        <div>
            <h2>Topics</h2>
            <ul>
                <li>
                    <Link to={`${match.url}/components`}>Components</Link>
                </li>
                <li>
                    <Link to={`${match.url}/props-v-state`}>Props v. State</Link>
                </li>
            </ul>
            
            {/*
                Topics页面有自己的<Switch>,其中包含更多的路线,建立在/topics路径之上
                您可以将第二个<Route>视为所有主题的“索引”页面,或者当未选择任何主题时显示的页面
            */}
            <Switch>
                <Route path={`${match.path}/:topicId`}>
                    <Topic />
                </Route>
                <Route path={match.path}>
                    <h3>Please select a topic.</h3>
                </Route>
            </Switch>
        </div>
    );
}

function App() {
    return <BrowserRouter>
        <ul>
            <li>
                <Link to={'/'}>Home</Link>
            </li>
            <li>
                <Link to={'/about'}>About</Link>
            </li>
            <li>
                <Link to={'/topics'}>Topics</Link>
            </li>
        </ul>
        
        <Switch>
            <Route path={'/about'}>
                <About />
            </Route>
            <Route path={'/topics'}>
                <Topics />
            </Route>
            <Route path={'/'}>
                <Home />
            </Route>
        </Switch>
    </BrowserRouter>
}

ReactDOM.render(<App />, document.querySelector('#root'));

继续

希望这些示例能让您对使用React Router创建web app有点感觉。继续阅读可以了解有关React Router中主要组件的更多信息!

主要组件

React Router中的组件主要分为三类:

  • 路由器,例如<BrowserRouter>和<HashRouter>
  • 路由匹配器,例如<Route>和<Switch>
  • 导航,例如<Link>,<NavLink>和<Redirect>

在Web应用程序中使用的所有组件都应从react-router-dom导入。

路由器

每个React Router应用程序的核心应该是路由器组件。对于Web项目,react-router-dom提供<BrowserRouter>和<HashRouter>路由器。两者之间的主要区别在于它们存储URL和与Web服务器通信的方式。

  1. <BrowserRouter>使用常规URL路径。 这些通常是外观最好的网址,但它们要求您的服务器配置正确。 具体来说,您的Web服务器需要在所有由React Router客户端管理的URL上提供相同的页面。Create React App在开发中即开即用地支持此功能,并附带有关如何配置生产服务器的说明
  2. <HashRouter>将当前位置存储在URL的哈希部分中,因此URL看起来类似于http://example.com/#/your/page。 由于哈希从不发送到服务器,因此这意味着不需要特殊的服务器配置。

要使用路由器,只需确保将其渲染在元素层次结构的根目录下即可。 通常,您会将顶级<App>元素包装在路由器中,如下所示:

import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter } from "react-router-dom";

function App() {
  return <h1>Hello React Router</h1>;
}

ReactDOM.render(
  <BrowserRouter>
    <App />
  </BrowserRouter>,
  document.getElementById("root")
);

路线匹配器

有两个路线匹配组件:Switch和Route。渲染<Switch>时,它会搜索其子元素<Route>,以查找其路径与当前URL匹配的元素。当找到一个时,它将渲染该<Route>并忽略所有其他路由。这意味着您应该将<Route>包含更多特定路径(通常较长)的路径放在不那么特定路径之前。

如果没有<Route>匹配,则<Switch>不渲染任何内容(null)。

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Switch, Route } from 'react-router-dom';

function App() {
    return <div>
        <Switch>
            {/* 如果当前URL是/ about,那么将渲染此路由,而其余路由将被忽略 */}
            <Route path={'/about'}>
                <h2>About</h2>
            </Route>
            {/* 请注意这两个路由的顺序。 更具体的path="/contact/id"位于path="/contact"之前,因此在查看单个联系人时将显示这个<Route> */}
            <Route path={'/contact/:id'}>
                <h2>Contact</h2>
            </Route>
            <Route path={'/contact'}>
                <h2>AllContact</h2>
            </Route>
            {/*
                如果先前的路由均未呈现任何内容,则此路由将充当后备路径。
                重要提示:路径="/"的路线将始终匹配任何路径的URL,因为所有URL均以/开头。 所以这就是为什么我们把这这个<Route>放在最后
            */}
            <Route path={'/'}>
                <h2>Home</h2>
            </Route>
        </Switch>
    </div>
}

ReactDOM.render(<BrowserRouter>
    <App />
</BrowserRouter>, document.querySelector('#root'));

⚠️ 需要注意的重要一件事是<Route path>匹配URL的开头,而不是整个开头。所以,<Route path ="/">将始终与任意一个URL匹配。因此,我们通常将此<Route>放在<Switch>的最后。另一种可能的解决方案是使用确实与整个URL匹配的<Route exact path="">。exact属性表示精准匹配。

⚠️注意:尽管React Router确实支持在<Switch>之外渲染<Route>元素,从5.1版开始,我们建议您改用useRouteMatch钩子。此外,我们不建议您渲染不带路径的<Route>,而是建议您使用钩子来访问您所使用的任何变量。

导航(或路线更改器)

React Router提供了一个<Link>组件来在您的应用程序中创建链接。 无论在何处渲染<Link>,锚点都将渲染在HTML文档中。

<NavLink>是<Link>的一种特殊类型,当其prop与当前位置匹配时,可以将其自身设置为“active”。

任何时候要强制导航,都可以渲染<Redirect>。渲染<Redirect>时,它将会使用其props进行导航

<Link to="/">Home</Link>
// <a href="/">Home</a>

<NavLink to="/react" activeClassName="hurray">
  React
</NavLink>
// 当URL为/react的时候, 渲染出来的以下内容:
// <a href="/react" className="hurray">React</a>
// 如果是其他URL,则渲染为:
// <a href="/react">React</a>

// 重定向到/login
<Redirect to="/login" />

NavLink例子:

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Switch, Route, NavLink } from 'react-router-dom';

function NavigationApp() {
    return <BrowserRouter>
        <ul>
            <li>
                <NavLink to={'/react'}>React</NavLink>
            </li>
            <li>
                <NavLink to={'/redux'}>redux</NavLink>
            </li>
        </ul>
        <div>
            <Switch>
                <Route path={'/react'}>
                    <h1>React</h1>
                </Route>
                <Route path={'/redux'}>
                    <h1>Redux</h1>
                </Route>
            </Switch>
        </div>
    </BrowserRouter>
}

ReactDOM.render(<NavigationApp />, document.querySelector('#root'));

服务器渲染

代码分割

网络应用的一个重要特色就是:我们无需让访问者下载整个应用程序即可使用,您可以将代码拆分视为增量下载应用程序。为此,我们将使用webpack,@babel/plugin-syntax-dynamic-import,和loadable-components做代码分割。

webpack内置了对动态导入的支持; 但是,如果您使用的是Babel(例如,将JSX编译为JavaScript),则需要使用@babel/plugin-syntax-dynamic-import插件。这是仅语法的插件,这意味着Babel不会进行任何其他转换。该插件仅允许Babel解析动态导入,因此webpack可以将它们捆绑为代码拆分。 您的.babelrc应该使用如下配置:

{
  "presets": ["@babel/preset-react"],
  "plugins": ["@babel/plugin-syntax-dynamic-import"]
}

loadable-components是用于通过动态导入加载组件的库。 它自动处理各种边缘情况,并使代码拆分变得简单! 这是有关如何使用loadable-components的示例:

import loadable from "@loadable/component";
import Loading from "./Loading.js";

const LoadableComponent = loadable(() => import("./Dashboard.js"), {
  fallback: <Loading />
});

export default class LoadableDashboard extends React.Component {
  render() {
    return <LoadableComponent />;
  }
}

这一切就是这么简单! 只需使用LoadableDashboard(或任何您命名的组件),当您在应用程序中使用它时,它将自动加载并渲染。fallback是一个占位符组件,用于在加载实际组件时显示。
完整的文档在这里

代码拆分和服务器端渲染

loadable-components包含服务器端渲染的指南

滚动还原

在早期版本的React Router中,我们提供了对滚动恢复的开箱即用的支持,从那以后人们一直在要求它。 希望本文档可以帮助您从滚动条和路由中获得所需的信息!
浏览器开始以自己的history.pushState处理滚动还原,其处理方式与使用普通浏览器导航时的处理方式相同。它已经可以在Chrome浏览器中使用,而且非常棒,这是滚动恢复规范
由于浏览器开始处理“默认情况”,并且应用具有不同的滚动需求(例如本网站!),因此我们不提供默认滚动管理功能。 本指南应帮助您实现任何滚动需求。

滚动到顶部

在大多数情况下,您所需要做的只是“滚动到顶部”,因为您有一个较长的内容页面,该页面在导航到该页面时始终保持向下滚动。 使用<ScrollToTop>组件可以轻松处理此问题,该组件将在每次导航时向上滚动窗口:
创建滚动到顶部组件:

import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
export default function ScrollToTop() {
    const { pathname } = useLocation();
    console.log('pathname=>', pathname);
    useEffect(() => {
        window.scrollTo(0, 0);
    }, [ pathname ]);
    return null;
}

如果您尚未运行React 16.8,则可以使用React.Component子类执行相同的操作:

import React from "react";
import { withRouter } from "react-router-dom";

class ScrollToTop extends React.Component {
  componentDidUpdate(prevProps) {
    if (
      this.props.location.pathname !== prevProps.location.pathname
    ) {
      window.scrollTo(0, 0);
    }
  }

  render() {
    return null;
  }
}

export default withRouter(ScrollToTop);

然后在您的应用程序的顶部渲染它,但是要把它路由器下面:

import React from 'react'
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import ScrollToTop from './ScrollToTop'

function App() {
    return <BrowserRouter>
        <ScrollToTop/>
        <h1>App</h1>
    </BrowserRouter>
}

ReactDOM.render(<App />, document.querySelector('#root'));

如果您将标签页接口连接到路由器,那么当他们切换标签页时,您可能不想滚动到顶部。 那么,您需要在的特定位置使用<ScrollToTopOnMount>?

import { useEffect } from "react";

function ScrollToTopOnMount() {
  useEffect(() => {
    window.scrollTo(0, 0);
  }, []);

  return null;
}

// 使用以下代码将此内容渲染到某处:
// <Route path="..." children={<LongContent />} />
function LongContent() {
  return (
    <div>
      <ScrollToTopOnMount />

      <h1>Here is my long content page</h1>
      <p>...</p>
    </div>
  );
}

再说一次,如果您运行的React小于16.8,则可以对React.Component子类做同样的事情:

with a React.Component subclass:import React from "react";

class ScrollToTopOnMount extends React.Component {
  componentDidMount() {
    window.scrollTo(0, 0);
  }

  render() {
    return null;
  }
}

// 使用以下代码将此内容渲染到某处:
// <Route path="..." children={<LongContent />} />
class LongContent extends React.Component {
  render() {
    return (
      <div>
        <ScrollToTopOnMount />

        <h1>Here is my long content page</h1>
        <p>...</p>
      </div>
    );
  }
}

通用解决方案

对于通用解决方案(以及哪些浏览器已开始在本机实现),我们谈论的是两件事:
1、向上滚动导航,这样就不会启动滚动到底部的新屏幕
2、恢复窗口的滚动位置和“后退”和“前进”单击上的溢出元素(但不单击“链接”单击!)
在某一时刻,我们希望提供一个通用的API。 这就是我们要研究的方向:

<Router>
  <ScrollRestoration>
    <div>
      <h1>App</h1>

      <RestoredScroll id="bunny">
        <div style={{ height: "200px", overflow: "auto" }}>
          I will overflow
        </div>
      </RestoredScroll>
    </div>
  </ScrollRestoration>
</Router>

首先,ScrollRestoration在导航中向上滚动窗口。其次,它将使用location.key将窗口滚动位置和RestoredScroll组件的滚动位置保存到sessionStorage。然后,在安装ScrollRestoration或RestoredScroll组件时,它们可以从sessionStorage查找其位置。

最棘手的部分是定义一个"opt-out"的API,当你不想滚动窗口时进行管理。例如,如果您在页面内容中浮动了一些选项卡导航,则可能不想滚动到顶部(选项卡可能会滚动到视图之外!)。当我们得知Chrome现在可以为我们管理滚动位置,并意识到不同的应用程序将具有不同的滚动需求时,我们有点迷失了我们需要提供某些东西的信念,尤其是当人们只想滚动到顶部时( 您可以直接将其直接添加到您的应用中)。基于此,我们不再有足够的力气自己完成工作(就像您一样,我们的时间有限!)。 但是,我们很乐意为有志于实施通用解决方案的任何人提供帮助。 一个可靠的解决方案甚至可以存在于项目中。 如果您开始使用它,请与我们联系:)

设计原理

本指南的目的是说明使用React Router时要具有的思维模型。 我们称之为“动态路由”,它与您可能更熟悉的“静态路由”完全不同。

静态路由

如果您使用过Rails,Express,Ember,Angular等,则使用了静态路由。 在这些框架中,您需要在进行任何渲染之前将路由声明为应用初始化的一部分。 React Router pre-v4也是静态的(大部分是静态的)。让我们看一下在express中如何配置路由:

Express路由配置模式:
app.get("/", handleIndex);
app.get("/invoices", handleInvoices);
app.get("/invoices/:id", handleInvoice);
app.get("/invoices/:id/edit", handleInvoiceEdit);

app.listen();

请注意在app监听之前如何声明路由。 我们使用的客户端路由器相似。 在Angular中,您先声明routes,然后在渲染之前将其导入顶级的AppModule中:

// Angular的路由配置样式:
const appRoutes: Routes = [
  {
    path: "crisis-center",
    component: CrisisListComponent
  },
  {
    path: "hero/:id",
    component: HeroDetailComponent
  },
  {
    path: "heroes",
    component: HeroListComponent,
    data: { title: "Heroes List" }
  },
  {
    path: "",
    redirectTo: "/heroes",
    pathMatch: "full"
  },
  {
    path: "**",
    component: PageNotFoundComponent
  }
];

@NgModule({
  imports: [RouterModule.forRoot(appRoutes)]
})
export class AppModule {}

Ember具有常规的route.js文件,该版本会为您读取并导入到应用程序中。 同样,这是在您的应用渲染之前发生的。

// Ember 路由配置样式:
Router.map(function() {
  this.route("about");
  this.route("contact");
  this.route("rentals", function() {
    this.route("show", { path: "/:rental_id" });
  });
});

export default Router;

虽然API是不同的,他们都有着“静态路由”的模式。 React Router也跟进了直到v4。
为了成功使用React Router,您需要忘记所有这些!

背后故事

坦率地说,我们对v2采取React Router的方向感到非常沮丧。 我们(Michael和Ryan)感到受到API的限制,认识到我们正在重新实现React的各个部分(生命周期等),而这与React为构建UI提供的思维模型不符。

我们走在一家酒店的走廊上,正在讨论如何解决这个问题。我们互相问:“如果我们使用我们在工作室里教的模式来建造路由器,那会是什么样子?”

仅仅在开发的几个小时内,我们就有了一个概念证明,我们知道这就是我们想要的路由的未来。我们最终得到的API不是React的“外部”API,而是一个由React的其余部分组成的API,或者自然地与之匹配。我们想你会喜欢的。

动态路由

当说动态路由时,是指在您的应用渲染时发生的路由,而不是在运行的应用之外的配置或约定中进行。 这意味着几乎所有内容都是React Router中的一个组件。 这是对该API的60秒回顾,以了解其工作原理:

首先,为您要定位的环境获取一个Router组件,并将其呈现在应用程序的顶部。

// react-native
import { NativeRouter } from "react-router-native";

// react-dom (我们将在这里使用什么)
import { BrowserRouter } from "react-router-dom";

ReactDOM.render(
  <BrowserRouter>
    <App />
  </BrowserRouter>,
  el
);

接下来,获取链接组件以链接到新位置:

const App = () => (
  <div>
    <nav>
      <Link to="/dashboard">Dashboard</Link>
    </nav>
  </div>
);

最后,渲染一个Route在用户访问/dashboard时显示一些UI。

const App = () => (
  <div>
    <nav>
      <Link to="/dashboard">Dashboard</Link>
    </nav>
    <div>
      <Route path="/dashboard" component={Dashboard} />
    </div>
  </div>
);

Route将渲染<Dashboard {... props} />,其中props是路由器特定的东西,props对象以这三个关键对象{match,location, history}。 如果用户不在/dashboard上,则Route将渲染null。 差不多就够了。

嵌套路由

很多路由器都有“嵌套路由”的概念。如果您使用的是v4之前的React Router版本,那么您也会知道它是这么做的!当您从静态路由配置转移到动态渲染路由时,如何“嵌套路由”?如何嵌套div呢?

const App = () => (
  <BrowserRouter>
    {/* 这是一个 div */}
    <div>
      {/* 这是一个 Route */}
      <Route path="/tacos" component={Tacos} />
    </div>
  </BrowserRouter>
);

// 当网址与`/ tacos`相匹配时,渲染此组件
const Tacos = ({ match }) => (
  // 这是一个嵌套的div
  <div>
    {/* 这是一条嵌套路线match.url帮助我们建立相对路径 */}
    <Route path={match.url + "/carnitas"} component={Carnitas} />
  </div>
);

看到路由器没有“嵌套”API了吗?路由只是一个组件,就像div一样。要嵌套一个路由或div,你只需要...
让我们更加棘手。

响应式路由

考虑用户导航到/invoices。 您的应用程序适应不同的屏幕尺寸,它们的viewport狭窄,因此您只向他们显示发票清单和发票仪表板的链接。 他们可以从那里更深入地导航。

小屏幕
url: /invoices

+----------------------+
|                      |
|      Dashboard       |
|                      |
+----------------------+
|                      |
|      Invoice 01      |
|                      |
+----------------------+
|                      |
|      Invoice 02      |
|                      |
+----------------------+
|                      |
|      Invoice 03      |
|                      |
+----------------------+
|                      |
|      Invoice 04      |
|                      |
+----------------------+

在较大的屏幕上,我们想显示一个主从视图,其中导航在左侧,仪表板或特定发票在右侧。

大屏幕
url: /invoices/dashboard

+----------------------+---------------------------+
|                      |                           |
|      Dashboard       |                           |
|                      |   Unpaid:             5   |
+----------------------+                           |
|                      |   Balance:   $53,543.00   |
|      Invoice 01      |                           |
|                      |   Past Due:           2   |
+----------------------+                           |
|                      |                           |
|      Invoice 02      |                           |
|                      |   +-------------------+   |
+----------------------+   |                   |   |
|                      |   |  +    +     +     |   |
|      Invoice 03      |   |  | +  |     |     |   |
|                      |   |  | |  |  +  |  +  |   |
+----------------------+   |  | |  |  |  |  |  |   |
|                      |   +--+-+--+--+--+--+--+   |
|      Invoice 04      |                           |
|                      |                           |
+----------------------+---------------------------+

现在暂停一分钟,并考虑两种屏幕尺寸的/invoices网址。 它甚至是大屏幕的有效路线吗? 我们应该在右边放什么?

大屏幕
url: /invoices
+----------------------+---------------------------+
|                      |                           |
|      Dashboard       |                           |
|                      |                           |
+----------------------+                           |
|                      |                           |
|      Invoice 01      |                           |
|                      |                           |
+----------------------+                           |
|                      |                           |
|      Invoice 02      |             ???           |
|                      |                           |
+----------------------+                           |
|                      |                           |
|      Invoice 03      |                           |
|                      |                           |
+----------------------+                           |
|                      |                           |
|      Invoice 04      |                           |
|                      |                           |
+----------------------+---------------------------+

在大屏幕上,/invoices不是有效的路径,但在小屏幕上则是! 为了使事情变得更有趣,请考虑使用大型手机的人。 他们可能会纵向查看/invoices,然后将手机旋转至横向。 突然,我们有足够的空间来显示主从界面,因此您应该立即进行重定向!
React Router以前版本的静态路由并没有真正解决这个问题的方法。 但是,当路由是动态的时,您可以声明性地组合此功能。 如果您开始考虑将路由选择为UI,而不是静态配置,那么您的直觉将引导您进入以下代码:

const App = () => (
  <AppLayout>
    <Route path="/invoices" component={Invoices} />
  </AppLayout>
);

const Invoices = () => (
  <Layout>
    {/* 总是显示导航 */}
    <InvoicesNav />

    <Media query={PRETTY_SMALL}>
      {screenIsSmall =>
        screenIsSmall ? (
          // 小屏幕没有重定向
          <Switch>
            <Route
              exact
              path="/invoices/dashboard"
              component={Dashboard}
            />
            <Route path="/invoices/:id" component={Invoice} />
          </Switch>
        ) : (
          // 大屏幕呢!
          <Switch>
            <Route
              exact
              path="/invoices/dashboard"
              component={Dashboard}
            />
            <Route path="/invoices/:id" component={Invoice} />
            <Redirect from="/invoices" to="/invoices/dashboard" />
          </Switch>
        )
      }
    </Media>
  </Layout>
);

当用户将手机从纵向旋转到横向时,此代码将自动将其重定向到仪表板。 有效routes会根据用户手中移动设备的动态性质而变化。
这只是一个例子。 我们可以讨论许多其他内容,但我们将总结以下建议:为了使您的直觉与React Router的直觉相符,请考虑组件而不是静态路由。 考虑一下如何使用React的声明式可组合性解决问题,因为几乎每个“ React Router问题”都可能是“ React问题”。

测试

React Router依靠React上下文来工作。 这会影响您如何测试在你的组件里使用我们的组件。

Context

如果您尝试对渲染<Link>或<Route>的组件之一进行单元测试,等等。您会收到一些有关上下文的错误和警告。 尽管您可能会想自己亲自设置路由器上下文,我们建议您将单元测试包装在路由器组件之一中:具有history属性的路由或<StaticRouter>,<MemoryRouter>或<BrowserRouter>的基本路由器(如果window.history在测试环境中可作为全局变量使用)。建议使用MemoryRouter或自定义历史记录,以便能够在两次测试之间重置路由器。

class Sidebar extends Component {
  // ...
  render() {
    return (
      <div>
        <button onClick={this.toggleExpand}>expand</button>
        <ul>
          {users.map(user => (
            <li>
              <Link to={user.path}>{user.name}</Link>
            </li>
          ))}
        </ul>
      </div>
    );
  }
}

// broken
test("it expands when the button is clicked", () => {
  render(<Sidebar />);
  click(theButton);
  expect(theThingToBeOpen);
});

// fixed!
test("it expands when the button is clicked", () => {
  render(
    <MemoryRouter>
      <Sidebar />
    </MemoryRouter>
  );
  click(theButton);
  expect(theThingToBeOpen);
});

从指定route开始

<MemoryRouter>支持initialEntries和initialIndex props,因此您可以在特定位置启动应用程序(或应用程序的任何较小部分)。

test("current user is active in sidebar", () => {
  render(
    <MemoryRouter initialEntries={["/users/2"]}>
      <Sidebar />
    </MemoryRouter>
  );
  expectUserToBeActive(2);
});

导航

我们进行了很多测试,以检查route在位置更改时是否有效,因此您可能不需要测试这些东西。 但是,如果您需要在应用程序中测试导航,则可以这样进行:

app.js (a component file)
import React from "react";
import { Route, Link } from "react-router-dom";

// 我们的主题,即应用,但您可以测试任何子项
// 您的应用程序部分
const App = () => (
  <div>
    <Route
      exact
      path="/"
      render={() => (
        <div>
          <h1>Welcome</h1>
        </div>
      )}
    />
    <Route
      path="/dashboard"
      render={() => (
        <div>
          <h1>Dashboard</h1>
          <Link to="/" id="click-me">
            Home
          </Link>
        </div>
      )}
    />
  </div>
);
// 您还可以在此处使用"@testing-library/react"或"enzyme/mount"之类的渲染器
import { render, unmountComponentAtNode } from "react-dom";
import { act } from 'react-dom/test-utils';
import { MemoryRouter } from "react-router-dom";

// app.test.js
it("navigates home when you click the logo", async => {
  // 在真实测试中,渲染器如"@testing-library/react"
  // 将负责设置DOM元素
  const root = document.createElement('div');
  document.body.appendChild(root);

  // Render app
  render(
    <MemoryRouter initialEntries={['/my/initial/route']}>
      <App />
    <MemoryRouter>,
    root
  );

  // 与页面互动
  act(() => {
    // 查找链接(可能使用文本内容)
    const goHomeLink = document.querySelector('#nav-logo-home');
    // Click it
    goHomeLink.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  });

  // 检查显示的页面内容是否正确
  expect(document.body.textContent).toBe('Home');
});

检查测试中的位置

在测试中,您不必经常访问location或history对象,但是如果你这样做了(比如验证在url栏中设置了新的查询参数),你可以在测试中添加一个更新变量的路由:

// app.test.js
test("clicking filter links updates product query params", () => {
  let history, location;
  render(
    <MemoryRouter initialEntries={["/my/initial/route"]}>
      <App />
      <Route
        path="*"
        render={({ history, location }) => {
          history = history;
          location = location;
          return null;
        }}
      />
    </MemoryRouter>,
    node
  );

  act(() => {
    // example: click a <Link> to /products?id=1234
  });

  // assert about url
  expect(location.pathname).toBe("/products");
  const searchParams = new URLSearchParams(location.search);
  expect(searchParams.has("id")).toBe(true);
  expect(searchParams.get("id")).toEqual("1234");
});

备选方案:
1、如果您的测试环境具有浏览器全局变量window.location和window.history(这是通过JSDOM在Jest中的默认设置,但您无法重置测试之间的历史记录),则也可以使用BrowserRouter。
2、您可以将基本路由器与history包中的history props一起使用,而不是将自定义路由传递给MemoryRouter:

// app.test.js
import { createMemoryHistory } from "history";
import { Router } from "react-router";

test("redirects to login page", () => {
  const history = createMemoryHistory();
  render(
    <Router history={history}>
      <App signedInUser={null} />
    </Router>,
    node
  );
  expect(history.location.pathname).toBe("/login");
});

React测试包

请参阅官方文档中的示例:Testing React Router with React Testing Library

Redux集成

Redux是React生态系统的重要组成部分。 对于想要同时使用React Router和Redux的人,我们希望使其无缝集成。

阻止更新

通常,React Router和Redux可以很好地协同工作。不过有时候,应用程序可以包含一个组件,该组件在位置更改时(子routes或活动的导航links不会更新)不会更新。

在以下情况下会发生这种情况:
1、该组件通过connect()(Comp)连接到redux。
2、该组件不是“路由组件”,这意味着它的渲染方式不是这样:<Route component = {SomeConnectedThing} />

问题在于Redux实现了shouldComponentUpdate,如果没有从路由器接收props,则没有任何迹象表明发生了任何变化。
这很容易姐姐,找到连接组件的位置,然后将组件使用withRouter包装在一起

深度集成

有些人想:
1、从store同步路由数据,并从store访问路由数据。
2、可以通过dispatch action操作导航
3、在Redux devtools中支持对路径更改进行时间行程调试。

所有这些都需要更深入的集成。

我们的建议是不要将routes完全保留在Redux store中。论证:
1、路由数据已经成为大多数关心它的组件的支持。 无论是来自store还是router,您组件的代码都基本相同。
2、在大多数情况下,您可以使用Link,NavLink和Redirect执行导航操作。有时您可能还需要以编程方式进行导航,有时您可能还需要以编程方式导航,在某个操作最初启动的异步任务之后。例如,您在用户提交登录表单时调度操作。然后,您的使用thunksaga或其他异步处理程序会对凭据进行身份验证,如果成功,则需要以某种方式导航到新页面。此处的解决方案只是将history对象(提供给所有路由组件)包括在操作的payload,并且异步处理程序可以在适当的时候使用此对象进行导航。
3、路线更改对于时间行程调试不太重要。唯一明显的情况是调试router/store同步中的问题,如果根本不同步它们,则该问题将消失。
但是,如果您强烈希望与store同步route,您可能需要尝试Connected React Router,这是React Router v4和Redux的第三方绑定。

静态Routes

以前版本的React Router使用静态路由来配置应用程序的路由。这样可以在渲染之前检查和匹配路线。由于v4转移到动态组件而不是路由配置,因此一些以前的用例变得不那么明显和棘手。我们正在开发一个可与静态路由配置和React Router配合使用的软件包,以继续满足这些用例。 现在正在开发中,但我们希望您能尝试一下并提供帮助。

React Router Config

API

Hooks

React Router附带了一些钩子,可让您访问路由器的状态并从组件内部执行导航。
请注意:您必须使用React> = 16.8才能使用这些钩子中的任何一个!

useHistory

useHistory钩子使您可以访问可用于导航的history实例。

import { useHistory } from "react-router-dom";

function HomeButton() {
  let history = useHistory();

  function handleClick() {
    history.push("/home");
  }

  return (
    <button type="button" onClick={handleClick}>
      Go home
    </button>
  );
}

useLocation

useLocation钩子返回代表当前URL的location对象。您可以像useState一样考虑它,只要URL更改,它就会返回一个新位置。
这可能非常有用,例如 在您希望每次加载新页面时都使用Web分析工具触发新的"page view"事件的情况下,如以下示例所示:

import React from "react";
import ReactDOM from "react-dom";
import {
  BrowserRouter as Router,
  Switch,
  useLocation
} from "react-router-dom";

function usePageViews() {
  let location = useLocation();
  React.useEffect(() => {
    ga.send(["pageview", location.pathname]);
  }, [location]);
}

function App() {
  usePageViews();
  return <Switch>...</Switch>;
}

ReactDOM.render(
  <Router>
    <App />
  </Router>,
  node
);

useParams

useParams返回URL参数的key/value的对象。 使用它来访问当前<Route>的match.params。

import React from "react";
import ReactDOM from "react-dom";
import {
  BrowserRouter as Router,
  Switch,
  Route,
  useParams
} from "react-router-dom";

function BlogPost() {
  let { slug } = useParams();
  return <div>Now showing post {slug}</div>;
}

ReactDOM.render(
  <Router>
    <Switch>
      <Route exact path="/">
        <HomePage />
      </Route>
      <Route path="/blog/:slug">
        <BlogPost />
      </Route>
    </Switch>
  </Router>,
  node
);

useRouteMatch

useRouteMatch钩子尝试以与<Route>相同的方式匹配当前URL。它主要用于在不实际渲染<Route>的情况下访问匹配数据。
不用useRouteMatch:

import { Route } from "react-router-dom";

function BlogPost() {
  return (
    <Route
      path="/blog/:slug"
      render={({ match }) => {
        // 用match做你想做的一切...
        return <div />;
      }}
    />
  );
}

使用useRouteMatch:

import { useRouteMatch } from "react-router-dom";

function BlogPost() {
  let match = useRouteMatch("/blog/:slug");

  // 用match做你想做的一切...
  return <div />;
}

<BrowserRouter>

一个<Router>,它使用HTML5 history API (pushState、replaceState和popstate事件)来保持UI与URL同步。

<BrowserRouter
  basename={optionalString}
  forceRefresh={optionalBool}
  getUserConfirmation={optionalFunc}
  keyLength={optionalNumber}
>
  <App />
</BrowserRouter>

basename: string

所有location的基本URL。如果您的应用是通过服务器上的子目录提供的,则需要将其设置为子目录。格式正确的basename应以斜杠开头,但不能以斜杠结尾。

getUserConfirmation: func

用于确认导航的功能。 默认使用window.confirm

forceRefresh: bool

如果为true,则路由器将在页面导航中使用整页刷新。您可能希望使用它来模仿传统的服务器渲染应用程序在页面导航之间刷新整个页面的方式。

keyLength: number

location.key的长度。 默认为6。

children: node

要渲染的子元素。
注意:在React <16上,您必须使用单个子元素,因为render方法不能返回多个元素。 如果需要多个元素,则可以尝试将它们包装在额外的<div>中。

<HashRouter>

<Router>使用URL的哈希部分(即window.location.hash)使UI与URL保持同步。
重要说明:Hash history不支持location.key或location.state。在以前的版本中,我们试图纠正这种行为,但是有些边缘情况我们无法解决。任何需要此行为的代码或插件都将无法使用。 由于此技术仅旨在支持旧版浏览器,我们建议您将服务器配置为与<BrowserHistory>一起使用。

<HashRouter
  basename={optionalString}
  getUserConfirmation={optionalFunc}
  hashType={optionalString}
>
  <App />
</HashRouter>

basename: string

所有location的基本URL。 格式正确的basename应以斜杠开头,但不能以斜杠结尾。

<HashRouter basename="/calendar"/>
<Link to="/today"/> // 渲染出来的样子: <a href="#/calendar/today">

getUserConfirmation: func

用于confirm导航的功能。 默认使用window.confirm。

<HashRouter
  getUserConfirmation={(message, callback) => {
    // this is the default behavior
    const allowTransition = window.confirm(message);
    callback(allowTransition);
  }}
/>

hashType: string

用于window.location.hash的编码类型。 可用值为:

  • "slash" - 创建像#/#/sunshine/lollipops的hash
  • "noslash" - 创建像##sunshine/lollipops的hash
  • "hashbang" - 创建诸如#!/#!/sunshine/lollipops之类的"ajax crawlable"(Google弃用)hash

默认为 "/"

children: node

要渲染的单个子元素

import React, { useEffect } from 'react';
import ReactDOM from 'react-dom';
import { HashRouter, Switch, Route, Link, withRouter } from 'react-router-dom';

function INav() {
    let homeRef;
    let anchorRef = React.createRef();
    console.log('before - anchorRef=>', anchorRef);
    useEffect(props => {
        console.log('after - anchorRef=>', anchorRef);
        console.log('after- homeRef=>', homeRef);
    });
    return (
        <ul className={'nav'}>
            <li>
                <Link to={'/home'} replace innerRef={homeRef}>Home</Link>
            </li>
            <li>
                <Link to={'/rule'} innerRef={anchorRef}>Rule</Link>
            </li>
            <li>
                <Link to={'/form'} innerRef={node => {
                    // "node"指的是被挂载的DOM元素
                    // 组件被卸载时为null
                    console.log('node=>', node);
                }}>Form</Link>
            </li>
            <li>
                <Link to={location => `/table?sort=name`}>Table</Link>
            </li>
            <li>
            <Link to={location => {
                console.log('Charts - location=>', location);
                return { ...location, pathname: '/charts' }
            }}>Charts</Link>
            </li>
            <li>
                <Link to={{
                    pathname: '/example',
                    search: '?sort=name',
                    hash: '#the-hash',
                    state: {
                        fromDashboard: true,
                        name: 'Jameswain'
                    }
                }}>Example</Link>
            </li>
        </ul>
    )
}

function Home(props) {
    console.log('Home:', props);
    return <h1>
        Home
    </h1>
}

function Form(props) {
    console.log('Form:', props);
    return <h1>Form</h1>;
}

function Table(props) {
    console.log('Table:', props);
    return <h1>Table</h1>
}

function Rule(props) {
    console.log('rule:', props);
    return <h1>
        Rule
    </h1>
}

const Example = withRouter((props) => {
    console.log('Example:', props);
    return <h1>Example</h1>
});

const Charts = withRouter((props) => {
    console.log('Charts:', props);
    return <h1>Charts</h1>
});


function App() {
    return (
        <HashRouter hashType={'noslash'} basename={'/calendar'}>
            <div className={'app'}>
                <INav/>
                <Switch>
                    <Route path={'/home'} exact>
                        <Home />
                    </Route>
                    <Route path={'/rule'} children={props => <Rule {...props} />} />
                    <Route path={'/form'} render={props => <Form {...props} />} />
                    <Route path={'/table'} component={props => <Table {...props} />} />
                    <Route path={'/charts'} children={<Charts />} />
                    <Route path={'/example'}>
                        <Example />
                    </Route>
                </Switch>
            </div>
        </HashRouter>
    );
}

ReactDOM.render(<App />, document.querySelector('#root'));

<Link>

提供围绕应用程序的声明式、可访问的导航,其实渲染出来的就是一个标签,对标签的封装。

<Link to="/about">About</Link>

to: string

链接位置的字符串表示形式,是通过将location的pathname,search和hash属性连接起来而创建的。

<Link to="/courses?sort=name" />

to: object

可以具有以下任何属性的对象:

  • pathname: 表示要链接到的路径的字符串。
  • search: query参数的字符串表示形式。
  • hash: 网址中的hash值,例如#a-hash。
  • state: 状态保留到该属性中,这个属性设置的内容会被传递到location.state
<Link
  to={{
    pathname: "/courses",
    search: "?sort=name",
    hash: "#the-hash",
    state: { fromDashboard: true }
  }}
/>

to: function

将当前位置作为参数传递给它的函数,该函数应该以字符串或对象的形式返回位置信息

<Link to={location => ({ ...location, pathname: "/courses" })} />
<Link to={location => `${location.pathname}?sort=name`} />

replace: bool

如果为true,则将单击链接替换为history记录堆栈中的当前条目,而不是添加一条新条目。
这样就没有回退功能了,因为它是把当前URL地址替换掉,不会产生历史记录。

<Link to="/courses" replace />

innerRef: function

从React Router 5.1开始,如果您使用的是React16,则不需要此props,因为我们会将ref转发到基础。允许访问组件的基础引用。

<Link
  to="/"
  innerRef={node => {
    // “node”指的是被挂载的DOM元素
    // 组件被卸载时为null
  }}
/>

innerRef: RefObject

从React Router 5.1开始,如果您使用的是React16,则不需要此props,因为我们会将ref转发到基础使用React.createRef获取组件的基础引用。

let anchorRef = React.createRef()
<Link to="/" innerRef={anchorRef} />

其他

您还可以传递想要在上显示的props,例如title,id,className等。

<NavLink>

<Link>的特殊版本,当它与当前URL匹配时,它将为渲染的元素添加样式属性。

<NavLink to="/about">About</NavLink>

activeClassName: string

当元素处于active时给该元素设置的class,默认给定的class是active的,这将与className属性连接在一起。

<NavLink to="/faq" activeClassName="selected">
  FAQs
</NavLink>

activeStyle: object

元素处于active状态时应用于该元素的样式。

<NavLink
  to="/faq"
  activeStyle={{
    fontWeight: "bold",
    color: "red"
  }}
>
  FAQs
</NavLink>

exact: bool

如果为true,则仅在locatiuon完全匹配时才应用active的class或style。

<NavLink exact to="/profile">
  Profile
</NavLink>

strict: bool

如果为true,则在确定位置是否与当前URL匹配时,将会考虑位置路径名上的斜杠,它需要和<Route>配合使用。有关更多信息,请参见<Route strict>文档。

// 严格模式,无法匹配,URL必须要一模一样才能匹配上
<NavLink strict to="/events">
  Events
</NavLink>
<Switch>
    <Route path={'/events/'} strict children={<Events />} />
</Switch>

isActive: func

一种添加额外逻辑以确定链接是否处于active状态的功能。如果您要做的事情不仅仅是验证链接的路径名是否与当前URL的路径名匹配,则可以使用此选项。

<NavLink
  to="/events/123"
  isActive={(match, location) => {
    if (!match) {
      return false;
    }

    // 仅当事件id为奇数时元素才为active状态
    const eventID = parseInt(match.params.eventID);
    return !isNaN(eventID) && eventID % 2 === 1;
  }}
>
  Event 123
</NavLink>

location: object

isActive比较当前历史记录位置(通常是当前浏览器URL)。如果要与其他location进行比较,可以传递一个位置。

aria-current: string

在active链接上使用的aria-current属性的值。可用值为:

  • "page"- 用于指示一组分页链接中的链接
  • "step"- 用于指示基于步骤的过程的步骤指示器中的链接
  • "location"- 用于指示视觉上突出显示的图像作为流程图的当前组成部分
  • "date"- 用于指示日历中的当前日期
  • "time"- 用于指示时间表中的当前时间
  • "true"- 用于指示NavLink是否处于活动状态

默认值为 "page"
基于WAI-ARIA 1.1规范

import React from 'react';
import ReactDOM from 'react-dom';
import { HashRouter, Switch, Route, NavLink } from 'react-router-dom';

function Home() {
    return <h1>Home</h1>
}

function About() {
    return <h1>About</h1>
}

const Charts = () => <h1>Charts</h1>;
const Table = () => <h1>Table</h1>;
const FAQ = () => <h1>FAQ</h1>;
const Events = () => <h1>Events</h1>;

function App() {
    return <div className={'app'}>
        <HashRouter hashType={'noslash'}>
            <ul>
                <li>
                    <NavLink to={'/home'} className={'home'}>Home</NavLink>
                </li>
                <li>
                    <NavLink to={'/about'} className={'about'}>About</NavLink>
                </li>
                <li>
                    <NavLink to={'/charts'} className={'charts'} activeClassName={'selected'}>Charts</NavLink>
                </li>
                <li>
                    <NavLink to={'/table'} className={'table'} activeClassName={'selected'}>Table</NavLink>
                </li>
                <li>
                    <NavLink to={'/faq'} activeStyle={{ fontWeight: 'bold', color: 'red' }}>FAQ</NavLink>
                </li>
                <li>
                    <NavLink strict to="/events">Events</NavLink>
                </li>
            </ul>
            <Switch>
                <Route path={'/home'} children={<Home/>} />
                <Route path={'/about'} children={<About/>} />
                <Route path={'/charts'} children={<Charts/>} />
                <Route path={'/table'} children={<Table/>} />
                <Route path={'/faq'} children={<FAQ />} />
                <Route path={'/events/'} strict children={<Events />} />
            </Switch>
        </HashRouter>
    </div>
}

ReactDOM.render(<App />, document.querySelector('#root'))

<Prompt>

用于在离开页面之前提示用户。当您的应用程序进入应阻止用户导航的状态时(例如,表单已被半填满),请渲染<Prompt>。

<Prompt
  when={formIsHalfFilledOut}
  message="您确定要离开吗?"
/>

message: string

当用户尝试离开时提示用户的消息。

<Prompt message="Are you sure you want to leave?" />

message: func

将与用户尝试导航到的下一个位置和操作一起调用。返回一个字符串以向用户显示提示,或者返回true以允许过渡。

<Prompt
  message={location =>
    location.pathname.startsWith("/app")
      ? true
      : `Are you sure you want to go to ${location.pathname}?`
  }
/>

when: bool

您可以始终渲染它,而可以通过when={true}或when={false}来阻止或允许进行相应的导航,而不是通过条件控制是否渲染<Prompt>。

<Prompt when={formIsHalfFilledOut} message="Are you sure?" />
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Switch, Route, Link, Prompt } from 'react-router-dom';

function Home() {
    return <h1>Home</h1>
}

function Table() {
    return <h1>Table</h1>;
}

function Charts() {
    return <h1>Charts</h1>
}

function About() {
    return <h1>About</h1>
}

function App() {
    return <BrowserRouter>
        <Prompt message={location => {
            if (location.pathname !== '/home') {
                return `您确定要前往${location.pathname}吗?`
            } else {
                return true;
            }
            return true;
        }} when={true} />
        <ul>
            <li>
                <Link to={'/home'}>Home</Link>
            </li>
            <li>
                <Link to={'/table'}>Table</Link>
            </li>
            <li>
                <Link to={'/charts'}>Charts</Link>
            </li>
            <li>
                <Link to={'/about'}>About</Link>
            </li>
        </ul>
        <Switch>
            <Route path={'/home'} children={props => <Home {...props} />} />
            <Route path={'/table'} render={props => <Table {...props} />} />
            <Route path={'/charts'} children={props => <Charts {...props} />} />
            <Route path={'/about'} render={props => <About {...props} />} />
        </Switch>
    </BrowserRouter>;
}

ReactDOM.render(<App />, document.querySelector('#root'));

<Redirect>

渲染<Redirect>将导航到新位置。新位置将覆盖历史记录堆栈中的当前位置,就像服务器端重定向(HTTP 3xx)一样。

<Route exact path="/">
  {loggedIn ? <Redirect to="/dashboard" /> : <PublicHomePage />}
</Route>

to: string

重定向到的URL。path-to-regexp@^1.7.0可以理解的任何有效URL路径。to中使用的所有URL参数必须由from覆盖。

<Redirect to="/somewhere/else" />

to: object

重定向到的位置。路径名可以是path-to-regexp@^1.7.0可以理解的任何有效URL路径。

<Redirect
  to={{
    pathname: "/login",
    search: "?utm=your+face",
    state: { referrer: currentLocation }
  }}
/>

可以通过重定向到组件中的this.props.location.state访问状态对象。然后,可以通过路径名"/login"指向的Login组件中的this.props.location.state.referrer访问此新的引用关键字(不是特殊名称)。

push: bool

<Redirect push to="/somewhere/else" />

设置为true时,重定向会将新条目推入历史记录,而不是替换当前条目。

from: string

要重定向的路径名。 path-to-regexp@^1.7.0可以理解的任何有效URL路径。所有匹配的URL参数都提供给模式中的to。必须包含用于to中的所有参数。to不使用的其他参数将被忽略。

<Switch>
  <Redirect from='/old-path' to='/new-path' />
  <Route path='/new-path'>
    <Place />
  </Route>
</Switch>

// 使用匹配的参数重定向
<Switch>
  <Redirect from='/users/:id' to='/users/profile/:id'/>
  <Route path='/users/profile/:id'>
    <Profile />
  </Route>
</Switch>

示例:

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Switch, Route, Redirect, Link, useParams } from 'react-router-dom';

function App() {
    return(
        <BrowserRouter>
            <ul>
                <li>
                    <Link to={'/home'}>Home</Link>
                </li>
                <li>
                    <Link to={'/charts/123123'}>Charts</Link>
                </li>
                <li>
                    <Link to={'/profile/111'}>Profile</Link>
                </li>
            </ul>
            <Switch>
                <Route path={'/home'} render={props => <Home {...props} />} />
                <Route path={'/profile/:id'} render={props => <Profile {...props} />} />
                <Redirect from={'/charts/:id'} to={'/profile/:id'} />
            </Switch>
        </BrowserRouter>
    )
}
function Home() {
    return <h1>Home</h1>
}
function Profile() {
    const params = useParams();
    console.log('params=>', params);
    return <>
        <h1>Profile</h1>
    </>
}
ReactDOM.render(<App />, document.querySelector('#root'));

exact: bool

完全匹配;等同于Route.exact
注意:只有在<Switch>内渲染<Redirect>时,才能与from结合使用,以完全匹配位置。有关更多详细信息,请参见<Switch children>

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Switch, Route, Link, Redirect } from 'react-router-dom';

const Home = () => <h1>Home</h1>;
const About = () => <h1>About</h1>;
const App = () => <BrowserRouter>
    <ul>
        <li>
            <Link to={'/home'}>Home</Link>
        </li>
        <li>
            <Link to={'/about'}>About</Link>
        </li>
    </ul>
    <Switch>
        <Route path={'/home'} render={props => <Home {...props} />} />
        <Route path={'/about'} children={props => <About {...props} />} />
        {/*这个一定要放到Route后面,等Route渲染完了,才可以重定向*/}
        <Redirect exact from={'/'} to={'/home'} />
    </Switch>
</BrowserRouter>;

ReactDOM.render(<App />, document.querySelector('#root'));

strict: bool

严格匹配;等同于Route.strict
注意:只有在<Switch>内部渲染<Redirect>时,此选项只有与from一起使用才能以严格匹配位置。有关更多详细信息,请参见<Switch children>

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Switch, Route, Link, Redirect } from 'react-router-dom';

const Home = () => <h1>Home</h1>;
const About = () => <h1>About</h1>;
const App = () => <BrowserRouter>
    <ul>
        <li>
            <Link to={'/home'}>Home</Link>
        </li>
        <li>
            <Link to={'/about'}>About</Link>
        </li>
        <li>
            <Link to={'/one'}>One</Link>
        </li>
    </ul>
    <Switch>
        <Route path={'/home'} render={props => <Home {...props} />} />
        <Route path={'/about'} children={props => <About {...props} />} />
        {/*这个一定要放到Route后面,等Route渲染完了,才可以重定向*/}
        <Redirect strict from="/one/" to="/home" />
    </Switch>
</BrowserRouter>;

ReactDOM.render(<App />, document.querySelector('#root'));

sensitive: bool

区分大小写匹配;等同于Route.sensitive

<Route sensitive path="/one">
  <About />
</Route>
pathlocation.pathnamesensitive是否匹配
/one/onetrueyes
/One/onetrueno
/One/onefalseyes

<Router>

Router组件可能是React Router中了解和学习使用的最重要组件。它的最基本职责是在其路径与当前URL匹配时显示一些UI。
研究以下代码:

import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter as Router, Route } from "react-router-dom";

ReactDOM.render(
  <Router>
    <div>
      <Route exact path="/">
        <Home />
      </Route>
      <Route path="/news">
        <NewsFeed />
      </Route>
    </div>
  </Router>,
  node
);

如果应用程序的location是/,则UI层次结构将类似于:

<div>
  <Home />
  <!-- react-empty: 2 -->
</div>

如果应用程序的location是/news,则UI层次结构将是:

<div>
  <!-- react-empty: 1 -->
  <NewsFeed />
</div>

"react-empty"注释只是React空渲染的实现细节。但是出于我们的目的,这是有益的。从技术上讲,即使始终为空,也总是对其进行"渲染"。当<Route>的路径与当前URL匹配时,它将渲染其子级(您的组件)。

Route render methods

使用<Route>渲染某些内容的方法建议使用子元素,如上所示。 但是,还有一些其他方法可用于使用<Route>渲染内容。 提供这些主要是为了支持在引入钩子之前使用早期版本的路由器构建的应用程序。

您应该在给定的<Route>上仅使用这些props。 请参阅下面的说明以了解它们之间的区别。

Route props

所有这三种渲染方法将通过相同的三个路由props

component

一个仅在location匹配时才渲染的React组件。 它将与route props一起渲染。

import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter as Router, Route } from "react-router-dom";

// 用户可以使用所有route props(match, location and history)
function User(props) {
  return <h1>Hello {props.match.params.username}!</h1>;
}

ReactDOM.render(
  <Router>
    <Route path="/user/:username" component={User} />
  </Router>,
  node
);

当您使用组件(而不是下面的渲染器或子组件)时,路由器会使用React.createElement从给定的组件中创建一个新的React元素。这意味着,如果您向组件prop提供内联函数,则将在每个渲染中创建一个新组件。这意味着,如果您向组件prop提供内联函数,则将在每个渲染中创建一个新组件。这将导致现有组件的卸载和新组件的安装,而不仅仅是更新现有组件。使用内联函数进行内联渲染时,请使用render或children属性(如下)。

render: func

这样可以方便地进行内联渲染和包装,而无需进行上述不必要的重新安装。
无需使用组件prop为您创建新的React元素,而是可以传递位置匹配时要调用的函数。render函数可以访问与组件渲染相同的所有route属性(match,location和history)。

import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter as Router, Route } from "react-router-dom";

// 方便的内联渲染
ReactDOM.render(
  <Router>
    <Route path="/home" render={props => <div>Home</div>} />
  </Router>,
  node
);
import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter as Router, Route } from "react-router-dom";
// wrapping/composing
// 您可以传播 route 属性 以使它们可用于渲染的组件
function FadingRoute({ component: Component, ...rest }) {
  return (
    <Route
      {...rest}
      render={routeProps => (
        <FadeIn>
          <Component {...routeProps} />
        </FadeIn>
      )}
    />
  );
}

ReactDOM.render(
  <Router>
    <FadingRoute path="/cool" component={Something} />
  </Router>,
  node
);

警告:<Route component>优先于<Route render>,因此请勿在同一<Route>中同时使用两者。

children: func

有时您需要渲染路径是否与位置匹配。 在这种情况下,您可以使用child道具功能。 它与render完全一样,除了是否存在匹配项而被调用。
子级渲染属性接收组件渲染函数相同的所有路由属性,除非路由未能与URL匹配,则match为null。 这样您可以根据路由是否匹配来动态调整UI。 如果路由匹配,我们在这里添加一个active class。

import React from "react";
import ReactDOM from "react-dom";
import {
  BrowserRouter as Router,
  Link,
  Route
} from "react-router-dom";

function ListItemLink({ to, ...rest }) {
  return (
    <Route
      path={to}
      children={({ match }) => (
        <li className={match ? "active" : ""}>
          <Link to={to} {...rest} />
        </li>
      )}
    />
  );
}

ReactDOM.render(
  <Router>
    <ul>
      <ListItemLink to="/somewhere" />
      <ListItemLink to="/somewhere-else" />
    </ul>
  </Router>,
  node
);

这对于动画也可能有用:

<Route
  children={({ match, ...rest }) => (
    {/* Animate将始终进行渲染,因此您可以使用生命周期来对其子对象进行动画制作 */}
    <Animate>
      {match && <Something {...rest}/>}
    </Animate>
  )}
/>

警告:<Route children>优先于<Route component>和<Route render>,因此请不要在同一<Route>中使用多个。

path: string | string[]

如果为true,则仅在路径与location.pathname完全匹配时才匹配。

<Route exact path="/one">
  <About />
</Route>
pathlocation.pathnameexact是否匹配
/one/one/twotrueno
/one/one/twofalseyes

strict: bool

设置为true时,带有斜杠的路径将只匹配带有斜杠的location.pathname。当location.pathname中有其他URL段时,这无效。

<Route strict path="/one/">
  <About />
</Route>
path    
pathlocation.pathname是否匹配
/one//oneno
/one//one/yes
/one//one/twoyes

警告:strict可以用于强制location.pathname不带斜杠,但是要做到这一点,strict和exact都必须为true。

<Route exact strict path="/one">
  <About />
</Route>
pathlocation.pathname是否匹配
/one/oneyes
/one/one/no
/one/one/twono

location: object

<Route>元素尝试将其路径与当前历史记录位置(通常是当前浏览器URL)匹配。但是,也可以传递路径名不同的位置进行匹配。

在需要将<Route>匹配到当前历史记录位置以外的位置时,这很有用,如Animated Transitions示例所示。

如果<Route>元素包装在<Switch>中并且与传递给<Switch>的位置(或当前历史记录位置)匹配,则传递给<Route>位置的prop将被<Switch>使用的那个props覆盖(此处给出)。

sensitive: bool

为true时,如果路径区分大小写,则将匹配。

<Route sensitive path="/one">
  <About />
</Route>
pathlocation.pathnamesensitive是否匹配
/one/onetrueyes
/One/onetrueno
/One/onefalseyes

<Router>

所有路由器组件的通用底层接口。通常,应用将使用高级路由器之一代替:

使用底层<Router>的最常见用例是将自定义历史记录与状态管理库(如Redux或Mobx)进行同步。 请注意,并不需要将状态管理库与React Router一起使用,它仅用于深度集成。

import React from "react";
import ReactDOM from "react-dom";
import { Router } from "react-router";
import { createBrowserHistory } from "history";

const history = createBrowserHistory();

ReactDOM.render(
  <Router history={history}>
    <App />
  </Router>,
  node
);

history: object

用于导航的history对象

import React from "react";
import ReactDOM from "react-dom";
import { createBrowserHistory } from "history";

const customHistory = createBrowserHistory();

ReactDOM.render(<Router history={customHistory} />, node);

children: node

要渲染的子元素。

<Router>
  <App />
</Router>

<StaticRouter>

永远不会更改位置的<Router>。

当用户实际上没有四处点击时,这在服务器端渲染方案中很有用,因此位置永远不会发生实际变化。 因此,名称为:static。它在简单测试中也很有用,您只需要插入一个位置并在渲染输出中进行断言时。

示例:这是一个node服务器,它为<Redirect>发送302状态代码,并为其他请求发送常规HTML:

requests:import http from "http";
import React from "react";
import ReactDOMServer from "react-dom/server";
import { StaticRouter } from "react-router";

http
  .createServer((req, res) => {
    // This context object contains the results of the render
    const context = {};

    const html = ReactDOMServer.renderToString(
      <StaticRouter location={req.url} context={context}>
        <App />
      </StaticRouter>
    );

    // context.url will contain the URL to redirect to if a <Redirect> was used
    if (context.url) {
      res.writeHead(302, {
        Location: context.url
      });
      res.end();
    } else {
      res.write(html);
      res.end();
    }
  })
  .listen(3000);

basename: string

所有位置的基本URL。格式正确的基本名称应以斜杠开头,但不能以斜杠结尾。

<StaticRouter basename="/calendar">
  <Link to="/today"/> // renders <a href="/calendar/today">
</StaticRouter>

location: string

服务器收到的URL,可能是node服务器上的req.url.

<StaticRouter location={req.url}>
  <App />
</StaticRouter>

location: object

形状为{ pathname, search, hash, state }的location对象

<StaticRouter location={{ pathname: "/bubblegum" }}>
  <App />
</StaticRouter>

context: object

一个普通的JavaScript对象。在渲染期间,组件可以向对象添加属性以存储有关渲染的信息

const context = {}
<StaticRouter context={context}>
  <App />
</StaticRouter>

当<Route>匹配时,它将把上下文对象传递给它作为staticContext属性呈现的组件。请查看服务器渲染指南,以获取有关如何自行执行此操作的更多信息。

渲染后,这些属性可用于配置服务器的响应。

if (context.status === "404") {
  // ...
}

children: node

要渲染的子元素。

注意:在React <16上,您必须使用单个子元素,因为render方法不能返回多个元素。如果需要多个元素,则可以尝试将它们包装在额外的<div>

<Switch>

渲染与位置匹配的第一个子元素<Route>或<Redirect>。
这与仅使用一堆<Route>有什么不同?
<Switch>的独特之处在于它专门渲染一条路由。相反,每个与该位置匹配的<Route>都将进行包含性渲染。研究以下route:

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Route } from 'react-router-dom';

function SwitchExample() {
    return <BrowserRouter>
        <Route path="/about">
            <h1>About</h1>
        </Route>
        <Route path="/:user">
            <h1>User</h1>
        </Route>
        <Route>
            <h1>NoMatch</h1>
        </Route>
    </BrowserRouter>;
}

ReactDOM.render(<SwitchExample />, document.querySelector('#root'));

如果URL是/about,则渲染<About>,<User>和<NoMatch>将全部渲染,因为它们都与所有路径都匹配。这是设计使然,允许我们以多种方式将<Route>组合到我们的应用中,例如边栏和面包屑,引导程序标签等。

但是,有时我们只选择一个<Route>进行渲染。如果我们位于/about,我们不想同时匹配/:user(或显示"404"页面)。使用Switch的方法如下:

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Switch, Route } from 'react-router-dom';

const Home = () => <h1>Home</h1>;
const About = () => <h1>About</h1>;
const User = () => <h1>User</h1>;
const NoMatch = () => <h1>NoMatch</h1>;

function SwitchExample() {
    return <BrowserRouter>
        <Switch>
            <Route exact path='/'>
                <Home />
            </Route>
            <Route path='/about'>
                <About />
            </Route>
            <Route path='/:user'>
                <User />
            </Route>
            <Route>
                <NoMatch />
            </Route>
        </Switch>
    </BrowserRouter>
}

ReactDOM.render(<SwitchExample />, document.querySelector('#root'));

现在,如果我们位于/about,<Switch>将开始寻找匹配的<Route>。<Route path ="/about" />将匹配,而<Switch>将停止寻找匹配并渲染<About>。同样,如果我们在/michael位置,则会显示<User>。

这对于动画过渡也很有用,因为匹配的<Route>呈现在与上一个相同的位置。

let routes = (
  <Fade>
    <Switch>
      {/* 这里只有一个子元素 */}
      <Route />
      <Route />
    </Switch>
  </Fade>
);

let routes = (
  <Fade>
    {/* 这里永远有两个子元素,但是可能会呈现null,进行转换,计算起来有点麻烦 */}
    <Route />
    <Route />
  </Fade>
);

location: object

用于匹配子元素的位置对象,而不是当前历史记录位置(通常是当前浏览器URL)。

children: node

<Switch>的所有子代应为<Route>或<Redirect>元素。仅第一个与当前位置匹配的子元素会被渲染。
<Route>元素使用其path属性进行匹配,而<Redirect>元素使用其from属性进行匹配。没有path属性的<Route>或没有from属性的<Redirect>将始终与当前位置匹配。
在<Switch>中包含<Redirect>时,它可以使用<Route>的任何位置匹配属性:path,exact和strict。 from只是path属性的别名。
如果给<Switch>一个location属性,它将覆盖匹配的子元素上的location属性。

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Route, Switch, Redirect } from 'react-router-dom';

const Home = () => <h1>Home</h1>;
const User = () => <h1>User</h1>;
const NoMatch = () => <h1>NoMatch</h1>;

function SwitchExample() {
    return <BrowserRouter>
        <Switch>
            <Route exact path="/">
                <Home />
            </Route>
            <Route path="/user">
                <User />
            </Route>
            <Redirect from="/account" to="/user" />
            <Route>
                <NoMatch /> 
            </Route>
        </Switch>
    </BrowserRouter>;
}

ReactDOM.render(<SwitchExample />, document.querySelector('#root'));

history

本文档中的"history"和"history对象"一词是指history包,它是React Router仅有的两个主要依赖项之一(除了React本身)并且提供了几种不同的实现,用于在各种环境中管理JavaScript中的会话历史记录。

也使用以下术语:

  • "browser history" - 特定于DOM的实现,在支持HTML5历史记录API的Web浏览器中很有用.
  • "hash history" - 遗留Web浏览器的DOM特定实现.
  • "memory history" - 内存历史记录实现,可用于测试和像React Native这样的非DOM环境.

history对象通常具有以下属性和方法:

  • length -(number)历史记录堆栈中的条目数
  • action - (string)当前操作(PUSH,REPLACE或POP)
  • location - (object)当前位置。可能具有以下属性:

    • pathname - (string)URL的路径
    • search - (string)URL查询字符串
    • hash - (string)URL哈希片段
    • state - (object)提供给例如当此位置被压入堆栈时,push(path,state)。仅在browser和memory history中可用。
  • push(path, [state]) - (function)将新条目推入历史记录堆栈
  • replace(path, [state]) - (function)替换历史记录堆栈上的当前条目
  • go(n) - (function)将历史记录堆栈中的指针移动n个条目
  • goBack() - (function)相当于go(-1)
  • goForward() - (function)相当于go(1)
  • block(prompt) - (function)防止导航(请参阅history文档

history是可变的

history对象是可变的,因此,建议从<Route>的渲染属性中访问location,而不是从history.location中访问。这确保了您对React的假设在生命周期钩子中是正确的。例如:

class Comp extends React.Component {
  componentDidUpdate(prevProps) {
    // 将为 true
    const locationChanged =
      this.props.location !== prevProps.location;

    // 不正确,由于history是可变的,因此*总是*为假。
    const locationChanged =
      this.props.history.location !== prevProps.history.location;
  }
}

<Route component={Comp} />;

根据您所使用的实现方式,可能还会显示其他属性。请参阅history文档以获取更多详细信息。

location

location表示该应用程序现在的位置,您希望其运行的位置,甚至是以前的位置。看起来像这样:

{
  key: 'ac3df4', // not with HashHistory!
  pathname: '/somewhere',
  search: '?some=search-string',
  hash: '#howdy',
  state: {
    [userDefined]: true
  }
}

router将在几个地方为您提供location对象:

也可以在history.location上找到它,但是您不应使用它,因为它是可变的。您可以在history文档
中阅读有关此内容的更多信息.

location对象永远不会发生变化,因此您可以在生命周期钩子中使用它来确定何时进行导航,这对于数据获取和动画处理非常有用。

componentWillReceiveProps(nextProps) {
  if (nextProps.location !== this.props.location) {
    // navigated!
  }
}

你可以提供位置,而不是字符串导航到不同的地方:

通常您只需要使用一个字符串,但是,如果您需要添加一些“位置状态”,只要应用返回到该特定位置即可使用,则可以使用location对象代替。如果您要基于导航历史而不是仅基于路径(如 modals)来分支UI,这将非常有用。

// 通常你所需要的
<Link to="/somewhere"/>

// 但是你可以使用location来代替
const location = {
  pathname: '/somewhere',
  state: { fromDashboard: true }
}

<Link to={location}/>
<Redirect to={location}/>
history.push(location)
history.replace(location)

最后,您可以将location传递给以下组件:

这样可以防止他们在路由器状态下使用实际位置。这对于动画和待处理的导航很有用,或者在您想要诱使组件在与真实位置不同的位置进行渲染时,这很有用。

match

match对象包含有关<Route path>如何与URL匹配的信息。匹配对象包含以下属性:

  • params- (object)从与路径的动态段相对应的URL解析的键/值对
  • isExact- (boolean)如果整个网址都匹配,则为“ true”(不包含结尾字符)
  • path- (string) 用于匹配的路径模式。对于构建嵌套的<Route>有用
  • url- (string) URL的匹配部分。对于构建嵌套的<Link>有用

您将可以在各个地方使用match对象:

如果Route没有path,因此会始终匹配,它将获取最接近的父项匹配项。和withRouter一样。

null matches

即使子路径的路径与当前位置不匹配,使用子项道具的<Route>也会调用其子函数。 在这种情况下,匹配将为空。 能够在匹配时呈现<Route>的内容可能会很有用,但是这种情况会带来一些挑战。

"解析"URL的默认方法是将match.url字符串连接到"相对"路径。

let path = `${match.url}/relative-path`;

如果在匹配为null时尝试执行此操作,则最终将出现TypeError。这意味着在使用children prop时尝试在<Route>内部加入"relative"路径是不安全的。

当在生成空匹配对象的<Route>中使用无路径<Route>时,会发生类似但更微妙的情况。

// location.pathname = '/matches'
<Route path="/does-not-match"
  children={({ match }) => (
    // match === null
    <Route
      render={({ match: pathlessMatch }) => (
        // pathlessMatch === ???
      )}
    />
  )}
/>

无路径<Route>从其父级继承其match对象。如果其父匹配项为null,则其匹配项也将为null。这意味着任何子级路由/链接都必须是绝对的,因为没有父级可以解析,并且父级匹配可以为null的无路径路由将需要使用子级prop进行渲染。

matchPath

这允许您使用与<Route>相同的匹配代码(除了正常的渲染周期之外),比如在服务器上呈现之前收集数据依赖关系。

import { matchPath } from "react-router";

const match = matchPath("/users/123", {
  path: "/users/:id",
  exact: true,
  strict: false
});

pathname

第一个参数是您要匹配的路径名。如果您是在Node.js的服务器上使用它,则为req.path。

props

第二个参数是要匹配的props,它们与Route接受的匹配props相同。它也可以是字符串或字符串数​​组,作为{path}的快捷方式:

{
  path, // 像/users/:id;单个字符串或字符串数​​组
  strict, // 可选,默认为false
  exact, // 可选,默认为false
}

returns

当提供的路径名与路径属性匹配时,它将返回一个对象。

matchPath("/users/2", {
  path: "/users/:id",
  exact: true,
  strict: true
});

//  {
//    isExact: true
//    params: {
//        id: "2"
//    }
//    path: "/users/:id"
//    url: "/users/2"
//  }

如果提供的路径名与路径属性不匹配,则返回null。

matchPath("/users", {
  path: "/users/:id",
  exact: true,
  strict: true
});

//  null

withRouter

您可以通过withRouter高阶组件访问history对象的属性和最接近的<Route>匹配项。每当渲染时,withRouter都会将更新的match,location和history属性传递给包装的组件。

import React from "react";
import PropTypes from "prop-types";
import { withRouter } from "react-router";

// 一个简单的组件,显示当前位置的路径名
class ShowTheLocation extends React.Component {
  static propTypes = {
    match: PropTypes.object.isRequired,
    location: PropTypes.object.isRequired,
    history: PropTypes.object.isRequired
  };

  render() {
    const { match, location, history } = this.props;

    return <div>You are now at {location.pathname}</div>;
  }
}

// 创建一个“连接”到路由器的新组件(借用redux术语)。
const ShowTheLocationWithRouter = withRouter(ShowTheLocation);

重要的提示:
withRouter不像React Redux的connect那样订阅位置更改以进行状态更改。而是在位置更改后从<Router>组件传播出去后重新渲染。这意味着withRouter不会在路由转换时重新渲染,除非其父组件重新渲染。
静态方法和属性
包装组件的所有非特定于反应的静态方法和属性将自动复制到“connected”组件。

Component.WrappedComponent

包装的组件在返回的组件上作为静态属性WrappedComponent公开,它可以用于隔离测试组件等。

// MyComponent.js
export default withRouter(MyComponent)

// MyComponent.test.js
import MyComponent from './MyComponent'
render(<MyComponent.WrappedComponent location={{...}} ... />)

wrappedComponentRef: func

该函数将作为ref prop传递给包装的组件。

class Container extends React.Component {
  componentDidMount() {
    this.component.doSomething();
  }

  render() {
    return (
      <MyComponent wrappedComponentRef={c => (this.component = c)} />
    );
  }
}
查看原文

赞 34 收藏 23 评论 0

52lidan 收藏了文章 · 2019-10-23

Hybrid APP架构设计思路

关于Hybrid模式开发app的好处,网络上已有很多文章阐述了,这里不展开。

本文将从以下几个方面阐述Hybrid app架构设计的一些经验和思考。

原文及讨论请到 github issue

通讯

作为一种跨语言开发模式,通讯层是Hybrid架构首先应该考虑和设计的,往后所有的逻辑都是基于通讯层展开。

Native(以Android为例)和H5通讯,基本原理:

  • Android调用H5:通过webview类的loadUrl方法可以直接执行js代码,类似浏览器地址栏输入一段js一样的效果

    webview.loadUrl("javascript: alert('hello world')");
  • H5调用Android:webview可以拦截H5发起的任意url请求,webview通过约定的规则对拦截到的url进行处理(消费),即可实现H5调用Android

    var ifm = document.createElement('iframe');
    ifm.src = 'jsbridge://namespace.method?[...args]';

JSBridge即我们通常说的桥协议,基本的通讯原理很简单,接下来就是桥协议具体实现。

P.S:注册私有协议的做法很常见,我们经常遇到的在网页里拉起一个系统app就是采用私有协议实现的。app在安装完成之后会注册私有协议到OS,浏览器发现自身不能识别的协议(http、https、file等)时,会将链接抛给OS,OS会寻找可识别此协议的app并用该app处理链接。比如在网页里以itunes://开头的链接是Apple Store的私有协议,点击后可以启动Apple Store并且跳转到相应的界面。国内软件开发商也经常这么做,比如支付宝的私有协议alipay://,腾讯的tencent://等等。

桥协议的具体实现

由于JavaScript语言自身的特殊性(单进程),为了不阻塞主进程并且保证H5调用的有序性,与Native通讯时对于需要获取结果的接口(GET类),采用类似于JSONP的设计理念:

hybrid jsbridge1

类比HTTP的request和response对象,调用方会将调用的api、参数、以及请求签名(由调用方生成)带上传给被调用方,被调用方处理完之后会吧结果以及请求签名回传调用方,调用方再根据请求签名找到本次请求对应的回调函数并执行,至此完成了一次通讯闭环。

H5调用Native(以Android为例)示意图:

hybrid jsbridge2

Native(以Android为例)调用H5示意图:

hybrid jsbridge3

基于桥协议的api设计(HybridApi)

jsbridge作为一种通用私有协议,一般会在团队级或者公司级产品进行共享,所以需要和业务层进行解耦,将jsbridge的内部细节进行封装,对外暴露平台级的API。

以下是笔者剥离公司业务代码后抽象出的一份HybridApi js部分的实现,项目地址:

hybrid-js

另外,对于Native提供的各种接口,也可以简单封装下,使之更贴近前端工程师的使用习惯:

// /lib/jsbridge/core.js
function assignAPI(name, callback) {
    var names = name.split(/\./);
    var ns = names.shift();

    var fnName = names.pop();
    var root = createNamespace(JSBridge[ns], names);

    if(fnName) root[fnName] = callback || function() {};
}

增加api:

// /lib/jsbridge/api.js
var assign = require('./core.js').assignAPI;
...
assign('util.compassImage', function(path, callback, quality, width, height) {
    JSBridge.invokeApp('os.getInfo', {
        path: path,
        quality: quality || 80,
        width: width || 'auto',
        height: height || 'auto',
        callback: callback
    });
});

H5上层应用调用:

// h5/music/index.js
JSBridge.util.compassImage('http://cdn.foo.com/images/bar.png', function(r) {
    console.log(r.value); // => base64 data
});

界面与交互(Native与H5职责划分)

本质上,Native和H5都能完成界面开发。几乎所有hybrid的开发模式都会碰到同样的一个问题:哪些由Native负责哪些由H5负责?

这个回到原始的问题上来:我们为什么要采用hybrid模式开发?简而言之就是同时利用H5的跨平台、快速迭代能力以及Native的流畅性、系统API调用能力。

根据这个原则,为了充分利用二者的优势,应该尽可能地将app内容使用H5来呈现,而对于js语言本身的缺陷,应该使用Native语言来弥补,如转场动画、多线程作业(密集型任务)、IO性能等。即总的原则是H5提供内容,Native提供容器,在有可能的条件下对Android原生webview进行优化和改造(参考阿里Hybrid容器的JSM),提升H5的渲染效率。

但是,在实际的项目中,将整个app所有界面都使用H5来开发也有不妥之处,根据经验,以下情形还是使用Native界面为好:

关键界面、交互性强的的界面使用Native

因H5比较容易被恶意攻击,对于安全性要求比较高的界面,如注册界面、登陆、支付等界面,会采用Native来取代H5开发,保证数据的安全性,这些页面通常UI变更的频率也不高。

对于这些界面,降级的方案也有,就是HTTPS。但是想说的是在国内的若网络环境下,HTTPS的体验实在是不咋地(主要是慢),而且只能走现网不能走离线通道。

另外,H5本身的动画开发成本比较高,在低端机器上可能有些绕不过的性能坎,原生js对于手势的支持也比较弱,因此对于这些类型的界面,可以选择使用Native来实现,这也是Native本身的优势不是。比如要实现下面这个音乐播放界面,用H5开发门槛不小吧,留意下中间的波浪线背景,手指左右滑动可以切换动画。

layout ui1

导航组件采用Native

导航组件,就是页面的头组件,左上角一般都是一个back键,中间一般都是界面的标题,右边的话有时是一个隐藏的悬浮菜单触发按钮有时则什么也没有。

移动端有一个特性就是界面下拉有个回弹效果,头不动body部分跟着滑动,这种效果H5比较难实现。

再者,也是最重要的一点,如果整个界面都是H5的,在H5加载过程中界面将是白屏,在弱网络下用户可能会很疑惑。

所以基于这两点,打开的界面都是Native的导航组件+webview来组成,这样即使H5加载失败或者太慢用户可以选择直接关闭。

在API层面,会相应的有一个接口来实现这一逻辑(例如叫JSBridge.layout.setHeader),下面代码演示定制一个只有back键和标题的导航组件:

// /h5/pages/index.js
JSBridge.layout.setHeader({
    background: {
        color: '#00FF00',
        opacity: 0.8
    },
    buttons: [
        // 默认只有back键,并且back键的默认点击处理函数就是back()
        {
            icon: '../images/back.png',
            width: 16,
            height: 16,
            onClick: function() {
                // todo...
                JSBridge.back();
            }
        },
        {
            text: '音乐首页',
            color: '#00FF00',
            fontSize: 14,
            left: 10
        }
    ]
});

上面的接口,可以满足绝大多数的需求,但是还有一些特殊的界面,通过H5代码控制生成导航组件这种方式达不到需求:

layout ui2

如上图所示,界面含有tab,且可以左右滑动切换,tab标题的下划线会跟着手势左右滑动。大多见于app的首页(mainActivity)或者分频道首页,这种界面一般采用定制webview的做法:定制的导航组件和内容框架(为了支持左右滑动手势),H5打开此类界面一般也是开特殊的API:

// /h5/pages/index.js
// 开打音乐频道下“我的音乐”tab
JSBridge.view.openMusic({'tab': 'personal'});

这种打开特殊的界面的API之所以特殊,是因为它内部要么是纯Native实现,要么是和某个约定的html文件绑定,调用时打开指定的html。假设这个例子中,tab内容是H5的,如果H5是SPA架构的那么openMusic({'tab': 'personal'})则对应/music.html#personal这个url,反之多页面的则可能对应/mucic-personal.html

至于一般的打开新界面,则有两种可能:

  • app内H5界面

    指的是由app开发者开发的H5页面,也即是app的功能界面,一般互相跳转需要转场动画,打开方式是采用Native提供的接口打开,例如:
    
    JSBridge.view.openUrl({
        url: '/music-list.html',
        title: '音乐列表'
    });
    再配合下面即将提到的离线访问方式,基本可以做到模拟Native界面的效果。
    
  • 第三方H5页面

    指的是app内嵌的第三方页面,一般由`a`标签直接打开,没有转场动画,但是要求打开webview默认的历史列表,以免打开多个链接后点回退直接回到Native主界面。
    

系统级UI组件采用Native

基于以下原因,一些通用的UI组件,如alert、toast等将采用Native来实现:

  • H5本身有这些组件,但是通常比较简陋,不能和APP UI风格统一,需要再定制,比如alert组件背景增加遮罩层

  • H5来实现这些组件有时会存在坐标、尺寸计算误差,比如笔者之前遇到的是页面load异常需要调用对话框组件提示,但是这时候页面高度为0,所以会出现弹窗“消失”的现象

  • 这些组件通常功能单一但是通用,适合做成公用组件整合到HybridApi里边

下面代码演示H5调用Native提供的UI组件:

JSBridge.ui.toast('Hello world!');

默认界面采用Native

由于H5是在H5容器里进行加载和渲染,所以Native很容易对H5页面的行为进行监控,包括进度条、loading动画、404监控、5xx监控、网络诊断等,并且在H5加载异常时提供默认界面供用户操作,防止APP“假死”。

下面是微信的5xx界面示意:

webview monitor

设计H5容器

Native除了负责部分界面开发和公共UI组件设计之外,作为H5的runtime,H5容器是hybrid架构的核心部分,为了让H5运行更快速稳定和健壮,还应当提供并但不局限于下面几方面。

H5离线访问

之所以选择hybrid方式来开发,其中一个原因就是要解决webapp访问慢的问题。即使我们的H5性能优化做的再好服务器在牛逼,碰到蜗牛一样的运营商网络你也没辙,有时候还会碰到流氓运营商再给webapp插点广告。。。哎说多了都是泪。

离线访问,顾名思义就是将H5预先放到用户手机,这样访问时就不会再走网络从而做到看起来和Native APP一样的快了。

但是离线机制绝不是把H5打包解压到手机sd卡这么简单粗暴,应该解决以下几个问题:

  1. H5应该有线上版本

    作为访问离线资源的降级方案,当本地资源不存在的时候应该走现网去拉取对应资源,保证H5可用。另外就是,对于H5,我们不会把所有页面都使用离线访问,例如活动页面,这类快速上线又快速下线的页面,设计离线访问方式开发周期比较高,也有可能是页面完全是动态的,不同的用户在不同的时间看到的页面不一样,没法落地成静态页面,还有一类就是一些说明类的静态页面,更新频率很小的,也没必要做成离线占用手机存储空间。
    
  2. 开发调试&抓包

    我们知道,基于file协议开发是完全基于开发机的,代码必须存放于物理机器,这意味着修改代码需要push到sd卡再看效果,虽然可以通过假链接访问开发机本地server发布时移除的方式,但是个人觉得还是太麻烦易出错。
    

为了实现同一资源的线上和离线访问,Native需要对H5的静态资源请求进行拦截判断,将静态资源“映射”到sd卡资源,即实现一个处理H5资源的本地路由,实现这一逻辑的模块暂且称之为Local Url Router,具体实现细节在文章后面。

H5离线动态更新机制

将H5资源放置到本地离线访问,最大的挑战就是本地资源的动态更新如何设计,这部分可以说是最复杂的了,因为这同时涉及到H5、Native和服务器三方,覆盖式离线更新示意图如下:

workflow

解释下上图,开发阶段H5代码可以通过手机设置HTTP代理方式直接访问开发机。完成开发之后,将H5代码推送到管理平台进行构建、打包,然后管理平台再通过事先设计好的长连接通道将H5新版本信息推送给客户端,客户端收到更新指令后开始下载新包、对包进行完整性校验、merge回本地对应的包,更新结束。

其中,管理平台推送给客户端的信息主要包括项目名(包名)、版本号、更新策略(增量or全量)、包CDN地址、MD5等。

通常来说,H5资源分为两种,经常更新的业务代码和不经常更新的框架、库代码和公用组件代码,为了实现离线资源的共享,在H5打包时可以采用分包的策略,将公用部分单独打包,在本地也是单独存放,分包及合并示意图:

multi package

Local Url Router

离线资源更新的问题解决了,剩下的就是如何使用离线资源了。

上面已经提到,对于H5的请求,线上和离线采用相同的url访问,这就需要H5容器对H5的资源请求进行拦截“映射”到本地,即Local Url Router

Local Url Router主要负责H5静态资源请求的分发(线上资源到sd卡资源的映射),但是不管是白名单还是过滤静态文件类型,Native拦截规则和映射规则将变得比较复杂。这里,阿里去啊app的思路就比较赞,我们借鉴一下,将映射规则交给H5去生成:H5开发完成之后会扫描H5项目然后生成一份线上资源和离线资源路径的映射表(souce-router.json),H5容器只需负责解析这个映射表即可。

H5资源包解压之后在本地的目录结构类似:

$ cd h5 && tree
.
├── js/
├── css/
├── img/
├── pages
│   ├── index.html
│   └── list.html
└── souce-router.json

souce-router.json的数据结构类似:

{
    "protocol": "http",
    "host": "o2o.xx.com",
    "localRoot": "[/storage/0/data/h5/o2o/]",
    "localFolder": "o2o.xx.com",
    "rules": {
        "/index.html": "pages/index.html",
        "/js/": "js/"
    }
}

H5容器拦截到静态资源请求时,如果本地有对应的文件则直接读取本地文件返回,否则发起HTTP请求获取线上资源,如果设计完整一点还可以考虑同时开启新线程去下载这个资源到本地,下次就走离线了。

下图演示资源在app内部的访问流程图:

url router

其中proxy指的是开发时手机设置代理http代理到开发机。

数据通道

  • 上报

由于界面由H5和Native共同完成,界面上的用户交互埋点数据最好由H5容器统一采集、上报,还有,由页面跳转产生的浏览轨迹(转化漏斗),也由H5容器记录和上报

  • ajax代理

因ajax受同源策略限制,可以在hybridApi层对ajax进行统一封装,同时兼容H5容器和浏览器runtime,采用更高效的通讯通道加速H5的数据传输

Native对H5的扩展

主要指扩展H5的硬件接口调用能力,比如屏幕旋转、摄像头、麦克风、位置服务等等,将Native的能力通过接口的形式提供给H5。

综述

最后来张图总结下,hybrid客户端整体架构图:

hybrid architecture

其中的Synchronize Service模块表示和服务器的长连接通信模块,用于接受服务器端各种推送,包括离线包等。Source Merge Service模块表示对解压后的H5资源进行更新,包括增加文件、以旧换新以及删除过期文件等。

可以看到,hybrid模式的app架构,最核心和最难的部分都是H5容器的设计。

查看原文

52lidan 收藏了文章 · 2019-09-26

Nginx 的 Location 从零开始配置

基础知识

  1. Nginx location 配置语法

        1. location [ = | ~ | ~* | ^~ ] uri { ... }
        2. location @name { ... }    
    1. location 配置可以有两种配置方法

      1.前缀 + uri(字符串/正则表达式)
      2.@ + name
      
    2. 前缀含义

          =  :精确匹配(必须全部相等)
          ~  :大小写敏感
          ~* :忽略大小写
          ^~ :只需匹配uri部分
          @  :内部服务跳转
  2. Location 基础知识

    1.location 是在 server 块中配置。
    2.可以根据不同的 URI 使用不同的配置(location 中配置),来处理不同的请求。
    3.location 是有顺序的,会被第一个匹配的location 处理。

Location 配置demo

1.=,精确匹配

        location = / {
            #规则
        }
        # 则匹配到 `http://www.example.com/` 这种请求。 

2.~,大小写敏感

        location ~ /Example/ {
                #规则
        }
        #请求示例
        #http://www.example.com/Example/  [成功]
        #http://www.example.com/example/  [失败]

3.~*,大小写忽略

    location ~* /Example/ {
                #规则
    }
    # 则会忽略 uri 部分的大小写
    #http://www.example.com/Example/  [成功]
    #http://www.example.com/example/  [成功]

4.^~,只匹配以 uri 开头

    location ^~ /img/ {
            #规则
    }
    #以 /img/ 开头的请求,都会匹配上
    #http://www.example.com/img/a.jpg   [成功]
    #http://www.example.com/img/b.mp4 [成功]

5.@,nginx内部跳转

    location /img/ {
        error_page 404 @img_err;
    }
    
    location @img_err {
        # 规则
    }
    #以 /img/ 开头的请求,如果链接的状态为 404。则会匹配到 @img_err 这条规则上。

总结

Nginx 中的 location 并没有想象中的很难懂,不必害怕。多找资料看看,多尝试。你就会有收获。

参考

  1. http://nginx.org/en/docs/http/ngx_http_core_module.html#location

  2. 统一资源标志符

查看原文

52lidan 赞了文章 · 2019-09-26

Nginx 的 Location 从零开始配置

基础知识

  1. Nginx location 配置语法

        1. location [ = | ~ | ~* | ^~ ] uri { ... }
        2. location @name { ... }    
    1. location 配置可以有两种配置方法

      1.前缀 + uri(字符串/正则表达式)
      2.@ + name
      
    2. 前缀含义

          =  :精确匹配(必须全部相等)
          ~  :大小写敏感
          ~* :忽略大小写
          ^~ :只需匹配uri部分
          @  :内部服务跳转
  2. Location 基础知识

    1.location 是在 server 块中配置。
    2.可以根据不同的 URI 使用不同的配置(location 中配置),来处理不同的请求。
    3.location 是有顺序的,会被第一个匹配的location 处理。

Location 配置demo

1.=,精确匹配

        location = / {
            #规则
        }
        # 则匹配到 `http://www.example.com/` 这种请求。 

2.~,大小写敏感

        location ~ /Example/ {
                #规则
        }
        #请求示例
        #http://www.example.com/Example/  [成功]
        #http://www.example.com/example/  [失败]

3.~*,大小写忽略

    location ~* /Example/ {
                #规则
    }
    # 则会忽略 uri 部分的大小写
    #http://www.example.com/Example/  [成功]
    #http://www.example.com/example/  [成功]

4.^~,只匹配以 uri 开头

    location ^~ /img/ {
            #规则
    }
    #以 /img/ 开头的请求,都会匹配上
    #http://www.example.com/img/a.jpg   [成功]
    #http://www.example.com/img/b.mp4 [成功]

5.@,nginx内部跳转

    location /img/ {
        error_page 404 @img_err;
    }
    
    location @img_err {
        # 规则
    }
    #以 /img/ 开头的请求,如果链接的状态为 404。则会匹配到 @img_err 这条规则上。

总结

Nginx 中的 location 并没有想象中的很难懂,不必害怕。多找资料看看,多尝试。你就会有收获。

参考

  1. http://nginx.org/en/docs/http/ngx_http_core_module.html#location

  2. 统一资源标志符

查看原文

赞 12 收藏 35 评论 4

52lidan 收藏了文章 · 2019-09-25

Ionic4+Vue+Capacitor 初体验

注:本文的目的在于记录自己基于最新的Ionic4构建一个App,也为同样需求的小伙伴提供参考。第一次写文章,文笔笨拙,还请见谅,不对之处,还请指出。


最新的Ionic4已经提供了其他 js 框架的支持,VueReact 等等,甚至不使用框架。接下来我将使用 VueIonic 开发一个最简单的app。

创建项目

使用Vue Cli创建一个Vue项目(默认配置):

npm install -g @vue/cli
vue create ionic-vue-app

cd ionic-vue-app

启动项目,看看是否创建成功:

npm run serve

clipboard.png
clipboard.png

成功了,接下来我们为项目添加 Vue RouterIonic 框架。

vue add router
npm install @ionic/vue

安装完成后,还需要引入到我们的项目中,这样就可以使用 Ionic 的组件了。
首先打开 src/main.js, 添加下面三行代码:

import Ionic from '@ionic/vue';
import '@ionic/core/css/ionic.bundle.css';

Vue.use(Ionic);

clipboard.png

接下来修改src/router.js:

import Vue from 'vue'
import Home from './views/Home.vue'
import { IonicVueRouter } from '@ionic/vue';

Vue.use(IonicVueRouter);

export default new IonicVueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes: [
    {
      path: '/',
      name: 'home',
      component: Home
    },
    {
      path: '/about',
      name: 'about',
      // route level code-splitting
      // this generates a separate chunk (about.[hash].js) for this route
      // which is lazy-loaded when the route is visited.
      component: () => import(/* webpackChunkName: "about" */ './views/About.vue')
    }
  ]
})

现在,我们就可以访问Ionic的组件了,如下修改Home.vue的代码。

<template>
  <div class="home">
    <div class="ion-page">
      <ion-header>
        <ion-toolbar>
          <ion-title>Hello World</ion-title>
        </ion-toolbar>
      </ion-header>
      <ion-content class="ion-padding">
        <h1>Welcome To @ionic/vue</h1>
        <img alt="Vue logo" data-original="../assets/logo.png">
      </ion-content>
    </div>
  </div>
</template>

<script>
export default {
  name: 'home'
}
</script>

clipboard.png

ion-action-sheet 为例,直接去官网复制示例代码:

clipboard.png

clipboard.png

完美~


那么如何把代码打包成为一个app呢,首先借助 ionic 开发的 capacitor,他是一个类似于 cordova 的可以提供本机接口的工具,同时它也兼容很多现有的 cordova 插件。我们回归到代码(以下仅演示Android环境):

首先,我们需要把我们的 vue 项目变成一个 ionic 项目:

ionic init

clipboard.png

注意:Project type 选择 custom (custom)

然后我们在该项目中安装capacitor:

npm install --save @capacitor/core @capacitor/cli

然后初始化 capacitorApp nameApp Package ID 根据你自己的项目去进行填写

npx cap init

初始化之后我们需要改一下 capacitor.config.json 里的 webDir,改成 dist,因为vue项目的构建目录为dist,这样可以省的我们去复制代码到 www 目录(并且我们也没有创建 www 目录)。

"webDir": "dist"

接下来我们构建项目:

npm run build

然后我们使用 capacitor 添加对Android平台的支持,并将构建的代码拷贝到Android项目库里:

npx cap add android
npx cap copy android

现在我们就可以使用Android Studio打开项目,使用模拟器运行项目,或是构建app。
也可以直接使用 capacitor启动Android Studio,运行:

npx cap open android

使用模拟器运行项目

clipboard.png

clipboard.png

clipboard.png

clipboard.png

完美~,那么本次的初体验就到此结束了,capacitor 的插件使用可以参考官方文档哦。

运行环境:

  1. 浏览器:Chrome
  2. 编辑器:VS Code
  3. 软件版本:
"dependencies": {
    "@capacitor/android": "^1.0.0",
    "@capacitor/cli": "^1.0.0",
    "@capacitor/core": "^1.0.0",
    "@ionic/vue": "0.0.4",
    "core-js": "^2.6.5",
    "vue": "^2.6.10",
    "vue-router": "^3.0.3"
  },

参考资料:

  1. Ionic文档
  2. Capacitor文档
  3. Ionic宣布vue测试版
查看原文

认证与成就

  • 获得 162 次点赞
  • 获得 135 枚徽章 获得 8 枚金徽章, 获得 54 枚银徽章, 获得 73 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2013-01-06
个人主页被 1.7k 人浏览