React全家桶与前端单元测试艺术

TL;DR——什么是好的单元测试?

其实我是个标题党,单元测试根本没有“艺术”可言。

好的测试来自于好的代码,如果说有艺术,那也是代码的艺术。

注:以下“测试”一词,如非特指均为单元测试。

 

单元测试的好坏在于“单元”而不在“测试”。如果一个系统毫无单元可言,那就没法进行单元测试,几乎只能用Selenium做大量的E2E测试,其成本和稳定性可想而知。科学的单元划分可以让你摆脱mock,减少依赖,提高并行度,不依赖实现/易重构,提高测试对业务的覆盖率,以及易学易用,大幅减少测试代码。

最好的单元是返回简单数据结构的函数:函数是最基本的抽象,可大可小,不需要mock,只依靠传参。简单数据结构可以判等。 最好的测试工具是Assert.Equal这种的:只是判等。判等容易,判断发生了什么很难。你可以看到后面对于DOM和异步操作这些和副作用相关的例子都靠判等测试。把作用幂等于数据,拿到数据就一定发生作用,然后再测数据,是一个基本思路。

以上是你以前学习测试第一天就会的内容,所以不存在门槛。

为什么不谈TDD?

首先,TDD肯定是有价值的(价值大小不论)。反对TDD的原因一般比较明显,对于TDD是否带来正收益不确定(动机不足)。 某些项目质量要求很高,预算宽绰,TDD势在必行。某些项目比较紧急,或者并非关键或无长期维护计划,TDD理由就不充分。

为什么谈测试?

因为测试难。

第一难学,第二难写。写测试是个挺困难的活,要在测试里正确重演业务要费好大劲,只能靠反复练习。虽然这些测试在某些项目中是值得的,但是可能并不适合其他某些项目的基本情况。

测试难,就代表训练成本高,生产成本也高,收益就下降。要提高采用TDD的动机,与其说服别人,不如从简化测试开始。

