2019年8月

React Hooks 在 2018 年年底就已经公布了,正式发布是在 2019 年 5 月,关于它到底能做什么用,并不在本文的探讨范围之内,本文旨在摸索,如何基于 Hooks 以及 Context,实现多组件的状态共享,完成一个精简版的 Redux。

初始化一个 React 项目

yarn create create-app hooks-context-based-state-management-react-app
cd hooks-context-based-state-management-react-app
yarn start

或者可以直接 clone 本文完成的项目:

git clone https://github.com/pantao/hooks-context-based-state-management-react-app.git

设置我们的 state

绝大多数情况下,我们其实只需要共享会话状态即可,在本文的示例中,我们也就只共享这个,在 src 目录下,创建一个 store/types.js 文件,它定义我们的 action 类型:

// 设置 session
const SET_SESSION = "SET_TOKEN";
// 销毁会话
const DESTROY_SESSION = "DESTROY_SESSION";

export { SET_SESSION, DESTROY_SESSION };

export default { SET_SESSION, DESTROY_SESSION };

接着定义我们的 src/reducers.js

import { SET_SESSION, DESTROY_SESSION } from "./types";

const initialState = {
  // 会话信息
  session: {
    // J.W.T Token
    token: "",
    // 用户信息
    user: null,
    // 过期时间
    expireTime: null
  }
};

const reducer = (state = initialState, action) => {
  console.log({ oldState: state, ...action });

  const { type, payload } = action;
  switch (type) {
    case SET_SESSION:
      return {
        ...state,
        session: {
          ...state.session,
          ...payload
        }
      };
    case DESTROY_SESSION:
      return {
        ...state,
        session: { ...initialState }
      };
    default:
      throw new Error("Unexpected action");
  }
};

export { initialState, reducer };

创建 src/actions.js

import { SET_SESSION, DESTROY_SESSION } from "./types";

export const useActions = (state, dispatch) => {
  return {
    login: async (username, password) => {
      console.log(`login with ${username} & ${password}`);
      const session = await new Promise(resolve => {
        // 模拟接口请求费事 1 秒
        setTimeout(
          () =>
            resolve({
              token: "J.W.T",
              expireTime: new Date("2030-09-09"),
              user: {
                username,
                password
              }
            }),
          1000
        );
      });

      // dispatch SET_TOKEN
      dispatch({
        type: SET_SESSION,
        payload: session
      });

      return session;
    },
    logout: () => {
      dispatch({
        type: DESTROY_SESSION
      });
    }
  };
};

关键时刻,创建 store/StoreContext.js

import React, { createContext, useReducer, useEffect } from "react";
import { reducer, initialState } from "./reducers";
import { useActions } from "./actions";

const StoreContext = createContext(initialState);

function StoreProvider({ children }) {
  // 设置 reducer,得到 `dispatch` 方法以及 `state`
  const [state, dispatch] = useReducer(reducer, initialState);

  // 生成 `actions` 对象
  const actions = useActions(state, dispatch);

  // 打印出新的 `state`
  useEffect(() => {
    console.log({ newState: state });
  }, [state]);

  // 渲染 state, dispatch 以及 actions
  return (
    <StoreContext.Provider value={{ state, dispatch, actions }}>
      {children}
    </StoreContext.Provider>
  );
}

export { StoreContext, StoreProvider };

修改 src/index.js

打开 src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';

ReactDOM.render(<App />, document.getElementById('root'));

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

做如下修改:

import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import * as serviceWorker from "./serviceWorker";
import { StoreProvider } from "./context/StoreContext"; // 导入 StoreProvider 组件

ReactDOM.render(
  <StoreProvider>
    <App />
  </StoreProvider>,
  document.getElementById("root")
);

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

src/App.js

内容如下:

import React, { useContext, useState } from "react";
import logo from "./logo.svg";
import "./App.css";

import { StoreContext } from "./store/StoreContext";
import { DESTROY_SESSION } from "./store/types";

function App() {
  const { state, dispatch, actions } = useContext(StoreContext);

  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [loading, setLoading] = useState(false);
  const { user, expireTime } = state.session;

  const login = async () => {
    if (!username) {
      return alert("请输入用户名");
    }
    if (!password) {
      return alert("请输入密码");
    }
    setLoading(true);
    try {
      await actions.login(username, password);
      setLoading(false);
      alert("登录成功");
    } catch (error) {
      setLoading(false);
      alert(`登录失败:${error.message}`);
    }
  };

  const logout = () => {
    dispatch({
      type: DESTROY_SESSION
    });
  };

  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        {loading ? <div className="loading">登录中……</div> : null}
        {user ? (
          <div className="user">
            <div className="field">用户名:{user.username}</div>
            <div className="field">过期时间:{`${expireTime}`}</div>
            <div className="button" onClick={actions.logout}>
              使用 actions.logout 退出登录
            </div>
            <div className="button" onClick={logout}>
              使用 dispatch 退出登录
            </div>
          </div>
        ) : (
          <div className="form">
            <label className="field">
              用户名:
              <input
                value={username}
                onChange={e => setUsername(e.target.value)}
              />
            </label>
            <label className="field">
              密码:
              <input
                value={password}
                onChange={e => setPassword(e.target.value)}
                type="password"
              />
            </label>
            <div className="button" onClick={login}>
              登录
            </div>
          </div>
        )}
      </header>
    </div>
  );
}

export default App;

总结

整个实现我们使用到了 ReactuseContext 共享上下文关系,这个是关系、useEffect 用来实现 reducerloguseReducer 实现 redux 里面的 combineReducer 功能,整体上来讲,实现还是足够绝大多数中小型项目使用的。

关于如何去除一个给定数组中的重复项,应该是 Javascript 面试中最常见的一个问题了,最常见的方式有三种:SetArray.prototype.filter 以及 Array.prototype.reduce,对于只有简单数据的数组来讲,我最喜欢 Set,没别的,就是写起来简单。

const originalArray = [1, 2, '咩', 1, 'Super Ball', '咩', '咩', 'Super Ball', 4]

const bySet = [...new Set(originalArray)]

const byFilter = originalArray.filter((item, index) => originalArray.indexOf(item) === index)

const byReduce = originalArray.reduce((unique, item) => unique.includes(item) ? unique : [...unique, item], [])

使用 Set

先让我们来看看 Set 到底是个啥

Set 对象允许你存储任何类型的唯一值,无论是原始值或者是对象引用。

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Set

  • 首先,Set 中只允许出现唯一值
  • 唯一性是比对原始值或者对象引用

const bySet = [...new Set(originalArray)] 这一段的操作,我们将它拆分来看:

const originalArray = [1, 2, '咩', 1, 'Super Ball', '咩', '咩', 'Super Ball', 4]

const uniqueSet = new Set(originalArray)
// 得到 Set(5) [ 1, 2, "咩", "Super Ball", 4 ]

const bySet = [...uniqueSet]
// 得到 Array(5) [ 1, 2, "咩", "Super Ball", 4 ]

在将 Set 转为 Array 时,也可以使用 Array.from(set)

使用 Array.prototype.filter

要理解 filter 方法为什么可以去重,需要关注一下另一个方法 indexOf

indexOf()方法返回在数组中可以找到一个给定元素的第一个索引,如果不存在,则返回 -1

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Array/indexOf

const beasts = ['ant', 'bison', 'camel', 'duck', 'bison'];

console.log(beasts.indexOf('bison'));
// expected output: 1

// start from index 2
console.log(beasts.indexOf('bison', 2));
// expected output: 4

console.log(beasts.indexOf('giraffe'));
// expected output: -1

filter() 方法创建一个新数组, 其包含通过所提供函数实现的测试的所有元素。

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Array/filter

filter 方法接受两个参数:

  • 第一个参数:一个回调函数, filter 会将数据中的每一项都传递给该函数,若该函数返回 真值,则数据保存,返回 假值,则数据将不会出现在新生成的数据中
  • 第二个参数:回调函数中 this 的指向

我们将上面的去重方法按下面这样重写一下,就可以看清整个 filter 的执行过程了。

const originalArray = [1, 2, '咩', 1, 'Super Ball', '咩', '咩', 'Super Ball', 4]

const table = []

const byFilter = originalArray.filter((item, index) => {
  // 如果找到的索引与当前索引一致,则保留该值
  const shouldKeep = originalArray.indexOf(item) === index
  table.push({
    序号: index,
    值: item,
    是否应该保留: shouldKeep ? '保留' : '删除'
  })
  return shouldKeep
})

console.log(byFilter)
console.table(table)
序号是否应该保留-
01保留第一次出现
12保留第一次出现
2保存第一次出现
31删除第二次出现
4Super Ball保留第一次出现
5删除第二次出现
6删除第三次出现
7Super Ball删除第二次出现
84保留第一次出现

使用 Array.prototype.reduce

