xh4722

xh4722 查看完整档案

杭州编辑郑州大学  |  计算机科学与技术 编辑行知汇元  |  网站开发 编辑 segmentfault.com/u/hbp 编辑
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 该用户太懒什么也没留下

个人动态

xh4722 回答了问题 · 1月8日

js对象元素排序问题

对象没有顺序的,只能帮你排序成数组了:

import { pipe, sort, toPairs, descend, path } from 'ramda'

const obj = {
    上海: [100, 200, 300],
    广州: [80, 100, 220],
    北京: [120, 130, 160],
    深圳: [99, 150, 180]
}

const sortedArray = pipe(
  toPairs,
  sort(descend(path([1, 0])))
)(obj)
// sortedArray:
// [["北京", [120, 130, 160]], ["上海", [100, 200, 300]], ["深圳", [99, 150, 180]], ["广州", [80, 100, 220]]]

关注 6 回答 4

xh4722 发布了文章 · 1月8日

What does mobx react to?

Mobx 是一个经过战火洗礼的库,它通过透明的函数响应式编程(transparently applying functional reactive programming - TFRP)使得状态管理变得简单和可扩展.

Mobx

上面这段话引自 Mobx 的官方文档,说明了 Mobx 是一个应用了函数响应式的状态管理库。所谓的响应式就是事件监听,也是 Mobx 背后的哲学:

任何源自应用状态的东西都应该自动地获得

        这里说的 “应用状态” 就是 state,在 Mobx 的世界里叫 observable;源自应用状态的 “东西” 叫做 derivations,derivations 可以分为两大类:computedreaction
        computed 表示从应用状态派生出来的新状态,也就是派生值。比如你定义了两个 state 分别叫做 a 和 b,它们的和叫做 total,而 total 可以通过 a + b 得到,你没必要定义一个新的 state,这个 total 就叫做 computed。
        reaction 表示从应用状态派生出来的副作用,也就是派生行为。比如有一个分页选择器:你用一个叫做 index 的 state 表示当前页码,初始值是 1,当你改变这个 index 值为 2 的时候,就需要触发一个跳转到第2页的行为,这个行为是由 index 派生出来的,就叫做 reaction。
        Mobx 的核心概念其实就是这三个:observable、computed 和 reaction。

依赖收集

任何源自应用状态的东西都应该自动地获得

        上面这句话还有很重要的一点没有讲到,就是 Mobx 哲学所声明的 “自动”,用高大上一点的术语讲就是依赖收集。我们可以举个栗子:

let message = observable({
    title: "Foo"
})

autorun(() => {
    console.log(message.title)
})
// 输出:
// "Foo"

message.title = "Bar"
// 输出:
// "Bar"

        我们声明了一个 observable 的 message 对象,并调用了一个 autorun 函数用来输出 message 的 title 属性,这时候控制台马上输出 "Foo"。嗯,一切都在掌控之中。
        接下来我们尝试修改了 message 的 title 属性为 "Bar"。这时候神奇的事情发生了,autorun 里面传入的函数又自动执行了一遍,控制台输出了新的 title 值 "Foo",到底发生了什么?
        我们先看下官方给我们的解释

MobX 会对在追踪函数执行过程读取现存的可观察属性做出反应。

        嗯,看不懂。

        接下来官方又对上面这句话做了解释:

  • “读取”是对象属性的间接引用,可以用过.(例如user.name) 或者[](例如user['name']) 的形式完成。
  • “追踪函数”computed表达式、observer 组件的render()方法和whenreactionautorun的第一个入参函数。
  • “过程(during)”意味着只追踪那些在函数执行时被读取的 observable 。这些值是否由追踪函数直接或间接使用并不重要。

        嗯,好像有点懂了,让我们重新分析下上面的代码:

// 这是可观察对象
let message = observable({
    title: "Foo"
})

// autorun 是“追踪函数”
autorun(() => {
    // message.title 是“读取”操作
    // 这次读取操作在函数执行“过程”中
    console.log(message.title)
})
// 输出:
// "Foo"

message.title = "Bar"
// 输出:
// "Bar"

        我们声明的 message 是一个可观察对象,我们注册了一个 autorun 作为追踪函数,在这个追踪函数中我们传入一个函数参数,这个函数进行了一次 message.title 的读取操作,且这次操作在函数执行过程中。满足所有条件,bingo!!!

        但是你真的懂了吗?

        我再举几个例子,大家可以根据上面的规则自己再判断一下:
例1.

let message = observable({
    title: "Foo"
})

autorun(() => {
    console.log(message.title)
})
// 输出:
// "Foo"

message = { title: "Bar" }

        上面我把 message.title = "Bar" 的赋值操作改为了直接修改 message 对象:message = { title: "Bar" },这时候 autorun 会执行吗?

例2.

let message = observable({
    title: "Foo"
})

let title = message.title
autorun(() => {
    console.log(title)
})
// 输出:
// "Foo"

message.title = "Bar"

        例2我们新定义了一个 title = message.title 的变量,然后在 autorun 中输出这个变量。

例3.

let message = observable({
    title: "Foo"
})

autorun(() => {
    console.log(message)
})

message.title = "Bar"

        例3我们在 autorun 中直接输出了 message 对象。

        上面3个例子都是不能在 message 的 title 变更的时候正常响应的:

  1. 例1因为 autorun 追踪的是 message 对 title 属性的读取操作,但是我们变更的是 message 引用,原 message 对象的 title 属性并没有发生变更,所以 autorun 不会自动执行;
  2. 例2因为 autorun 里面并没有 “读取” 操作,所以不会追踪 message.title 的变更;
  3. 例3可能难理解一些,因为 console.log(message) 其实是 “读取” 了 message 对象的所有属性并输出到控制台上的,所以这里满足了 “追踪函数”“读取” 两个条件,既然还有问题,那肯定是没有满足 “过程” 这个条件。原因就是 console.log 函数是异步的,它并没有在函数的执行过程中立即调用。

        是不是发现事情开始并得复杂了起来?

        现在让我们放慢一下脚步,停止对 Mobx 官方解释的过度理解,这些只是 Mobx 实现者的文字游戏,他们并没有告诉我们事情的本质。

依赖收集的实现

        让我们换一个角度,思考一下 Mobx 的依赖收集到底是如何实现的?
        还是上文的例子,这一次让我们剖析一下这段代码的实现原理:

1  let message = observable({
2     title: "Foo"
3  })
4
5  autorun(() => {
6     console.log(message.title)
7  })
8  // 输出:
9  // "Foo"
10
11 message.title = "Bar"
12 // 输出:
13 // "Bar"

        上面的 1 到 3 行代码我们声明了一个 message 对象,并且用 Mobx 的 observable 进行了封装。这里 observable 的意思就是让 message 对象变成可观察对象,observable 做的事情就是用 ES6 Proxy 代理了 { title: "Foo" } 这个普通对象并返回代理对象给 message。这样 Mobx 就有能力去监听 message 的变更了,我们可以自己实现一个 observable:

 function observable(origin) {
  return new Proxy(origin, {
    // 监听取值操作
    get: function (target, propKey, receiver) {
        // ...
        return Reflect.get(target, propKey, receiver);
    },
    // 监听赋值操作
    set: function (target, propKey, value, receiver) {
        // ...
        return Reflect.set(target, propKey, value, receiver);
    }
  })
}

        第 5 到 7 行我们传入了一个函数参数调用了 autorun,函数参数只是简单输出 message 的 title 属性到控制台。经过这一步以后我们在 11 行修改了 message 的 title 属性,autorun 的注册函数就会自动执行,在控制台输出最新的 message.title 信息。
        再重新看一下上面的代码,思考一个问题:autorun 为什么会知道它需要去关心 message 对象的 title 属性?我们没有传类似 ["message", "title"] 这样明确的参数给他,它接受的唯一参数只是一个执行函数,看起来就好像它自动去解析了执行函数的函数体内容,这就像个魔术一样。
        Mobx 的执行确实像魔术一样神奇,但是就像很多魔术的原理都很简单,Mobx 的依赖收集原理也很简单。解开这个魔术的钥匙就是 “全局变量”
        联系一下上面提供的几个线索:

  1. message 对象是一个 Proxy 对象;
  2. autorun 注册了一个执行函数,执行函数内部有 message.title 的 get 操作;
  3. 对 message.title 进行 set,autorun 的注册函数自动运行;

        让我们解开 autorun 的秘密:

function autorun(trigger) {
  window.globalState = trigger
  trigger()
  window.globalState = null
}

        autorun 函数先将接收的执行函数挂载到 globalState 的全局变量上,接下来立即触发一次执行函数,最后将 globalState 重置为 null。
        我们再改写一下我们的 observable 函数:

 function observable(origin) {
  let listeners = {}
  return new Proxy(origin, {
    // 监听取值操作
    get: function (target, propKey, receiver) {
        if(window.globalState) {
          listeners[propKey] = listeners[propKey] || []
          listeners[propKey] = [...listeners, window.globalState]
        }
        return Reflect.get(target, propKey, receiver);
    },
    // 监听赋值操作
    set: function (target, propKey, value, receiver) {
        listeners[propKey].forEach((fn) => fn())
        return Reflect.set(target, propKey, value, receiver);
    }
  })
}

        新的 observable 函数维护了一个事件队列,在每次对象属性的取值操作时去检查全局的 globalState 属性,如果发现当前取值操作是在一个追踪函数内执行的,就将 globalState 的值放入事件队列中;在每次对象的赋值操作发生时执行一遍事件队列。
        上面的 observable 和 autorun 只用于解释基本原理,不代表 Mobx 的真实实现。

        现在我们对 Mobx 的依赖收集有了更深刻的理解,再让我们回过头去看一下比较难理解的例3:

let message = observable({
    title: "Foo"
})

autorun(() => {
    console.log(message)
})

message.title = "Bar"

        这里的关键在于 console.log 是一个异步的函数,将它代入 autorun:

function autorun(trigger) {
  window.globalState = trigger
  trigger()
  window.globalState = null
}

autorun(() => {
  console.log(message)
})

        让我们解构一下函数执行:

window.globalState = () => console.log(message)
// async
console.log(message)
window.globalState = null

        假设有一个 print 函数可以在控制台同步输出信息,因为 console.log 是异步的,上面的代码执行会变成:

window.globalState = () => console.log(message)
window.globalState = null
print(`{ message: ${ message.title } }`)

        虽然 message.title 做了一次 get 操作,但这时候的 globalState 已经变成 null 了,message 对象的事件队列当然不能注册到这个执行函数。下次遇到类似的问题,你都可以试着把执行函数代入到 autorun 中分析一下,结果就能一目了然了。

        Mobx 对于 autorun 的说明也从侧面验证了我们上面的实现:

当使用autorun时,所提供的函数总是立即被触发一次,然后每次它的依赖关系改变时会再次被触发。

What does mobx react to?

        那么 Mobx 对于什么会做出响应,你现在比以前更清楚一些了吗?

查看原文

赞 2 收藏 0 评论 0

xh4722 发布了文章 · 1月6日

react-hooks toolkit

        React hooks 发布于 React V16.8.0,想了解 react-hooks 基本概念的同学可以参考 官方文档,想深入了解 useEffect 的同学可以看一下 Dan Abramov 写的 A complete guide to useEffect,对于 react-hooks 的 rules 有疑惑的同学可以参考 React hooks:not magic, just arrays 这一篇文章。
        这篇文章的目的在于记录我在学习和应用 react-hooks 过程中的一些感悟,也希望能对你学习和应用 react-hooks 提供帮助。

State Management

        之前 React 对于状态管理没有提供很好的支持,所以我们会依赖 reduxmobx 这些第三方的状态管理库。redux 很好地实践了 immutable 和 pure function;mobx 则是 mutable 和 reactive 的代表。现在使用 React 内置 useReduceruseContext 这两个 hook 可以让我们实现 redux 风格的状态管理;结合 useMemouseEffect 可以模拟 mobx 的 computedreaction。(hooks 没有使用 Proxy,所以跟 mobx 的代理响应是不一样的)
        下面我们使用 hooks 实现一个原生的状态管理库。

Global Store

        跟 redux 一样,我们使用 React Context 实现 global store:

// store.ts
import { useContext, createContext, useReducer } from 'react'
// User 和 Env 是 reducer 文件
import { default as User } from './User'
import { default as Env } from './Env'

// 创建一个全局 context
// 这里使用了 ReturnType 作为 Context 的范型声明
export const GlobalContext = createContext<ReturnType<typeof useGlobalStore>>(
  null
)

// custom hook:封装所有全局数据,用于 GlobalContext.Provider 的 value 属性赋值
export function useGlobalStore() {
  const currentUser = useReducer(User.reducer, User.init())
  const env = useReducer(Env.reducer, Env.init())

  return {
    currentUser,
    env,
  }
}

// custom hook:实现了 GlobalContext.Consumer 的功能
export function useGlobal() {
  return useContext(GlobalContext)
}

        上面的代码定义了 store 模块作为项目的全局状态库,导出了三个实体:

  1. GlobalContext:全局 context,用于链接 store 和相关组件;
  2. useGlobalStore:自定义 hook,将多个数据模块封装在一起,用于 GlobalContext.Provider 的 value 属性;
  3. useGlobal:自定义 hook,用于子组件引用全局 store;

Provider

        下面是使用 store 模块封装的 Provider 组件:

// GlobalProvider.tsx
import React from 'react'
import { GlobalContext, useGlobalStore } from './store'

export function GlobalProvider({ children }) {
  const store = useGlobalStore()

  return (
    <GlobalContext.Provider value={store}>{children}</GlobalContext.Provider>
  )
}

        将 Provider 模块引用到项目根组件:

// App.tsx
import React from 'react'
import { GlobalProvider } from './components'
import { Home } from './Home' 

export function App() {
  return <GlobalProvider><Home /></GlobalProvider>
}

Consumer

        现在可以在 Home 组件里面消费 store:

// Home.tsx
import React from 'react'
import { useGlobal } from './store'

export default function Home() {
  const { currentUser } = useGlobal()
  const [user, dispatch] = currentUser
  
  // 使用 dispatch 修改用户姓名
  function changeName(event) {
    dispatch({
      type: 'update_name',
      payload: event.target.value
    })
  }
  
  return <div>
    <h2>{user.name}</h2>
    <input onChange={changeName} />
  </div>
}

Reducer

        下面是定义 reducer 的 User.ts 代码:

// User.ts
function init() {
  return {
    name: ''
  }
}

const reducer = (state, { type, payload }) => {
  switch(type) {
    case 'UPDATE_NAME': {
      return {
        ...state,
        name: payload
      }
    }
    
    case 'UPDATE': {
      return {
        ...state,
        ...payload
      }
    }
    
    case 'INIT': {
      return init()
    }

    default: {
      return state
    }
  }
}

export default {
  init,
  reducer
}

Reducer Typing

        上面的 store 已经能够正常运作了,但是还有优化空间,我们再次聚焦到 Home.tsx 上,有些同学应该已经发现了问题:

// Home.tsx
import React from 'react'
import { useGlobal } from './store'

export default function Home() {
  const { currentUser } = useGlobal()
  const [user, dispatch] = currentUser
  
  function changeName(event) {
    dispatch({
      // 这里的 update_name 与 User.ts 中定义的 UPDATE_NAME 不一致
      // 这是个 bug,但是只有在运行时会报错
      type: 'update_name',
      payload: event.target.value
    })
  }
  
  return <div>
    <h2>{user.name}</h2>
    <input onChange={changeName} />
  </div>
}

        我们需要加一些类型提示🤔

Have a try

        我们先确定下期望,我们希望 reducer typing 能带给我们的提示有:

  1. 在 Consumer 中调用 dispatch,输入 type 以后会显示该 reducer 相关的所有 action type 名称,输入不存在的 type 会有 错误提示
  2. 在第一步输入正确的 type 以后,再输入 payload,会有对应 type 的 payload 的类型提示,输入不一致的 payload 会有 错误提示

        上面的粗体部分是我们期望 typing 提供的 4 个功能,让我们尝试解决一下:

// User.ts
// IUser 作为用户数据结构类型
type IUser = {
  name: string
}

function init() {
  return {
    name: '',
  }
}

// 使用 Union Types 枚举所有的 action
type ActionType =
  | {
      type: 'INIT'
      payload: void
    }
  | {
      type: 'UPDATE_NAME'
      payload: string
    }
  | {
      type: 'UPDATE'
      payload: IUser
    }

const reducer = (state, { type, payload }: ActionType) => {
  switch (type) {
    case 'UPDATE_NAME': {
      return {
        ...state,
        name: payload
      }
    }
    
    case 'UPDATE': {
      return {
        ...state,
        ...payload
      }
    }
    
    case 'INIT': {
      return init()
    }

    default: {
      return state
    }
  }
}

export default {
  init,
  reducer,
}

        看起来好像很不错,但是。。。

屏幕快照 2020-01-06 下午6.02.01.png

        typescript 把 type 和 payload 两个字段分别做了 union,导致对象展开符报错了。
        我们可以把 payload 定义成 any 解决这个问题,但是就会失去上面期望的对于 paylod 的类型提示。
        一种更健壮的方案是使用 Type Guard,代码看起来会像下面这样:

// User.ts
type IUser = {
  name: string
}

function init() {
  return {
    name: '',
  }
}

type InitAction = {
  type: 'INIT'
  payload: void
}
type UpdateNameAction = {
  type: 'UPDATE_NAME'
  payload: string
}
type UpdateAction = {
  type: 'UPDATE'
  payload: IUser
}
type ActionType = InitAction | UpdateNameAction | UpdateAction

function isInitAction(action): action is InitAction {
  return action.type === 'INIT'
}
function isUpdateNameAction(action): action is UpdateNameAction {
  return action.type === 'UPDATE_NAME'
}
function isUpdateAction(action): action is UpdateAction {
  return action.type === 'UPDATE'
}

const reducer = (state, action: ActionType) => {
  if (isUpdateNameAction(action)) {
    return {
      ...state,
      name: action.payload,
    }
  }

  if (isUpdateAction(action)) {
    return {
      ...state,
      ...action.payload,
    }
  }

  if (isInitAction(action)) {
    return init()
  }

  return state
}

export default {
  init,
  reducer,
}

        我们得到了我们想要的:

  1. 输入 type 以后会显示该 reducer 相关的所有 action type 名称
    屏幕快照 2020-01-06 下午6.27.52.png
  2. 输入不存在的 type 会有 错误提示
    屏幕快照 2020-01-06 下午6.28.26.png
  3. payload 的类型提示
    屏幕快照 2020-01-06 下午7.14.00.png
  4. 输入不一致的 payload 会有 错误提示
    屏幕快照 2020-01-06 下午7.14.51.png

        但是每写一个 action 都要加一个 guard,太浪费宝贵的时间了。让我们用 deox 这个库优化一下我们的代码:

// User.ts
import { createReducer, createActionCreator } from 'deox'

type IUser = {
  name: string
}

function init() {
  return {
    name: '',
  }
}

const reducer = createReducer(init(), action => [
  action(createActionCreator('INIT'), () => init()),
  action(
    createActionCreator('UPDATE', resolve => (payload: IUser) =>
      resolve(payload)
    ),
    (state, { payload }) => ({
      ...state,
      ...payload,
    })
  ),
  action(
    createActionCreator('UPDATE_NAME', resolve => (payload: string) =>
      resolve(payload)
    ),
    (state, { payload }) => ({
      ...state,
      name: payload,
    })
  ),
])

export default {
  init,
  reducer,
}

        让我们再做一些小小的优化,把繁重的 createActionCreator 函数简化一下:

// User.ts
import { createReducer, createActionCreator } from 'deox'

type IUser = {
  name: string
}

function init() {
  return {
    name: '',
  }
}

// 简化以后的 createAction 需要调用两次
// 第一次调用使用类型推断出 action type
// 第二次调用使用范型声明 payload type
function createAction<K extends string>(name: K) {
  return function _createAction<T = void, M = void>() {
    return createActionCreator(name, resolve => (payload: T, meta?: M) =>
      resolve(payload, meta)
    )
  }
}

const reducer = createReducer(init(), action => [
  action(createAction('INIT')(), () => init()),
  action(createAction('UPDATE')<IUser>(), (state, { payload }) => ({
    ...state,
    ...payload,
  })),
  action(createAction('UPDATE_NAME')<string>(), (state, { payload }) => ({
    ...state,
    name: payload,
  })),
])

export default {
  init,
  reducer,
}

        大功告成,可以开始愉快地写代码了😊