(图片来自:http://t.cn/Rpw9WKg

为什么谈前端测试?

一般项目都是后端测试覆盖率高,同时后端套路也比较固定。测RESTful API粒度足够大,可以很好地避开实现并且覆盖业务。同时RESTful API一般也正好对应Web框架的Action handler,在这里同时它粒度也足够小,刚好可以直接调用而不启动真的Web server,使得测试最大程度并行化。所以这样测试收益总是最高的,争议很小。

前端不说套路不固定,测不测都有待商榷。因为前端流派不统一,资源不规则,边界也不清晰,有渲染又有点业务,有导航有请求,很多团队不测试/测Model/测Component/测E2E,五花八门。 但得益于JavaScript本身,前端测试其实是可以非常高效的。

下面你可以看到各种极简极快的测试工具和测试方式,并且它们完全可以贯穿开发始终,而非仅给Hello World体量项目准备的,你可以在很大的全家桶项目中完全机械地套用这些方法。(机械也是极限的一部分,你不应该在使用工具过程中面临太多抉择,而应当专注于将业务翻译成测试)。

为什么谈React全家桶?

前端从每周刷新一个框架,稳定到了Angular, React, Vue3个主流框架并存的阶段。网络中争论这三个框架盖的楼已经可以绕太阳系了。根据盖的各种大楼看来,现在哪个更优秀还没个定论。不过具体到单元测试方面,得益于Virtual DOM本身和模块化设计(不然全家桶白叫了),React全家桶明显更优秀些。

测试工具

我们本篇中的测试有三个目标:学得快,写得快,跑得快

(图片来自:http://t.cn/RpwCke3

平台上Selenium, Phantom, Chrome, 包括Karma都比较重,最好的测试框架就是直接跑在node上的。本着极限编程的原则,我们将测试本身和测试环境尽可能简化,以达到加快测试速度,最终反馈到开发速度的目的。

我们使用AVA进行测试,它非常简洁,速度非常快,和mocha不同,它默认会启动多线程并发测试。因此我们的测试必须减少共享状态来提高并发能力,不然就会出现意想不到的错误。安装和运行:

yarn add ava
ava --watch

这样可以运行并watch测试。改变代码测试结果会立刻改变,你也可以看到友善的错误信息,以及expected和actual之间的diff。写下第一段测试:

import test from 'ava'

test(t => {
  t.is(1 + 1, 2)
})

除了is方法以外,我们还会用到deepEqual和true方法。好,你现在已经完全会用AVA了。其他的功能我们完全不关心。

Redux测试 (Model测试)

Redux就是用一堆Reducer函数来reduce所有事件用来做全局Store的状态机(FSM)。用源码本身介绍它甚至比用上一小段文字介绍还快:

const createStore = reducer => {
  let state, listeners = []

  const dispatch = action => {
    state = reducer(state, action)
    listeners.forEach(listeners => listeners())
  }

  return {
    getState() { return state },
    subscribe(listener) {
      listeners.push(listener)
      return () => { listeners = listeners.filter(l => l !== listener)}
    },
    dispatch,
  }
}

这是一个简化版的代码,去掉了抛错等等细节,但功能是完整的。把你自己写的reducer扔进去,然后可以发事件来使其更新,你还可以订阅它来拿状态。有点像Event Sourcing,以消息而非调用来处理逻辑,更新和订阅的逻辑不在一起(事件是写模型,各种view就是多个读模型)。

reducer几乎包括了我们所有前端业务的核心,测好它就测了大半。它们全都是(State, Action) => nextState形式的纯函数,无异步操作,用swtich case来模拟模式匹配来处理事件。比如用喜闻乐见的简陋版的栈停车场举例:

export const parkingLot = (state = [], action) => {
  switch (action.type) {
    case 'parkingLot/PARK':
      return [action.car, ...state]
    case 'parkingLot/PICK':
      const [_, ...rest] = state
      return rest
    default: return state
  }
}

Reducer是这么用的:

const store = createStore(parkingLot)
store.subscribe(() => renderMyView(store.getState()))
store.dispatch({ type: 'parkingLot/PARK' })

好,现在你又理解了Redux。那我们可以看看怎么测试上面的parkingLot reducer了:

test('parking lot', t => {
  const initial = parkingLot(undefined, {})
  t.deepEqual(initial, [], 'should be empty when init')

  const parked = parkingLot(initial, { type: 'parkingLot/PARK', car: 'Tesla Model S' })
  t.deepEqual(parked, ['Tesla Model S'], 'should park Model S in lot')

  const picked = parkingLot(parked, { type: 'parkingLot/PICK' })
  t.deepEqual(picked, [], 'should remove the car')
})

它就是你第一天学测试就会写的那种测试。这些测试不受任何上下文影响,是幂等的。试着把那几个const声明的state挪到任何地方,你都可以发现测试还是正确的,这和我们平常小心翼翼分离各个测试case,并用beforeEach和afterEach重置截然不同。

(图片来自:http://t.cn/RpwS3AK

测试Reducer是非常机械的,你不需要问自己“我到底应该测哪些东西”,只需要机械地测试初始state和每个switch case就好了。(小秘密:redux-devtools写完实现,在浏览器里打开,反过来还可以自动生成各种框架的测试代码,粘贴回来就行了。推荐不写测试的项目尝试下,反正白送的测试……而且跟你写的没两样)

随着业务变得复杂,当state树变大时,我们可以将reducer结构继续往下抽,并继续传递事件,函数没有this,重构起来比普通OO要简单得多,就不赘述了。这时候测试还是完全一样的,这种树形结构保证了我们能最大限度地覆盖一个bounded context—也就是root reducer。

另外更好的方式是用t.is(断言引用相同)而非t.deepEqual。但是JavaScript对象本身是可变的,引入immutable.js可以让你只用t.is测试,不过immutable的API有点别扭,不展开了。

组件测试 (View测试)

React是一个View library,它干的活就是DOM domain里的两个事:渲染和捕获事件。我们在这里依然从简,只用stateless component这个子集,虽然在用到生命周期方法的时候需要用一下class,但绝大多数时候应该只用stateless component。

它以Virtual DOM的形式封装了恶心的浏览器基础设施,让我们以函数和数据结构来描述组件,所以和大部分框架不同,我们的测试依然可以在node上并行运行。如果用Karma + Chrome真正地渲染测试,你会发现共享一个浏览器实例的测试非常慢,几乎无法watch测试,因此我们的TDD cycle就会变得不那么流畅了。

最基本的就是state => UI这种纯函数组件:

const Greeter = ({ name }) => <p>Greetings {name}!</p>

使用的时候就像HTML一样传递attribute就可以了。

render(<Greeter name="React"/>, document.body)

最简单的测试还是判等,我们用一个叫jsx-test-helpers的库来帮我们渲染:

import { renderJSX, JSX } from 'jsx-test-helpers'

const Paragraph = ({ children }) => <p>{children}</p>
const Greeter = ({ name }) => <Paragraph>Greetings {name}!</Paragraph>

test('Greeter', t => {
  t.is(renderJSX(<Greeter name="React"/>), 
       JSX(<Paragraph>Greetings React!</Paragraph>), 
       'should render greeting text with name')
})

这里我多加了一层叫做Paragraph的组件,它的作用仅仅是传递给p标签,children这个prop表示XML标签传进来的子元素。多加这层Paragraph是为了展示renderJSX只向下渲染了一层,而非最终需要渲染的p标签。这样我们在View上的测试粒度就会变得更小,成本更低,速度更快。

(图片来自:http://t.cn/RpwYskG

View不像业务本身那么稳定,细粒度低成本的快速测试更划算些,这也是为什么我们的View都只是接受参数渲染,这样你只用测很少的case就能保证View可以正确渲染。假如你的FSM Model有M种可能性,View显示的逻辑有N种,如果将两个集成在一起测试可能就需要M×N种Path,如果分开测就有M+N种。View和Model的边界清晰时,你的Model测试不容易被更困难的View测试干扰,View测试也减少了混沌程度,需要测试的情形就减少了。

我们的组件不应该只有渲染,还有事件,比如我们封装个TextField组件:

const TextField = ({ label, onChange }) => <label>
  {label}
  <input type="text" onChange={onChange} />
</label>

当然我们还可以判等,只要onChange函数引用相同就好了。

test('TextField', t => {
  const onChange = () => {}
  const actual = renderJSX(<TextField label="Email" onChange={onChange} />)
  const expected = JSX(<label>
    Email
    <input type="text" onChange={onChange}/>
  </label>)
  t.is(actual, expected)
})

当然有时候你的组件更复杂些,测试时并不关心组件是不是完全按你想要的样子渲染,可能你想像jQuery一样选择什么,触发什么。这样可以用更主流的enzyme来测试:

import {shallow} from 'enzyme'
import sinon from 'sinon'

test('TextField with enzyme', t => {
  const onChange = sinon.spy()
  const wrapper = shallow(<TextField label="Email" onChange={onChange} />)
  t.true(wrapper.contains(<label>Email</label>), 'should render label')

  const event = { target: { value: 'foo@bar.com' } }
  wrapper.find('input').simulate('change', event)
  t.true(onChange.calledWith(event))
})

这里用的shallow顾名思义,也是向下渲染一层。此外我们还用了spy,这样测试就变得有点复杂了,丢掉了我们之前声明式的优雅,所以组件还是小一点、一下测完比较好。

还不够快?Facebook就觉得不够快,他们觉得View测试成本比较浪费,干脆搞了个Snapshot测试——意思就是照个像,只断言它不变。下次谁改了别的地方不小心影响到这里,就会挂掉,如果无意的就修好,如果有意的话和git一样commit一下就修好了:

import render from 'react-test-renderer'

test('Greeter', t => {
  const tree = render.create(<Greeter name="React"/>).toJSON()
  t.snapshot(tree, 'should not change')
})

当你修改Greeter的时候,测试就会挂掉,这时候运行:

ava --update-snapshots

就好了。Facebook自家的Jest对snapshot的支持更好,当snapshot不匹配时按个y/n就完事了,够快了吧。要有更快的可能就是不测了……

小结

这节里我们展示了3种测试View的不同方式,它们都比传统框架更简单更快速。我们的思路还是以判等为主,但不同于Model,粒度越大越好。View测试粒度越小越好,足够小、足够幂等之后,其实不用测试你也可以发现组件总是按照预期工作。相比之下MVVM天然有一种让View和Model粒度拟合的倾向,很容易让测试变得既难测又缺乏价值。

异步Effect测试

这算个续集……异步操作不复杂的项目可以无视这段,可以选择性不测。

React先解决了恶心的DOM问题,把Model的问题留下了。然后Redux把同步逻辑解决了,其实前端还留下异步操作的大问题没有解决。这种类似“Unix只做一件事”的哲学是React全家桶的根基。我们用一个叫做Redux-saga的库来展现全家桶的异步测试怎么写,Redux模仿的目标是Elm architecture,但是简化掉了Elm的作用模型,只保留了同步模型,Redux-saga其实就是把Elm的作用模型又拿回来了。

Saga是一种worker模式,很早之前在Java社区就存在了。Redux-saga抽象出来多种通用的作用比如call / takeEvery等等,然后有了这些作用,我们又可以愉快地判等了。比如:

import { takeEvery, put, call, fork, cancel } from 'redux-saga/effects'

function *account() {
  yield call(takeEvery, 'login/REQUESTED', login)
}
function *login({ name, password }) {
  try {
    const { token } = yield call(fetch, '/login', { method: 'POST', body: { name, password } })
    yield put({ type: 'login/SUCCEEDED', token })
  }
  catch (error) {
    yield put ({ type: 'login/FAILED', error })
  }
}

这段代码乍看起来很丑,这是因为它把程序里所有异步操作全都集中在自己身上了。其他部分都可以开心地发同步事件了,此外有了Saga之后Redux终于有了“用事件触发事件”的机制了,只用redux,应用复杂到一定程度你一定会想这个问题的。

这是个最普通的API处理saga,一个account worker看到每个’login/REQUESTED’就会forward给login worker(takeEvery),让它继续管下面的事。然后login worker拿到消息就会去发请求(call),之后傻傻地等着回复,或者是出错。最后它会发出和结果相关的事件。用这个方式你可以轻松解决疯狂难度的异步问题。

test('account saga', t => {
  const gen = account()
  t.deepEqual(gen.next().value, call(takeEvery, 'login/REQUESTED', login))
})

test('login saga', t => {
  const gen = login({ name: 'John', password: 'super-secret-123'})

  const request = gen.next().value
  t.deepEqual(request, call(fetch, '/login', { method: 'POST', body: { name: 'John', password: 'super-secret-123'} }))
  const response = gen.next({ token: 'non-human-readable-token' }).value
  t.deepEqual(response, put({ type: 'login/SUCCEEDED', token: 'non-human-readable-token' }))
  const failure = gen.throw('You code just exploded!').value
  t.deepEqual(failure, put({ type: 'login/FAILED', error: 'You code just exploded!'}))
})

你看我们的测试连异步操作都还可以无耻地判等。call就是以某些参数调用某个函数,put就是发事件。

可以试着把fetch覆盖成空函数,你可以发现实际上副作用根本没发生,“fetch到底是个啥”对测试一点影响都没有。你可能发现了,其实saga就是用数据结构表示作用,而不着急执行,在这里又走回幂等的老路了。这和React Virtual DOM的思路异曲同工。

结语

首先是文章开头提到的TL;DR的内容。函数是个好东西,测函数不等同“测1+1=2”这种没营养的单元,函数是可以包含很大上下文的。这种输入输出的模型既简单又有效。

我们消灭了mock,减少了依赖,并发了测试,加快了速度,降低了门槛,减少了测试路径等等。如果你的React项目原来在TDD的边缘摇摆不定,现在是时候入一发这种唯快不破了。

全家桶让Model/View/Async这三者之间的边界变得清晰,任由业务变更,它们之间的职责是不会互相替代的,这样你测它们的时候才更容易。后端之所以测试稳定是因为有API。所以想让前端好测也是一样的思路。

文中好多次提到“幂等”这个概念,幂等可以让你减少测试的case,写代码更有底气。抛开测试不谈,代码幂等的地方越多,程序越可控可预期。其实仔细思考一下我们的实际项目,大部分业务都是非常确定的,并没有什么随机因素。为什么最后还是会出现很多随机现象呢?

声明优于命令,描述发生什么、想要什么比亲自指导具体步骤好。

消息机制优于调用机制。Smalltalk > Simula。其实RESTful API一定程度上也是消息。简单的对象直接互相作用是完全没问题的,人作为复杂对象主要通过语言媒介来交流,听到内容思考其中的含义,而不是靠肢体接触,或者像连体婴儿那样共享器官。所以才有一句俗语叫“你的对象都想成长为Actor”。

从View的几种测试里我们也可以看到,测试并不是只有测或者不测这两种选择,我们老提测试金字塔,意思是测试可多可少,不同层级的测试保持正金字塔形状比较健康,像今天我们说的就可以大幅加宽你测试金字塔的底座。所以你的项目有可能测试过少,也可能测试过度,所以时间可以动态调整。

没用全家桶的项目可以把“大Model小View”的思想拿走,这样更容易于专注价值。尽量抽出Model层,不要把逻辑写在VM里,看那样似省事,行数在测试里都还回来了。


更多精彩洞见,请关注微信公众号:思特沃克

Share

解读GraphQL(三):Relay—面向未来的前端框架

这篇文章是这个系列的尾声,也是我们可以一窥GraphQL、Relay生态究竟可以为我们提供怎样优势的时候了。

我们都知道,一项技术的普及,很大程度来源于旧工具解决某些新问题过于棘手的问题。我们看到前端迅猛发展的过程中,社区逐渐开始抛弃jQuery和Angular——因为当我们的场景越来越复杂时,它们就会开始失控,让我们面临越来越多的维护和性能问题。即使对于现在流行的React + Redux而言,这个方案依然有很多不便之处,但我们的前端发展还没有那么快,所以它们遭遇的痛点有限,社区也没有大规模抛弃它们。但这并不能说明这些痛点不存在,而是我们的前端应用还没发展那么快,因此也不是非得用更好的技术才能实现我们的需求。

然而前端逐渐趋于复杂的趋势还没有结束,Facebook的场景更复杂苛刻,因此在我们之前就遭遇了即将到来的问题。Relay是他们比Flux更进一步的解决方案,它把我们不需要关注的地方全都抽象到了Relay的代码库中,解决了绝大多数让我们头疼的前端问题。

这不代表你现在就可以开始采用

鉴于Relay现在并非主流解决方案,并且它的API也未经简化,有时候看起来难以理解,因此并不推荐在普通场景直接使用:

  • 与第一次接触React类似,这个lib非常奇怪,和之前用的框架完全不同,文档还特别难读。时至今日,大家反而发现React的API远比Angular少且简单。所以其实学习曲线分为两部分:一是你是否熟悉类似的解决方案,二是这个方案本来简单与否——Angular有更多更复杂的指令和依赖注入系统。其实Relay的心智模型要比Redux还简单,而后者经过社区的反复安利现在已经非常主流了。现在React/Redux在我们这期雷达的框架象限中已经在4个Adopt中占2个名额了。
  • 虽然Relay本身概念不是特别复杂,但它的API实现非常不符合直觉。这和刚出现Flux时Facebook官方放出的Flux库类似,API又臭又长,完全不知道在说什么。直到Redux的出现,用更简洁的API实现了等价功能才被广泛接受,Relay也类似,社区也在逐渐出现其他GraphQL客户端。
  • Relay解决的痛点存在于各种SPA和移动端开发中,但对于现在的主流场景还不算致命,随着时间的推移,这很可能会改变。

本文的主要价值在于我们可以看到这些被Facebook仔细总结过的问题,当在自己的项目中看到这些问题可能会引发更多的思考。因为大多数时候,部分问题会贯穿客户端开发的始终,另外一部分问题则是进一步扩展之后的坑,这些可能会是宝贵的经验。

开始之前:

其实我一年之前试用过一下Relay,像Apollo这些简单的lib其实大部分跟着Code迅速敲一边就知道是怎么回事了。然而对于Relay,由于它封装了太多的内容,导致Relay文档的第一篇Tutorial看起来不知所云,完全没有帮助,于是就没继续看。

前一阵和豪门的某个同事聊到这个奇怪的lib时,他也认为好像写文档的喝多了。于是当天我就回顾了一下Relay的文档,当我跳过Code部分,看到文档后面分别从GraphQL和Relay的出发点来叙述各种细节时,事情才开始make sense起来。起初想自己写一篇关于Relay的文章,最后发现其实还是用自己的话和自己的例子叙述完全相同的内容,于是这篇其实主要算搬运。所以初衷是这样的:

我知道有非常多的人跟我一样看了第一页就跑了,所以把更有价值后两页内容搬运过来(为了辅助说明,有部分增删改)。 对于字太多不看党:这里有一个图文并茂的拟人版Relay生动叙述了主旨思想,很有意思。相信很多人已经看过FluxRedux版了。 另外,如果觉得下面的cache机制不容易理解——可以把Relay的cache看做文明系列,或者魔兽争霸之类的战争迷雾。也就是你没探地图的时候是黑的,这时候你没cache过,但是你走开的时候虽然不是黑的,但你的cache可能是过期的,等你过去又update了。自认为比喻得十分恰当……

从GraphQL的角度出发

GraphQL允许客户端在一次请求中准确声明视图所有所需的数据。与传统的REST方式相比,它的请求效率要更高,而且也避免了服务端API代码之间雷同的问题。更重要的是,GraphQL将服务端开发与客户端开发彻底解耦,客户端视图的变动完全不会影响到服务端的代码结构。

请求数据

我们现在有一个请求stories列表的需求,除此之外我们还要请求每个story的详情。我们用面向资源的严格REST可能会这么做:

// Fetch the list of story IDs but not their details:rest.get('/stories').then(stories =>
  // This resolves to a list of items with linked resources:
  // `[ { href: "http://.../story/1" }, ... ]`
  Promise.all(stories.map(story =>
    rest.get(story.href) // Follow the links
  ))
).then(stories => {
  // This resolves to a list of story items:
  // `[ { id: "...", text: "..." } ]`
  console.log(stories);
});

这种做法会出现n+1次HTTP请求,更别提对数据库的请求了:1次用来获取列表,n次用来获取每个story详情。用GraphQL,我们不用专门维护一个同时获取stories和stoy信息的API,同时一次请求所有数据:

graphql.get(`query { stories { id, text } }`).then(
  stories => {
    // A list of story items:
    // `[ { id: "...", text: "..." } ]`
    console.log(stories);
  }
);

这个请求有两点好处:

  • 所有的数据都一次请求完毕
  • 服务端和客户端完全解耦:客户端声明自己需要的数据,而不依赖服务端来编写正确的数据结构。

客户端缓存

反复重新获取数据会让应用变得很慢。比如,当你跳转到获取一个stories列表和每个story详情的页面意味着我们需要重新请求整个列表。我们现在用国际统一方式来解决:缓存。在严格的REST系统里我们会将返回的数据,以uri为单位缓存,也就是每个特定的uri缓存一次:

var _cache = new Map();
rest.get = uri => {
  if (!_cache.has(uri)) {
    _cache.set(uri, fetch(uri));
  }
  return _cache.get(uri);
};

这种缓存策略也可以对GraphQL生效,最简单的方式就是和REST一样,以query为单位来缓存:

var _cache = new Map();
graphql.get = queryText => {
  if (!_cache.has(queryText)) {
    _cache.set(queryText, fetchGraphQL(queryText));
  }
  return _cache.get(queryText);
};

现在我们的新请求就会立刻返回缓存的结果了。这对于提高性能来说是很有效的方式,但这回引起数据一致性问题。

缓存一致性

用GraphQL的时候,我们的Query之间其实经常会有交集,也就是重复的部分。但我们上面的办法没办法cache掉这些交集,因为我们的cache是以url或者单个query为单位的。比如说我们发这么个请求,请求所有stories:

query { stories { id, text, likeCount } }

然后再重新请求下其中的一个story,假设这时候它的likeCount可能已经增加了:

query { story(id: "123") { id, text, likeCount } }

现在我们通过不同的方式可以获得不同的likeCount,如果你用上面的query会拿到旧的count,如果你用下面的query会拿到新一点的count。

将图缓存起来

其实正确cache GraphQL请求的方式应该是将query的嵌套结构打平,以获得一个称为records的集合。Relay内置实现了从ID到records以map形式缓存的过程。每个record也可以用一个link指向其他的record用以描述一个有向有环图,这个link是一个可以从顶层map开始搜寻的特殊数据结构。用这个办法,我们即使通过不同的方式请求也会获取到相同的cache结果。如下有个请求story下text和作者名字的请求:

query {
  story(id: "1") {
    text,
    author {
      name
    }
  }
}

上述请求的返回值可能是这样的:

    query: {
  story: {
     text: "Relay is open-source!",
     author: {
       name: "Jan"
     }
  }
}

即便返回值是嵌套的,Relay也会把它拍扁,变成records。所以对上面那个返回,Relay会这么cache:

   Map {
  // `story(id: "1")`
  1: Map {
    text: 'Relay is open-source!',
    author: Link(2),
  },
  // `story.author`
  2: Map {
    name: 'Jan',
  },
};

这只是一个最简单的例子:实际使用起来Relay还会处理一对多和分页的场景。

使用缓存

当我们接收到数据时我们会把它写进缓存里,当我们在缓存中找到我们要的我们就直接读取(概念上跟_cache.has(key)是类似的,只不过GraphQL是从图里面读cache)。

写入缓存

写入缓存要先遍历返回的GraphQL结构,然后返回一个拍平的cache records。直觉上这个办法很直接,但这只对很简单的query有效。思考一下这么个query: user(id: "456") { photo(size: 32) { uri } } 我们应该怎么存这个photo?光用photo当cache键会导致不同参数之间的冲突(比如再缓存一个photo(size: 64) {...})。当我们分页时也有同样的问题:如果我们用stories(first: 10, offset: 10)获取第11个到20个story的时候,返回结果应该拼接到缓存列表内(而不是单独缓存,想象一下你在列表新加了一条数据,如果按照传统page/{n}的API方式处理,所有的分页都是错位的,因此缓存必须全部失效,因此正确做法应该是缓存一个列表,这样和页数就完全无关了)。 因此所有拍平的GraphQL缓存结构应当包含参数信息然后并排放在一起。比如说刚才的photo就应该缓存在photo_size(32)这种键下来算上参数一起来唯一标识它自己。

读取缓存

如果要读取缓存的话我们的query就要遍历各个field。其实这样做听起来起来和我们在GraphQL服务端做的事情完全相同。读取缓存其实跟构建一个服务端可执行的GraphQL一样,只是变成了一个更简单的特例:

  • 因为我们是按特定规则存进去的,因此我们不用像在服务端那样声明自己的resolver,这个规则是自动的。
  • 因为这个cache graph在本地,所以请求cache是同步的:不然我们cache了,不然就没cache,没有请求的中间状态。

Relay内部实现了许多种query遍历的方式:比如它可以自己读cache,也可以接受服务端的response。比如说,当我们发出一个query的时候,Relay会自动遍历并Diff,然后决定哪些field没被获取过(跟React diff Virtual DOM树的原理是类似的)。这样我们就可以减少我们query的实际请求数量,甚至如果所有的field都被cache过Relay就直接不发请求了。假如我们首页的某个组件会先发这么一个请求:

query { viewer { name, company } }

比如我们进入下一页时,可能是个人中心页面,我们的某个组件会请求更具体的信息:

query { viewer { name, company, email, phone } }

当你打开Network面板的时候,你会发现Relay只请求了:

query { viewer { email, phone } }

更新缓存

我们可以发现,Relay这种扁平的cache池在缓存互相重叠的各种query时是不会重复的。无论你怎么请求数据,一个具体record只可能缓存在同一个地方。我们回顾一下上面缓存不一致的小例子:

query { stories { id, text, likeCount } }

当我们cache这个query返回的数据时,每个stories列表中的每个story都会存成一个record,然后stories这个字段会Link到所有的上述record上。于是我们接着请求了其中的某个story:

query { story(id: "123") { id, text, likeCount } }

当Relay拍平这个query返回的数据时,Relay就能根据每个实体身上的id(GraphQL的ID类型跟UUID类似,是全局唯一,自动分配的,只不过默认是Base64的),找到这个已经存在的123 record并更新,而不是去新建一个。于是所有新的query如果要请求likeCount都一定会拿到这个新的cache结果。

数据/视图一致性

一个扁平的cache结构保证了缓存一致性。但对于View来说呢?我们知道React会相应数据变化,渲染最新数据。我们现在考虑一个渲染story内容、评论以及作者名字和照片的场景,比如这就是我们要发的GraphQL query:

query {
      node(id: "1") {
        text,
        author { name, photo },
        comments {
          text,
          author { name, photo }
        }
      }
    }

当你发出这么个query,Relay会缓存成这样(你能发现story和comment都会指向同一个author):

// Note: This is pseudo-code for `Map` initialization to make the structure// more obvious.Map {
      // `story(id: "1")`
      1: Map {
        author: Link(2),
        comments: [Link(3)],
      },
      // `story.author`
      2: Map {
        name: 'Yuzhi',
        photo: 'http://.../photo1.jpg',
      },
      // `story.comments[0]`
      3: Map {
        author: Link(2),
      },
    }

这个作者自己占了个沙发——也是挺常见的情形。接下来,假如这个作者改了自己的photo,然后其他组件又要去获取这个更新的话,以下是唯一会变动的地方:

Map {
      ...
      2: Map {
        ...
        photo: 'http://.../photo2.jpg',
      },
    }

photo的值已经变了,于是Relay就去修改record 2,仅此而已。cache其他的地方完全没变。但很明显,我们的view需要相应这种变化:这个story作者的photo,和他自己占沙发的photo都要更新。熟悉React的人的第一反应就是”上immutable.js”,因为当你变化节点的时候,整棵树的引用就会产生变化(类似Java有常量池的字符串)——但我们看看这样会发生什么:

ImmutableMap {
      1: ImmutableMap {/* same as before */}
      2: ImmutableMap {
        ... // other fields unchanged
        photo: 'http://.../photo2.jpg',
      },
      3: ImmutableMap {/* same as before */}
    }

如果我们把2换成一个新的immutable record,我们会得到一个全新的cache对象。但我们已经拍平了这个缓存结构,这时候因为13并没变,因此他们的Link就失效了。

实现视图一致性

面对这个扁平cache,要更新视图其实有多种方法可以选择。Relay的办法是自动订阅每个view指向cache的ID集合,也就是每个特定引用的集合。在上面那个例子中,story的view会订阅story(1),author(2)和comments(3和其他comments)。当有新数据写入cache时,Relay会找出所有被影响的ID,然后只会通知那些订阅这个ID的所有view。这样就仅会通知重绘那些需要重绘的,而不需要重绘的就会无视这个update(实现原理是Relay container安全高效地override了shouldComponentUpdate)。如果没有这种设计的话,任何一个小变化都会导致所有view重绘。 注意除了更新以外,所有的写入cache的操作也会影响view,因为写入仅仅是另一种形式的更新。

Mutations

我们现在已经了解query的过程,也知道view是如何更新的了。但我们还没谈到写入操作。在GraphQL里,我们把写入操作成为mutation。我们可以把它当做有副作用的query。比如下面就是个用current user来like一下某个story的mutation:

// Give a human-readable name and define the types of the inputs,// in this case the id of the story to mark as liked.mutation StoryLike($storyID: String) {
       // Call the mutation field and trigger its side effects
       storyLike(storyID: $storyID) {
         // Define fields to re-fetch after the mutation completes
         likeCount
       }
    }

但mutation很可能导致我们的某些query结果产生变化。这时候你可能会问:为什么服务端就不能直接告诉我哪些字段已经变化了呢?其实这个问题非常复杂。GraphQL只是一个可以封装任何存储系统(或者仅仅将多种资源整合起来)的抽象层,它可以实现在任何语言上。更重要的是,GraphQL的核心目的是产生客户端渲染视图所需的数据结构

我们发现其实GraphQL schema经常和你的database schema经常有本质上的区别。简单地说,数据变化可能意味着你的database变化,也可能意味着你产品可见的数据(比如GraphQL)的变化。最恰当的例子就是隐私:你要在客户端获取一个age字段,在database里会有一大堆字段来控制UAC来决定你是不是能看见这个age(比如是不是好友,这个人的age是不是公开的,有没有屏蔽请求方之类的字段)。

所以考虑到现实约束,GraphQL客户端还是得声明mutation之后哪些query可能会变化。但是我们怎么声明呢?Facebook在实现Relay的过程中考虑过几种方式,我们按顺序看一下,就容易理解为什么Relay如此设计了:

  • 方案1:重新请求所有曾经query过的数据。即使一个非常小的变化也会重新请求所有数据,这样非常低效。
  • 方案2:重新请求正在被view渲染的数据。这样快了一点,但没被显示的数据就没被更新,当你切换view的时候就又不能保证数据一致性了。
  • 方案3:我们将可能变化的query声明出来,然后重新请求这些数据。我们将其称为fat-query。其实这也很低效,因为一般来说我们只会展示部分fat-query要请求的数据,这样我还没请求过的数据fat-query会浪费感情地帮我也获取了。
  • 方案4(也就是Relay采用的):重新获取fat-query和cache过的所有字段的交集。除了cache数据本身,Relay也会在cache中标记这条数据是被哪条query请求来的。我们将其称为tracked queries。Relay用这种求交集的方法,就总可以精确地请求仅需要更新的字段了。

数据获取的API

我们已经看过Relay底层是如何封装数据缓存的了。我们现在退一步看看我们应该如何获取数据:

  • 从一个嵌套的view结构中找到所有要请求的字段。
  • 处理所有异步请求,并赋值给应用内的状态。
  • 处理网络错误。
  • 遇到错误之后重试。
  • 在query或mutation后更新本地cache。
  • 还要串行请求mutation防止race conditions。
  • 有时还要在服务器返回之前直接update视图,让用户感觉延迟更低(这称为 optimistically update)。我们发现基于命令式API的传统数据请求流程会让开发人员处理太多不必要的复杂性。拿optimistically update来说,其实要做的事情很明显:当用户点击”like”按钮时,直接将按钮变成”liked”然后发请求到服务端。但这实现起来就经常很复杂了。命令式的做法通常要求我们实现以下步骤:找到相应的view然后控制状态按下button,然后开始发送网络请求,如果挂掉还要重试,如果重试不成功再显示错误等等。对于数据获取这个过程来说也一样:其实声明我们需要什么数据就很大程度上直接决定了如何以及何时获取。这时候我们就可以来看看Relay的上层的声明式设计了。

从Relay的角度出发

Relay的数据获取方式其实很大程度来自于React的使用经验。具体地说,React将前端复杂的接口变成了可复用的组件,这允许开发者专注开发应用中被解耦出来的一部分。更重要的是,所有这些组件都是声明式的:它允许开发者仅声明在某种状态下组件应该渲染成什么样子,完全不管这个组件是如何被渲染的。不同于以前直接在DOM上赋值修改数据,React接受描述UI的数据结构来帮你渲染。我们来看一些场景来理解这种思想在Relay理是怎么体现的。

为视图获取数据

在客户端中,绝大多数情况下你都要做的事情是:为一个嵌套的view获取所有它需要的数据,在数据获取前可能需要一个进度条,然后获取到数据之后开始显示。一个解决方案是让根组件获取数据然后传递给子组件。然而,这种做法会引入耦合:每次你修改子组件的时候都要修改所有用到渲染这个组件的父组件。这种耦合意味着更多的bug和更慢的开发速度。最终我们发现这种做法并不会让你在React这种组件模型中收益:这种数据依赖放进组件内部才比较自然。 另外一个看起来很靠谱的办法是调用render()钩子时开始请求数据。这也是通常的做法,我们渲染一个stories组件,然后请求stories,然后获取完之后开始显示具体的story,最后再去请求所有的story详情。这听起来还行,但是问题是这个请求是分段的:先得渲染出来一层组件,才知道下一步应该请求什么,然后再渲染,再去请求。这会导致请求和渲染交替串行,最后渲染过程变得极慢无比。因此我们需要提前或者静态地知道我们要获取什么数据。 最终Relay采用了静态方法:组件的query都是静态成员,因此可以立刻找齐所有query,用来描述整个要渲染的组件树需要什么数据。但这种方式需要我们有办法把所有的query结合成query树,这样才能一次加载出所有数据,因此这也是Relay需要GraphQL来支持的核心原因——每组件内部query之间要可以结合成完整的query,而不是拿某部分query去直接调用某个请求API。

数据组件(即Container)

Relay允许开发者像React一样创建一个数据container来为组件提供数据,而这种容器实质上就是在外面包一层组件而已。一个现实问题是React组件的目的是为了复用,因此我们的container也得跟着可复用才行。举例来说,一个组件必须要可以渲染任何Story数据。至于渲染的具体内容,取决于交给组件的数据:。在Relay里面对等的概念就是fragment——一个声明某个GraphQL类型需要获取什么内容的具名query片段,比如我们给Story的fragment长这样:

fragment on Story {
  text
  author {
    name
    photo
  }
}

然后我们就可以把这段fragment放进Story作为组件的数据container了:

// Plain React component.// Usage: `<Story story={ ... } />`class Story extends React.Component { ... } // "Higher-order" component that wraps `<Story>`var StoryContainer = Relay.createContainer(Story, {
  fragments: {
    // Define a fragment with a name matching the `story` prop expected above
    story: () => Relay.QL`
      fragment on Story {
        text,
        author { ... }
      }
    `
  }
})