reduce() 方法对数组中的每个元素执行一个由您提供的 reducer 函数(升序执行),将其结果汇总为单个返回值。

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce

Array.prototype.reduce 方法接受两个参数:

  • Callback:回调函数,它可以接收四个参数

    • Accumulator:累计器,这个其实是让很多人忽略的一点,就是,累计器其实可以是任何类型的数据
    • Current Value:当前值
    • Current Index:当前值的索引
    • Source Array:源数组
  • Initial Value:累计器的初始值,就跟累计器一样,这个参数也总是被绝大多数人忽略

就像 filter 章节一样,我们来看看 reduce 的执行过程:

const originalArray = [1, 2, '咩', 1, 'Super Ball', '咩', '咩', 'Super Ball', 4]

const byReduce = originalArray.reduce((unique, item, index, source) => {
  const exist = unique.includes(item)
  const next = unique.includes(item) ? unique : [...unique, item]
  console.group(`遍历第 ${index} 个值`)
  console.log('当前累计器:', unique)
  console.log('当前值:', item)
  console.log('是否已添加进累计器?', exist)
  console.log('新值', next)
  console.groupEnd()
  return next
}, [])

先来回答一下下面这个问题:对于 setTimeout(function() { console.log('timeout') }, 1000) 这一行代码,你从哪里可以找到 setTimeout 的源代码(同样的问题还会是你从哪里可以看到 setInterval 的源代码)?

很多时候,可以我们脑子里面闪过的第一个答案肯定是 V8 引擎或者其它 VM们,但是要知道的一点是,所有我们所见过的 Javascript 计时函数,都没有出现在 ECMAScript 标准中,也没有被任何 Javascript 引擎实现,计时函数,其实都是由浏览器(或者其它运行时,比如 Node.js)实现的,并且,在不同的运行时下,其表现形式有可能都不一致

在浏览器中,主计时器函数是 Window 接口的一部分,这保证了包括如 setTimeoutsetInterval 等计时器函数以及其它函数和对象能被全局访问,这才是你可以随时随地使用 setTimeout 的原因。同样的,在 Node.js 中,setTimeoutglobal 对象的一部分,这拿得你也可以像在浏览器里面一样,随时随地的使用它。

到现在可能会有一些人感觉这个问题其实并没有实际的价值,但是作为一个 Javascript 开发者,如果不知道本质,那么就有可能不能完全的理解 V8 (或者其它VM)是到底是如何与浏览器或者 Node.js 相互作用的。

暂缓一个函数的执行

计时器函数都是更高阶的函数,它们可以用于暂缓一个函数的执行,或者让一个函数重复执行(由他们的第一个参数执行需要执行的函数)。

下面这是一个暂缓执行的示例:

setTimeout(() => {
  console.log('距离函数的调用,已经过去 4 秒了')
}, 4 * 1000)

在上面的示例中, setTimeoutconsole.log 的执行暂缓了 4 * 1000 毫秒,也就是 4 秒钟, setTimeout 的第一个函数,就是需要暂缓执行的函数,它是一个函数的引用,下面这个示例是我们更加常见到的写法:

const fn = () => {
  console.log('距离函数的调用,已经过去 4 秒了')
}

setTimeout(fn, 4 * 1000)

传递参数

如果被 setTimeout 暂缓的函数需要接收参数,我们可以从第三个参数开始添加需要传递给被暂缓函数的参数:

const fn = (name, gender) => {
  console.log(`I'm ${name}, I'm a ${gender}`)
}

setTimeout(fn, 4 * 1000, 'Tao Pan', 'male')

上面的 setTimeout 调用,其结果与下面这样调用类似:

setTimeout(() => {
  fn('Tao Pan', 'male')
}, 4 * 1000)

但是记住,只是结果类似,本质上是不一样的,我们可以用伪代码来表示 setTimeout 的函数实现:

const setTimeout = (fn, delay, ...args) => {
  wait(delay) // 这里表示等待 delay 指定的毫秒数
  fn(...args)
}

挑战一下

编写一个函数:

  • delay 为 4 秒的时候,打印出:距离函数的调用,已经过去 4 秒了
  • delay 为 8 秒的时候,打印出:距离函数的调用,已经过去 8 秒了
  • delay 为 N 秒的时候,打印出:距离函数的调用,已经过去 N 秒了

下面这个是我的一个实现:

const delayLog = delay => {
  setTimeout(console.log, delay * 1000, `距离函数的调用,已经过去 ${delay} 秒了`)
}

delayLog(4) // 输出:距离函数的调用,已经过去 4 秒了
delayLog(8) // 输出:距离函数的调用,已经过去 8 秒了

我们来理一下 delayLog(4) 的整个执行过程:

  1. delay = 4
  2. setTimeout 执行
  3. 4 * 1000 毫秒后, setTimeout 调用 console.log 方法
  4. setTimeout 计算其第三个参数 距离函数的调用,已经过去 ${delay} 秒了 得到 距离函数的调用,已经过去 4 秒了
  5. setTimeout 将计算得到的字符串当作 console.log 的第一个参数
  6. console.log('距离函数的调用,已经过去 4 秒了') 执行,输出结果

规律性重复一个函数的执行以及停止重复调用

如果我们现在要每 4 秒第印一次呢?这里面就有很多种实现方式了,假如我们还是使用 setTimeout 来实现,我们可以这样做:

const loopMessage = delay => {
  setTimeout(() => {
    console.log('这里是由 loopMessage 打印出来的消息')
    loopMessage(delay)
  }, delay * 1000)
}

loopMessage(1) // 此时,每过 1 秒钟,就会打印出一段消息:*这里是由 loopMessage 打印出来的消息*

但是这样有一个问题,就是开始之后,我们就没有办法停止,怎么办?可以稍稍改改实现:

let loopMessageTimer

const loopMessage = delay => {
  loopMessageTimer = setTimeout(() => {
    console.log('这里是由 loopMessage 打印出来的消息')
    loopMessage(delay)
  }, delay * 1000)
}

loopMessage(1)

clearTimeout(loopMessageTimer) // 我们随时都可以使用 `clearTimeout` 清除这个循环

但是这样还是有问题的,如果 loopMessage 被调用多次,那么他们将共用一个 loopMessageTimer,清除一个,将清除所有,这是肯定不行的,所以,还得再改造一下:

const loopMessage = delay => {
  let timer
  
  const log = () => {
    timer = setTimeout(() => {
      console.log(`每 ${delay} 秒打印一次`)
      log()
    }, delay * 1000)
  }

  log()

  return () => clearTimeout(timer)
}

const clearLoopMessage = loopMessage(1)
const clearLoopMessage2 = loopMessage(1.5)

clearLoopMessage() // 我们在任何时候都可以取消任何一个重复调用,而不影响其它的

这…… 实现是实现了,但是其它有更好的解决办法:

const timer = setInterval(console.log, 1000, '每 1 秒钟打印一次')

clearInterval(timer) // 随时可以 `clearInterval` 清除

更加深入了认识取消计时器(Cancel Timers)

上面的示例只是简单的给我们展现了 setTimeout 以及 setInterval,也看到了,我们可以通过 clearTimeout 或者 clearInterval 取消计时器,但是关于计时器,远远不止这点知识,请看下面的代码(请):

const cancelImmediate = () => {
  const timerId = setTimeout(console.log, 0, '暂缓了 0 秒执行')
  clearTimeout(timerId)
}

cancelImmediate() // 这里并不会有任何输出

或者看下面这样的代码:

const cancelImmediate2 = () => setTimeout(console.log, 0, '暂缓了 0 秒执行')

const timerId = cancelImmediate2()

clearTimeout(timerId)

请将上面的的任一代码片段同时复制到浏览器的控制台中(有多行复制多行)执行,你会发现,两个代码片段都没有任何输出,这是为什么?

这是因为,Javascript 的运行机制导致,任何时刻都只能存在一个任务在进行,虽然我们调用的是暂缓 0 秒,但是,由于当前的任务还没有执行完成,所以,setTimeout 中被暂缓的函数即使时间到了也不会被执行,必须等到当前的任务完全执行完成,那么,再试着,上面的代码分行复制到控制台,看看结果是不是会打印出 暂缓了 0 秒执行 了?答案是肯定的。

当你一行一行复制执行的时候, cancelImmediate2 执行完成之后,当前任务就已经全部执行完成了,所以开始执行下一个任务(console.log 开始执行)。

从上面的示例中,我们可以看出,setTimeout 其实是将一个任务安排进一个 Javascript 的任务队列里面去,当前面的所有任务都执行完成之后,如果这个任务时间到了,那么就立即执行,否则,继续等待计时结束。

此时,你应该发现,只要是 setTimeout 所暂缓的函数没有被执行(任务还没有完成),那么,我们就可以随时使用 clearTimeout 清除掉这个暂缓(将这条任务从队列里面移除)