useResize

        开发前端页面的时候会遇到很多页面自适应的需求,这个时候子组件就需要根据父组件的宽高来调整自己的尺寸,我们可以开发一个获取父容器 boundingClientRect 的 hook,获取 boundingClientRect 在 React 官网有介绍:

function useClientRect() {
  const [rect, setRect] = useState(null);
  const ref = useCallback(node => {
    if (node !== null) {
      setRect(node.getBoundingClientRect());
    }
  }, []);
  return [rect, ref];
}

        我们扩展一下让它支持监听页面自适应:

// useLayoutRect.ts
import { useState, useEffect, useRef } from 'react'

export function useLayoutRect(): [
  ClientRect,
  React.MutableRefObject<any>
] {
  const [rect, setRect] = useState({
    width: 0,
    height: 0,
    left: 0,
    top: 0,
    bottom: 0,
    right: 0,
  })
  const ref = useRef(null)

  const getClientRect = () => {
    if (ref.current) {
      setRect(ref.current.getBoundingClientRect())
    }
  }

  // 监听 window resize 更新 rect
  useEffect(() => {
    getClientRect()
    window.addEventListener('resize', getClientRect)

    return () => {
      window.removeEventListener('resize', getClientRect)
    }
  }, [])

  return [rect, ref]
}

        你可以这么使用:

export function ResizeDiv() {
  const [rect, ref] = useLayoutRect()
  
  return <div ref={ref}>The width of div is {rect.width}px</div>
}

        如果你的需求只是监听 window resize 的话,这个 hook 写到这里就可以了,但是如果你需要监听其他会引起界面尺寸变更的事件(比如菜单的伸缩)时要怎么办?让我们改造一下 useLayoutRect 让它更加灵活:

// useLayoutRect.ts
import { useState, useEffect, useRef } from 'react'

export function useLayoutRect(): [
  ClientRect,
  React.MutableRefObject<any>,
  () => void
] {
  ...

  const getClientRect = () => {
    if (ref.current) {
      setRect(ref.current.getBoundingClientRect())
    }
  }

  ...
  
  // 额外导出 getClientRect 方法
  return [rect, ref, getClientRect]
}

        上面的代码我们多导出了 getClientRect 方法,然后我们可以组合成新的 useResize hook:

// useResize.ts
import { useLayoutRect } from '@/utils'
import { useGlobal } from './store'
import { useEffect } from 'react'

export function useResize(): [ClientRect, React.MutableRefObject<any>] {
  // menuExpanded 存在 global store 中,用于获取菜单的伸缩状态
  const { env } = useGlobal()
  const [{ menuExpanded }] = env
  const [rect, ref, resize] = useLayoutRect()

  // 因为菜单伸缩有 300ms 的动画,我们需要加个延时
  useEffect(() => {
    setTimeout(resize, 300)
  }, [menuExpanded])

  return [rect, ref]
}

        你可以在 useResize 中添加其他的业务逻辑,hooks 具有很好的组合性和灵活性👍

useReactRouter

        Charles Stover 在 git 上发布了一个很好用的 react-router hook,可以在 functional component 中实现 withRouter 的功能。实现的原理可以参考他的这篇博客 How to convert withRouter to a react hook。使用方法如下:

import useReactRouter from 'use-react-router';

const MyPath = () => {
  const { history, location, match } = useReactRouter();

  return (
    <div>
      My location is {location.pathname}!
    </div>
  )
}

useDidUpdate

        useEffect 的第二个参数传递空数组可以让 effect 只执行第一次,但是 React 没有提供让 useEffect 跳过第一次执行的机制(类似 componentDidUpdate),我们可以自己实现一个 useDidUpdate hook:

// useDidUpdate.ts
import { useRef, useEffect } from 'react'

export function useDidUpdate(fn, deps?: any[]) {
  const didMountRef = useRef(false)

  useEffect(() => {
    if (didMountRef.current) {
      fn()
    } else {
      didMountRef.current = true
    }
  }, deps)
}

usePromise

        在使用 Promise 的时候我们需要追踪 Promise 的进行中(loading)、结果(result)、异常(error)状态,为了避免重复的 then catch 或者 try catch,我们可以提供一个对这些功能开箱即用的 custom hook:

// usePromise.ts
import { useState, useEffect } from 'react'

export function usePromise<T>(
  fn: () => Promise<T>,
  deps?: any[]
): [boolean, T, Error] {
  const [state, setState] = useState({
    loading: false,
    result: null,
    error: null,
  })

  useEffect(() => {
    setState({
      loading: true,
      result: null,
      error: null,
    })

    // fix race condition
    let didCancel = false

    const trigger = async () => {
      let result
      let error
      try {
        result = await fn()
      } catch (err) {
        error = err
      } finally {
        if (!didCancel) {
          setState({
            loading: false,
            result,
            error,
          })
        }
      }
    }

    trigger()

    return () => {
      didCancel = true
    }
  }, deps || [])

  return [state.loading, state.result, state.error]
}

结语

        学习 react hooks 的过程中愈发觉得 hooks 的强大,也更加能理解为什么说 react hooks 是 future。相信随着时间的发展,社区能够创造出越来越的 custom hook,也会涌现出越来越多像 hooks 这样开创性的设计。

查看原文

赞 3 收藏 2 评论 0

xh4722 关注了用户 · 2019-07-19

羽阁丶 @prettypice

coding for fan

关注 7

xh4722 赞了文章 · 2019-02-27

使用 React + Rxjs 实现一个虚拟滚动组件

原文同样发布在知乎专栏
https://zhuanlan.zhihu.com/p/...

为什么使用虚拟列表

在我们的业务场景中遇到这么一个问题,有一个商户下拉框选择列表,我们简单的使用 antd 的 select 组件,发现每次点击下拉框,从点击到弹出会存在很严重的卡顿,在本地测试时,数据库只存在370条左右数据,这个量级的数据都能感到很明显的卡顿了(开发环境约700+ms),更别提线上 2000+ 的数据了。Antd 的 select 性能确实不敢恭维,它会简单的将全部数据 map 出来,在点击的时候初始化并保存在 document.body 下的一个 DOM 节点中缓存起来,这又带来了另一个问题,我们的场景中,商户选择列表很多模块都用到了,每次点击之后都会新生成 2000+ 的 DOM 节点,如果把这些节点都存到 document 下,会造成 DOM 节点数量暴涨。

虚拟列表就是为了解决这种问题而存在的。

虚拟列表原理

虚拟列表本质就是使用少量的 DOM 节点来模拟一个长列表。如下图左所示,不论多长的一个列表,实际上出现在我们视野中的不过只是其中的一部分,这时对我们来说,在视野外的那些 item 就不是必要的存在了,如图左中 item 5 这个元素)。即使去掉了 item 5 (如右图),对于用户来说看到的内容也完全一致。

图片描述

下面我们来一步步将步骤分解,具体代码可以查看 Online Demo

这里是我通过这种思想实现的一个库,功能会更完善些。

https://github.com/musicq/vist

创建适合容器高度的 DOM 元素

以上图为例,想象一个拥有 1000 元素的列表,如果使用上图左的方式的话,就需要创建 1000 个 DOM 节点添加在 document 中,而其实每次出现在视野中的元素,只有4个,那么剩余的 996 个元素就是浪费。而如果就只创建 4 个 DOM 节点的话,这样就能节省 996 个DOM 节点的开销。

解题思路

真实 DOM 数量 = Math.ceil(容器高度 / 条目高度)

定义组件有如下接口

interface IVirtualListOptions {
  height: number
}

interface IVirtualListProps {
  data$: Observable<string[]>
  options$: Observable<IVirtualListOptions>
}

首先需要有一个容器高度的流来装载容器高度

 private containerHeight$ = new BehaviorSubject<number>(0)

需要在组件 mount 之后,才能测量容器的真实高度。可以通过一个 ref 来绑定容器元素,在 componentDidMount 之后,获取容器高度,并通知 containerHeight$

this.containerHeight$.next(virtualListContainerElm.clientHeight)

获取了容器高度之后,根据上面的公式来计算视窗内应该显示的 DOM 数量

const actualRows$ = combineLatest(this.containerHeight$, this.props.options$).pipe(
    map(([ch, { height }]) => Math.ceil(ch / height))
)

通过组合 actualRows$data$ 两个流,来获取到应当出现在视窗内的数据切片

const dataInViewSlice$ = combineLatest(this.props.data$, actualRows$).pipe(
    map(([data, actualRows]) => data.slice(0, actualRows))
)

这样,一个当前时刻的数据源就获取到了,订阅它来将列表渲染出来

dataInViewSlice$.subscribe(data => this.setState({ data }))

效果

图片描述

给定的数据有 1000 条,只渲染了前 7 条数据出来,这符合预期。

现在存在另一个问题,容器的滚动条明显不符合 1000 条数据该有的高度,因为我们只有 7 条真实 DOM,没有办法将容器撑开。

撑开容器

在原生的列表实现中,我们不需要处理任何事情,只需要把 DOM 添加到 document 中就可以了,浏览器会计算容器的真实高度,以及滚动到什么位置会出现什么元素。但是虚拟列表不会,这就需要我们自行解决容器的高度问题。

为了能让容器看起来和真的拥有1000条数据一样,就需要将容器的高度撑开到 1000 条元素该有的高度。这一步很容易,参考下面公式

解题思路

真实容器高度 = 数据总数 * 每条 item 的高度

将上述公式换成代码

const scrollHeight$ = combineLatest(this.props.data$, this.props.options$).pipe(
    map(([data, { height }]) => data.length * height)
)

效果

图片描述

这样看起来就比较像有 1000 个元素的列表了。

但是滚动之后发现,下面全是空白的,由于列表只存在7个元素,空白是正常的。而我们期望随着滚动,元素能正确的出现在视野中。

滚动列表

这里有三种实现方式,而前两种基本一样,只有细微的差别,我们先从最初的方案说起。

完全重刷列表

这种方案是最简单的实现,我们只需要在列表滚动到某一位置的时候,去计算出当前的视窗中列表的索引,有了索引就能得到当前时刻的数据切片,从而将数据渲染到视图中。

为了让列表效果更好,我们将渲染的真实 DOM 数量多增加 3 个

const actualRows$ = combineLatest(this.containerHeight$, this.props.options$).pipe(
    map(([ch, { height }]) => Math.ceil(ch / height) + 3)
)