渲染

在React中,渲染需要两个东西:一个是你要渲染的组件,另外一个是你要把这个组件要渲染进的root DOM节点。渲染Relay container也类似:我们需要渲染一个要渲染的container,还有一个root query。所以类似ReactDOM.render(component, domNode),Relay有<Relay.Renderer Container={…} queryConfig={…}。所以container就是我们要渲染的东西,而queryConfig就是我们要请求的东西:

 ReactDOM.render(
  <Relay.Renderer
    Container={StoryContainer}
    queryConfig={{
      queries: {
        story: () => Relay.QL`
          query {
            node(id: "123") /* our `Story` fragment will be added here */
          }
        `
      },
    }}
  />,
  rootEl
)

Relay.Renderer可以将所有query合并,并与cache池做diff,然后请求所有没cache的信息,之后更新cache,最后渲染StoryContainer。默认加载过程中不显示内容,不过你也可以为这个Renderer添加一个render属性来渲染一个loading组件。正如React可以让你避免和DOM打交道一样,Relay也可以帮你避免手动管理网络请求。

数据封装

组件之间有隐式依赖很常见。举例说,会用到一些不一定会被获取到的数据——这些数据大多被其他部分获取,比如。当我们修改然后去除部分请求逻辑时,会直接挂掉。这种bug,特别是在大项目里很难快速发现,因为只有渲染所有用到这个组件的地方之后你才发现它挂掉了。手动和自动化测试能帮你的其实很有限,因为它还是会反复break,对于这种问题最好的办法是让框架来帮你从根源上避免这些问题,让它不可能出现。