计时器是没有任何保证的

通过前面的例子,我们知道了 setTimeoutdelay0 时,并不表示立马就会执行了,它必须等到所有的当前任务(对于一个 JS 文件来讲,就是需要执行完当前脚本中的所有调用)执行完成之后都会执行,而这里面就包括我们调用的 clearTimeout

下面用一个示例来更清楚了说明这个问题:

setTimeout(console.log, 1000, '1 秒后执行的')

// 开始时间
const startTime = new Date()
// 距离开始时间已经过去几秒
let secondsPassed = 0
while (true) {
  // 距离开始时间的毫秒数
  const duration = new Date() - startTime
  // 如果距离开始时间超过 5000 毫秒了, 则终止循环
  if (duration > 5000) {
    break
  } else {
    // 如果距离开始时间增长一秒,更新 secondsPassed
    if (Math.floor(duration / 1000) > secondsPassed) {
      secondsPassed = Math.floor(duration / 1000)
      console.log(`已经过去 ${secondsPassed} 秒了。`)
    }
  }
}

你们猜上面这段代码会有什么样的输出?是下面这样的吗?

1 秒后执行的
已经过去 1 秒了。
已经过去 2 秒了。
已经过去 3 秒了。
已经过去 4 秒了。
已经过去 5 秒了。

并不是这样的,而是下面这样的:

已经过去 1 秒了。
已经过去 2 秒了。
已经过去 3 秒了。
已经过去 4 秒了。
已经过去 5 秒了。
1 秒后执行的

怎么会这样?这是因为 while(true) 这个循环必须要执行超过 5 秒钟的时间之后,才算当前所有任务完成,在它 break 之前,其它所有的操作都是没有用的,当然,我们不会在开发的过程中去写这样的代码,但是并不表示就不存在这样的情况,想象以下下面这样的场景:

setTimeout(somethingMustDoAfter1Seconds, 1000)

openFileSync('file more then 1gb')

这里面的 openFileSync 只是一个伪代码,它表示我们需要同步进行一个特别费时的操作,这个操作很有可能会超过 1 秒,甚至更长的时间,但是上面那个 somethingMustDoAfter1Seconds 将一直处于挂起状态,只要这个操作完成,它才有可能执行,为什么叫有可能?那是因为,有可能还有别的任务又会占用资源。所以,我们可以将 setTimeout 理解为:计时结束是执行任务的必要条件,但是不是任务是否执行的决定性因素

setTimeout(somethingMustDoAfter1Seconds, 1000) 的意思是,必须超过 1000 毫秒后,somethingMustDoAfter1Seconds 才允许执行。

再来一个小挑战

那如果我需要每一秒钟都打印一句话怎么办?从上面的示例中,已经很明显的看到了,setTimeout 是肯定解决不了这个问题了,不信我们可以试试下面这个代码片段:

const log = (delay) => {
  timer = setTimeout(() => {
    console.log(`每 ${delay} 秒打印一次`)
    log(delay)
  }, delay * 1000)
}

log(1)

上面的代码是没有任何问题的,在浏览器的控制台观察,你会发现确实每一秒钟都打印了一行,但是再试试下面这样的代码:

const log = (delay) => {
  timer = setTimeout(() => {
    console.log(`每 ${delay} 秒打印一次`)
    log(delay)
  }, delay * 1000)
}

const readLargeFileSync = () => {
  // 开始时间
  const startTime = new Date()
  // 距离开始时间已经过去几秒
  let secondsPassed = 0
  while (true) {
    // 距离开始时间的毫秒数
    const duration = new Date() - startTime
    // 如果距离开始时间超过 5000 毫秒了, 则终止循环
    if (duration > 5000) {
      break
    } else {
      // 如果距离开始时间增长一秒,更新 secondsPassed
      if (Math.floor(duration / 1000) > secondsPassed) {
        secondsPassed = Math.floor(duration / 1000)
        console.log(`已经过去 ${secondsPassed} 秒了。`)
      }
    }
  }
}

log(1)

setTimeout(readLargeFileSync, 1300)

输出结果是:

每 1 秒打印一次
已经过去 1 秒了。
已经过去 2 秒了。
已经过去 3 秒了。
已经过去 4 秒了。
已经过去 5 秒了。
每 1 秒打印一次
  1. 第一秒的时候, log 执行
  2. 第 1300 毫秒时,开始执行 readLargeFileSync 这会需要整整 5 秒钟的时间
  3. 第 2 秒的时候,log 执行时间到了,但是当前任务并没有完成,所以,它不会打印
  4. 第 5 秒的时候, readLargeFileSync 执行完成了,所以 log 继续执行
关于这个具体怎么实现,就不在本文讨论了

最终,到底是谁在调用那个被暂缓的函数?

当我们在一个 function 中调用 this 时,this 关键字会指向当前函数的 caller

function whoCallsMe() {
  console.log('My caller is: ', this)
}

当我们在浏览器的控制台中调用 whoCallsMe 时,会打印出 Window,当在 Node.js 的 REPL 中执行时,会执行出 global,如果我们将 whoCallsMe 设置为一个对象的属性:

function whoCallsMe() {
  console.log('My caller is: ', this)
}

const person = {
  name: 'Tao Pan',
  whoCallsMe
}

person.whoCallsMe()

这会打印出:My caller is: Object { name: "Tao Pan", whoCallsMe: whoCallsMe() }

那么?

function whoCallsMe() {
  console.log('My caller is: ', this)
}

const person = {
  name: 'Tao Pan',
  whoCallsMe
}

setTimeout(person.whoCallsMe, 0)

这会打印出什么?这个很容易被忽视的问题,其实真的值得我们去思考。

请直接将上面这个代码片段复制进浏览器的控制台,看执行的结果:

My caller is:  Window https://pub.ofcrab.com/admin/write-post.php?cid=2952

再打开系统终端,进入 Node.js REPL 中,执行同样的代码,看执行结果:

My caller is:  Timeout {
  _idleTimeout: 1,
  _idlePrev: null,
  _idleNext: null,
  _idleStart: 7052,
  _onTimeout: [Function: whoCallsMe],
  _timerArgs: undefined,
  _repeat: null,
  _destroyed: false,
  [Symbol(refed)]: true,
  [Symbol(asyncId)]: 221,
  [Symbol(triggerId)]: 5
}

回到这句话:当我们在一个 function 中调用 this 时,this 关键字会指向当前函数的 caller,当我们使用 setTimeout 时,这个 caller 是跟当前的运行时有关系的,如果我想 this 总是指向 person 对象呢?

function whoCallsMe() {
  console.log('My caller is: ', this)
}

const person = {
  name: 'Tao Pan'
}
person.whoCallsMe = whoCallsMe.bind(person)

setTimeout(person.whoCallsMe, 0)

结语

标题是写上了 你需要知道的一切都在这里,但是如果有什么没有考虑到了,欢迎大家指出。

本文列举了一些日常会使用到的 Javascript技巧,可以明显提升代码的表现力。

解构赋值

首先,我们来看一下下面这段代码:

const animal = {
  type: {
    mammal: {
      bear: {
        age: 12
      },
      deer: {
        age: 4
      }
    }
  }
}

console.log(animal.type.mammal.bear) // 输出:{ age: 12 }
console.log(animal.type.mammal.deer) // 输出:{ age: 4 }

对象解构赋值

其实我们可以利用解构赋值做得更好:

const animal = {
  type: {
    mammal: {
      bear: {
        age: 12
      },
      deer: {
        age: 4
      }
    }
  }
}

const { bear, deer } = animal.type.mammal
console.log(bear) // 输出:{ age: 12 }
console.log(deer) // 输出:{ age: 4 }

不管上面哪种实现方式,我们都使用的 const,这表示这些被定义的变量不允许再被赋值,我们推荐 在编写 Javascript 代码时,尽可能的使用 const,除非这个变量确实需要被多次赋值,比如,年龄是可以增长的:

const animal = {
  type: {
    mammal: {
      bear: {
        age: 12
      },
      deer: {
        age: 4
      }
    }
  }
}

const { age } = animal.type.mammal.bear

age += 1 // 这里会报错,因为 age 是一个 const 变量

在这种情况下,我们可以将 const 改为 let

const animal = {
  type: {
    mammal: {
      bear: {
        age: 12
      },
      deer: {
        age: 4
      }
    }
  }
}

let { age } = animal.type.mammal.bear

age += 1
console.log(age) // 输出:13

接下来,我们给每一个 animal 增加一个姓名字段 name,然后,同时使用 letconst,任何动物年龄是会增长的,但是姓名不允许修改:

const animal = {
  type: {
    mammal: {
      bear: {
        name: 'Bug',
        age: 12
      },
      deer: {
        name: 'Debug',
        age: 4
      }
    }
  }
}

const { name } = animal.type.mammal.bear
let { age } = animal.type.mammal.bear