首先定义一个视窗滚动事件流

const scrollWin$ = fromEvent(virtualListElm, 'scroll').pipe(
    startWith({ target: { scrollTop: 0 } })
)

在每次滚动的时候去计算当前状态的索引

const shouldUpdate$ = combineLatest(
    scrollWin$.pipe(map(() => virtualListElm.scrollTop)),
    this.props.options$,
    actualRows$
).pipe(
    // 计算当前列表中最顶部的索引
    map(([st, { height }, actualRows]) => {
        const firstIndex = Math.floor(st / height)
        const lastIndex = firstIndex + actualRows - 1
        return [firstIndex, lastIndex]
    })
)

这样就能在每一次滚动的时候得到视窗内数据的起止索引了,接下来只需要根据索引算出 data 切片就好了。

const dataInViewSlice$ = combineLatest(this.props.data$, shouldUpdate$).pipe(
    map(([data, [firstIndex, lastIndex]]) => data.slice(firstIndex, lastIndex + 1))
);

拿到了正确的数据,还没完,想象一下,虽然我们随着滚动的发生计算出了正确的数据切片,但是正确的数据却没有出现在正确的位置,因为他们的位置是固定不变的。

因此还需要对元素的位置做位移(逮虾户)的操作,首先修改一下传给视图的数据结构

const dataInViewSlice$ = combineLatest(
    this.props.data$,
    this.props.options$,
    shouldUpdate$
).pipe(
    map(([data, { height }, [firstIndex, lastIndex]]) => {
        return data.slice(firstIndex, lastIndex + 1).map(item => ({
            origin: item,
            // 用来定位元素的位置
            $pos: firstIndex * height,
            $index: firstIndex++
        }))
    })
);

接下把 HTML 结构也做一下修改,将每一个元素的位移添加进去

this.state.data.map(data => (
  <div
    key={data.$index}
    style={{
      position: 'absolute',
      width: '100%',
      // 定位每一个 item
      transform: `translateY(${data.$pos}px)`
    }}
  >
    {(this.props.children as any)(data.origin)}
  </div>
))

这样就完成了一个虚拟列表的基本形态和功能了。

效果如下

图片描述

但是这个版本的虚拟列表并不完美,它存在以下几个问题

  1. 计算浪费
  2. DOM 节点的创建和移除

计算浪费

每次滚动都会使得 data 发生计算,虽然借助 virtual DOM 会将不必要的 DOM 修改拦截掉,但是还是会存在计算浪费的问题。

实际上我们确实应该触发更新的时机是在当前列表的索引发生了变化的时候,即开始我的列表索引为 [0, 1, 2],滚动之后,索引变为了 [1, 2, 3],这个时机是我们需要更新视图的时机。借助于 rxjs 的操作符,可以很轻松的搞定这个事情,只需要把 shouldUpdate$ 流做一次过滤操作即可。

const shouldUpdate$ = combineLatest(
  scrollWin$.pipe(map(() => virtualListElm.scrollTop)),
  this.props.options$,
  actualRows$
).pipe(
  // 计算当前列表中最顶部的索引
  map(([st, { height }, actualRows]) => [Math.floor(st / height), actualRows]),
  // 如果索引有改变,才触发重新 render
  filter(([curIndex]) => curIndex !== this.lastFirstIndex),
  // update the index
  tap(([curIndex]) => this.lastFirstIndex = curIndex),
  map(([firstIndex, actualRows]) => {
    const lastIndex = firstIndex + actualRows - 1
    return [firstIndex, lastIndex]
  })
)

效果

图片描述

DOM 节点的创建和移除

如果仔细对比会发现,每次列表发生更新之后,是会发生 DOM 的创建和删除的,如下图所示,在滚动了之后,原先位于列表中的第一个节点被移除了。

图片描述

而我期望的理想的状态是,能够重用 DOM,不去删除和创建它们,这就是第二个版本的实现。

复用 DOM 重刷列表

为了达到节点的复用,我们需要将列表的 key 设置为数组索引,而非一个唯一的 id,如下

this.state.data.map((data, i) => <div key={i}>{data}</div>)

只需要这一点改动,再看看效果

图片描述

可以看到数据变了,但是 DOM 并没有被移除,而是被复用了,这是我想要的效果。

观察一下这个版本的实现与上一版本有何区别

图片描述

是的,这个版本,每一次 render 都会使得整个列表样式发生变化,而且还有一个问题,就是列表滚动到最后的时候,会发生 DOM 减少的情况,虽然并不影响显示,但是还是有 DOM 的创建和移除的问题存在。

复用 DOM + 按需更新列表

为了能让列表只按照需要进行更新,而不是全部重刷,我们就需要明确知道有哪些 DOM 节点被移出了视野范围,操作这些视野范围外的节点来补充列表,从而完成列表的按需更新,如下图

图片描述

假设用户在向下滚动列表的时候,item 1 的 DOM 节点被移出了视野,这时我们就可以把它移动到 item 5 的位置,从而完成一次滚动的连续,这里我们只改变了元素的位置,并没有创建和删除 DOM

dataInViewSlice$ 流依赖props.data$props.options$shouldUpdate$三个流来计算出当前时刻的 data 切片,而视图的数据完全是根据 dataInViewSlice$ 来渲染的,所以如果想要按需更新列表,我们就需要在这个流里下手。

在容器滚动的过程中存在如下几种场景

  1. 用户慢慢地向上或者向下滚动:移出视野的元素是一个接一个的
  2. 用户直接跳转到列表的一个指定位置:这时整个列表都可能完全移出视野

但是这两种场景其实都可以归纳为一种情况,都是求前一种状态与当前状态之间的索引差集

实现

dataInViewSlice$ 流中需要做两步操作。第一,在初始加载,还没有数组的时候,填充一个数组出来;第二,根据滚动到当前时刻时的起止索引,计算出二者的索引差集,更新数组,这一步便是按需更新的核心所在。

先来实现第一步,只需要稍微改动一下原先的 dataInViewSlice$ 流的 map 实现即可完成初始数据的填充

const dataSlice = this.stateDataSnapshot;

if (!dataSlice.length) {
  return this.stateDataSnapshow = data.slice(firstIndex, lastIndex + 1).map(item => ({
    origin: item,
    $pos: firstIndex * height,
    $index: firstIndex++
  }))
}

接下来完成按需更新数组的部分,首先需要知道滚动前后两种状态之间的索引差异,比如滚动前的索引为 [0,1,2],滚动后的索引为 [1,2,3],那么他们的差集就是 [0],说明老数组中的第一个元素被移出了视野,那么就需要用这第一个元素来补充到列表最后,成为最后一个元素。

首先将数组差集求出来

// 获取滚动前后索引差集
const diffSliceIndexes = this.getDifferenceIndexes(dataSlice, firstIndex, lastIndex);

有了差集就可以计算新的数组组成了。还以此图为例,用户向下滚动,当元素被移除视野的时候,第一个元素(索引为0)就变成最后一个元素(索引为4),也就是,oldSlice [0,1,2,3] -> newSlice [1,2,3,4]

图片描述

在变换的过程中,[1,2,3] 三个元素始终是不需要动的,因此我们只需要截取不变的 [1,2,3]再加上新的索引 4 就能变成 [1,2,3,4]了。

// 计算视窗的起始索引
let newIndex = lastIndex - diffSliceIndexes.length + 1;

diffSliceIndexes.forEach(index => {
  const item = dataSlice[index];
  item.origin = data[newIndex];
  item.$pos = newIndex * height;
  item.$index = newIndex++;
});

return this.stateDataSnapshot = dataSlice;

这样就完成了一个向下滚动的数组拼接,如下图所示,DOM 确实是只更新超出视野的元素,而没有重刷整个列表。

图片描述

但是这只是针对向下滚动的,如果往上滚动,这段代码就会出问题。原因也很明显,数组在向下滚动的时候,是往下补充元素,而向上滚动的时候,应该是向上补充元素。如 [1,2,3,4] -> [0,1,2,3],对它的操作是 [1,2,3] 保持不变,而 4号元素变成了 0号元素,所以我们需要根据不同的滚动方向来补充数组。

先创建一个获取滚动方向的流 scrollDirection$

// scroll direction Down/Up
const scrollDirection$ = scrollWin$.pipe(
  map(() => virtualListElm.scrollTop),
  pairwise(),
  map(([p, n]) => n - p > 0 ? 1 : -1),
  startWith(1)
);

scrollDirection$ 流加入到 dataInViewSlice$ 的依赖中

const dataInViewSlice$ = combineLatest(this.props.data$, this.options$, shouldUpdate$).pipe(
  withLatestFrom(scrollDirection$)
)

有了滚动方向,我们只需要修改 newIndex 就好了

// 向下滚动时 [0,1,2,3] -> [1,2,3,4] = 3
// 向上滚动时 [1,2,3,4] -> [0,1,2,3] = 0
let newIndex = dir > 0 ? lastIndex - diffSliceIndexes.length + 1 : firstIndex;

至此,一个功能完善的按需更新的虚拟列表就基本完成了,效果如下

图片描述

是不是还差了什么?

没错,我们还没有解决列表滚动到最后时会创建、删除 DOM 的问题了。

分析一下问题原因,应该能想到是 shouldUpdate$ 这里在最后一屏的时候,计算出来的索引与最后一个索引的差小于了 actualRows$ 中计算出来的数,所以导致了列表数量的变化,知道了原因就好解决问题了。

我们只需要计算出数组在维持真实 DOM 数量不变的情况下,最后一屏的起始索引应为多少,再和计算出来的视窗中第一个元素的索引进行对比,取二者最小为下一时刻的起始索引。

计算最后一屏的索引时需要得知 data 的长度,所以先将 data 依赖拉进来

const shouldUpdate$ = combineLatest(
  scrollWin$.pipe(map(() => virtualListElm.scrollTop)),
  this.props.data$,
  this.props.options$,
  actualRows$
)

然后来计算索引

// 计算当前列表中最顶部的索引
map(([st, data, { height }, actualRows]) => {
  const firstIndex = Math.floor(st / height)
  // 在维持 DOM 数量不变的情况下计算出的索引
  const maxIndex = data.length - actualRows < 0 ? 0 : data.length - actualRows;
  // 取二者最小作为起始索引
  return [Math.min(maxIndex, firstIndex), actualRows];
})