我们可以看到Relay container会自动保证所有数据获取到之后再渲染。此外,container也带来了数据封装的好处。Relay只允许组件声明和自己相关fragment内的字段。所以一个组件获取了一个Story的text,另外一个获取了author,他们之间的数据是不可见的。事实上,甚至父级组件都不能知道子级到底请求了什么数据:如果知道的话,这又破坏了封装性。

在此基础上,Relay还更进一步:它在内部隐式地通过props验证你是不是真获取到需要的数据了。如果渲染了但没加入后者的fragment,Relay会警告你后者没有拿到本应拿到的数据。当有其他组件也请求完全相同的数据时,Relay甚至能提醒你检查你是不是在不知情的情况下又重复造了一个雷同的组件。这些检查可以有效帮助你避免在组件化开发中绝大多数的Code smell。

小结

GraphQL提供了强大、高效并与客户端解耦的工具链。Relay提供了完全声明式的数据同步机制(和Apollo不同,Mutation通过fat-query和cache池交集的办法完成了声明式API)。通过将请求什么怎么完全分离的方式,Relay给开发者默认保证了绝大部分的健壮性,透明性和性能优化。它也会让React的组件化设计更进一步。虽然React,Relay和GraphQL每一个都非常强大,但这三者的结合为开发者提供了快速开发优质可扩展的完整UI解决方案