age += 1
console.log(age) // 输出:13
console.log(name) // 输出:Bug

数组解构赋值

我们现在有三只动物,有一个数组保存了它们的名字:

const animalNames = ['Bug', 'Debug', 'Bugfix']
const [bug, debug, bugfix] = animalNames
console.log(debug) // 输出:Debug

解构赋值时重命名

我们还有可能有这样的需求,我想同时拿到上面示例中 animal 那个对象中,两只动物的姓名,这个时候我们可以完全按照对象的结构去解构它:

const animal = {
  type: {
    mammal: {
      bear: {
        name: 'Bug',
        age: 12
      },
      deer: {
        name: 'Debug',
        age: 4
      }
    }
  }
}

const { bear: { name }, deer: { name }} = animal.type.mammal

上面的意思是:从 animal.type.mammal 对象中,访问 bear,并拿到它的 name 的值,并赋值给一个 const 变量,变量名为 name,同时再从 deer 中拿到 name 的值,赋值给一个名为 nameconst 变量。

看出问题来了吧? name 被声明了两次,这是不允许的,此时,我们可以在声明时,为两个 name 指定不同的名称:

const animal = {
  type: {
    mammal: {
      bear: {
        name: 'Bug',
        age: 12
      },
      deer: {
        name: 'Debug',
        age: 4
      }
    }
  }
}

const { bear: { name: bearName }, deer: { name: deerName }} = animal.type.mammal
console.log(bearName) // 输出:Bug
console.log(deerName) // 输出:Debug
数组的解构中同样支持重命名。

箭头函数

箭头函数可以大大减少编码工作量,但是它们并非普通函数的完全替代者,先来看看下面的代码:

const animals = ['Bug', 'Debug', 'Bugfix']

animals.forEach(function (animal) {
  console.log(animal)
})

我们使用箭头函数改写上面的代码:

const animals = ['Bug', 'Debug', 'Bugfix']

animals.forEach(animal => {
  console.log(animal)
})

这是一个简单的示例,只是为了证明箭头函数能让我们的代码更清晰可读,编码量也能大大减少,有一个不成文的经验是,一个项目的代码量越少,维护的成本一般情况下,都会越低,那为了证明箭头函数确实有用,我们再来看一个更复杂点的例子:

function multiplyAndAdd(multiply) {
  const pow = multiply ** multiply
  return function (number) {
    return pow + number
  }
}

const result = multipleAndAdd(3)(5) // 等于:3 ** 3 + 5 = 27 + 5 = 32

console.log(result) // 输出:32

用箭头函数再来改写一次:

const multiplyAndAdd = multiply => {
  const pow = multiply ** multiply
  return number => pow + number
}

如果熟练的话,我一般会这么写:

const multiplyAndAdd = multiply => number => multiply ** multiply + number

这里面可以这么阅读:

  • 声明一个 const 值:multiplyAndAdd,它的值为 multiply => number => multiply ** multiply + number,这个都很好理解
  • 这个值是一个箭头函数,该函数接受一个名为 multiply 的参数,返回 number => multiply ** multiply + number
  • 它的返回值还是一个箭头函数,这个箭头函数接受一个 number 参数,返回 multiply ** multiply + number
这么写可能会提升阅读难度,但是确实能节省代码量,但是个人还是不太推荐在多人协作的项目里面大量使用这样的写法。

原文:https://pub.ofcrab.com/press/instant-post-message-between-wechat-mini-program-webview-h5.html

在做 React Native 应用时,如果需要在 App 里面内嵌 H5 页面,那么 H5 与 App 之间可以通过 Webview 的 PostMessage 功能实现实时的通讯,但是在小程序里面,虽然也提供了一个 webview 组件,但是,在进行 postMessage 通讯时,官方文档里面给出了一条很变态的说明:

网页向小程序 postMessage 时,会在特定时机(小程序后退、组件销毁、分享)触发并收到消息。e.detail = { data }data 是多次 postMessage 的参数组成的数组

这里面已经说的很明白了,不管我们从 H5 页面里面 postMessage 多少次,小程序都是收不到的,除非:

  1. 用户做了回退到上一页的操作
  2. 组件销毁
  3. 用户点击了分享

这里面其实我没有完全说对,官方其实说的是 小程序后退,并没有说是用户做回退操作,经过我的实测,确实人家表达得很清楚了,我们通过微信官方的SDK调起的回退也是完全可行的:

wx.miniProgram.navigateBack()

大体思路

从上面的分析和实测中我们可以知道,要实现无需要用户操作即可完成的通讯,第三种情况我们是完全不需要考虑了的,那么来仔细考虑第 1 和第 2 种场景。

第 1 种方式:回退

当我们想通过网页向小程序发送数据,同时还可以回退到上一个页面时,我们可以在 wx.miniProgram.postMessage 之后,立马调用一次 wx.miniProgram.navigateBack(),此时小程序的操作是:

  1. 处理 postMessage 信息
  2. 回退到上一页

我们在处理 postMessage 的时候做一些特殊操作,可以将这些数据保存下来

第 2 种方式:组件销毁

这是我感觉最合适的一种方式,可以让小程序拿到数据,同时还保留在当前页面,只需要销毁一次 webview 即可,大概的流程就是:

  1. 小程序 postMessage
  2. 小程序 navigateTo 将小程序页面导向一个特殊的页面
  3. 小程序的那个特殊页面立马回退到 webview 所在的页面
  4. webview 所在的页面的 onShow 里面,做一次处理,将 webview 销毁,然后再次打开
  5. 触发 onMessage 拿到数据
  6. H5 页面再次被打开

这种方式虽然变态,但是至少可以做到实时拿到数据,同时还保留在当前 H5 页面,唯一需要解决的是,在做这整套操作前,H5 页面需要做好状态的缓存,要不然,再次打开之后,H5 的数据就清空了。

第 1 种方式:通过回退,将数据提交给小程序之后传递给 webview 的上一页面

这种方式实现起来其实是很简单的,我们现在新建两个页面:

sandbox/canvas-by-webapp/index.js

const app = getApp();

Page({
  data: {
    url: '',
    dimension: null,
    mime: '',
  },
  handleSaveTap: function() {
    wx.navigateTo({
      url: '/apps/browser/index',
      events: {
        receiveData: data => {
          console.log('receiveData from web browser: ', data);
          if (typeof data === 'object') {
            const { url, mime, dimension } = data;
            if (url && mime && dimension) {
              this.setData({
                url,
                dimension,
                mime,
              });

              this.save(data);
            }
          }
        }
      }
    })
  },

  save: async function({ url, mime, dimension }) {
    try {
      await app.saveImages([url]);
      app.toast('保存成功!');
    } catch (error) {
      console.log(error);
      app.toast(error.message || error);
    }
  },
});

上面的代码中,核心点,就在于 wx.navigateTo 调用时,里面的 events 参数,这是用来进行与 /apps/browser/index 页面通讯,接收数据用的。

apps/browser/index.js

我省略了绝大多数与本文无关的代码,保存最主要的三个:

Page({
  onLoad() {
    if (this.getOpenerEventChannel) {
      this.eventChannel = this.getOpenerEventChannel();
    }
  },
  handleMessage: function(message) {
    const { action, data } = message;
    if (action === 'postData') {
      if (this.eventChannel) {
        this.eventChannel.emit('receiveData', data);
      }
    }
  },

  handlePostMessage: function(e) {
    const { data } = e.detail;
    if (Array.isArray(data)) {
      const messages = data.map(item => {
        try {
          const object = JSON.parse(item);
          this.handleMessage(object);
          return object;
        } catch (error) {
          return item;
        }
      });

      this.setData({
        messages: [...messages],
      });
    }
  },
})

其实,onLoad 方法中,我们使用了自微信 SDK 2.7.3 版本开始提供的 getOpenerEventChannel 方法,它可以创建一个与上一个页面的事件通讯通道,这个我们会在 handleMessage 中使用。

handlePostMessage 就是被 bindmessagewebview 上面的方法,它用于处理从 H5 页面中 postMessage 过来的消息,由于小程序是将多次 postMessage 的消息放在一起发送过来的,所以,与其它的Webview不同点在于,我们拿到的是一个数组: e.detail.datahandlePostMessage 的作用就是遍历这个数组,取出每一条消息,然后交由 handleMessage 处理。

handleMessage 在拿到 message 对象之后,将 message.actionmessage.data 取出来(*这里需要注意,这是我们在 H5 里面的设计的一种数据结构,你完全可以在自己的项目中设计自己的结构),根据 action 作不同的操作,我在这里面的处理是,当 action === 'postData' 时,就通过 getOpenerEventChannel 得到的消息通道 this.eventChannel 将数据推送给上一级页面,也就是 /sandbox/canvas-by-webapp,但是不需要自己执行 navigateBack ,因为这个需要交由 H5 页面去执行。