这样就真正完成了完全复用 DOM + 按需更新 DOM 的虚拟列表组件。


Github

https://github.com/musicq/vist

上述代码具体请看在线 DEMO

Online Demo

查看原文

赞 23 收藏 16 评论 0

xh4722 赞了文章 · 2018-12-26

RxJS: map, flatMap和flatMapLatest的区别

在这篇文章中我会对map,flatMapflatMapLatest三个操作符进行比较,下面我们来举个例子。

let stream = Observable.interval(1000).take(10);
return stream.map(n => n * 2);

上面的代码模拟了异步行为,每隔1s发射一个数字。这个例子很简单,你会随着时间推移得到一连串的数字。

AAEAAQAAAAAAAAltAAAAJDEyYTNhMjQ4LTIxYWEtNDMwNi1iZGM2LThkOTMxYzBhNGZhZA.png

我们再来看另一个例子。

let stream = Observable.interval(1000).take(10);
return stream.map(n => Observable.timer(500).map(() => n));

这里stream会返回一个Observable而不是数字。

AAEAAQAAAAAAAAetAAAAJDkwMGVhNTIzLWMyMGMtNGEyZi04MTZhLWQ2ZjQ2Njc4ZTI4ZQ.png

如果我想要拿到那些数字,我该怎么办?

let stream = Observable.interval(1000).take(10);
return stream.flatMap(n => Observable.timer(500).map(() => n));

这里使用了flatMap而不是mapflatMap将响应数据“打平”,也就是说把映射后新的Observable转化为了数据流,订阅之后会获得这个新Observable发射的数据,而不是Observable本身。

AAEAAQAAAAAAAAcrAAAAJDc2NDA3NmExLWJiNjItNDdmNC05ZDBhLTczNzZjNTIwNTQ3Yw.png

译者注:flatMap有一个很适用的场景,就是搜索框。在用户输入一串字符后,将其发送到服务器并获取搜索结果,这里就涉及到两个Observable

Observable
.fromEvent($input, 'keyup')
.flatMap(text => getHttpResponse(text))
.subscribe(data => console.log(data))

使用flatMap就可以直接获取到新的Observable返回的数据。但是这里存在一个问题,如果用户有多次输入,由于网络原因可能会发生前一次响应时间比后一次长的情况,这时后一次的结果就被覆盖了。
flatMapLatest可以解决这个问题。如果之前的Observable还没有未触发,而又收到了新的ObservableflatMapLatest会取消之前的Observable,只处理最新收到的Observable,这样就保证了处理请求的先后顺序,flatMapLatestRxJS 5.x中已更名为switchMap

查看原文

赞 16 收藏 13 评论 1

xh4722 赞了文章 · 2018-11-01

用可视化来理解switchMap, concatMap, flatMap,exhaustMap

Rxjs中有三种操作符都是用作从主流转换到从流上的,它们分别是switchMapconcatMapflatMapexhaustMapRxViz可以将流进行可视化,我们就利用RxViz来一探这四种操作符的异同。

一、相同点

总体上来说,四个操作符的作用都是:存在一个主流和一个从流,主流上每发射一次数据都会触发从流开始发射数据,最后数据都被打平到同一个输出流中,如下图所示。
图片描述

二、不同点

以下四个例子,主流是每500毫秒发射一次数据,从流是每200毫秒发射数据,每次主流发射数据都会触发从流。

1. switchMap

  • 代码:
var mainstream = Rx.Observable.interval(500);
mainstream.switchMap((x) => Rx.Observable.interval(200).take(5));
  • 可视化演示:

clipboard.png

  • 分析:

从结果可以看到,用switchMap的时候,从流每次只能发射2个数据0-1,这是因为主流每发射一次触发了从流的发射,但是在从流发射的过程中,如果主流又一次发射了数据,switchMap会截断上一次的从流,响应本次的主流,从而开启新的一段的从流发射。

2. concatMap

  • 代码:
var mainstream= Rx.Observable.interval(500);
mainstream.concatMap((x) => Rx.Observable.interval(200).take(5));
  • 可视化演示:

clipboard.png

  • 分析:

从结果可以看到,用concatMap的时候,虽然在从流还没有结束的时候,主流还在发射数据,主流会先把发射的数据缓存起来,等从流结束后立即响应主流的数据从而引发新一轮的从流发射,这有些类似与js的消息队列机制。所以我们看到它的输出流响应是连续的。

3. flatMap / mergeMap

代码:

var mainstream= Rx.Observable.interval(500);
mainstream.flatMap((x) => Rx.Observable.interval(200).take(5));
  • 可视化演示:

clipboard.png

分析:
从结果可以看出来,flatMap/mergeMap会即使响应主流中发射的每一个数据,它既不会忽略也不会缓存,这就导致主流中数据对应的从流产生了叠加。

4. exhaustMap

  • 代码:
var mainstream= Rx.Observable.interval(500);
mainstream.exhaustMap((x) => Rx.Observable.interval(200).take(5));
  • 可视化演示:

clipboard.png

  • 分析:

从结果可以看出,exhaustMap在从流还没有结束的时候如果主流仍然有数据在发射,它会忽略此时主流发射的数据,而在从流结束以后才会去响应主流中发射的数据。

查看原文

赞 15 收藏 11 评论 3

xh4722 赞了文章 · 2018-09-14