解读GraphQL(一): WHAT & WHY

解读GraphQL(二): 使用Apollo Data构建GraphQL应用


更多精彩懂见,请关注微信公众号:思特沃克

Share

解读GraphQL(二): 使用Apollo Data构建GraphQL应用

今天的主角是Apollo Data,它提供了一套与GraphQL相关的服务端/客户端工具,大幅简化GraphQL的开发。并且Apollo client分别有React(Native),Angular2,IOS,以及正在开发的Android版本。如果你所使用的平台不在上述范围内,Apollo client也有原生JavaScript版本可用。它内部基于Redux编写,因此你可以用非常科幻的Redux devtools检测应用状态,然而你并不需要了解Redux也可以使用Apollo。Talk is cheap,第一篇已经解释了很多主要概念,Apollo也非常简单。因此这篇将以Code为主。
文章较长,开头先部分引用下结尾来提取一个TL;DR版本来说明主旨思想:

我们以完全声明式的代码完成了query和渲染:也就是说我们代码上只关心What,只字不提When和How。这会极大地提高代码的可维护性,简化开发,因为状态和赋值总是万恶之源,更别提在JavaScript环境下必须大量使用异步操作的情形。在不牺牲SPA复杂业务逻辑的情况下,通过GraphQL,我们可以把前端的开发工作重新简化成编写类似编写HTML的简单活动。

Task0:GraphQL Hello World

我们就先遵循惯例,通过GraphQL Hello一下:

const { graphql, buildSchema } = require('graphql') 
const schema = buildSchema(`   
    type Query {     
        hello: String   
    } 
`) 
const resolver = { hello: () => 'Hello GraphQL!' } 
const query = '{ hello }' 
graphql(schema, query, resolver).then(console.log.bind(console))

首先