H5 页面的实现

我的 H5 主要就是使用 html2canvas 库生成 Canvas 图(没办法,自己在小程序里面画太麻烦了),但是这个不在本文讨论过程中,我们就当是已经生成了 canvas 图片了,将其转为 base64 文本了,然后像下面这样做:

wx.miniProgram.postMessage({
  data: JSON.stringify({
    action: 'postData',
    data: 'BASE 64 IMAGE STRING'
  })
});

wx.miniProgram.navigateBack()

将数据 postMessage 之后,立即 navigateBack() ,来触发一次回退,也就触发了 bindmessage 事件。

使用销毁 webview 实现实时通讯

接下来,咱就开始本文的重点了,比较变态的方式,但是也没想到更好的办法,所以,大家将就着交流吧。

H5 页面的改变

wx.miniProgram.postMessage({
  data: JSON.stringify({
    action: 'postData',
    data: 'BASE 64 IMAGE STRING'
  })
});

wx.miniProgram.navigateTo('/apps/browser/placeholder');

H5 页面只是将 wx.miniProgram.navigateBack() 改成了 wx.miniProgram.navigateTo('/apps/browser/placeholder') ,其它的事情就先都交由小程序处理了。

/apps/browser/placeholder

这个页面的功能其实很简单,当打开它了之后,做一点点小操作,立马回退到上一个页面(就是 webview 所在的页面。

Page({
  data: { loading: true },
  onLoad(options) {

    const pages = getCurrentPages();

    const webviewPage = pages[pages.length - 2];

    webviewPage.setData(
      {
        shouldReattachWebview: true
      },
      () => {
        app.wechat.navigateBack();
      }
    );
  },
});

我们一行一行来看:

const pages = getCurrentPages();

这个可以拿到当前整个小程序的页面栈,由于这个页面我们只允许从小程序的 Webview 页面过来,所以,它的上一个页面一定是 webview 所在的页面:

const webviewPage = pages[pages.length - 2];

拿到 webviewPage 这个页面对象之后,调用它的方法 setData 更新一个值:

    webviewPage.setData(
      {
        shouldReattachWebview: true
      },
      () => {
        app.wechat.navigateBack();
      }
    );

shouldReattachWebview 这个值为 true 的时候,表示需要重新 attach 一次 webview,这个页面的事件现在已经做完了,回到 webview 所在的页面

apps/browser/index.js 页面

我同样只保留最核心的代码,具体的逻辑,我就直接写进代码里面了。

Page({
  data: {
    shouldReattachWebview: false, // 是否需要重新 attach 一次 webview 组件
    webviewReattached: false,     // 是否已经 attach 过一次 webview 了
    hideWebview: false            // 是否隐藏 webview 组件
  },
  onShow() {
    // 如果 webview 需要重新 attach 
    if (this.data.shouldReattachWebview) {
      this.setData(
        {
          // 隐藏 webview
          hideWebview: true,
        },
        () => {
          this.setData(
            {
              // 隐藏之后立马显示它,此时完成一次 webview 的销毁,拿到了 postMessage 中的数据
              hideWebview: false,
              webviewReattached: true,
            },
            () => {
              // 拿到数据之后,处理 canvasData
              this.handleCanvasData();
            }
          );
        }
      );
    }
  },
  // 当 webview 被销毁时,该方法被触发
  handlePostMessage: function(e) {
    const { data } = e.detail;
    if (Array.isArray(data)) {
      const messages = data.map(item => {
        try {
          const object = JSON.parse(item);
          this.handleMessage(object);
          return object;
        } catch (error) {
          return item;
        }
      });

      this.setData({
        messages: [...messages],
      });
    }
  },
  // 处理每一条消息
  handleMessage: function(message) {
    const {action, data} = message
    // 如果 saveCanvas action
    if (action === 'saveCanvas') {
      // 将数据先缓存进 Snap 中
      const { canvasData } = this.data;
      // app.checksum 是我自己封装的方法,计算任何数据的 checksum,我拿它来当作 key
      // 这可以保证同一条数据只会被处理一次
      const snapKey = app.checksum(data);
      // 只要未处理过的数据,才需要再次数据
      if (canvasData[snapKey] !== true) {
        if (canvasData[snapKey] === undefined) {
          // 将数据从缓存进 `snap` 中
          // 这也是我自己封装的一个方法,可以将数据缓存起来,并且只能被读取一次
          app.snap(snapKey, data);
          // 设置 canvasData 中 snapKey 字段为 `false`
          canvasData[snapKey] = false;
          this.setData({
            canvasData,
          });
        }
      }
    }
  },
  // 当 webview 被重新 attach 之后,canvas 数据已经被保存进 snap 中了,
  handleCanvasData: async function handleCanvasData() {
    const { canvasData } = this.data;
    // 从 canvasData 中拿到所有的 key,并过滤到已经处理过的数据
    const keys = Object.keys(canvasData).filter(key => canvasData[key] === false);

    if (keys.length === 0) {
      return;
    }

    for (let i = 0; i < keys.length; i += 1) {
      try {
        const key = keys[i];
        const { url } = app.snap(key);
        // 通过自己封装的方法,将 url(也就是Base64字符)保存至相册
        const saved = await app.saveImages(url);
        // 更新 canvasData 对象
        canvasData[key] = true
        this.setData({
          canvasData
        })
        console.log('saved: ', saved);
      } catch (error) {
        app.toast(error.message);
        return;
      }
    }
  },
})

对应的 index.wxml 文件内容如下:

<web-view src="{{src}}" wx:if="{{src}}" bindmessage="handlePostMessage" wx:if="{{!hideWebview}}" />

流程回顾与总结

  1. 打开 webview 页面,打开 h5
  2. h5 页面生成 canvas 图,并转为 base64 字符
  3. 通过 wx.miniProgram.postMessagebase64 发送给小程序
  4. 调用 wx.miniProgram.navigateTo 将页面导向一个特殊页面
  5. 在特殊页面中,将 webview 所在页面的 shouldReattachWebview 设置为 true
  6. 在特殊页面中回退至 webview 所在页面
  7. webview 所在页面的 onShow 事件被触发
  8. onShow 事件检测 shouldReattachWebview 是否为 true,若为 true
  9. hideWebview 设置为 true,引起 web-view 组件的销毁
  10. handlePostMessage 被触发,解析所有的 message 之后交给 handleMessage 逐条处理
  11. handleMessage 发现 action === 'saveCanvas' 的事件,拿到 data
  12. 根据 data 计算 checksum ,以 checksumkey 缓存下来数据,并将这个 checksum 保存到 canvasData 对象中
  13. 此时 hideWebviewonShow 里面 setData 的回调中的 setData 重新置为 falseweb-view 重新加 attach,H5页面重新加载
  14. webview 重新 attach 之后, this.handleCanvasData 被触发,
  15. handleCanvasData 检测是否有需要保存的 canvas 数据,如果有,保存,修改 canvasData 状态

整个流程看旧去很繁琐,但是写起来其实还好,这里面最主要的是需要注意,数据去重,微信的 postMessage 里面拿到的永远 都是 H5 页面从被打开到关闭的所有数据。

原文:https://pub.ofcrab.com/press/react-native-deep-linking-for-ios-android.html
代码:https://github.com/pantao/react-native-deep-linking-example

我们生活在一个万物兼可分享的年代,而分享的过程,几乎最终都会分享某一个链接,那么,作为开发者,最常遇到的问题中应该包括如何通过一个URL地址快速的打开App,并导航至特定的页面。

什么是深度链接(Deep Link)

深度链接是一项可以让一个App通过一个URL地址打开,之后导航至特定页面或者资源,或者展示特定UI的技术,Deep 的意思是指被打开的页面或者资源并不是App的首页,最常使用到的地方包括但远远不限于 Push Notification、邮件、网页链接等。

其实这个技术在很久很久以前就已经存在了,鼠标点击一下 mailto:pantao@parcmg.com 这样的链接,系统会打开默认的邮件软件,然后将 pantao@parcmg.com 这个邮箱填写至收件人输入栏里,这就是深度链接。

本文将从零开始创建一个应用,让它支持通过一个如 deep-linking://articles/{ID} 这样的 URL 打开 文章详情 页面,同时加载 {ID} 指定的文章,比如:deep-linking://articles/4 将打开 ID4 的文章详情页面。

深度链接解决了什么问题?

网页链接是无法打开原生应用的,如果一个用户访问你的网页中的某一个资源,他的手机上面也已经安装了你的应用,那么,我们要如何让系统自动的打开应用,然后在应用中展示用户所访问的那一个页面中的资源?这就是深度链接需要解决的问题。

实现深度链接的不同方式

有两种方式可以实现深度链接:

  • URL scheme
  • Universal links

前端是最常见的方式,后者是 iOS 新提供的方式,可以一个普通的网页地址链接至App的特定资源。

本文将创建一个名为 DeepLinkingExample 的应用,使得用户可以通过打开 deep-linking://home 以及 deep-linking://articles/4 分别打开 App 的首页以及 App 中 ID 为 4 的文章详情页面。

react-native init DeepLinkingExample
cd DeepLinkingExample

安装必要的库

紧跟 TypeScript 大潮流,我们的 App 写将使用 TypeScript 开发。

yarn add react-navigation react-native-gesture-handler
react-native link react-native-gesture-handler

我们将使用 react-navigation 模块作为 App 的导航库。

添加 TypeScript 相关的开发依赖:

yarn add typescript tslint tslint-react tslint-config-airbnb tslint-config-prettier ts-jest react-native-typescript-transformer -D
yarn add @types/jest @types/node @types/react @types/react-native @types/react-navigation @types/react-test-renderer

添加 tsconfig.json

{
  "compilerOptions": {
    "target": "es2017",                       /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */
    "module": "es2015",                       /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
    "lib": [                                  /* Specify library files to be included in the compilation:  */
      "es2017",
      "dom"
    ],
    "resolveJsonModule": true,
    "allowJs": false,                         /* Allow javascript files to be compiled. */
    "skipLibCheck": true,                     /* Skip type checking of all declaration files. */
    "jsx": "react-native",                    /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
    "declaration": true,                      /* Generates corresponding '.d.ts' file. */
    "sourceMap": true,                        /* Generates corresponding '.map' file. */
    "outDir": "./lib",                        /* Redirect output structure to the directory. */
    "removeComments": true,                   /* Do not emit comments to output. */
    "noEmit": true,                           /* Do not emit outputs. */

    /* Strict Type-Checking Options */
    "strict": true,                           /* Enable all strict type-checking options. */
    "noImplicitAny": true,                    /* Raise error on expressions and declarations with an implied 'any' type. */
    "strictNullChecks": true,                 /* Enable strict null checks. */
    "strictFunctionTypes": true,              /* Enable strict checking of function types. */
    "noImplicitThis": true,                   /* Raise error on 'this' expressions with an implied 'any' type. */
    "alwaysStrict": true,                     /* Parse in strict mode and emit "use strict" for each source file. */

    /* Additional Checks */
    "noUnusedLocals": true,                   /* Report errors on unused locals. */
    "noUnusedParameters": true,               /* Report errors on unused parameters. */
    "noImplicitReturns": true,                /* Report error when not all code paths in function return a value. */
    "noFallthroughCasesInSwitch": true,       /* Report errors for fallthrough cases in switch statement. */

    /* Module Resolution Options */
    "moduleResolution": "node",               /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
    "baseUrl": "./",                          /* Base directory to resolve non-absolute module names. */
    "paths": {                                /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
      "*": [
        "*.android",
        "*.ios",
        "*.native",
        "*.web",
        "*"
      ]
    },
    "typeRoots": [                            /* List of folders to include type definitions from. */
      "@types",
      "../../@types"
    ],
    // "types": [],                           /* Type declaration files to be included in compilation. */
    "allowSyntheticDefaultImports": true,     /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
    // "preserveSymlinks": true,              /* Do not resolve the real path of symlinks. */

    /* Experimental Options */
    "experimentalDecorators": true,           /* Enables experimental support for ES7 decorators. */
    "emitDecoratorMetadata": true             /* Enables experimental support for emitting type metadata for decorators. */
  },
  "exclude": [
    "node_modules",
    "web"
  ]
}

添加 tslint.json 文件

{
  "defaultSeverity": "warning",
  "extends": [
    "tslint:recommended", 
    "tslint-react",
    "tslint-config-airbnb",
    "tslint-config-prettier"
  ],
  "jsRules": {},
  "rules": {
    "curly": false,
    "function-name": false,
    "import-name": false,
    "interface-name": false,
    "jsx-boolean-value": false,
    "jsx-no-multiline-js": false,
    "member-access": false,
    "no-console": [true, "debug", "dir", "log", "trace", "warn"],
    "no-empty-interface": false,
    "object-literal-sort-keys": false,
    "object-shorthand-properties-first": false,
    "semicolon": false,
    "strict-boolean-expressions": false,
    "ter-arrow-parens": false,
    "ter-indent": false,
    "variable-name": [
      true,
      "allow-leading-underscore",
      "allow-pascal-case",
      "ban-keywords",
      "check-format"
    ],
    "quotemark": false
  },
  "rulesDirectory": []
}

添加 .prettierrc 文件:

{
  "parser": "typescript",
  "printWidth": 100,
  "semi": false,
  "singleQuote": true,
  "trailingComma": "all"
}

编写我们的应用

在项目根目录下创建一个 src 目录,这个将是项目原代码的目录。

添加 src/App.tsx 文件

import React from 'react'

import { createAppContainer, createStackNavigator } from 'react-navigation'

import About from './screens/About'
import Article from './screens/Article'
import Home from './screens/Home'

const AppNavigator = createStackNavigator(
  {
    Home: { screen: Home },
    About: { screen: About, path: 'about' },
    Article: { screen: Article, path: 'article/:id' },
  },
  {
    initialRouteName: 'Home',
  },
)

const prefix = 'deep-linking://'

const App = createAppContainer(AppNavigator)

const MainApp = () => <App uriPrefix={prefix} />

export default MainApp

添加 src/screens/Home.tsx 文件

import React from 'react';

添加 src/screens/About.tsx

import React from 'react'

import { StyleSheet, Text, View } from 'react-native'

import { NavigationScreenComponent } from 'react-navigation'

interface IProps {}

interface IState {}

const AboutScreen: NavigationScreenComponent<IProps, IState> = props => {
  return (
    <View style={styles.container}>
      <Text style={styles.title}>About Page</Text>
    </View>
  )
}

AboutScreen.navigationOptions = {
  title: 'About',
}

export default AboutScreen

const styles = StyleSheet.create({
  container: {},
  title: {},
})

添加 src/screens/Article.tsx

import React from 'react'

import { StyleSheet, Text, View } from 'react-native'

import { NavigationScreenComponent } from 'react-navigation'

interface NavigationParams {
  id: string
}

const ArticleScreen: NavigationScreenComponent<NavigationParams> = ({ navigation }) => {
  const { params } = navigation.state

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Article {params ? params.id : 'No ID'}</Text>
    </View>
  )
}