React 高阶组件(HOC)入门指南

  之前的文章React Mixins入门指南介绍了React Mixin的使用。在实际使用中React Mixin的作用还是非常强大的,能够使得我们在多个组件中共用相同的方法。但是工程中大量使用Mixin也会带来非常多的问题。Dan Abramov在文章[Mixins Considered Harmful
](https://facebook.github.io/re...介绍了Mixin带来的一些问题,总结下来主要是以下几点:

  • 破坏组件封装性: Mixin可能会引入不可见的属性。例如在渲染组件中使用Mixin方法,给组件带来了不可见的属性(props)和状态(state)。并且Mixin可能会相互依赖,相互耦合,不利于代码维护。

  • 不同的Mixin中的方法可能会相互冲突

  为了处理上述的问题,React官方推荐使用高阶组件(High Order Component)

高阶组件(HOC)

  刚开始学习高阶组件时,这个概念就透漏着高级的气味,看上去就像是一种先进的编程技术的一个深奥术语,毕竟名字里就有"高阶"这种字眼,实质上并不是如此。高阶组件的概念应该是来源于JavaScript的高阶函数:

高阶函数就是接受函数作为输入或者输出的函数

  这么看来柯里化也是高阶函数了。React官方定义高阶组件的概念是:

A higher-order component is a function that takes a component and returns a new component.

  (本人也翻译了React官方文档的Advanced Guides部分,官方的高阶组件中文文档戳这里)

  这么看来,高阶组件仅仅只是是一个接受组件组作输入并返回组件的函数。看上去并没有什么,那么高阶组件能为我们带来什么呢?首先看一下高阶组件是如何实现的,通常情况下,实现高阶组件的方式有以下两种:

  1. 属性代理(Props Proxy)

  2. 反向继承(Inheritance Inversion)

属性代理

  又是一个听起来很高大上的名词,实质上是通过包裹原来的组件来操作props,举个简单的例子:


import React, { Component } from 'React';
//高阶组件定义
const HOC = (WrappedComponent) =>
  class WrapperComponent extends Component {
    render() {
      return <WrappedComponent {...this.props} />;
    }
}
//普通的组件
class WrappedComponent extends Component{
    render(){
        //....
    }
}

//高阶组件使用
export default HOC(WrappedComponent)

  上面的例子非常简单,但足以说明问题。我们可以看见函数HOC返回了新的组件(WrapperComponent),这个组件原封不动的返回作为参数的组件(也就是被包裹的组件:WrappedComponent),并将传给它的参数(props)全部传递给被包裹的组件(WrappedComponent)。这么看起来好像并没有什么作用,其实属性代理的作用还是非常强大的。

操作props

  我们看到之前要传递给被包裹组件WrappedComponent的属性首先传递给了高阶组件返回的组件(WrapperComponent),这样我们就获得了props的控制权(这也就是为什么这种方法叫做属性代理)。我们可以按照需要对传入的props进行增加、删除、修改(当然修改带来的风险需要你自己来控制),举个例子:

const HOC = (WrappedComponent) =>
    class WrapperComponent extends Component {
        render() {
            const newProps = {
                name: 'HOC'
            }
            return <WrappedComponent
                {...this.props}
                {...newProps}
            />;
        }
    }

  在上面的例子中,我们为被包裹组件(WrappedComponent)新增加了固定的name属性,因此WrappedComponent组件中就会多一个name的属性。

获得refs的引用

  我们在属性代理中,可以轻松的拿到被包裹的组件的实例引用(ref),例如:

import React, { Component } from 'React';
 
const HOC = (WrappedComponent) =>
    class wrapperComponent extends Component {
        storeRef(ref) {
            this.ref = ref;
        }
        render() {
            return <WrappedComponent
                {...this.props}
                ref = {::this.storeRef}
            />;
        }
    }

  上面的例子中,wrapperComponent渲染接受后,我们就可以拿到WrappedComponent组件的实例,进而实现调用实例方法的操作(当然这样会在一定程度上是反模式的,不是非常的推荐)。

抽象state

  属性代理的情况下,我们可以将被包裹组件(WrappedComponent)中的状态提到包裹组件中,一个常见的例子就是实现不受控组件受控的组件的转变(关于不受控组件和受控组件戳这里)

class WrappedComponent extends Component {
    render() {
        return <input name="name" {...this.props.name} />;
    }
}

const HOC = (WrappedComponent) =>
    class extends Component {
        constructor(props) {
            super(props);
            this.state = {
                name: '',
            };

            this.onNameChange = this.onNameChange.bind(this);
        }

        onNameChange(event) {
            this.setState({
                name: event.target.value,
            })
        }

        render() {
            const newProps = {
                name: {
                    value: this.state.name,
                    onChange: this.onNameChange,
                },
            }
            return <WrappedComponent {...this.props} {...newProps} />;
        }
    }

  上面的例子中通过高阶组件,我们将不受控组件(WrappedComponent)成功的转变为受控组件.

用其他元素包裹组件

  我们可以通过类似:

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

  这种方式将被包裹组件包裹起来,来实现布局或者是样式的目的。

  在属性代理这种方式实现的高阶组件,以上述为例,组件的渲染顺序是: 先WrappedComponent再WrapperComponent(执行ComponentDidMount的时间)。而卸载的顺序是先WrapperComponent再WrappedComponent(执行ComponentWillUnmount的时间)。

反向继承

  反向继承是指返回的组件去继承之前的组件(这里都用WrappedComponent代指)

const HOC = (WrappedComponent) =>
  class extends WrappedComponent {
    render() {
      return super.render();
    }
  }

   我们可以看见返回的组件确实都继承自WrappedComponent,那么所有的调用将是反向调用的(例如:super.render()),这也就是为什么叫做反向继承。

渲染劫持

  渲染劫持是指我们可以有意识地控制WrappedComponent的渲染过程,从而控制渲染控制的结果。例如我们可以根据部分参数去决定是否渲染组件:

const HOC = (WrappedComponent) =>
  class extends WrappedComponent {
    render() {
      if (this.props.isRender) {
        return super.render();
      } else {
        return null;
      }
    }
  }

  甚至我们可以修改修改render的结果:

//例子来源于《深入React技术栈》

const HOC = (WrappedComponent) =>
    class extends WrappedComponent {
        render() {
            const elementsTree = super.render();
            let newProps = {};
            if (elementsTree && elementsTree.type === 'input') {
                newProps = {value: 'may the force be with you'};
            }
            const props = Object.assign({}, elementsTree.props, newProps);
            const newElementsTree = React.cloneElement(elementsTree, props, elementsTree.props.children);
            return newElementsTree;
    }
}
class WrappedComponent extends Component{
    render(){
        return(
            <input value={'Hello World'} />
        )
    }
}
export default HOC(WrappedComponent)
//实际显示的效果是input的值为"may the force be with you"

  上面的例子中我们将WrappedComponent中的input元素value值修改为:may the force be with you。我们可以看到前后elementTree的区别:
elementsTree:

elementsTree
newElementsTree:

newElementsTree

  在反向继承中,我们可以做非常多的操作,修改state、props甚至是翻转Element Tree。反向继承有一个重要的点: 反向继承不能保证完整的子组件树被解析,开始我对这个概念也不理解,后来在看了React Components, Elements, and Instances这篇文章之后对这个概念有了自己的一点体会。
React Components, Elements, and Instances这篇文章主要明确了一下几个点:

  • 元素(element)是一个是用DOM节点或者组件来描述屏幕显示的纯对象,元素可以在属性(props.children)中包含其他的元素,一旦创建就不会改变。我们通过JSXReact.createClass创建的都是元素。

  • 组件(component)可以接受属性(props)作为输入,然后返回一个元素树(element tree)作为输出。有多种实现方式:Class或者函数(Function)。

  所以, 反向继承不能保证完整的子组件树被解析的意思的解析的元素树中包含了组件(函数类型或者Class类型),就不能再操作组件的子组件了,这就是所谓的不能完全解析。举个例子:

import React, { Component } from 'react';

const MyFuncComponent = (props)=>{
    return (
        <div>Hello World</div>
    );
}

class MyClassComponent extends Component{

    render(){
        return (
            <div>Hello World</div>
        )
    }

}

class WrappedComponent extends Component{
    render(){
        return(
            <div>
                <div>
                    <span>Hello World</span>
                </div>
                <MyFuncComponent />
                <MyClassComponent />
            </div>

        )
    }
}

const HOC = (WrappedComponent) =>
    class extends WrappedComponent {
        render() {
            const elementsTree = super.render();
            return elementsTree;
        }
    }

export default HOC(WrappedComponent);

element tree1
element tree2

  我们可以查看解析的元素树(element tree),div下的span是可以被完全被解析的,但是MyFuncComponentMyClassComponent都是组件类型的,其子组件就不能被完全解析了。

操作props和state

  在上面的图中我们可以看到,解析的元素树(element tree)中含有propsstate(例子的组件中没有state),以及refkey等值。因此,如果需要的话,我们不仅可以读取propsstate,甚至可以修改增加、修改和删除。

  在某些情况下,我们可能需要为高阶属性传入一些参数,那我们就可以通过柯里化的形式传入参数,例如:

import React, { Component } from 'React';

const HOCFactoryFactory = (...params) => {
    // 可以做一些改变 params 的事
    return (WrappedComponent) => {
        return class HOC extends Component {
            render() {
                return <WrappedComponent {...this.props} />;
            }
        }
    }
}

可以通过下面方式使用:

HOCFactoryFactory(params)(WrappedComponent)

  这种方式是不是非常类似于React-Redux库中的connect函数,因为connect也是类似的一种高阶函数。反向继承不同于属性代理的调用顺序,组件的渲染顺序是: 先WrappedComponent再WrapperComponent(执行ComponentDidMount的时间)。而卸载的顺序也是先WrappedComponent再WrapperComponent(执行ComponentWillUnmount的时间)。

HOC和Mixin的比较

  借用《深入React技术栈》一书中的图:
HOCandMixin

  高阶组件属于函数式编程(functional programming)思想,对于被包裹的组件时不会感知到高阶组件的存在,而高阶组件返回的组件会在原来的组件之上具有功能增强的效果。而Mixin这种混入的模式,会给组件不断增加新的方法和属性,组件本身不仅可以感知,甚至需要做相关的处理(例如命名冲突、状态维护),一旦混入的模块变多时,整个组件就变的难以维护,也就是为什么如此多的React库都采用高阶组件的方式进行开发。

查看原文

赞 14 收藏 18 评论 3

xh4722 收藏了文章 · 2018-09-03

30分钟,让你彻底明白Promise原理

原文链接

前言

前一阵子记录了promise的一些常规用法,这篇文章再深入一个层次,来分析分析promise的这种规则机制是如何实现的。ps:本文适合已经对promise的用法有所了解的人阅读,如果对其用法还不是太了解,可以移步我的上一篇博文

本文的promise源码是按照Promise/A+规范来编写的(不想看英文版的移步Promise/A+规范中文翻译

引子

为了让大家更容易理解,我们从一个场景开始讲解,让大家一步一步跟着思路思考,相信你一定会更容易看懂。

考虑下面一种获取用户id的请求处理

//例1
function getUserId() {
    return new Promise(function(resolve) {
        //异步请求
        http.get(url, function(results) {
            resolve(results.id)
        })
    })
}

getUserId().then(function(id) {
    //一些处理
})

getUserId方法返回一个promise,可以通过它的then方法注册(注意注册这个词)在promise异步操作成功时执行的回调。这种执行方式,使得异步调用变得十分顺手。

原理剖析

那么类似这种功能的Promise怎么实现呢?其实按照上面一句话,实现一个最基础的雏形还是很easy的。

极简promise雏形

function Promise(fn) {
    var value = null,
        callbacks = [];  //callbacks为数组,因为可能同时有很多个回调

    this.then = function (onFulfilled) {
        callbacks.push(onFulfilled);
    };

    function resolve(value) {
        callbacks.forEach(function (callback) {
            callback(value);
        });
    }

    fn(resolve);
}

上述代码很简单,大致的逻辑是这样的:

  1. 调用then方法,将想要在Promise异步操作成功时执行的回调放入callbacks队列,其实也就是注册回调函数,可以向观察者模式方向思考;
  2. 创建Promise实例时传入的函数会被赋予一个函数类型的参数,即resolve,它接收一个参数value,代表异步操作返回的结果,当一步操作执行成功后,用户会调用resolve方法,这时候其实真正执行的操作是将callbacks队列中的回调一一执行;

可以结合例1中的代码来看,首先new Promise时,传给promise的函数发送异步请求,接着调用promise对象的then属性,注册请求成功的回调函数,然后当异步请求发送成功时,调用resolve(results.id)方法, 该方法执行then方法注册的回调数组。

相信仔细的人应该可以看出来,then方法应该能够链式调用,但是上面的最基础简单的版本显然无法支持链式调用。想让then方法支持链式调用,其实也是很简单的:

this.then = function (onFulfilled) {
    callbacks.push(onFulfilled);
    return this;
};

see?只要简单一句话就可以实现类似下面的链式调用:

// 例2
getUserId().then(function (id) {
    // 一些处理
}).then(function (id) {
    // 一些处理
});

加入延时机制

细心的同学应该发现,上述代码可能还存在一个问题:如果在then方法注册回调之前,resolve函数就执行了,怎么办?比如promise内部的函数是同步函数:

// 例3
function getUserId() {
    return new Promise(function (resolve) {
        resolve(9876);
    });
}
getUserId().then(function (id) {
    // 一些处理
});

这显然是不允许的,Promises/A+规范明确要求回调需要通过异步方式执行,用以保证一致可靠的执行顺序。因此我们要加入一些处理,保证在resolve执行之前,then方法已经注册完所有的回调。我们可以这样改造下resolve函数:

function resolve(value) {
    setTimeout(function() {
        callbacks.forEach(function (callback) {
            callback(value);
        });
    }, 0)
} 

上述代码的思路也很简单,就是通过setTimeout机制,将resolve中执行回调的逻辑放置到JS任务队列末尾,以保证在resolve执行时,then方法的回调函数已经注册完成.

但是,这样好像还存在一个问题,可以细想一下:如果Promise异步操作已经成功,这时,在异步操作成功之前注册的回调都会执行,但是在Promise异步操作成功这之后调用的then注册的回调就再也不会执行了,这显然不是我们想要的。

加入状态

恩,为了解决上一节抛出的问题,我们必须加入状态机制,也就是大家熟知的pendingfulfilledrejected

Promises/A+规范中的2.1Promise States中明确规定了,pending可以转化为fulfilledrejected并且只能转化一次,也就是说如果pending转化到fulfilled状态,那么就不能再转化到rejected。并且fulfilledrejected状态只能由pending转化而来,两者之间不能互相转换。一图胜千言:

alt promise state

改进后的代码是这样的:

function Promise(fn) {
    var state = 'pending',
        value = null,
        callbacks = [];

    this.then = function (onFulfilled) {
        if (state === 'pending') {
            callbacks.push(onFulfilled);
            return this;
        }
        onFulfilled(value);
        return this;
    };

    function resolve(newValue) {
        value = newValue;
        state = 'fulfilled';
        setTimeout(function () {
            callbacks.forEach(function (callback) {
                callback(value);
            });
        }, 0);
    }

    fn(resolve);
}

上述代码的思路是这样的:resolve执行时,会将状态设置为fulfilled,在此之后调用then添加的新回调,都会立即执行。

这里没有任何地方将state设为rejected,为了让大家聚焦在核心代码上,这个问题后面会有一小节专门加入。

链式Promise

那么这里问题又来了,如果用户再then函数里面注册的仍然是一个Promise,该如何解决?比如下面的例4

// 例4
getUserId()
    .then(getUserJobById)
    .then(function (job) {
        // 对job的处理
    });

function getUserJobById(id) {
    return new Promise(function (resolve) {
        http.get(baseUrl + id, function(job) {
            resolve(job);
        });
    });
}

这种场景相信用过promise的人都知道会有很多,那么类似这种就是所谓的链式Promise

链式Promise是指在当前promise达到fulfilled状态后,即开始进行下一个promise(后邻promise)。那么我们如何衔接当前promise和后邻promise呢?(这是这里的难点)。

其实也不是辣么难,只要在then方法里面return一个promise就好啦。Promises/A+规范中的2.2.7就是这么说哒(微笑脸)~

下面来看看这段暗藏玄机的then方法和resolve方法改造代码:


function Promise(fn) {
    var state = 'pending',
        value = null,
        callbacks = [];

    this.then = function (onFulfilled) {
        return new Promise(function (resolve) {
            handle({
                onFulfilled: onFulfilled || null,
                resolve: resolve
            });
        });
    };

    function handle(callback) {
        if (state === 'pending') {
            callbacks.push(callback);
            return;
        }
        //如果then中没有传递任何东西
        if(!callback.onFulfilled) {
            callback.resolve(value);
            return;
        }

        var ret = callback.onFulfilled(value);
        callback.resolve(ret);
    }

    
    function resolve(newValue) {
        if (newValue && (typeof newValue === 'object' || typeof newValue === 'function')) {
            var then = newValue.then;
            if (typeof then === 'function') {
                then.call(newValue, resolve);
                return;
            }
        }
        state = 'fulfilled';
        value = newValue;
        setTimeout(function () {
            callbacks.forEach(function (callback) {
                handle(callback);
            });
        }, 0);
    }

    fn(resolve);
}

我们结合例4的代码,分析下上面的代码逻辑,为了方便阅读,我把例4的代码贴在这里:

// 例4
getUserId()
    .then(getUserJobById)
    .then(function (job) {
        // 对job的处理
    });

function getUserJobById(id) {
    return new Promise(function (resolve) {
        http.get(baseUrl + id, function(job) {
            resolve(job);
        });
    });
}
  1. then方法中,创建并返回了新的Promise实例,这是串行Promise的基础,并且支持链式调用。
  2. handle方法是promise内部的方法。then方法传入的形参onFulfilled以及创建新Promise实例时传入的resolve均被push到当前promisecallbacks队列中,这是衔接当前promise和后邻promise的关键所在(这里一定要好好的分析下handle的作用)。
  3. getUserId生成的promise(简称getUserId promise)异步操作成功,执行其内部方法resolve,传入的参数正是异步操作的结果id
  4. 调用handle方法处理callbacks队列中的回调:getUserJobById方法,生成新的promisegetUserJobById promise
  5. 执行之前由getUserId promisethen方法生成的新promise(称为bridge promise)的resolve方法,传入参数为getUserJobById promise。这种情况下,会将该resolve方法传入getUserJobById promisethen方法中,并直接返回。
  6. getUserJobById promise异步操作成功时,执行其callbacks中的回调:getUserId bridge promise中的resolve方法
  7. 最后执行getUserId bridge promise的后邻promisecallbacks中的回调。

更直白的可以看下面的图,一图胜千言(都是根据自己的理解画出来的,如有不对欢迎指正):

alt promise analysis

失败处理

在异步操作失败时,标记其状态为rejected,并执行注册的失败回调:

//例5
function getUserId() {
    return new Promise(function(resolve) {
        //异步请求
        http.get(url, function(error, results) {
            if (error) {
                reject(error);
            }
            resolve(results.id)
        })
    })
}

getUserId().then(function(id) {
    //一些处理
}, function(error) {
    console.log(error)
})

有了之前处理fulfilled状态的经验,支持错误处理变得很容易,只需要在注册回调、处理状态变更上都要加入新的逻辑:

function Promise(fn) {
    var state = 'pending',
        value = null,
        callbacks = [];

    this.then = function (onFulfilled, onRejected) {
        return new Promise(function (resolve, reject) {
            handle({
                onFulfilled: onFulfilled || null,
                onRejected: onRejected || null,
                resolve: resolve,
                reject: reject
            });
        });
    };

    function handle(callback) {
        if (state === 'pending') {
            callbacks.push(callback);
            return;
        }

        var cb = state === 'fulfilled' ? callback.onFulfilled : callback.onRejected,
            ret;
        if (cb === null) {
            cb = state === 'fulfilled' ? callback.resolve : callback.reject;
            cb(value);
            return;
        }
        ret = cb(value);
        callback.resolve(ret);
    }

    function resolve(newValue) {
        if (newValue && (typeof newValue === 'object' || typeof newValue === 'function')) {
            var then = newValue.then;
            if (typeof then === 'function') {
                then.call(newValue, resolve, reject);
                return;
            }
        }
        state = 'fulfilled';
        value = newValue;
        execute();
    }

    function reject(reason) {
        state = 'rejected';
        value = reason;
        execute();
    }

    function execute() {
        setTimeout(function () {
            callbacks.forEach(function (callback) {
                handle(callback);
            });
        }, 0);
    }

    fn(resolve, reject);
}

上述代码增加了新的reject方法,供异步操作失败时调用,同时抽出了resolvereject共用的部分,形成execute方法。

错误冒泡是上述代码已经支持,且非常实用的一个特性。在handle中发现没有指定异步操作失败的回调时,会直接将bridge promise(then函数返回的promise,后同)设为rejected状态,如此达成执行后续失败回调的效果。这有利于简化串行Promise的失败处理成本,因为一组异步操作往往会对应一个实际功能,失败处理方法通常是一致的:

//例6
getUserId()
    .then(getUserJobById)
    .then(function (job) {
        // 处理job
    }, function (error) {
        // getUserId或者getUerJobById时出现的错误
        console.log(error);
    });

异常处理

细心的同学会想到:如果在执行成功回调、失败回调时代码出错怎么办?对于这类异常,可以使用try-catch捕获错误,并将bridge promise设为rejected状态。handle方法改造如下:

function handle(callback) {
    if (state === 'pending') {
        callbacks.push(callback);
        return;
    }

    var cb = state === 'fulfilled' ? callback.onFulfilled : callback.onRejected,
        ret;
    if (cb === null) {
        cb = state === 'fulfilled' ? callback.resolve : callback.reject;
        cb(value);
        return;
    }
    try {
        ret = cb(value);
        callback.resolve(ret);
    } catch (e) {
        callback.reject(e);
    } 
}

如果在异步操作中,多次执行resolve或者reject会重复处理后续回调,可以通过内置一个标志位解决。

总结

刚开始看promise源码的时候总不能很好的理解then和resolve函数的运行机理,但是如果你静下心来,反过来根据执行promise时的逻辑来推演,就不难理解了。这里一定要注意的点是:promise里面的then函数仅仅是注册了后续需要执行的代码,真正的执行是在resolve方法里面执行的,理清了这层,再来分析源码会省力的多。

现在回顾下Promise的实现过程,其主要使用了设计模式中的观察者模式:

  1. 通过Promise.prototype.then和Promise.prototype.catch方法将观察者方法注册到被观察者Promise对象中,同时返回一个新的Promise对象,以便可以链式调用。
  2. 被观察者管理内部pending、fulfilled和rejected的状态转变,同时通过构造函数中传递的resolve和reject方法以主动触发状态转变和通知观察者。

参考文献

深入理解 Promise
JavaScript Promises ... In Wicked Detail

查看原文

xh4722 赞了文章 · 2018-08-31

30分钟,让你彻底明白Promise原理

原文链接

前言

前一阵子记录了promise的一些常规用法,这篇文章再深入一个层次,来分析分析promise的这种规则机制是如何实现的。ps:本文适合已经对promise的用法有所了解的人阅读,如果对其用法还不是太了解,可以移步我的上一篇博文

本文的promise源码是按照Promise/A+规范来编写的(不想看英文版的移步Promise/A+规范中文翻译

引子

为了让大家更容易理解,我们从一个场景开始讲解,让大家一步一步跟着思路思考,相信你一定会更容易看懂。

考虑下面一种获取用户id的请求处理

//例1
function getUserId() {
    return new Promise(function(resolve) {
        //异步请求
        http.get(url, function(results) {
            resolve(results.id)
        })
    })
}

getUserId().then(function(id) {
    //一些处理
})

getUserId方法返回一个promise,可以通过它的then方法注册(注意注册这个词)在promise异步操作成功时执行的回调。这种执行方式,使得异步调用变得十分顺手。

原理剖析

那么类似这种功能的Promise怎么实现呢?其实按照上面一句话,实现一个最基础的雏形还是很easy的。

极简promise雏形

function Promise(fn) {
    var value = null,
        callbacks = [];  //callbacks为数组,因为可能同时有很多个回调

    this.then = function (onFulfilled) {
        callbacks.push(onFulfilled);
    };

    function resolve(value) {
        callbacks.forEach(function (callback) {
            callback(value);
        });
    }

    fn(resolve);
}

上述代码很简单,大致的逻辑是这样的:

  1. 调用then方法,将想要在Promise异步操作成功时执行的回调放入callbacks队列,其实也就是注册回调函数,可以向观察者模式方向思考;
  2. 创建Promise实例时传入的函数会被赋予一个函数类型的参数,即resolve,它接收一个参数value,代表异步操作返回的结果,当一步操作执行成功后,用户会调用resolve方法,这时候其实真正执行的操作是将callbacks队列中的回调一一执行;

可以结合例1中的代码来看,首先new Promise时,传给promise的函数发送异步请求,接着调用promise对象的then属性,注册请求成功的回调函数,然后当异步请求发送成功时,调用resolve(results.id)方法, 该方法执行then方法注册的回调数组。

相信仔细的人应该可以看出来,then方法应该能够链式调用,但是上面的最基础简单的版本显然无法支持链式调用。想让then方法支持链式调用,其实也是很简单的:

this.then = function (onFulfilled) {
    callbacks.push(onFulfilled);
    return this;
};

see?只要简单一句话就可以实现类似下面的链式调用:

// 例2
getUserId().then(function (id) {
    // 一些处理
}).then(function (id) {
    // 一些处理
});

加入延时机制

细心的同学应该发现,上述代码可能还存在一个问题:如果在then方法注册回调之前,resolve函数就执行了,怎么办?比如promise内部的函数是同步函数:

// 例3
function getUserId() {
    return new Promise(function (resolve) {
        resolve(9876);
    });
}
getUserId().then(function (id) {
    // 一些处理
});

这显然是不允许的,Promises/A+规范明确要求回调需要通过异步方式执行,用以保证一致可靠的执行顺序。因此我们要加入一些处理,保证在resolve执行之前,then方法已经注册完所有的回调。我们可以这样改造下resolve函数:

function resolve(value) {
    setTimeout(function() {
        callbacks.forEach(function (callback) {
            callback(value);
        });
    }, 0)
} 

上述代码的思路也很简单,就是通过setTimeout机制,将resolve中执行回调的逻辑放置到JS任务队列末尾,以保证在resolve执行时,then方法的回调函数已经注册完成.

但是,这样好像还存在一个问题,可以细想一下:如果Promise异步操作已经成功,这时,在异步操作成功之前注册的回调都会执行,但是在Promise异步操作成功这之后调用的then注册的回调就再也不会执行了,这显然不是我们想要的。

加入状态

恩,为了解决上一节抛出的问题,我们必须加入状态机制,也就是大家熟知的pendingfulfilledrejected

Promises/A+规范中的2.1Promise States中明确规定了,pending可以转化为fulfilledrejected并且只能转化一次,也就是说如果pending转化到fulfilled状态,那么就不能再转化到rejected。并且fulfilledrejected状态只能由pending转化而来,两者之间不能互相转换。一图胜千言:

alt promise state

改进后的代码是这样的:

function Promise(fn) {
    var state = 'pending',
        value = null,
        callbacks = [];

    this.then = function (onFulfilled) {
        if (state === 'pending') {
            callbacks.push(onFulfilled);
            return this;
        }
        onFulfilled(value);
        return this;
    };

    function resolve(newValue) {
        value = newValue;
        state = 'fulfilled';
        setTimeout(function () {
            callbacks.forEach(function (callback) {
                callback(value);
            });
        }, 0);
    }

    fn(resolve);
}

上述代码的思路是这样的:resolve执行时,会将状态设置为fulfilled,在此之后调用then添加的新回调,都会立即执行。

这里没有任何地方将state设为rejected,为了让大家聚焦在核心代码上,这个问题后面会有一小节专门加入。

链式Promise

那么这里问题又来了,如果用户再then函数里面注册的仍然是一个Promise,该如何解决?比如下面的例4

// 例4
getUserId()
    .then(getUserJobById)
    .then(function (job) {
        // 对job的处理
    });

function getUserJobById(id) {
    return new Promise(function (resolve) {
        http.get(baseUrl + id, function(job) {
            resolve(job);
        });
    });
}

这种场景相信用过promise的人都知道会有很多,那么类似这种就是所谓的链式Promise

链式Promise是指在当前promise达到fulfilled状态后,即开始进行下一个promise(后邻promise)。那么我们如何衔接当前promise和后邻promise呢?(这是这里的难点)。

其实也不是辣么难,只要在then方法里面return一个promise就好啦。Promises/A+规范中的2.2.7就是这么说哒(微笑脸)~

下面来看看这段暗藏玄机的then方法和resolve方法改造代码:


function Promise(fn) {
    var state = 'pending',
        value = null,
        callbacks = [];

    this.then = function (onFulfilled) {
        return new Promise(function (resolve) {
            handle({
                onFulfilled: onFulfilled || null,
                resolve: resolve
            });
        });
    };

    function handle(callback) {
        if (state === 'pending') {
            callbacks.push(callback);
            return;
        }
        //如果then中没有传递任何东西
        if(!callback.onFulfilled) {
            callback.resolve(value);
            return;
        }

        var ret = callback.onFulfilled(value);
        callback.resolve(ret);
    }

    
    function resolve(newValue) {
        if (newValue && (typeof newValue === 'object' || typeof newValue === 'function')) {
            var then = newValue.then;
            if (typeof then === 'function') {
                then.call(newValue, resolve);
                return;
            }
        }
        state = 'fulfilled';
        value = newValue;
        setTimeout(function () {
            callbacks.forEach(function (callback) {
                handle(callback);
            });
        }, 0);
    }

    fn(resolve);
}

我们结合例4的代码,分析下上面的代码逻辑,为了方便阅读,我把例4的代码贴在这里:

// 例4
getUserId()
    .then(getUserJobById)
    .then(function (job) {
        // 对job的处理
    });

function getUserJobById(id) {
    return new Promise(function (resolve) {
        http.get(baseUrl + id, function(job) {
            resolve(job);
        });
    });
}
  1. then方法中,创建并返回了新的Promise实例,这是串行Promise的基础,并且支持链式调用。
  2. handle方法是promise内部的方法。then方法传入的形参onFulfilled以及创建新Promise实例时传入的resolve均被push到当前promisecallbacks队列中,这是衔接当前promise和后邻promise的关键所在(这里一定要好好的分析下handle的作用)。
  3. getUserId生成的promise(简称getUserId promise)异步操作成功,执行其内部方法resolve,传入的参数正是异步操作的结果id
  4. 调用handle方法处理callbacks队列中的回调:getUserJobById方法,生成新的promisegetUserJobById promise
  5. 执行之前由getUserId promisethen方法生成的新promise(称为bridge promise)的resolve方法,传入参数为getUserJobById promise。这种情况下,会将该resolve方法传入getUserJobById promisethen方法中,并直接返回。
  6. getUserJobById promise异步操作成功时,执行其callbacks中的回调:getUserId bridge promise中的resolve方法
  7. 最后执行getUserId bridge promise的后邻promisecallbacks中的回调。

更直白的可以看下面的图,一图胜千言(都是根据自己的理解画出来的,如有不对欢迎指正):

alt promise analysis

失败处理

在异步操作失败时,标记其状态为rejected,并执行注册的失败回调:

//例5
function getUserId() {
    return new Promise(function(resolve) {
        //异步请求
        http.get(url, function(error, results) {
            if (error) {
                reject(error);
            }
            resolve(results.id)
        })
    })
}

getUserId().then(function(id) {
    //一些处理
}, function(error) {
    console.log(error)
})

有了之前处理fulfilled状态的经验,支持错误处理变得很容易,只需要在注册回调、处理状态变更上都要加入新的逻辑:

function Promise(fn) {
    var state = 'pending',
        value = null,
        callbacks = [];

    this.then = function (onFulfilled, onRejected) {
        return new Promise(function (resolve, reject) {
            handle({
                onFulfilled: onFulfilled || null,
                onRejected: onRejected || null,
                resolve: resolve,
                reject: reject
            });
        });
    };

    function handle(callback) {
        if (state === 'pending') {
            callbacks.push(callback);
            return;
        }

        var cb = state === 'fulfilled' ? callback.onFulfilled : callback.onRejected,
            ret;
        if (cb === null) {
            cb = state === 'fulfilled' ? callback.resolve : callback.reject;
            cb(value);
            return;
        }
        ret = cb(value);
        callback.resolve(ret);
    }

    function resolve(newValue) {
        if (newValue && (typeof newValue === 'object' || typeof newValue === 'function')) {
            var then = newValue.then;
            if (typeof then === 'function') {
                then.call(newValue, resolve, reject);
                return;
            }
        }
        state = 'fulfilled';
        value = newValue;
        execute();
    }

    function reject(reason) {
        state = 'rejected';
        value = reason;
        execute();
    }

    function execute() {
        setTimeout(function () {
            callbacks.forEach(function (callback) {
                handle(callback);
            });
        }, 0);
    }

    fn(resolve, reject);
}

上述代码增加了新的reject方法,供异步操作失败时调用,同时抽出了resolvereject共用的部分,形成execute方法。

错误冒泡是上述代码已经支持,且非常实用的一个特性。在handle中发现没有指定异步操作失败的回调时,会直接将bridge promise(then函数返回的promise,后同)设为rejected状态,如此达成执行后续失败回调的效果。这有利于简化串行Promise的失败处理成本,因为一组异步操作往往会对应一个实际功能,失败处理方法通常是一致的:

//例6
getUserId()
    .then(getUserJobById)
    .then(function (job) {
        // 处理job
    }, function (error) {
        // getUserId或者getUerJobById时出现的错误
        console.log(error);
    });

异常处理

细心的同学会想到:如果在执行成功回调、失败回调时代码出错怎么办?对于这类异常,可以使用try-catch捕获错误,并将bridge promise设为rejected状态。handle方法改造如下:

function handle(callback) {
    if (state === 'pending') {
        callbacks.push(callback);
        return;
    }

    var cb = state === 'fulfilled' ? callback.onFulfilled : callback.onRejected,
        ret;
    if (cb === null) {
        cb = state === 'fulfilled' ? callback.resolve : callback.reject;
        cb(value);
        return;
    }
    try {
        ret = cb(value);
        callback.resolve(ret);
    } catch (e) {
        callback.reject(e);
    } 
}

如果在异步操作中,多次执行resolve或者reject会重复处理后续回调,可以通过内置一个标志位解决。

总结

刚开始看promise源码的时候总不能很好的理解then和resolve函数的运行机理,但是如果你静下心来,反过来根据执行promise时的逻辑来推演,就不难理解了。这里一定要注意的点是:promise里面的then函数仅仅是注册了后续需要执行的代码,真正的执行是在resolve方法里面执行的,理清了这层,再来分析源码会省力的多。

现在回顾下Promise的实现过程,其主要使用了设计模式中的观察者模式:

  1. 通过Promise.prototype.then和Promise.prototype.catch方法将观察者方法注册到被观察者Promise对象中,同时返回一个新的Promise对象,以便可以链式调用。
  2. 被观察者管理内部pending、fulfilled和rejected的状态转变,同时通过构造函数中传递的resolve和reject方法以主动触发状态转变和通知观察者。

参考文献

深入理解 Promise
JavaScript Promises ... In Wicked Detail

查看原文

赞 181 收藏 247 评论 37

认证与成就

  • 获得 40 次点赞
  • 获得 6 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 6 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2015-11-07
个人主页被 554 人浏览