ArticleScreen.navigationOptions = {
  title: 'Article',
}

export default ArticleScreen

const styles = StyleSheet.create({
  container: {},
  title: {},
})

配置 iOS

打开 ios/DeepLinkingExample.xcodeproj

open ios/DeepLinkingExample.xcodeproj

点击 Info Tab 页,找到 URL Types 配置,添加一项:

  • identifier:deep-linking
  • URL Schemes:deep-linking
  • 其它两项留空

打开项目跟目录下的 AppDelegate.m 文件,添加一个新的 import

#import "React/RCTLinkingManager.h"

然后在 @end 前面,添加以下代码:

- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url sourceApplication:(NSString *)sourceApplication annotation:(id)annotation {
  return [RCTLinkingManager application:application openURL:url sourceApplication:sourceApplication annotation:annotation];
}

至此,我们已经完成了 iOS 的配置,运行并测试是否成功。

react-native run-ios

打开 simulator 之后,打开 Safari 浏览器,在地址栏中输入:deep-linking://article/4 ,你的应用将会自动打开,并同时进入到 Article 页面。

同样的,你还可以在命令行工具中执行以下命令:

xcrun simctl openurl booted deep-linking://article/4

配置 Android

要为Android应用也创建 External Linking,需要创建一个新的 intent,打开 android/app/src/main/AndroidManifest.xml,然后在 MainActivity 节点添加一个新的 intent-filter

<application ...>
  <activity android:name=".MainActivity" ...>
    ...
    <intent-filter>
      <action android:name="android.intent.action.VIEW" />
      <category android:name="android.intent.category.DEFAULT" />
      <category android:name="android.intent.category.BROWSABLE" />
      <data android:scheme="deep-linking" />
    </intent-filter>
    ...
  </activity>
</application>

Android 只需要完成上面的配置即可。

执行:

react-native run-android

打开系统浏览器,输入:

deep-linking://article/4

系统会自动打开你的应用,并进入 Article 页面

也可以在命令行工具中使用以下命令打开:

adb shell am start -W -a android.intent.action.VIEW -d "deep-linking://article/3" com.deeplinkingexample;

附录

点击以下链接即可:

Javascript 总是以超自然的方式执行我们的代码,这是一件很神奇的事情,如果不信的话,思考一下 ['1', '7', '11'].map(parseInt) 的结果是什么?你以为会是 [1, 7, 11] 吗?我都这么问了,那肯定不是:

['1', '7', '11'].map(parseInt)
// 输出:(3) [1, NaN, 3]

要理解为什么为会这里,首先需要了解一些 Javascript 概念,如果你是一个不太喜欢阅读长文的人,那直接跳到最后看结论吧

真值与假值

请看下面示例:

if (true) {
  // 这里将启动都会被执行
} else {
  // 这里永远都不会被执行
}

在上面的示例中, if-else 声明为 true,所以 if-block 会一直都执行,而 else-block 永远都会被忽略掉,这是个很容易理解的示例,下面我们来看看另一个例子:

if ("hello world") {
  // 这里会被执行吗?
  console.log("条件判断为真");
} else {
  // 或者会执行这里吗?
  console.log("条件判断为假");
}

打开你的开发者工具 console 面板,执行上面的代码,你会得到看到会打印出 条件判断为真,也就是说 "hello world" 这一个字符串被认为是真值

在 JavaScript 中,truthy(真值)指的是在布尔值上下文中,转换后的值为真的值。所有值都是真值,除非它们被定义为 假值(即除 false、0、""、null、undefined 和 NaN 以外皆为真值)。

引用自:Mozilla Developer: Truthy(真值)

这里一定要划重点:在布尔上下文中,除 false0""(空字符串)、nullundefined 以及 NaN 外,其它所有值都为真值

基数

0 1 2 3 4 5 6 7 8 9 10

当我们从 0 数到 9,每一个数字的表示符号都是不一样的(0-9),但是当我们数到 10 的时候,我们就需要两个不同的符号 10 来表示这个值,这是因为,我们的数学计数系统是一个十进制的。

基数,是一个进制系统下,能使用仅仅超过一个符号表示的数字的最小值,不同的计数进制有不同的基数,所以,同一个数字在不同的计数体系下,表示的真实数据可能并不一样,我们来看一下下面这张在十进制、二进制以及十六进制不同值在具体表示方法:

十进制(基数:10)二进制(基数:2)十六进制(基数:16)
000
111
2102
3113
41004
51015
61106
71117
810008
910019
101010A
111011B
121100C
131101D
141110E
151111F
161000010
171000111
181000212

你应该已经注意到了, 11 在上表中,总共出现了三次。

函数的参数

在 Javascript 中,一个函数可以传递任何多个数量的参数,即使调用时传递的数量与定义时的数量不一致。缺失的参数会以 undefined 作为实际值传递给函数体,然后多余的参数会直接被忽略掉(但是你还是可以在函数体内通过一个类数组对象 arguments 访问到)。

function sum(a, b) {
  console.log(a);
  console.log(b);
}

sum(1, 2);    // 输出:1, 2
sum(1);       // 输出:1, undefined
sum(1, 2, 3); // 输出:1, 2

map()

快要有结果了。

map() 是一个存在于数组原型链上的方法,它将其数组实例中的每一个元素,传递给它的第一个参数(在本文最开始的例子中就是 parseInt),然后将每一个返回值都保存到同一个新的数组中,遍历完所有元素之后,将新的包含了所有结果的数组返回。

function multiplyBy3 (number) {
  return number * 3;
}

const result = [1, 2, 3, 4, 5].map(multiplyBy3);

console.log(result); // 输出:[3, 6, 9, 12, 15]

现在,假想一下,我们想使用 map() 将一个数组中的每一个元素都打印到控制台,我可以直接将 console.log 函数传递给 map() 方法:

[1, 2, 3, 4, 5].map(console.log);

输出:

1 0 (5) [1, 2, 3, 4, 5]
2 1 (5) [1, 2, 3, 4, 5]
3 2 (5) [1, 2, 3, 4, 5]
4 3 (5) [1, 2, 3, 4, 5]
5 4 (5) [1, 2, 3, 4, 5]

是不是感觉有点神奇了?为什么会这样?来分析一下上面输出的内容都有些什么?

  • 第一列:这个看上去跟我们想要的输出是一致的
  • 第二列:这个是一个从 0 开始递增的数字
  • 第三列:总是 (5) [1, 2, 3, 4, 5]

再来看看下面这段代码:

[1, 2, 3, 4, 5].map((value, index, array) => console.log(value, index, array));

在控制台上试试执行上面这行代码,你会发现,它与 [1, 2, 3, 4, 5].map(console.log); 输出的结果是完全一致的,**总是会被我们忽略的一点是,map() 方法会将三个参数传递传递给它的函数,分别是 currentValue(当前值)、currentIndex(当前索引)以及 array 本身,这就是,上面为什么结果有三列的原因,如果我们只是想将每一个值打印,那么需要像下面这样写:

[1, 2, 3, 4, 5].map((value) => console.log(value));

回到最开始的问题

现在让我们来回顾一下本文最开始的问题:

为什么 ['1', '7', '11'].map(parseInt) 的结果是 [1, NaN, 3]

来看看 parseInt() 是一个什么样的函数:

parseInt(string, radix)将一个字符串 string 转换为 radix 进制的整数, radix 为介于 2-36 之间的数。

详情:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/parseInt

这里还有一条需要注意的是,虽然我们一般都是使用 parseInt('11') 这样的调用方式,认为是将 11 按十进制转换成数字,但是,请在使用的时候,永远都添加 radix 参数,因为, radix10 并不保证永远有效(这并不是规范的一部分)。

那么看看下面这些的输出:

parseInt('11');             // 输出:11
parseInt('11', 2);          // 输出:3
parseInt('11', 16);         // 输出:17

parseInt('11', undefined);  // 输出:11 (radix 是假值)
parseInt('11', 0);          // 输出:11 (radix 是假值)

现在,让我们一步一步来看 ['1', '7', '11'].map(parseInt) 的整个执行过程,首先,我们可以将这一行代码,转换为完整的版本:

['1', '7', '11'].map((value, index, array) => parseInt(value, index, array))

根据前面的知识,我们应该知道, parseInt(value, index, array) 中的 array 会被忽略(并放入 arguments 对象中。

那么,完整的代码就是下面这样的:

['1', '7', '11'].map((value, index, array) => parseInt(value, index))
  • 遍历到第一个元素时:

    parseInt('1', 0);

    radix0,是一个假值,在我们的控制台中,一般将使用 10 进制,所有,得到结果 1

  • 遍历到第二个元素时:

    parseInt('7', 1)

    在一个 1 进制系统中,7 是不存在的,所以得到结果 NaN(不是一个数字)

  • 遍历到第三个元素时:

    parseInt('11', 2)

    在一个 2 进制系统中,11 就是进十制的 3

总结

['1', '7', '11'].map(parseInt) 的结果是 [1, NaN, 3] 的原因是因为,map() 方法是向传递给他的函数中传递三个参数,分别为当前值,当前索引以及整个数组,而 parseInt 函数接收两个参数:需要转换的字符串,以及进制基数,所以,整个语句可以写作:['1', '7', '11'].map((value, index, array) => parseInt(value, index, array))arrayparseInt 舍弃之后,得到的结果分别是:parseInt('1', 0)parseInt('7', 1) 以及 parseInt('11', 2),也就是上面看到的 [1, NaN, 3]

正确的写法应该是:

['1', '7', '11'].map(numStr => parseInt(numStr, 10));

自己的一句话理解:JSON Web Token 并不是一种认证方式,他只是认证信息的载体。

基于自己的理解,谈谈 JSON Web Token 的一些事儿。

Session 认证

  1. 用户向服务器A发送用户名和密码
  2. 服务器验证通过后,在当前会话里保存相关数据,比如登录时间、过期时间,用户角色等,并生成一个 session_id
  3. 服务器响应用户登录时,将 session_id 写入 cookies
  4. 用户随后发送的每一次请求都会将 session_id 传回服务器
  5. 服务器在处理请求之前,先拿到 sesion_id ,然后找到前面创建的会话信息

这种模式最简单,但是它的扩展性(Scaling)不好,如果是多台服务器,还需要考虑到所有服务器都能读取会话,那么会话就需要在多台服务器之间共享了,然后,如果有跨域的话,还需要保证 Cookie 在多个域下都是可访问的,这种方式看起来,确实就很麻烦了。

客户端保存会话数据

这种方式比 Session 实现起来就简单得多了:

  1. 用户还是发起登录请求
  2. 服务器验证通过之后生成会话数据
  3. 服务器直接把会话数据发送给请求方
  4. 请求方之后去请求任何服务的时候,自己带上就可以了
  5. 服务器接收到请求之后,自己解析会话数据,然后接下步处理即可

那么,这种方法,需要关注的问题并不是会话数据如何共享的问题了,要关注的点是:

  1. 我如何解析会话数据?
  2. 我如何认定客户端发送的会话数据是合法的?

JSON Web Token(JWT)的原理

JWT 就是为解决上面这个问题的,它首先能保存下很多会话数据,其次,它还提供了数据校验机制,下面我们来看看它的原理。

用户在登录时,服务器校验成功之后,生成一个 JSON 对象,比如:

{
  "id": 1,
  "name": "pantao",
  "role": "manager",
  "loginTime": "2019-08-01"
}

上面的这个 JSON 对象保存下了当前登录用户的ID、姓名、角色以及登录时间,以后的客户端在请求任何服务时,都应该要带上这些信息。

当然,真正的 JWT 肯定不止是保存这些数据就足够了的,上面这样的信息,只是载体(Payload),也就是认证的数据本身,但是我们还需要提供一些别的信息,来帮助服务器确定这条数据是合法的(你不能随便造一条这样的信息我就认为你是真实的吧?),在完整的 JWT 中,还会带有另外两种数据:

  • Header:头部信息,也是一个 JSON 对象,用于描述当前这个 JWT 数据是什么的元数据,比如通常是下面这样的:

    {
      "alg": "HS256",
      "typ": "JWT"
    }

    在上面的代码中, algalgorithm 的缩写,表示了当前这个JWT使用了什么签名算法,默认就是 HMAC SHA256,缩写就是 HS256typ 属性表示了,这个是一个 JWT 类型的 token

  • Signature:这部分是对 Header 跟 Payload 两部分的签名字符串,在生成这个字符串的时候,服务器端会有一个 Secret 密钥,这使得,除了服务器自己,别人是没有办法生成正确的签名的,所以,即使前面两个内容可以随意的造,别人也没有黑涩会生成正确的签名,那么数据是否合法,最主要就是通过这个内容了。

整个认证流程就是:

  1. 客户端拿到 JWT 数据之后,在以后的请求里面带上 JWT 信息
  2. 服务器先校验这个JWT是不是自己生成的(根据 Header, Payload 以及自己保存的 Secret 再计算一次 Signature 看是不是跟用户传进来的一致就成了)
  3. 如果是自己生成的,则解析 Payload 部分,拿到上面所设计的载体JSON对象,这里面就保存了我们的会话信息
  4. 拿到会话信息后进行进一步

Base64URL

上面说了 Header.Payload.Signature 这样三段式的格式,客户端收到这样的一个 TOKEN 之后,可能是通过 COOKIE 发送服务器,也可能是通过 Header,还可能是通过 POST 请求的Payload中的一个字段,也可能是 URL 里面的查询参数,为了保证在各种不同的一方传输的过程中都通用,所以,我们肯定不能直接把两个JSON字符串以及一个签名字符串用两个 . 连起来就用,这里面就用到了 Base64URL 的算法。

在 Base64 算法里面,有三个字符 += 以及 / 是有特殊含义的,Base64URL 算法就是,将字符串按 Base64 处理之后,再将 = 省略,将 + 换成 - ,将 / 替换成 _,看个示例:

const jwt = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ7XCJ1c2VySWRcIjpcIjExNTE4NjA4NjM5NTA0NTg4ODFcIixcInBob25lXCI6XCIxODM3NDUwMDk5OVwiLFwiYXBwSURcIjpcInd4YTJhY2IwZGVhODgyYmNmN1wiLFwib3BlbklkXCI6XCJvckpxcDVRMlo3U3V4Y3Jxa3dmelJNZ2RpVGRJXCIsXCJ1bmlvbklkXCI6XCJvZ0FtLTFBbm9mMU1rVzdtZEY3bGVsalZDcURvXCIsXCJjaXR5XCI6XCJcIixcImNvdW50cnlcIjpcIkNoaW5hXCIsXCJnZW5kZXJcIjpcIlwiLFwibmlja05hbWVcIjpcIuWkp-iDoeWtkOWGnOawkeW3pea9mOWNiuS7mVwiLFwicHJvdmluY2VcIjpcIlwiLFwiYXZhdGFyVXJsXCI6XCJodHRwczovL3d4LnFsb2dvLmNuL21tb3Blbi92aV8zMi9EWUFJT2dxODNlckhLVTNPdEk3WUliazB1NmliQlA2eTdZeDZpY2dwbXpUdWRPbEVQeHUydldpYmhudlhwWmlhSndpYjhjcEpOVjRaUFRtbERjb09vMnR5Q2ljQS8xMzJcIn0iLCJpc3MiOiJkZXZlbG9wIiwiZXhwIjoxNTY4MDc2ODcxLCJpYXQiOjE1NjU0ODQ4NzF9.kta-7LP7dIEbWYILDfw93aiKg1FRC4IOAajsUzSXeUY';

上面这个就是一个完整的 JWT 字符串,如何能解析出里面的数据呢?很简单:

  1. . 号分割字符串
  2. 将第0与第1项里面的 - 号换成 + 号,_ 号换成 / 号,
  3. 再使用 atob 将字符串从 base64 转成正常的字符
const [header, payload] = jwt.split('.').slice(0,2).map(s => s.replace(/-/gi, '+').replace(/_/gi, '/')).map(s => atob(s));

// header = {typ: "JWT", alg: "HS256"}
// payload = {"sub":"{\"userId\":\"1151860863950458881\",\"phone\":\"18374500999\",\"appID\":\"wxa2acb0dea882bcf7\",\"openId\":\"orJqp5Q2Z7SuxcrqkwfzRMgdiTdI\",\"unionId\":\"ogAm-1Anof1MkW7mdF7leljVCqDo\",\"city\":\"\",\"country\":\"China\",\"gender\":\"\",\"nickName\":\"大胡子农民工潘半仙\",\"province\":\"\",\"avatarUrl\":\"https://wx.qlogo.cn/mmopen/vi_32/DYAIOgq83erHKU3OtI7YIbk0u6ibBP6y7Yx6icgpmzTudOlEPxu2vWibhnvXpZiaJwib8cpJNV4ZPTmlDcoOo2tyCicA/132\"}","iss":"develop","exp":1568076871,"iat":1565484871}

要转成对象的话,再 JSON.parse() 一下就可以了:

const [header, payload] = jwt.split('.').slice(0,2).map(s => s.replace(/-/gi, '+').replace(/_/gi, '/')).map(s => atob(s)).map(s => JSON.parse(s));

可以得到下面这样结构的对象:

{
  "typ": "JWT",
  "alg": "HS256"
}

{
  "sub": "{\"userId\":\"1151860863950458881\",\"phone\":\"18374500999\",\"appID\":\"wxa2acb0dea882bcf7\",\"openId\":\"orJqp5Q2Z7SuxcrqkwfzRMgdiTdI\",\"unionId\":\"ogAm-1Anof1MkW7mdF7leljVCqDo\",\"city\":\"\",\"country\":\"China\",\"gender\":\"\",\"nickName\":\"大胡子农民工潘半仙\",\"province\":\"\",\"avatarUrl\":\"https://wx.qlogo.cn/mmopen/vi_32/DYAIOgq83erHKU3OtI7YIbk0u6ibBP6y7Yx6icgpmzTudOlEPxu2vWibhnvXpZiaJwib8cpJNV4ZPTmlDcoOo2tyCicA/132\"}",
  "iss": "develop",
  "exp": 1568076871,
  "iat": 1565484871
}

可以看到, Payload 段中的 sub ,应该也是一个 JSON 字符串,再解析一下即可:

{
  "userId": "1151860863950458881",
  "phone": "18374500999",
  "appID": "wxa2acb0dea882bcf7",
  "openId": "orJqp5Q2Z7SuxcrqkwfzRMgdiTdI",
  "unionId": "ogAm-1Anof1MkW7mdF7leljVCqDo",
  "city": "",
  "country": "China",
  "gender": "",
  "nickName": "大胡子农民工潘半仙",
  "province": "",
  "avatarUrl": "https://wx.qlogo.cn/mmopen/vi_32/DYAIOgq83erHKU3OtI7YIbk0u6ibBP6y7Yx6icgpmzTudOlEPxu2vWibhnvXpZiaJwib8cpJNV4ZPTmlDcoOo2tyCicA/132"
}

虽然我们可以解析出 Header 跟 Payload,但是只要我们对其修改之后再发送回服务器,内容一改,签名就会改,所以,服务器直接就认定这是假的 TOKEN了

安全性

  • JWT 默认是不加密的,但是,我们也可以对生成之后的 Header 与 Payload 字符串进行一次加密,这样客户端就无法直接解析出明文了,只是在接收时,在进行 JWT校验之前,先对 Header 与 Payload 密文进行一次解密即可
  • JWT 不加密的情况下,不能将私密数据写入 JWT
  • JWT 除了认证外,还可以用于数据交换,可以大大减少查询数据库的次数
  • 如果服务器端不做特殊的其它逻辑,那么一个JWT签发之后,除非它过期,否则将永远有效,如果权限记录在 JWT 中,那么,就算修改了某个用户的权限,在签发给他的TOKEN过期前,他的权限还将是上一次签发的,由于这条特性,对于重要的操作,比如转帐等,应该进行二次确认。
  • 应该使用 HTTPS 协议传输数据