蒋鹏飞

蒋鹏飞 查看完整档案

成都编辑四川大学  |  计算机科学与技术 编辑  |  填写所在公司/组织 github.com/dennis-jiang/Front-End-Knowledges 编辑
编辑

掘金优秀作者,ID同名~
公众号:进击的大前端!
分享各种大前端进阶知识!
不打广告,不写水文,只发高质量原创,与君共勉,共同学习~
更多文章和示例源码请看:https://github.com/dennis-jia...

个人动态

蒋鹏飞 发布了文章 · 10月26日

使用Node.js原生API写一个web服务器

Node.jsJavaScript基础上发展起来的语言,所以前端开发者应该天生就会一点。一般我们会用它来做CLI工具或者Web服务器,做Web服务器也有很多成熟的框架,比如ExpressKoa。但是ExpressKoa都是对Node.js原生API的封装,所以其实不借助任何框架,只用原生API我们也能写一个Web服务器出来。本文要讲的就是不借助框架,只用原生API怎么写一个Web服务器。因为在我的计划中,后面会写ExpressKoa的源码解析,他们都是使用原生API来实现的。所以本文其实是这两个源码解析的前置知识,可以帮我们更好的理解ExpressKoa这种框架的意义和源码。本文仅为说明原生API的使用方法,代码较丑,请不要在实际工作中模仿!

本文可运行代码示例已经上传GitHub,大家可以拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/HttpServer

Hello World

要搭建一个简单的Web服务器,使用原生的http模块就够了,一个简单的Hello World程序几行代码就够了:

const http = require('http')

const port = 3000

const server = http.createServer((req, res) => {
  res.statusCode = 200
  res.setHeader('Content-Type', 'text/plain')
  res.end('Hello World')
})

server.listen(port, () => {
  console.log(`Server is running on http://127.0.0.1:${port}/`)
})

这个例子就很简单,直接用http.createServer创建了一个服务器,这个服务器也没啥逻辑,只是在访问的时候返回Hello World。服务器创建后,使用server.listen运行在3000端口就行。

这个例子确实简单,但是他貌似除了输出一个Hello World之外,啥也干不了,离我们一般使用的Web服务器还差了很远,主要是差了这几块:

  1. 不支持HTTP动词,比如GETPOST
  2. 不支持路由
  3. 没有静态资源托管
  4. 不能持久化数据

前面三点是一个Web服务器必备的基础功能,第四点是否需要要看情况,毕竟目前很多NodeWeb服务器只是作为一个中间层,真正跟数据库打交道做持久化的还是各种微服务,但是我们也应该知道持久化怎么做。

所以下面我们来写一个真正能用的Web服务器,也就是说把前面缺的几点都补上。

处理路由和HTTP动词

前面我们的那个Hello World也不是完全不能用,因为代码位置还是得在http.createServer里面,我们就在里面添加路由的功能。为了跟后面的静态资源做区分,我们的API请求都以/api开头。要做路由匹配也不难,最简单的就是直接用if条件判断就行。为了能拿到请求地址,我们需要使用url模块来解析传过来的地址。而Http动词直接可以用req.method拿到。所以http.createServer改造如下:

const url = require('url');

const server = http.createServer((req, res) => {
  // 获取url的各个部分
  // url.parse可以将req.url解析成一个对象
  // 里面包含有pathname和querystring等
  const urlObject = url.parse(req.url);
  const { pathname } = urlObject;

  // api开头的是API请求
  if (pathname.startsWith('/api')) {
    // 再判断路由
    if (pathname === '/api/users') {
      // 获取HTTP动词
      const method = req.method;
      if (method === 'GET') {
        // 写一个假数据
        const resData = [
          {
            id: 1,
            name: '小明',
            age: 18
          },
          {
            id: 2,
            name: '小红',
            age: 19
          }
        ];
        res.setHeader('Content-Type', 'application/json')
        res.end(JSON.stringify(resData));
        return;
      }
    }
  }
});

现在我们访问/api/users就可以拿到用户列表了:

image.png

支持静态文件

上面说了API请求是以/api开头,也就是说不是以这个开头的可以认为都是静态文件,不同文件有不同的Content-Type,我们这个例子里面暂时只支持一种.jpg吧。其实就是给我们的if (pathname.startsWith('/api'))加一个else就行。返回静态文件需要:

  1. 使用fs模块读取文件。
  2. 返回文件的时候根据不同的文件类型设置不同的Content-Type

所以我们这个else就长这个样子:

// ... 省略前后代码 ...

else {
  // 使用path模块获取文件后缀名
  const extName = path.extname(pathname);

  if (extName === '.jpg') {
    // 使用fs模块读取文件
    fs.readFile(pathname, (err, data) => {
      res.setHeader('Content-Type', 'image/jpeg');
      res.write(data);
      res.end();
    })
  }
}

然后我们在同级目录下放一个图片试一下:

image.png

数据持久化

数据持久化的方式有好几种,一般都是存数据库,少数情况下也有存文件的。存数据库比较麻烦,还需要创建和连接数据库,我们这里不好demo,我们这里演示一个存文件的例子。一般POST请求是用来存新数据的,我们在前面的基础上再添加一个POST /api/users来新增一条数据,只需要在前面的if (method === 'GET')后面加一个POST的判断就行:

// ... 省略其他代码 ...

else if (method === 'POST') {
  // 注意数据传过来可能有多个chunk
  // 我们需要拼接这些chunk
  let postData = '';
  req.on('data', chunk => {
    postData = postData + chunk;
  })

  req.on('end', () => {
    // 数据传完后往db.txt插入内容
    fs.appendFile(path.join(__dirname, 'db.txt'), postData, () => {
      res.end(postData);  // 数据写完后将数据再次返回
    });
  })
}

然后我们测试一下这个API:

image-20201007165330636

再去看看文件里面写进去没有:

image-20201007165506756

总结

到这里我们就完成了一个具有基本功能的web服务器,代码不复杂,但是对于帮我们理解Node web服务器的原理很有帮助。但是上述代码还有个很大的问题就是:代码很丑!所有代码都写在一堆,而且HTTP动词和路由匹配全部是使用if条件判断,如果有几百个API,再配合十来个动词,那代码简直就是个灾难!所以我们应该将路由处理HTTP动词静态文件数据持久化这些功能全部抽离出来,让整个应用变得更优雅,更好扩展。这就是ExpressKoa这些框架存在的意义,下一篇文章我们就去Express的源码看看他是怎么解决这个问题的,点个关注不迷路~

本文可运行代码示例已经上传GitHub,大家可以拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/HttpServer

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

作者博文GitHub项目地址: https://github.com/dennis-jiang/Front-End-Knowledges

我也搞了个公众号[进击的大前端],不打广告,不写水文,只发高质量原创,欢迎关注~

查看原文

赞 33 收藏 27 评论 0

蒋鹏飞 发布了文章 · 10月19日

手写Redux-Saga源码

上一篇文章我们分析了Redux-Thunk的源码,可以看到他的代码非常简单,只是让dispatch可以处理函数类型的action,其作者也承认对于复杂场景,Redux-Thunk并不适用,还推荐了Redux-Saga来处理复杂副作用。本文要讲的就是Redux-Saga,这个也是我在实际工作中使用最多的Redux异步解决方案。Redux-SagaRedux-Thunk复杂得多,而且他整个异步流程都使用Generator来处理,Generator也是我们这篇文章的前置知识,如果你对Generator还不熟悉,可以看看这篇文章

本文仍然是老套路,先来一个Redux-Saga的简单例子,然后我们自己写一个Redux-Saga来替代他,也就是源码分析。

本文可运行的代码已经上传到GitHub,可以拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/redux-saga

简单例子

网络请求是我们经常需要处理的异步操作,假设我们现在的一个简单需求就是点击一个按钮去请求用户的信息,大概长这样:

Sep-11-2020 16-31-55

这个需求使用Redux实现起来也很简单,点击按钮的时候dispatch出一个action。这个action会触发一个请求,请求返回的数据拿来显示在页面上就行:

import React from 'react';
import { connect } from 'react-redux';

function App(props) {
  const { dispatch, userInfo } = props;

  const getUserInfo = () => {
    dispatch({ type: 'FETCH_USER_INFO' })
  }

  return (
    <div className="App">
      <button onClick={getUserInfo}>Get User Info</button>
      <br></br>
      {userInfo && JSON.stringify(userInfo)}
    </div>
  );
}

const matStateToProps = (state) => ({
  userInfo: state.userInfo
})

export default connect(matStateToProps)(App);

上面这种写法都是我们之前讲Redux就介绍过的Redux-Saga介入的地方是dispatch({ type: 'FETCH_USER_INFO' })之后。按照Redux一般的流程,FETCH_USER_INFO被发出后应该进入reducer处理,但是reducer都是同步代码,并不适合发起网络请求,所以我们可以使用Redux-Saga来捕获FETCH_USER_INFO并处理。

Redux-Saga是一个Redux中间件,所以我们在createStore的时候将它引入就行:

// store.js

import { createStore, applyMiddleware } from 'redux';
import createSagaMiddleware from 'redux-saga';
import reducer from './reducer';
import rootSaga from './saga';

const sagaMiddleware = createSagaMiddleware()

let store = createStore(reducer, applyMiddleware(sagaMiddleware));

// 注意这里,sagaMiddleware作为中间件放入Redux后
// 还需要手动启动他来运行rootSaga
sagaMiddleware.run(rootSaga);

export default store;

注意上面代码里的这一行:

sagaMiddleware.run(rootSaga);

sagaMiddleware.run是用来手动启动rootSaga的,我们来看看rootSaga是怎么写的:

import { call, put, takeLatest } from 'redux-saga/effects';
import { fetchUserInfoAPI } from './api';

function* fetchUserInfo() {
  try {
    const user = yield call(fetchUserInfoAPI);
    yield put({ type: "FETCH_USER_SUCCEEDED", payload: user });
  } catch (e) {
    yield put({ type: "FETCH_USER_FAILED", payload: e.message });
  }
}

function* rootSaga() {
  yield takeEvery("FETCH_USER_INFO", fetchUserInfo);
}

export default rootSaga;

上面的代码我们从export开始看吧,export的东西是rootSaga这个Generator函数,这里面就一行:

yield takeEvery("FETCH_USER_INFO", fetchUserInfo);

这一行代码用到了Redux-Saga的一个effect,也就是takeEvery,他的作用是监听每个FETCH_USER_INFO,当FETCH_USER_INFO出现的时候,就调用fetchUserInfo函数,注意这里是每个FETCH_USER_INFO。也就是说如果同时发出多个FETCH_USER_INFO,我们每个都会响应并发起请求。类似的还有takeLatesttakeLatest从名字都可以看出来,是响应最后一个请求,具体使用哪一个,要看具体的需求。

然后看看fetchUserInfo函数,这个函数也不复杂,就是调用一个API函数fetchUserInfoAPI去获取数据,注意我们这里函数调用并不是直接的fetchUserInfoAPI(),而是使用了Redux-Sagacall这个effect,这样做可以让我们写单元测试变得更简单,为什么会这样,我们后面讲源码的时候再来仔细看看。获取数据后,我们调用了put去发出FETCH_USER_SUCCEEDED这个action,这里的put类似于Redux里面的dispatch,也是用来发出action的。这样我们的reducer就可以拿到FETCH_USER_SUCCEEDED进行处理了,跟以前的reducer并没有太大区别。

// reducer.js

const initState = {
  userInfo: null,
  error: ''
};

function reducer(state = initState, action) {
  switch (action.type) {
    case 'FETCH_USER_SUCCEEDED':
      return { ...state, userInfo: action.payload };
    case 'FETCH_USER_FAILED':
      return { ...state, error: action.payload };
    default:
      return state;
  }
}

export default reducer;

通过这个例子的代码结构我们可以看出:

  1. action被分为了两种,一种是触发异步处理的,一种是普通的同步action
  2. 异步action使用Redux-Saga来监听,监听的时候可以使用takeLatest或者takeEvery来处理并发的请求。
  3. 具体的saga实现可以使用Redux-Saga提供的方法,比如callput之类的,可以让单元测试更好写。
  4. 一个action可以被Redux-SagaReducer同时响应,比如上面的FETCH_USER_INFO发出后我还想让页面转个圈,可以直接在reducer里面加一个就行:

    ...
    case 'FETCH_USER_INFO':
          return { ...state, isLoading: true };
    ...

手写源码

通过上面这个例子,我们可以看出,Redux-Saga的运行是通过这一行代码来实现的:

sagaMiddleware.run(rootSaga);

整个Redux-Saga的运行和原本的Redux并不冲突,Redux甚至都不知道他的存在,他们之间耦合很小,只在需要的时候通过put发出action来进行通讯。所以我猜测,他应该是自己实现了一套完全独立的异步任务处理机制,下面我们从能感知到的API入手,一步一步来探寻下他源码的奥秘吧。本文全部代码参照官方源码写成,函数名字和变量名字尽量保持一致,写到具体的方法的时候我也会贴出对应的代码地址,主要代码都在这里:https://github.com/redux-saga/redux-saga/tree/master/packages/core/src

先来看看我们用到了哪些API,这些API就是我们今天手写的目标:

  1. createSagaMiddleware:这个方法会返回一个中间件实例sagaMiddleware
  2. sagaMiddleware.run: 这个方法是真正运行我们写的saga的入口
  3. takeEvery:这个方法是用来控制并发流程的
  4. call:用来调用其他方法
  5. put:发出action,用来和Redux通讯

从中间件入手

之前我们讲Redux源码的时候详细分析了Redux中间件的原理和范式,一个中间件大概就长这个样子:

function logger(store) {
  return function(next) {
    return function(action) {
      console.group(action.type);
      console.info('dispatching', action);
      let result = next(action);
      console.log('next state', store.getState());
      console.groupEnd();
      return result
    }
  }
}

这其实就相当于一个Redux中间件的范式了:

  1. 一个中间件接收store作为参数,会返回一个函数
  2. 返回的这个函数接收老的dispatch函数作为参数(也就是上面的next),会返回一个新的函数
  3. 返回的新函数就是新的dispatch函数,这个函数里面可以拿到外面两层传进来的store和老dispatch函数

依照这个范式以及前面对createSagaMiddleware的使用,我们可以先写出这个函数的骨架:

// sagaMiddlewareFactory其实就是我们外面使用的createSagaMiddleware
function sagaMiddlewareFactory() {
  // 返回的是一个Redux中间件
  // 需要符合他的范式
  const sagaMiddleware = function (store) {
    return function (next) {
      return function (action) {
        // 内容先写个空的
        let result = next(action);
        return result;
      }
    }
  }
  
  // sagaMiddleware上还有个run方法
  // 是用来启动saga的
  // 我们先留空吧
  sagaMiddleware.run = () => { }

  return sagaMiddleware;
}

export default sagaMiddlewareFactory;

梳理架构

现在我们有了一个空的骨架,接下来该干啥呢?前面我们说过了,Redux-Saga很可能是自己实现了一套完全独立的异步事件处理机制。这种异步事件处理机制需要一个处理中心来存储事件和处理函数,还需要一个方法来触发队列中的事件的执行,再回看前面的使用的API,我们发现了两个类似功能的API:

  1. takeEvery(action, callback):他接收的参数就是actioncallback,而且我们在根saga里面可能会多次调用它来注册不同action的处理函数,这其实就相当于往处理中心里面塞入事件了。
  2. put(action)put的参数是action,他唯一的作用就是触发对应事件的回调运行。

可以看到Redux-Saga这种机制也是用takeEvery先注册回调,然后使用put发出消息来触发回调执行,这其实跟我们其他文章多次提到的发布订阅模式很像。

手写channel

channelRedux-Saga保存回调和触发回调的地方,类似于发布订阅模式,我们先来写个:

export function multicastChannel() {
  const currentTakers = [];     // 一个变量存储我们所有注册的事件和回调

  // 保存事件和回调的函数
  // Redux-Saga里面take接收回调cb和匹配方法matcher两个参数
  // 事实上take到的事件名称也被封装到了matcher里面
  function take(cb, matcher) {
    cb['MATCH'] = matcher;
    currentTakers.push(cb);
  }

  function put(input) {
    const takers = currentTakers;

    for (let i = 0, len = takers.length; i < len; i++) {
      const taker = takers[i]

      // 这里的'MATCH'是上面take塞进来的匹配方法
      // 如果匹配上了就将回调拿出来执行
      if (taker['MATCH'](input)) {
        taker(input);
      }
    }
  }
  
  return {
    take,
    put
  }
}

上述代码中有一个奇怪的点,就是将matcher作为属性放到了回调函数上,这么做的原因我想是为了让外部可以自定义匹配方法,而不是简单的事件名称匹配,事实上Redux-Saga本身就支持好几种匹配模式,包括字符串,Symbol,数组等等。

内置支持的匹配方法可以看这里:https://github.com/redux-saga/redux-saga/blob/master/packages/core/src/internal/matcher.js

channel对应的源码可以看这里:https://github.com/redux-saga/redux-saga/blob/master/packages/core/src/internal/channel.js#L153

有了channel之后,我们的中间件里面其实只要再干一件事情就行了,就是调用channel.put将接收的action再发给channel去执行回调就行,所以我们加一行代码:

// ... 省略前面代码

const result = next(action);

channel.put(action);     // 将收到的action也发给Redux-Saga

return result;

// ... 省略后面代码

sagaMiddleware.run

前面的put是发出事件,执行回调,可是我们的回调还没注册呢,那注册回调应该在什么地方呢?看起来只有一个地方了,那就是sagaMiddleware.run。简单来说,sagaMiddleware.run接收一个Generator作为参数,然后执行这个Generator,当遇到take的时候就将它注册到channel上面去。这里我们先实现taketakeEvery是在这个基础上实现的。Redux-Saga中这块代码是单独抽取了一个文件,我们仿照这种做法吧。

首先需要在中间件里面将ReduxgetStatedispatch等参数传递进去,Redux-Saga使用的是bind函数,所以中间件方法改造如下:

function sagaMiddleware({ getState, dispatch }) {
  // 将getState, dispatch通过bind传给runSaga
  boundRunSaga = runSaga.bind(null, {
    channel,
    dispatch,
    getState,
  })

  return function (next) {
    return function (action) {
      const result = next(action);

      channel.put(action);

      return result;
    }
  }
}

然后sagaMiddleware.run就直接将boundRunSaga拿来运行就行了:

sagaMiddleware.run = (...args) => {
  boundRunSaga(...args)
}

注意这里的...args,这个其实就是我们传进去的rootSaga。到这里其实中间件部分就已经完成了,后面的代码就是具体的执行过程了。

中间件对应的源码可以看这里:https://github.com/redux-saga/redux-saga/blob/master/packages/core/src/internal/middleware.js

runSaga

runSaga其实才是真正的sagaMiddleware.run,通过前面的分析,我们已经知道他的作用是接收Generator并执行,如果遇到take就将它注册到channel上去,如果遇到put就将对应的回调拿出来执行,但是Redux-Saga又将这个过程分为了好几层,我们一层一层来看吧。runSaga的参数先是通过bind传入了一些上下文相关的变量,比如getState, dispatch,然后又在运行的时候传入了rootSaga,所以他应该是长这个样子的:

import proc from './proc';

export function runSaga(
  { channel, dispatch, getState },
  saga,
  ...args
) {
  // saga是一个Generator,运行后得到一个迭代器
  const iterator = saga(...args);

  const env = {
    channel,
    dispatch,
    getState,
  };

  proc(env, iterator);
}

可以看到runSaga仅仅是将Generator运行下,得到迭代器对象后又调用了proc来处理。

runSaga对应的源码看这里:https://github.com/redux-saga/redux-saga/blob/master/packages/core/src/internal/runSaga.js

proc

proc就是具体执行这个迭代器的过程,Generator的执行方式我们之前在另一篇文章详细讲过,简单来说就是可以另外写一个方法next来执行Generatornext里面检测到如果Generator没有执行完,就继续执行next,然后外层调用一下next启动这个流程就行。

export default function proc(env, iterator) {
  // 调用next启动迭代器执行
  next();

  // next函数也不复杂
  // 就是执行iterator
  function next(arg, isErr) {
    let result;
    if (isErr) {
      result = iterator.throw(arg);
    } else {
      result = iterator.next(arg);
    }

    // 如果他没结束,就继续next
    // digestEffect是处理当前步骤返回值的函数
    // 继续执行的next也由他来调用
    if (!result.done) {
      digestEffect(result.value, next)
    }
  }
}

digestEffect

上面如果迭代器没有执行完,我们会将它的值传给digestEffect处理,那么这里的result.value的值是什么的呢?回想下我们前面rootSaga里面的用法

yield takeEvery("FETCH_USER_INFO", fetchUserInfo);

result.value的值应该是yield后面的值,也就是takeEvery("FETCH_USER_INFO", fetchUserInfo)的返回值,takeEvery是再次包装过的effect,他包装了take,fork这些简单的effect。其实对于像take这种简单的effect来说,比如:

take("FETCH_USER_INFO", fetchUserInfo);

这行代码的返回值直接就是一个对象,类似于这样:

{
  IO: true,
  type: 'TAKE',
  payload: {},
}

所以我们这里digestEffect拿到的result.value也是这样的一个对象,这个对象就代表了我们的一个effect,所以我们的digestEffect就长这样:

function digestEffect(effect, cb) {    // 这个cb其实就是前面传进来的next
    // 这个变量是用来解决竞争问题的
    let effectSettled;
    function currCb(res, isErr) {
      // 如果已经运行过了,直接return
      if (effectSettled) {
        return
      }

      effectSettled = true;

      cb(res, isErr);
    }

    runEffect(effect, currCb);
  }

runEffect

可以看到digestEffect又调用了一个函数runEffect,这个函数会处理具体的effect:

// runEffect就只是获取对应type的处理函数,然后拿来处理当前effect
function runEffect(effect, currCb) {
  if (effect && effect.IO) {
    const effectRunner = effectRunnerMap[effect.type]
    effectRunner(env, effect.payload, currCb);
  } else {
    currCb();
  }
}

这点代码可以看出,runEffect也只是对effect进行了检测,通过他的类型获取对应的处理函数,然后进行处理,我这里代码简化了,只支持IO这种effect,官方源码中还支持promiseiterator,具体的可以看看他的源码:https://github.com/redux-saga/redux-saga/blob/master/packages/core/src/internal/proc.js

effectRunner

effectRunner是通过effect.type匹配出来的具体的effect的处理函数,我们先来看两个:takefork

runTakeEffect

take的处理其实很简单,就是将它注册到我们的channel里面就行,所以我们建一个effectRunnerMap.js文件,在里面添加take的处理函数runTakeEffect:

// effectRunnerMap.js

function runTakeEffect(env, { channel = env.channel, pattern }, cb) {
  const matcher = input => input.type === pattern;

  // 注意channel.take的第二个参数是matcher
  // 我们直接写一个简单的matcher,就是输入类型必须跟pattern一样才行
  // 这里的pattern就是我们经常用的action名字,比如FETCH_USER_INFO
  // Redux-Saga不仅仅支持这种字符串,还支持多种形式,也可以自定义matcher来解析
  channel.take(cb, matcher);
}

const effectRunnerMap = {
  'TAKE': runTakeEffect,
};

export default effectRunnerMap;

注意上面代码channel.take(cb, matcher);里面的cb,这个cb其实就是我们迭代器的next,也就是说take的回调是迭代器继续执行,也就是继续执行下面的代码。也就是说,当你这样写时:

yield take("SOME_ACTION");
yield fork(saga);

当运行到yield take("SOME_ACTION");这行代码时,整个迭代器都阻塞了,不会再往下运行。除非你触发了SOME_ACTION,这时候会把SOME_ACTION的回调拿出来执行,这个回调就是迭代器的next,所以就可以继续执行下面这行代码了yield fork(saga)

runForkEffect

我们前面的示例代码其实没有直接用到fork这个API,但是用到了takeEverytakeEvery其实是组合takefork来实现的,所以我们先来看看forkfork的使用跟call很像,也是可以直接调用传进来的方法,只是call会等待结果回来才进行下一步,fork不会阻塞这个过程,而是当前结果没回来也会直接运行下一步:

fork(fn, ...args);

所以当我们拿到fork的时候,处理起来也很简单,直接调用proc处理fn就行了,fn应该是一个Generator函数。

function runForkEffect(env, { fn }, cb) {
  const taskIterator = fn();    // 运行fn得到一个迭代器

  proc(env, taskIterator);      // 直接将taskIterator给proc处理

  cb();      // 直接调用cb,不需要等待proc的结果
}

runPutEffect

我们前面的例子还用到了put这个effect,他就更简单了,只是发出一个action,事实上他也是调用的Reduxdispatch来发出action

function runPutEffect(env, { action }, cb) {
  const result = env.dispatch(action);     // 直接dispatch(action)

  cb(result);
}

注意我们这里的代码只需要dispatch(action)就行了,不需要再手动调channel.put了,因为我们前面的中间件里面已经改造了dispatch方法了,每次dispatch的时候都会自动调用channel.put

runCallEffect

前面我们发起API请求还用到了call,一般我们使用axios这种库返回的都是一个promise,所以我们这里写一种支持promise的情况,当然普通同步函数肯定也是支持的:

function runCallEffect(env, { fn, args }, cb) {
  const result = fn.apply(null, args);

  if (isPromise(result)) {
    return result
      .then(data => cb(data))
      .catch(error => cb(error, true));
  }

  cb(result);
}

这些effect具体处理的方法对应的源码都在这个文件里面:https://github.com/redux-saga/redux-saga/blob/master/packages/core/src/internal/effectRunnerMap.js

effects

上面我们讲了几个effect具体处理的方法,但是这些都不是对外暴露的effect API。真正对外暴露的effect API还需要单独写,他们其实都很简单,都是返回一个带有type的简单对象就行:

const makeEffect = (type, payload) => ({
  IO: true,
  type,
  payload
})

export function take(pattern) {
  return makeEffect('TAKE', { pattern })
}

export function fork(fn) {
  return makeEffect('FORK', { fn })
}

export function call(fn, ...args) {
  return makeEffect('CALL', { fn, args })
}

export function put(action) {
  return makeEffect('PUT', { action })
}

可以看到当我们使用effect时,他的返回值就仅仅是一个描述当前任务的对象,这就让我们的单元测试好写很多。因为我们的代码在不同的环境下运行可能会产生不同的结果,特别是这些异步请求,我们写单元测试时来造这些数据也会很麻烦。但是如果你使用Redux-Sagaeffect,每次你代码运行的时候得到的都是一个任务描述对象,这个对象是稳定的,不受运行结果影响,也就不需要针对这个造测试数据了,大大减少了工作量。

effects对应的源码文件看这里:https://github.com/redux-saga/redux-saga/blob/master/packages/core/src/internal/io.js

takeEvery

我们前面还用到了takeEvery来处理同时发起的多个请求,这个API是一个高级API,是封装前面的takefork来实现的,官方源码又构造了一个新的迭代器来组合他们,不是很直观。官方文档中的这种写法反而很好理解,我这里采用文档中的这种写法:

export function takeEvery(pattern, saga) {
  function* takeEveryHelper() {
    while (true) {
      yield take(pattern);
      yield fork(saga);
    }
  }

  return fork(takeEveryHelper);
}

上面这段代码就很好理解了,我们一个死循环不停的监听pattern,即目标事件,当目标事件过来的时候,就执行对应的saga,然后又进入下一次循环继续监听pattern

总结

到这里我们例子中用到的API已经全部自己实现了,我们可以用自己的这个Redux-Saga来替换官方的了,只是我们只实现了他的一部分功能,还有很多功能没有实现,不过这已经不妨碍我们理解他的基本原理了。再来回顾下他的主要要点:

  1. Redux-Saga其实也是一个发布订阅模式,管理事件的地方是channel,两个重点APItakeput
  2. take是注册一个事件到channel上,当事件过来时触发回调,需要注意的是,这里的回调仅仅是迭代器的next,并不是具体响应事件的函数。也就是说take的意思就是:我在等某某事件,这个事件来之前不许往下走,来了后就可以往下走了。
  3. put是发出事件,他是使用Redux dispatch发出事件的,也就是说put的事件会被ReduxRedux-Saga同时响应。
  4. Redux-Saga增强了Reduxdispatch函数,在dispatch的同时会触发channel.put,也就是让Redux-Saga也响应回调。
  5. 我们调用的effects和真正实现功能的函数是分开的,表层调用的effects只会返回一个简单的对象,这个对象描述了当前任务,他是稳定的,所以基于effects的单元测试很好写。
  6. 当拿到effects返回的对象后,我们再根据他的type去找对应的处理函数来进行处理。
  7. 整个Redux-Saga都是基于Generator的,每往下走一步都需要手动调用next,这样当他执行到中途的时候我们可以根据情况不再继续调用next,这其实就相当于将当前任务cancel了。

本文可运行的代码已经上传到GitHub,可以拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/redux-saga

参考资料

Redux-Saga官方文档:https://redux-saga.js.org/

Redux-Saga源码地址: https://github.com/redux-saga/redux-saga/tree/master/packages/core/src

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

作者博文GitHub项目地址: https://github.com/dennis-jiang/Front-End-Knowledges

我也搞了个公众号[进击的大前端],不打广告,不写水文,只发高质量原创,欢迎关注~

查看原文

赞 21 收藏 20 评论 0

蒋鹏飞 收藏了文章 · 10月14日

setTimeout和setImmediate到底谁先执行,本文让你彻底理解Event Loop

笔者以前面试的时候经常遇到写一堆setTimeout,setImmediate来问哪个先执行。本文主要就是来讲这个问题的,但是不是简单的讲讲哪个先,哪个后。笼统的知道setImmediatesetTimeout(fn, 0)先执行是不够的,因为有些情况下setTimeout(fn, 0)是会比setImmediate先执行的。要彻底搞明白这个问题,我们需要系统的学习JS的异步机制和底层原理。本文就会从异步基本概念出发,一直讲到Event Loop的底层原理,让你彻底搞懂setTimeout,setImmediatePromise, process.nextTick谁先谁后这一类问题。

同步和异步

同步异步简单理解就是,同步的代码都是按照书写顺序执行的,异步的代码可能跟书写顺序不一样,写在后面的可能先执行。下面来看个例子:

const syncFunc = () => {
  const time = new Date().getTime();
  while(true) {
    if(new Date().getTime() - time > 2000) {
      break;
    }
  }
  console.log(2);
}

console.log(1);
syncFunc();
console.log(3);

上述代码会先打印出1,然后调用syncFuncsyncFunc里面while循环会运行2秒,然后打印出2,最后打印出3。所以这里代码的执行顺序跟我们的书写顺序是一致,他是同步代码:

image-20200320144654281

再来看个异步例子:

const asyncFunc = () => {
  setTimeout(() => {
    console.log(2);
  }, 2000);
}

console.log(1);
asyncFunc();
console.log(3);

上述代码的输出是:

image-20200320145012565

可以看到我们中间调用的asyncFunc里面的2却是最后输出的,这是因为setTimeout是一个异步方法。他的作用是设置一个定时器,等定时器时间到了再执行回调里面的代码。所以异步就相当于做一件事,但是并不是马上做,而是你先给别人打了个招呼,说xxx条件满足的时候就干什么什么。就像你晚上睡觉前在手机上设置了一个第二天早上7天的闹钟,就相当于给了手机一个异步事件,触发条件是时间到达早上7点。使用异步的好处是你只需要设置好异步的触发条件就可以去干别的事情了,所以异步不会阻塞主干上事件的执行。特别是对于JS这种只有一个线程的语言,如果都像我们第一个例子那样去while(true),那浏览器就只有一直卡死了,只有等这个循环运行完才会有响应

JS异步是怎么实现的

我们都知道JS是单线程的,那单线程是怎么实现异步的呢?事实上所谓的"JS是单线程的"只是指JS的主运行线程只有一个,而不是整个运行环境都是单线程。JS的运行环境主要是浏览器,以大家都很熟悉的Chrome的内核为例,他不仅是多线程的,而且是多进程的:

image-20200320151227013

上图只是一个概括分类,意思是Chrome有这几类的进程和线程,并不是每种只有一个,比如渲染进程就有多个,每个选项卡都有自己的渲染进程。有时候我们使用Chrome会遇到某个选项卡崩溃或者没有响应的情况,这个选项卡对应的渲染进程可能就崩溃了,但是其他选项卡并没有用这个渲染进程,他们有自己的渲染进程,所以其他选项卡并不会受影响。这也是Chrome单个页面崩溃并不会导致浏览器崩溃的原因,而不是像老IE那样,一个页面卡了导致整个浏览器都卡。

对于前端工程师来说,主要关心的还是渲染进程,下面来分别看下里面每个线程是做什么的。

GUI线程

GUI线程就是渲染页面的,他解析HTML和CSS,然后将他们构建成DOM树和渲染树就是这个线程负责的。

JS引擎线程

这个线程就是负责执行JS的主线程,前面说的"JS是单线程的"就是指的这个线程。大名鼎鼎的Chrome V8引擎就是在这个线程运行的。需要注意的是,这个线程跟GUI线程是互斥的。互斥的原因是JS也可以操作DOM,如果JS线程和GUI线程同时操作DOM,结果就混乱了,不知道到底渲染哪个结果。这带来的后果就是如果JS长时间运行,GUI线程就不能执行,整个页面就感觉卡死了。所以我们最开始例子的while(true)这样长时间的同步代码在真正开发时是绝对不允许的

定时器线程

前面异步例子的setTimeout其实就运行在这里,他跟JS主线程根本不在同一个地方,所以“单线程的JS”能够实现异步。JS的定时器方法还有setInterval,也是在这个线程。

事件触发线程

定时器线程其实只是一个计时的作用,他并不会真正执行时间到了的回调,真正执行这个回调的还是JS主线程。所以当时间到了定时器线程会将这个回调事件给到事件触发线程,然后事件触发线程将它加到事件队列里面去。最终JS主线程从事件队列取出这个回调执行。事件触发线程不仅会将定时器事件放入任务队列,其他满足条件的事件也是他负责放进任务队列。

异步HTTP请求线程

这个线程负责处理异步的ajax请求,当请求完成后,他也会通知事件触发线程,然后事件触发线程将这个事件放入事件队列给主线程执行。

所以JS异步的实现靠的就是浏览器的多线程,当他遇到异步API时,就将这个任务交给对应的线程,当这个异步API满足回调条件时,对应的线程又通过事件触发线程将这个事件放入任务队列,然后主线程从任务队列取出事件继续执行。这个流程我们多次提到了任务队列,这其实就是Event Loop,下面我们详细来讲解下。

Event Loop

所谓Event Loop,就是事件循环,其实就是JS管理事件执行的一个流程,具体的管理办法由他具体的运行环境确定。目前JS的主要运行环境有两个,浏览器和Node.js。这两个环境的Event Loop还有点区别,我们会分开来讲。

浏览器的Event Loop

事件循环就是一个循环,是各个异步线程用来通讯和协同执行的机制。各个线程为了交换消息,还有一个公用的数据区,这就是事件队列。各个异步线程执行完后,通过事件触发线程将回调事件放到事件队列,主线程每次干完手上的活儿就来看看这个队列有没有新活儿,有的话就取出来执行。画成一个流程图就是这样:

image-20200320161732238

流程讲解如下:

  1. 主线程每次执行时,先看看要执行的是同步任务,还是异步的API
  2. 同步任务就继续执行,一直执行完
  3. 遇到异步API就将它交给对应的异步线程,自己继续执行同步任务
  4. 异步线程执行异步API,执行完后,将异步回调事件放入事件队列上
  5. 主线程手上的同步任务干完后就来事件队列看看有没有任务
  6. 主线程发现事件队列有任务,就取出里面的任务执行
  7. 主线程不断循环上述流程

定时器不准

Event Loop的这个流程里面其实还是隐藏了一些坑的,最典型的问题就是总是先执行同步任务,然后再执行事件队列里面的回调。这个特性就直接影响了定时器的执行,我们想想我们开始那个2秒定时器的执行流程:

  1. 主线程执行同步代码
  2. 遇到setTimeout,将它交给定时器线程
  3. 定时器线程开始计时,2秒到了通知事件触发线程
  4. 事件触发线程将定时器回调放入事件队列,异步流程到此结束
  5. 主线程如果有空,将定时器回调拿出来执行,如果没空这个回调就一直放在队列里。

上述流程我们可以看出,如果主线程长时间被阻塞,定时器回调就没机会执行,即使执行了,那时间也不准了,我们将开头那两个例子结合起来就可以看出这个效果:

const syncFunc = (startTime) => {
  const time = new Date().getTime();
  while(true) {
    if(new Date().getTime() - time > 5000) {
      break;
    }
  }
  const offset = new Date().getTime() - startTime;
  console.log(`syncFunc run, time offset: ${offset}`);
}

const asyncFunc = (startTime) => {
  setTimeout(() => {
    const offset = new Date().getTime() - startTime;
    console.log(`asyncFunc run, time offset: ${offset}`);
  }, 2000);
}

const startTime = new Date().getTime();

asyncFunc(startTime);

syncFunc(startTime);

执行结果如下:

image-20200320163640760

通过结果可以看出,虽然我们先调用的asyncFunc,虽然asyncFunc写的是2秒后执行,但是syncFunc的执行时间太长,达到了5秒,asyncFunc虽然在2秒的时候就已经进入了事件队列,但是主线程一直在执行同步代码,一直没空,所以也要等到5秒后,同步代码执行完毕才有机会执行这个定时器回调。所以再次强调,写代码时一定不要长时间占用主线程

引入微任务

前面的流程图我为了便于理解,简化了事件队列,其实事件队列里面的事件还可以分两类:宏任务和微任务。微任务拥有更高的优先级,当事件循环遍历队列时,先检查微任务队列,如果里面有任务,就全部拿来执行,执行完之后再执行一个宏任务。执行每个宏任务之前都要检查下微任务队列是否有任务,如果有,优先执行微任务队列。所以完整的流程图如下:

image-20200322201434386

上图需要注意以下几点:

  1. 一个Event Loop可以有一个或多个事件队列,但是只有一个微任务队列。
  2. 微任务队列全部执行完会重新渲染一次
  3. 每个宏任务执行完都会重新渲染一次
  4. requestAnimationFrame处于渲染阶段,不在微任务队列,也不在宏任务队列

所以想要知道一个异步API在哪个阶段执行,我们得知道他是宏任务还是微任务。

常见宏任务有:

  1. script (可以理解为外层同步代码)
  2. setTimeout/setInterval
  3. setImmediate(Node.js)
  4. I/O
  5. UI事件
  6. postMessage

常见微任务有:

  1. Promise
  2. process.nextTick(Node.js)
  3. Object.observe
  4. MutaionObserver

上面这些事件类型中要注意Promise,他是微任务,也就是说他会在定时器前面运行,我们来看个例子:

console.log('1');
setTimeout(() => {
  console.log('2');
},0);
Promise.resolve().then(() => {
  console.log('5');
})
new Promise((resolve) => {
  console.log('3');
  resolve();
}).then(() => {
  console.log('4');
})

上述代码的输出是1,3,5,4,2。因为:

  1. 先输出1,这个没什么说的,同步代码最先执行
  2. console.log('2');setTimeout里面,setTimeout是宏任务,“2”进入宏任务队列
  3. console.log('5');Promise.then里面,进入微任务队列
  4. console.log('3');在Promise构造函数的参数里面,这其实是同步代码,直接输出
  5. console.log('4');在then里面,他会进入微任务队列,检查事件队列时先执行微任务
  6. 同步代码运行结果是“1,3”
  7. 然后检查微任务队列,输出“5,4”
  8. 最后执行宏任务队列,输出“2”

Node.js的Event Loop

Node.js是运行在服务端的js,虽然他也用到了V8引擎,但是他的服务目的和环境不同,导致了他API与原生JS有些区别,他的Event Loop还要处理一些I/O,比如新的网络连接等,所以与浏览器Event Loop也是不一样的。Node的Event Loop是分阶段的,如下图所示:

image-20200322203318743

  1. timers: 执行setTimeoutsetInterval的回调
  2. pending callbacks: 执行延迟到下一个循环迭代的 I/O 回调
  3. idle, prepare: 仅系统内部使用
  4. poll: 检索新的 I/O 事件;执行与 I/O 相关的回调。事实上除了其他几个阶段处理的事情,其他几乎所有的异步都在这个阶段处理。
  5. check: setImmediate在这里执行
  6. close callbacks: 一些关闭的回调函数,如:socket.on('close', ...)

每个阶段都有一个自己的先进先出的队列,只有当这个队列的事件执行完或者达到该阶段的上限时,才会进入下一个阶段。在每次事件循环之间,Node.js都会检查它是否在等待任何一个I/O或者定时器,如果没有的话,程序就关闭退出了。我们的直观感受就是,如果一个Node程序只有同步代码,你在控制台运行完后,他就自己退出了。

还有个需要注意的是poll阶段,他后面并不一定每次都是check阶段,poll队列执行完后,如果没有setImmediate但是有定时器到期,他会绕回去执行定时器阶段:

image-20200322205308151

setImmediatesetTimeout

上面的这个流程说简单点就是在一个异步流程里,setImmediate会比定时器先执行,我们写点代码来试试:

console.log('outer');

setTimeout(() => {
  setTimeout(() => {
    console.log('setTimeout');
  }, 0);
  setImmediate(() => {
    console.log('setImmediate');
  });
}, 0);

上述代码运行如下:

image-20200322210304757

和我们前面讲的一样,setImmediate先执行了。我们来理一下这个流程:

  1. 外层是一个setTimeout,所以执行他的回调的时候已经在timers阶段了
  2. 处理里面的setTimeout,因为本次循环的timers正在执行,所以他的回调其实加到了下个timers阶段
  3. 处理里面的setImmediate,将它的回调加入check阶段的队列
  4. 外层timers阶段执行完,进入pending callbacksidle, preparepoll,这几个队列都是空的,所以继续往下
  5. 到了check阶段,发现了setImmediate的回调,拿出来执行
  6. 然后是close callbacks,队列是空的,跳过
  7. 又是timers阶段,执行我们的console

但是请注意我们上面console.log('setTimeout')console.log('setImmediate')都包在了一个setTimeout里面,如果直接写在最外层会怎么样呢?代码改写如下:

console.log('outer');

setTimeout(() => {
  console.log('setTimeout');
}, 0);

setImmediate(() => {
  console.log('setImmediate');
});

我们来运行下看看效果:

image-20200322214105295

好像是setTimeout先输出来,我们多运行几次看看:

image-20200322214148090

怎么setImmediate又先出来了,这代码是见鬼了还是啥?这个世界上是没有鬼怪的,所以事情都有原因的,我们顺着之前的Event Loop再来理一下。在理之前,需要告诉大家一件事情,node.js里面setTimeout(fn, 0)会被强制改为setTimeout(fn, 1),这在官方文档中有说明。(说到这里顺便提下,HTML 5里面setTimeout最小的时间限制是4ms)。原理我们都有了,我们来理一下流程:

  1. 外层同步代码一次性全部执行完,遇到异步API就塞到对应的阶段
  2. 遇到setTimeout,虽然设置的是0毫秒触发,但是被node.js强制改为1毫秒,塞入times阶段
  3. 遇到setImmediate塞入check阶段
  4. 同步代码执行完毕,进入Event Loop
  5. 先进入times阶段,检查当前时间过去了1毫秒没有,如果过了1毫秒,满足setTimeout条件,执行回调,如果没过1毫秒,跳过
  6. 跳过空的阶段,进入check阶段,执行setImmediate回调

通过上述流程的梳理,我们发现关键就在这个1毫秒,如果同步代码执行时间较长,进入Event Loop的时候1毫秒已经过了,setTimeout执行,如果1毫秒还没到,就先执行了setImmediate。每次我们运行脚本时,机器状态可能不一样,导致运行时有1毫秒的差距,一会儿setTimeout先执行,一会儿setImmediate先执行。但是这种情况只会发生在还没进入timers阶段的时候。像我们第一个例子那样,因为已经在timers阶段,所以里面的setTimeout只能等下个循环了,所以setImmediate肯定先执行。同理的还有其他poll阶段的API也是这样的,比如:

var fs = require('fs')

fs.readFile(__filename, () => {
    setTimeout(() => {
        console.log('setTimeout');
    }, 0);
    setImmediate(() => {
        console.log('setImmediate');
    });
});

这里setTimeoutsetImmediatereadFile的回调里面,由于readFile回调是I/O操作,他本身就在poll阶段,所以他里面的定时器只能进入下个timers阶段,但是setImmediate却可以在接下来的check阶段运行,所以setImmediate肯定先运行,他运行完后,去检查timers,才会运行setTimeout

类似的,我们再来看一段代码,如果他们两个不是在最外层,而是在setImmediate的回调里面,其实情况跟外层一样,结果也是随缘的,看下面代码:

console.log('outer');

setImmediate(() => {
  setTimeout(() => {
    console.log('setTimeout');
  }, 0);
  setImmediate(() => {
    console.log('setImmediate');
  });
});

原因跟写在最外层差不多,因为setImmediate已经在check阶段了,里面的循环会从timers阶段开始,会先看setTimeout的回调,如果这时候已经过了1毫秒,就执行他,如果没过就执行setImmediate

process.nextTick()

process.nextTick()是一个特殊的异步API,他不属于任何的Event Loop阶段。事实上Node在遇到这个API时,Event Loop根本就不会继续进行,会马上停下来执行process.nextTick(),这个执行完后才会继续Event Loop。我们写个例子来看下:

var fs = require('fs')

fs.readFile(__filename, () => {
    setTimeout(() => {
        console.log('setTimeout');
    }, 0);

    setImmediate(() => {
        console.log('setImmediate');
        
        process.nextTick(() => {
          console.log('nextTick 2');
        });
    });

    process.nextTick(() => {
      console.log('nextTick 1');
    });
});

这段代码的打印如下:

image-20200322221221927

我们还是来理一下流程:

  1. 我们代码基本都在readFile回调里面,他自己执行时,已经在poll阶段
  2. 遇到setTimeout(fn, 0),其实是setTimeout(fn, 1),塞入后面的timers阶段
  3. 遇到setImmediate,塞入后面的check阶段
  4. 遇到nextTick,立马执行,输出'nextTick 1'
  5. 到了check阶段,输出'setImmediate',又遇到个nextTick,立马输出'nextTick 2'
  6. 到了下个timers阶段,输出'setTimeout'

这种机制其实类似于我们前面讲的微任务,但是并不完全一样,比如同时有nextTickPromise的时候,肯定是nextTick先执行,原因是nextTick的队列比Promise队列优先级更高。来看个例子:

const promise = Promise.resolve()
setImmediate(() => {
  console.log('setImmediate');
});
promise.then(()=>{
    console.log('promise')
})
process.nextTick(()=>{
    console.log('nextTick')
})

代码运行结果如下:

image-20200323094907234

总结

本文从异步基本概念出发一直讲到了浏览器和Node.js的Event Loop,现在我们再来总结一下:

  1. JS所谓的“单线程”只是指主线程只有一个,并不是整个运行环境都是单线程
  2. JS的异步靠底层的多线程实现
  3. 不同的异步API对应不同的实现线程
  4. 异步线程与主线程通讯靠的是Event Loop
  5. 异步线程完成任务后将其放入任务队列
  6. 主线程不断轮询任务队列,拿出任务执行
  7. 任务队列有宏任务队列和微任务队列的区别
  8. 微任务队列的优先级更高,所有微任务处理完后才会处理宏任务
  9. Promise是微任务
  10. Node.js的Event Loop跟浏览器的Event Loop不一样,他是分阶段的
  11. setImmediatesetTimeout(fn, 0)哪个回调先执行,需要看他们本身在哪个阶段注册的,如果在定时器回调或者I/O回调里面,setImmediate肯定先执行。如果在最外层或者setImmediate回调里面,哪个先执行取决于当时机器状况。
  12. process.nextTick不在Event Loop的任何阶段,他是一个特殊API,他会立即执行,然后才会继续执行Event Loop

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

作者博文GitHub项目地址: https://github.com/dennis-jiang/Front-End-Knowledges

查看原文

蒋鹏飞 发布了文章 · 10月12日

Redux异步解决方案之Redux-Thunk原理及源码解析

前段时间,我们写了一篇Redux源码分析的文章,也分析了跟React连接的库React-Redux的源码实现。但是在Redux的生态中还有一个很重要的部分没有涉及到,那就是Redux的异步解决方案。本文会讲解Redux官方实现的异步解决方案----Redux-Thunk,我们还是会从基本的用法入手,再到原理解析,然后自己手写一个Redux-Thunk来替换它,也就是源码解析。

Redux-Thunk和前面写过的ReduxReact-Redux其实都是Redux官方团队的作品,他们的侧重点各有不同:

Redux:是核心库,功能简单,只是一个单纯的状态机,但是蕴含的思想不简单,是传说中的“百行代码,千行文档”。

React-Redux:是跟React的连接库,当Redux状态更新的时候通知React更新组件。

Redux-Thunk:提供Redux的异步解决方案,弥补Redux功能的不足。

本文手写代码已经上传GitHub,大家可以拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/blob/master/Examples/React/redux-thunk/src/myThunk.js

基本用法

还是以我们之前的那个计数器作为例子,为了让计数器+1,我们会发出一个action,像这样:

function increment() {
  return {
    type: 'INCREMENT'
  }
};

store.dispatch(increment());

原始的Redux里面,action creator必须返回plain object,而且必须是同步的。但是我们的应用里面经常会有定时器,网络请求等等异步操作,使用Redux-Thunk就可以发出异步的action

function increment() {
  return {
    type: 'INCREMENT'
  }
};

// 异步action creator
function incrementAsync() {
  return (dispatch) => {
    setTimeout(() => {
      dispatch(increment());
    }, 1000);
  }
}

// 使用了Redux-Thunk后dispatch不仅仅可以发出plain object,还可以发出这个异步的函数
store.dispatch(incrementAsync());

下面再来看个更实际点的例子,也是官方文档中的例子:

import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './reducers';

// createStore的时候传入thunk中间件
const store = createStore(rootReducer, applyMiddleware(thunk));

// 发起网络请求的方法
function fetchSecretSauce() {
  return fetch('https://www.baidu.com/s?wd=Secret%20Sauce');
}

// 下面两个是普通的action
function makeASandwich(forPerson, secretSauce) {
  return {
    type: 'MAKE_SANDWICH',
    forPerson,
    secretSauce,
  };
}

function apologize(fromPerson, toPerson, error) {
  return {
    type: 'APOLOGIZE',
    fromPerson,
    toPerson,
    error,
  };
}

// 这是一个异步action,先请求网络,成功就makeASandwich,失败就apologize
function makeASandwichWithSecretSauce(forPerson) {
  return function (dispatch) {
    return fetchSecretSauce().then(
      (sauce) => dispatch(makeASandwich(forPerson, sauce)),
      (error) => dispatch(apologize('The Sandwich Shop', forPerson, error)),
    );
  };
}

// 最终dispatch的是异步action makeASandwichWithSecretSauce
store.dispatch(makeASandwichWithSecretSauce('Me'));

为什么要用Redux-Thunk

在继续深入源码前,我们先来思考一个问题,为什么我们要用Redux-Thunk,不用它行不行?再仔细看看Redux-Thunk的作用:

// 异步action creator
function incrementAsync() {
  return (dispatch) => {
    setTimeout(() => {
      dispatch(increment());
    }, 1000);
  }
}

store.dispatch(incrementAsync());

他仅仅是让dispath多支持了一种类型,就是函数类型,在使用Redux-Thunk前我们dispatchaction必须是一个纯对象(plain object),使用了Redux-Thunk后,dispatch可以支持函数,这个函数会传入dispatch本身作为参数。但是其实我们不使用Redux-Thunk也可以达到同样的效果,比如上面代码我完全可以不要外层的incrementAsync,直接这样写:

setTimeout(() => {
  store.dispatch(increment());
}, 1000);

这样写同样可以在1秒后发出增加的action,而且代码还更简单,那我们为什么还要用Redux-Thunk呢,他存在的意义是什么呢?stackoverflow对这个问题有一个很好的回答,而且是官方推荐的解释。我再写一遍也不会比他写得更好,所以我就直接翻译了:

----翻译从这里开始----

不要觉得一个库就应该规定了所有事情!如果你想用JS处理一个延时任务,直接用setTimeout就好了,即使你使用了Redux也没啥区别。Redux确实提供了另一种处理异步任务的机制,但是你应该用它来解决你很多重复代码的问题。如果你没有太多重复代码,使用语言原生方案其实是最简单的方案。

直接写异步代码

到目前为止这是最简单的方案,Redux也不需要特殊的配置:

store.dispatch({ type: 'SHOW_NOTIFICATION', text: 'You logged in.' })
setTimeout(() => {
  store.dispatch({ type: 'HIDE_NOTIFICATION' })
}, 5000)

(译注:这段代码的功能是显示一个通知,5秒后自动消失,也就是我们经常使用的toast效果,原作者一直以这个为例。)

相似的,如果你是在一个连接了Redux组件中使用:

this.props.dispatch({ type: 'SHOW_NOTIFICATION', text: 'You logged in.' })
setTimeout(() => {
  this.props.dispatch({ type: 'HIDE_NOTIFICATION' })
}, 5000)

唯一的区别就是连接组件一般不需要直接使用store,而是将dispatch或者action creator作为props注入,这两种方式对我们都没区别。

如果你不想写重复的action名字,你可以将这两个action抽取成action creator而不是直接dispatch一个对象:

// actions.js
export function showNotification(text) {
  return { type: 'SHOW_NOTIFICATION', text }
}
export function hideNotification() {
  return { type: 'HIDE_NOTIFICATION' }
}

// component.js
import { showNotification, hideNotification } from '../actions'

this.props.dispatch(showNotification('You just logged in.'))
setTimeout(() => {
  this.props.dispatch(hideNotification())
}, 5000)

或者你已经通过connect()注入了这两个action creator

this.props.showNotification('You just logged in.')
setTimeout(() => {
  this.props.hideNotification()
}, 5000)

到目前为止,我们没有使用任何中间件或者其他高级技巧,但是我们同样实现了异步任务的处理。

提取异步的Action Creator

使用上面的方式在简单场景下可以工作的很好,但是你可能已经发现了几个问题:

  1. 每次你想显示toast的时候,你都得把这一大段代码抄过来抄过去。
  2. 现在的toast没有id,这可能会导致一种竞争的情况:如果你连续快速的显示两次toast,当第一次的结束时,他会dispatchHIDE_NOTIFICATION,这会错误的导致第二个也被关掉。

为了解决这两个问题,你可能需要将toast的逻辑抽取出来作为一个方法,大概长这样:

// actions.js
function showNotification(id, text) {
  return { type: 'SHOW_NOTIFICATION', id, text }
}
function hideNotification(id) {
  return { type: 'HIDE_NOTIFICATION', id }
}

let nextNotificationId = 0
export function showNotificationWithTimeout(dispatch, text) {
  // 给通知分配一个ID可以让reducer忽略非当前通知的HIDE_NOTIFICATION
  // 而且我们把计时器的ID记录下来以便于后面用clearTimeout()清除计时器
  const id = nextNotificationId++
  dispatch(showNotification(id, text))

  setTimeout(() => {
    dispatch(hideNotification(id))
  }, 5000)
}

现在你的组件可以直接使用showNotificationWithTimeout,再也不用抄来抄去了,也不用担心竞争问题了:

// component.js
showNotificationWithTimeout(this.props.dispatch, 'You just logged in.')

// otherComponent.js
showNotificationWithTimeout(this.props.dispatch, 'You just logged out.')  

但是为什么showNotificationWithTimeout()要接收dispatch作为第一个参数呢?因为他需要将action发给store。一般组件是可以拿到dispatch的,为了让外部方法也能dispatch,我们需要给他dispath作为参数。

如果你有一个单例的store,你也可以让showNotificationWithTimeout直接引入这个store然后dispatchaction

// store.js
export default createStore(reducer)

// actions.js
import store from './store'

// ...

let nextNotificationId = 0
export function showNotificationWithTimeout(text) {
  const id = nextNotificationId++
  store.dispatch(showNotification(id, text))

  setTimeout(() => {
    store.dispatch(hideNotification(id))
  }, 5000)
}

// component.js
showNotificationWithTimeout('You just logged in.')

// otherComponent.js
showNotificationWithTimeout('You just logged out.') 

这样做看起来不复杂,也能达到效果,但是我们不推荐这种做法!主要原因是你的store必须是单例的,这让Server Render实现起来很麻烦。在Server端,你会希望每个请求都有自己的store,比便于不同的用户可以拿到不同的预加载内容。

一个单例的store也让单元测试很难写。测试action creator的时候你很难mockstore,因为他引用了一个具体的真实的store。你甚至不能从外部重置store状态。

所以从技术上来说,你可以从一个module导出单例的store,但是我们不鼓励这样做。除非你确定加肯定你以后都不会升级Server Render。所以我们还是回到前面一种方案吧:

// actions.js

// ...

let nextNotificationId = 0
export function showNotificationWithTimeout(dispatch, text) {
  const id = nextNotificationId++
  dispatch(showNotification(id, text))

  setTimeout(() => {
    dispatch(hideNotification(id))
  }, 5000)
}

// component.js
showNotificationWithTimeout(this.props.dispatch, 'You just logged in.')

// otherComponent.js
showNotificationWithTimeout(this.props.dispatch, 'You just logged out.')  

这个方案就可以解决重复代码和竞争问题。

Thunk中间件

对于简单项目,上面的方案应该已经可以满足需求了。

但是对于大型项目,你可能还是会觉得这样使用并不方便。

比如,似乎我们必须将dispatch作为参数传递,这让我们分隔容器组件和展示组件变得更困难,因为任何发出异步Redux action的组件都必须接收dispatch作为参数,这样他才能将它继续往下传。你也不能仅仅使用connect()来绑定action creator,因为showNotificationWithTimeout()并不是一个真正的action creator,他返回的也不是Redux action

还有个很尴尬的事情是,你必须记住哪个action cerator是同步的,比如showNotification,哪个是异步的辅助方法,比如showNotificationWithTimeout。这两个的用法是不一样的,你需要小心的不要传错了参数,也不要混淆了他们。

这就是我们为什么需要找到一个“合法”的方法给辅助方法提供dispatch参数,并且帮助Redux区分出哪些是异步的action creator,好特殊处理他们

如果你的项目中面临着类似的问题,欢迎使用Redux Thunk中间件。

简单来说,React Thunk告诉Redux怎么去区分这种特殊的action----他其实是个函数:

import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'

const store = createStore(
  reducer,
  applyMiddleware(thunk)
)

// 这个是普通的纯对象action
store.dispatch({ type: 'INCREMENT' })

// 但是有了Thunk,他就可以识别函数了
store.dispatch(function (dispatch) {
  // 这个函数里面又可以dispatch很多action
  dispatch({ type: 'INCREMENT' })
  dispatch({ type: 'INCREMENT' })
  dispatch({ type: 'INCREMENT' })

  setTimeout(() => {
    // 异步的dispatch也可以
    dispatch({ type: 'DECREMENT' })
  }, 1000)
})

如果你使用了这个中间件,而且你dispatch的是一个函数,React Thunk会自己将dispatch作为参数传进去。而且他会将这些函数action“吃了”,所以不用担心你的reducer会接收到奇怪的函数参数。你的reducer只会接收到纯对象action,无论是直接发出的还是前面那些异步函数发出的。

这个看起来好像也没啥大用,对不对?在当前这个例子确实是的!但是他让我们可以像定义一个普通的action creator那样去定义showNotificationWithTimeout

// actions.js
function showNotification(id, text) {
  return { type: 'SHOW_NOTIFICATION', id, text }
}
function hideNotification(id) {
  return { type: 'HIDE_NOTIFICATION', id }
}

let nextNotificationId = 0
export function showNotificationWithTimeout(text) {
  return function (dispatch) {
    const id = nextNotificationId++
    dispatch(showNotification(id, text))

    setTimeout(() => {
      dispatch(hideNotification(id))
    }, 5000)
  }
}

注意这里的showNotificationWithTimeout跟我们前面的那个看起来非常像,但是他并不需要接收dispatch作为第一个参数。而是返回一个函数来接收dispatch作为第一个参数。

那在我们的组件中怎么使用这个函数呢,我们当然可以这样写:

// component.js
showNotificationWithTimeout('You just logged in.')(this.props.dispatch)

这样我们直接调用了异步的action creator来得到内层的函数,这个函数需要dispatch做为参数,所以我们给了他dispatch参数。

然而这样使用岂不是更尬,还不如我们之前那个版本的!我们为啥要这么干呢?

我之前就告诉过你:只要使用了Redux Thunk,如果你想dispatch一个函数,而不是一个纯对象,这个中间件会自己帮你调用这个函数,而且会将dispatch作为第一个参数传进去。

所以我们可以直接这样干:

// component.js
this.props.dispatch(showNotificationWithTimeout('You just logged in.'))

最后,对于组件来说,dispatch一个异步的action(其实是一堆普通action)看起来和dispatch一个普通的同步action看起来并没有啥区别。这是个好现象,因为组件就不应该关心那些动作到底是同步的还是异步的,我们已经将它抽象出来了。

注意因为我们已经教了Redux怎么区分这些特殊的action creator(我们称之为thunk action creator),现在我们可以在任何普通的action creator的地方使用他们了。比如,我们可以直接在connect()中使用他们:

// actions.js

function showNotification(id, text) {
  return { type: 'SHOW_NOTIFICATION', id, text }
}
function hideNotification(id) {
  return { type: 'HIDE_NOTIFICATION', id }
}

let nextNotificationId = 0
export function showNotificationWithTimeout(text) {
  return function (dispatch) {
    const id = nextNotificationId++
    dispatch(showNotification(id, text))

    setTimeout(() => {
      dispatch(hideNotification(id))
    }, 5000)
  }
}

// component.js

import { connect } from 'react-redux'

// ...

this.props.showNotificationWithTimeout('You just logged in.')

// ...

export default connect(
  mapStateToProps,
  { showNotificationWithTimeout }
)(MyComponent)

在Thunk中读取State

通常来说,你的reducer会包含计算新的state的逻辑,但是reducer只有当你dispatchaction才会触发。如果你在thunk action creator中有一个副作用(比如一个API调用),某些情况下,你不想发出这个action该怎么办呢?

如果没有Thunk中间件,你需要在组件中添加这个逻辑:

// component.js
if (this.props.areNotificationsEnabled) {
  showNotificationWithTimeout(this.props.dispatch, 'You just logged in.')
}

但是我们提取action creator的目的就是为了集中这些在各个组件中重复的逻辑。幸运的是,Redux Thunk提供了一个读取当前store state的方法。那就是除了传入dispatch参数外,他还会传入getState作为第二个参数,这样thunk就可以读取store的当前状态了。

let nextNotificationId = 0
export function showNotificationWithTimeout(text) {
  return function (dispatch, getState) {
    // 不像普通的action cerator,这里我们可以提前退出
    // Redux不关心这里的返回值,没返回值也没关系
    if (!getState().areNotificationsEnabled) {
      return
    }

    const id = nextNotificationId++
    dispatch(showNotification(id, text))

    setTimeout(() => {
      dispatch(hideNotification(id))
    }, 5000)
  }
}

但是不要滥用这种方法!如果你需要通过检查缓存来判断是否发起API请求,这种方法就很好,但是将你整个APP的逻辑都构建在这个基础上并不是很好。如果你只是用getState来做条件判断是否要dispatch action,你可以考虑将这些逻辑放到reducer里面去。

下一步

现在你应该对thunk的工作原理有了一个基本的概念,如果你需要更多的例子,可以看这里:https://redux.js.org/introduction/examples#async

你可能会发现很多例子都返回了Promise,这个不是必须的,但是用起来却很方便。Redux并不关心你的thunk返回了什么值,但是他会将这个值通过外层的dispatch()返回给你。这就是为什么你可以在thunk中返回一个Promise并且等他完成:

dispatch(someThunkReturningPromise()).then(...)

另外你还可以将一个复杂的thunk action creator拆分成几个更小的thunk action creator。这是因为thunk提供的dispatch也可以接收thunk,所以你可以一直嵌套的dispatch thunk。而且结合Promise的话可以更好的控制异步流程。

在一些更复杂的应用中,你可能会发现你的异步控制流程通过thunk很难表达。比如,重试失败的请求,使用token进行重新授权认证,或者在一步一步的引导流程中,使用这种方式可能会很繁琐,而且容易出错。如果你有这些需求,你可以考虑下一些更高级的异步流程控制库,比如Redux Saga或者Redux Loop。可以看看他们,评估下,哪个更适合你的需求,选一个你最喜欢的。

最后,不要使用任何库(包括thunk)如果你没有真实的需求。记住,我们的实现都是要看需求的,也许你的需求这个简单的方案就能满足:

store.dispatch({ type: 'SHOW_NOTIFICATION', text: 'You logged in.' })
setTimeout(() => {
  store.dispatch({ type: 'HIDE_NOTIFICATION' })
}, 5000)

不要跟风尝试,除非你知道你为什么需要这个!

----翻译到此结束----

StackOverflow的大神Dan Abramov对这个问题的回答实在太细致,太到位了,以致于我看了之后都不敢再写这个原因了,以此翻译向大神致敬,再贴下这个回答的地址:https://stackoverflow.com/questions/35411423/how-to-dispatch-a-redux-action-with-a-timeout/35415559#35415559

PS: Dan Abramov是Redux生态的核心作者,这几篇文章讲的ReduxReact-ReduxRedux-Thunk都是他的作品。

源码解析

上面关于原因的翻译其实已经将Redux适用的场景和原理讲的很清楚了,下面我们来看看他的源码,自己仿写一个来替换他。照例我们先来分析下要点:

  1. Redux-Thunk是一个Redux中间件,所以他遵守Redux中间件的范式。
  2. thunk是一个可以dispatch的函数,所以我们需要改写dispatch让他接受函数参数。

Redux中间件范式

在我前面那篇讲Redux源码的文章讲过中间件的范式以及Redux中这块源码是怎么实现的,没看过或者忘了的朋友可以再去看看。我这里再简单提一下,一个Redux中间件结构大概是这样:

function logger(store) {
  return function(next) {
    return function(action) {
      console.group(action.type);
      console.info('dispatching', action);
      let result = next(action);
      console.log('next state', store.getState());
      console.groupEnd();
      return result
    }
  }
}

这里注意几个要点:

  1. 一个中间件接收store作为参数,会返回一个函数
  2. 返回的这个函数接收老的dispatch函数作为参数(也就是代码中的next),会返回一个新的函数
  3. 返回的新函数就是新的dispatch函数,这个函数里面可以拿到外面两层传进来的store和老dispatch函数

仿照这个范式,我们来写一下thunk中间件的结构:

function thunk(store) {
  return function (next) {
    return function (action) {
      // 先直接返回原始结果
      let result = next(action);
      return result
    }
  }
}

处理thunk

根据我们前面讲的,thunk是一个函数,接收dispatch getState两个参数,所以我们应该将thunk拿出来运行,然后给他传入这两个参数,再将它的返回值直接返回就行。

function thunk(store) {
  return function (next) {
    return function (action) {
      // 从store中解构出dispatch, getState
      const { dispatch, getState } = store;

      // 如果action是函数,将它拿出来运行,参数就是dispatch和getState
      if (typeof action === 'function') {
        return action(dispatch, getState);
      }

      // 否则按照普通action处理
      let result = next(action);
      return result
    }
  }
}

接收额外参数withExtraArgument

Redux-Thunk还提供了一个API,就是你在使用applyMiddleware引入的时候,可以使用withExtraArgument注入几个自定义的参数,比如这样:

const api = "http://www.example.com/sandwiches/";
const whatever = 42;

const store = createStore(
  reducer,
  applyMiddleware(thunk.withExtraArgument({ api, whatever })),
);

function fetchUser(id) {
  return (dispatch, getState, { api, whatever }) => {
    // 现在你可以使用这个额外的参数api和whatever了
  };
}

这个功能要实现起来也很简单,在前面的thunk函数外面再包一层就行:

// 外面再包一层函数createThunkMiddleware接收额外的参数
function createThunkMiddleware(extraArgument) {
  return function thunk(store) {
    return function (next) {
      return function (action) {
        const { dispatch, getState } = store;

        if (typeof action === 'function') {
          // 这里执行函数时,传入extraArgument
          return action(dispatch, getState, extraArgument);  
        }

        let result = next(action);
        return result
      }
    }
  }
}

然后我们的thunk中间件其实相当于没传extraArgument

const thunk = createThunkMiddleware();

而暴露给外面的withExtraArgument函数就直接是createThunkMiddleware了:

thunk.withExtraArgument = createThunkMiddleware;

源码解析到此结束。啥,这就完了?是的,这就完了!Redux-Thunk就是这么简单,虽然背后的思想比较复杂,但是代码真的只有14行!我当时也震惊了,来看看官方源码吧:

function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => (next) => (action) => {
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument);
    }

    return next(action);
  };
}

const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;

export default thunk;

总结

  1. 如果说Redux是“百行代码,千行文档”,那Redux-Thunk就是“十行代码,百行思想”。
  2. Redux-Thunk最主要的作用是帮你给异步action传入dispatch,这样你就不用从调用的地方手动传入dispatch,从而实现了调用的地方和使用的地方的解耦。
  3. ReduxRedux-Thunk让我深深体会到什么叫“编程思想”,编程思想可以很复杂,但是实现可能并不复杂,但是却非常有用。
  4. 在我们评估是否要引入一个库时最好想清楚我们为什么要引入这个库,是否有更简单的方案。

本文手写代码已经上传GitHub,大家可以拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/blob/master/Examples/React/redux-thunk/src/myThunk.js

参考资料

Redux-Thunk文档:https://github.com/reduxjs/redux-thunk

Redux-Thunk源码: https://github.com/reduxjs/redux-thunk/blob/master/src/index.js

Dan Abramov在StackOverflow上的回答: https://stackoverflow.com/questions/35411423/how-to-dispatch-a-redux-action-with-a-timeout/35415559#35415559

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

作者博文GitHub项目地址: https://github.com/dennis-jiang/Front-End-Knowledges

我也搞了个公众号[进击的大前端],不打广告,不写水文,只发高质量原创,欢迎关注~
image.png

查看原文

赞 19 收藏 15 评论 1

蒋鹏飞 发布了文章 · 9月7日

RSA初探,聊聊怎么破解HTTPS

这篇文章跟大家讨论一个比较有意思的问题:怎么破解https?大家都知道,现在几乎整个互联网都采用了https,不是https的网站某些浏览器还会给出警告。面试中也经常问到https,本文会深入https原理,一直讲到https破解思路。

HTTPS

要想破解https,必须先知道https原理,下面我们先来讲讲https原理。

公私钥

https的公私钥经常在面试中出现,各种面经也会给出答案:https有两个秘钥,公钥和私钥,网站自己持有私钥,用户持有公钥,网站用自己的私钥加密数据发给用户,用户用公钥解密数据。用户要发信息就反过来,用户用公钥加密数据,网站用私钥解密数据。这种加密和解密使用不同秘钥的加密算法叫做非对称加密。这个流程有点绕,下面举例来说明下,假设网站A启用了https,小明要来访问这个网站了(以下例子仅为讲解公私钥用途,并非https真实流程,真实流程是“HTTPS握手流程”一节):

  1. 网站A启用https,自然有一对秘钥,私钥和公钥,私钥他自己藏起来了,公钥任何访问用户都可以拿到
  2. 小明访问网站A,拿到了A的公钥
  3. 小明要给网站A发消息就用公钥给信息加密,然后发给网站A
  4. 网站A拿到密文后,用自己的私钥解密得到消息内容
  5. 网站A要给小明回信,用自己的私钥加密信息,发送给小明
  6. 小明拿到密文后,用自己手上的公钥解密信息

通过上面的流程我们可以看出,由于公钥是公开的,所以网站私钥加密的信息其实所有用户都可以解开。在这一个阶段,保护的其实是用户发给服务器的数据,因为用户加密的数据必须要服务器的私钥才能解开。这里大家想一个有意思的问题:既然所有用户都能拿到公钥,那是不是小明加密的信息,小红也能解开呢,因为小红也有公钥啊?如果小红也能解开,那小红只要截获了小明的流量,不就知道内容了吗?这个问题简化一下就是,公钥加密的信息用同一个公钥能解开吗?答案是不能!要知道这个原因必须要知道RSA算法,我们后面会讲,先一步步来。

数字证书

前面小明访问网站A的流程是有隐患,可以被攻击的。假设小红是个中间人黑客,现在想攻击小明,她偷偷在小明电脑上做了手脚,将网站A的公钥换成了自己的:

  1. 小明访问网站A,网站A给小明发送公钥,但是这一步被小红攻击了
  2. 小红劫持了小明的流量,将网站A发过来的公钥替换成了自己的公钥
  3. 小明拿到了错误的公钥,用这个公钥加密自己的信息,这个信息可能包含他的用户名,密码等敏感信息
  4. 小明将加密信息发送给网站A,这个流量被小红截获
  5. 因为密文是用小红的公钥加密的,小红用对应的私钥解密,得到小明的密码,攻击完成

可以看到仅仅是公私钥还是不能应对中间人的流量劫持,传输过程中信息被截获仍然会被破解。这个攻击能成功的关键点就是小明拿到了错误的公钥,所以需要一种机制来保证小明拿到正确的网站A公钥,这个机制就是数字证书。数字证书说开了很简单,他里面核心东西就一个,就是网站A的公钥。网站A将自己的公钥放到数字证书里面发送给小明,小明一看,这个公钥是证书认证的,可信,就用这个了。即使小红替换了公钥,因为小红的公钥没有证书认证,所以小明也可以识别出这个假冒货。

那数字证书的安全性又是怎么保证的呢,小红再伪造一个数字证书不就行了吗?这就要说到CA(CertificateAuthority)了,CA是颁发数字证书的机构,CA有自己的公私钥。CA用自己的私钥加密一个信息,这个信息就是网站A的公钥,然后发送给用户,用户拿到这个信息用CA的公钥解密,就拿到了正确的网站A的公钥了。所以,数字证书其实就是CA私钥加密过的网站公钥。小红没有CA的私钥,她就伪造不出来网站的数字证书了,也就没法替换小明拿到的公钥了。所以,数字证书其实保证了网站公钥的正确性,CA保证了数字证书的安全性

既然CA保证了数字证书的安全性,那谁来保证CA的安全性呢?假设有个东西X保证了CA的安全性,那谁来保证X的安全性呢?感觉这个信任链条可以无穷尽呢。。。现实中,CA的安全级别非常高,他的安全不仅仅有技术手段,还有法律,物理措施等。反过来说,回到本文的主题,破解https,到这里我们其实有了第一个思路:黑掉CA!你就可以将它名下所有证书的公钥都替换成自己的,解密使用他证书的所有网站。

评论区有朋友提到,Charles可以解密https,这个原理不就跟小红攻击小明的原理一样嘛。Charles解密https的前提是你要安装他的证书,安装了他的证书,你其实就相当于信任了Charles这个假的CA。攻击流程将前面的小红换成Charles就行了。

会话秘钥

公私钥的加密解密确实很安全,但是他的速度很慢,如果每条信息都这么操作,会影响整个交流效率,所以当我们跟https建立连接后,通过公私钥交换的信息其实只有一个:会话秘钥。会话秘钥不是非对称加密,而是对称加密。对称加密在某些影视作品中很常见:某主人公得到一个藏宝图,苦于藏宝图是密码写的,看不懂,百般无奈下,想起祖传的某某书籍,拿到一对照,那本书刚好可以解密藏宝图密码。那这本书其实就是密码本,二战中很多信息加密就用的密码本的方式,通过截获密码本获取对方军事情报的事情也不少。加密解密都用密码本,其实就是用了同一个秘钥,这就是对称加密。用计算机领域的话来说,这个密码本不就是一个hash函数嘛,这个函数将一个字符映射成另外一个字符。举个例子,我们加密的hash函数就是将字符后移三位,a -> d, b -> e 这种,那"hello"就变成了:

h -> k

e -> h

l -> o

l -> o

o -> r

"hello"就变成了"khoor",那攻击者只要知道了你这个算法,再反算回来,前移三位就解密了。所以对称加密相对来说并不安全,但是,如果我能保证他的密码本(也就是秘钥)是安全的,对称加密也可以是安全的。那对称加密的秘钥怎么保证安全呢?用公私钥再加一次密啊!所以https连接后,公私钥交换的信息只有一个,那就是对称加密秘钥,也就是会话秘钥。对称加密的算法就是一个hash函数,加密解密相对更快,这种设计是从效率的角度考虑的。

数字签名

数字签名其实很简单,是用来保障信息的完整性和正确性的:

  1. 小明先将明文信息用摘要算法生成一个摘要,这个算法类似于MD5,SHA-1,SHA-2,就是一个不能反解的hash函数
  2. 小明用公钥对这个摘要进行加密
  3. 小明将这个签名附加在内容后面一起发给服务端
  4. 服务端接收到签名后,用私钥解密出摘要
  5. 服务端对内容进行同样的摘要算法,得出摘要
  6. 服务端算出的摘要如果跟签名里面的一样,则内容完整,没有被篡改

HTTPS握手流程

前面几个知识点其实已经把https的关键点都讲了,下面我们来总结下https握手流程:

  1. 小明向网站A发起请求
  2. 网站A将CA数字证书返回给客户端,证书里面有网站A的公钥
  3. 小明通过自己电脑内置的CA公钥解密证书,拿到网站A的公钥(CA公钥内置在浏览器中)
  4. 小明生成随机的对称秘钥,也就是会话秘钥。会话秘钥一定要客户端生成,因为前面说了,这里公私钥只能保证客户端发给网站信息的安全,公钥加密的信息只有私钥才能解开,私钥网站藏起来了,所以其他人拿到信息也解不开。但是如果网站生成会话秘钥,用他的私钥加密,那所有人都有公钥,所有人都能解开了。
  5. 小明将会话秘钥通过网站A的公钥加密,发送给网站A
  6. 接下来网站A和小明使用会话秘钥进行HTTP通信

RSA算法

前面我们提到过公钥加密的信息用同一个公钥也解不开,只能用私钥解密,这其实就是非对称加密的核心机密,下面我们来讲讲这个机密是怎么做到的,这其实就是RSA算法。RSA算法计算流程如下:

  1. 随机选取两个质数p和q
  2. 计算 n = pq
  3. 计算 φ(n) = (p-1)(q-1)
  4. 找一个与φ(n)互质的小奇数e,互质是指两个数的公约数只有1
  5. 对模φ(n),计算e的乘法逆元d,即找到一个d,使下列等式成立:(e*d) mod φ(n) = 1
  6. 得到公钥:(e, n),私钥: (d, n)
  7. 加密过程:c = (m^e) mod n, (c为加密后的密文,m为原文)
  8. 解密过程:m = (c^d) mod n

第七步说明下,m的e次方,m就是我们发送的原文,可以是文本,json,图片,虽然形式多样,但是在计算机里面都是二进制01,所以可以转换成数字求次方。下面我们找两个数来试一下这个算法:

  1. 随便选两个质数23和61
  2. 计算 n = 23 * 61 = 1403
  3. 计算 φ(n) = (23-1) * (61-1) = 22 * 60 = 1320
  4. 找一个与φ(n)互质的小奇数e,我们选7
  5. 计算乘法逆元d,我这里算好的是 d =943。对乘法逆元感兴趣的朋友可以网上搜搜怎么算,因为不是本文主题,我就不展开了。
  6. 得到公钥(7, 1403),私钥(943, 1403)
  7. 我们用公钥随便加密一个5试试,加密 c = (m^e) mod n = (5^7) % 1403 = 78125 % 1403 = 960
  8. 私钥解密: m = (c^d) mod n = (960^943) % 1403 = 5,(960^943)这个数字超级大,一般计算器算不出来,JS计算更不行,我是用这个网站算的:https://defuse.ca/big-number-...
  9. 再试试私钥加密:c = (m^d) mod n = (5^943) % 1403 = 283
  10. 公钥解密: m = (c^e) mod n = (283 ^ 7) % 1403 = 5

知道了算法,我们就可以来解答前面的那个问题了,为什么公钥自己加密的数据自己还解不出来?注意看加密算法(m^e) mod n这是个模运算啊,模运算是不能反解的。比如5对4取模,5%4=1,但是反过来,知道x%4=1,求x。这个x可以有无限个,5,9,13,17。。。所以即使你有公钥(e,n),和密文c,你也不知道(m^e)到底取哪个值,是反解不出来的,这就是非对称加密的核心机密,私钥加密同理,自己加密的自己也反解不出来。

RSA破解思路

所谓破解RSA,其实就是通过公开的信息推测出他藏起来的信息,具体来说就是已知公钥(e, n)求私钥(d,n),也就是求d。要求d,其实就是反解(e*d) mod φ(n) = 1,要反解这个式子,就必须知道φ(n),因为φ(n) = (p-1)(q-1),所以必须知道p和q。我们知道n=pq,而且n是已知的,所以还是有可能知道p和q的。所以破解RSA其实就是一句话:n是已知的,将n拆成两个质数之积就行了。说起来简单,做起来非常难!因为实际使用时,n非常大,现在好多地方用的n都是2048 bits甚至4096 bits,这个数字转换成十进制也有几百位上千位长,做个对比,JS整数最多支持53 bits。。。所以现实中有两条路来破解RSA:

  1. 找出一个算法,能够高效的将大数n拆分成两个质数。可惜目前数学界也还没找到这个算法。
  2. 没有好办法就用笨办法,穷举,从2开始遍历p, q,直到他们的乘积为n为止。据说有人花了5个月时间算出了一个512 bits的n,然后人家早就换了秘钥,RSA还升级到了1024 bits...

总结

  1. HTTPS其实就是HTTP+RSA+数字证书+会话秘钥
  2. RSA实现了非对称加密,可以让公钥任意分发,私钥即使丢失了,也可以迅速换一对公私钥。解决了对称加密密码本的漏洞。
  3. 数字证书保证了分发的公钥不能被篡改。
  4. CA保证了数字证书的安全性。
  5. CA的安全性由谁保证是个玄学
  6. 会话秘钥是对称加密,目的是为了加快加密解密速度
  7. RSA算法精髓:

    1. 加密使用模运算,完全不能反解
    2. n取一个超大数,超出了数学界理论极限和计算机的工业极限
  8. 破解HTTPS三条路:

    1. 黑掉CA,将它名下证书的公钥都换成你的,方法勿论。。。
    2. 数学之神附体,找到高效大数分解算法,分分钟算出p,q
    3. 图灵附体,研发出超快的量子计算机,秒秒钟算出p,q
  9. 自己网站没开https的赶紧回去开,记得找个靠谱的CA买证书

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

作者博文GitHub项目地址: https://github.com/dennis-jiang/Front-End-Knowledges

我也搞了一个公众号[进击的大前端],不打广告,不写水文,只发高质量原创,欢迎关注
image.png

查看原文

赞 30 收藏 21 评论 2

蒋鹏飞 赞了文章 · 9月4日

思否有约丨@AKevin:“武林秘籍”在网吧广为流传,转专业学计算机被保送北大

AKevin

本期访谈嘉宾:@AKevin
访谈编辑:芒果果

高中的时候 AKevin 就对编程有兴趣了,不过那时十八线小城市的高中计算机老师似乎也教不了什么,AKevin 就开始跑到网吧“自学”。说是自学,其实更多的还是为了打游戏。那时候,他写的游戏按键脚本就像武林秘籍一样,在当地网吧的众位“大侠”手里广为流传。

不过,毕业后 AKevin 没有继续写代码,而是进了一家科研事业单位,完美错过了移动互联网高速发展的那几年,害怕丢掉手艺他才有跳回了这个圈子。

在互联网技术圈的这些年,AKevin 做过很多不同的工作,给 App 做过 rank 和推荐,做过跨境物流,现在又成了 Python 讲师。

Q:你是怎么开始走上编程这条路的?

其实自己大一读的是商学院,但是课程实在太简单了,整天闲得没事干。索性转专业到计算机,刚学计算机比较痛苦,原来课程这么多,一门微机原理都能分成 3 门课讲,还有完全听不懂的抽象代数。转专业的还有不少课程要补,后来咬咬牙把课程都补完也拿到资格保送到北大。

毕业后去了一家科研事业单位,感觉完美地错过了移动互联网最高速发展的几年。害怕自己丢掉了代码手艺,赶紧跳槽到互联网技术圈,再从百度出去后薪资涨幅就比较大了。也算找回初心,也算是对得起自己学了这么多年的计算机。

Q:因为闲课程太简单就转到计算机专业了,还真是任性啊。

那你是什么时候开始学习 Python 的呢?

以前做外卖 App 的时候工程和算法都做,为了兼顾算法的快速迭代学习了 Python。学了之后感觉打开了一扇大门,原来 Python 这么万能,我用 Python来 算特征,跑机器学习模型。也用 Python 写脚本爬数据,当时做外卖做商超时,我们从淘宝爬了很多标品数据。

Q:你有没有尝试过新的编程语言?一般通过什么方式和渠道提升自己的能力?

最近尝试了 Groovy,打算把部分单元测试、集成测试的代码改成 Groovy。比 Java 方便,能早点下班。喜欢反思总结,慢慢打磨属于自己的方法论。技术层面通过 Code Review 和看优秀源码提高。从面试官角度偷偷告诉大家,基础比较差的同学可以多刷题来提高“钱”途。

从事业单位到技术岗位再到上台当讲师,AKevin 做过的每一份工作可能都是某些人的“人生目标”了,更让人羡慕的是,除了学习能力出众,能被保送北大之外,AKevin 也不是个书呆子,在工作上也是成就满满。

image.png

Q:给学生讲课和从事技术工作有哪些不同?这个转变是怎么完成的?

从小都有当老师的想法,念书时也辅导不少同学考上名校、进入大厂。可能自己有那么一点喜欢“好为人师”。讲课和做技术都需要扣细节。讲课生怕哪个细节没弄明白误导学生,做技术也怕没清楚细节让系统出错。

Q:你的工作习惯是怎样的,可以介绍下工作流吗?

无论多大多小的项目,我都是先设计再开发,开发后有测试代码。动手之前想清楚,凡事先做框架设计。想得越清楚工作起来越顺利,出错的概率越小。我开发的系统是很少出错的,因为在评估完需求后,我都会拿出时间专门做系统设计。怎么设计扩展性更好,怎么设计更稳健,更简洁。想好系统设计后,我才开始动手写代码,写的过程中我喜欢用单元测试来验证代码,而不是每次都要启动系统去请求接口。设计、开发、测试的时间差不多是1:1:1. 从我的经验看,这样打造的系统可靠性最高,也能适应快速迭代而不出错。

Q:工作之后有哪个瞬间最让你有成就感?最满意的开发项目是什么?

每次系统上线时看迅速滚动的日志,都能感觉到自己开发的系统服务了无数的用户。再从网络渠道看到用户的好评,瞬间会让你觉得很有成就感。

最满意的应该是模考系统,这是一个免费的系统,服务了上千万的用户。从微博上经常能看到用户对它的好评,看到用户的好评感觉自己的工作很有价值。模考系统是瞬时并发量很高的,差不多到 10w TPS,写入的请求占比高。为了系统的稳定,我为它开发了不少集成测试和校验,也会有自动化运行的压力测试随时评估系统的稳定性。很自豪的是系统在我手里没崩过,每次模考都稳定服务几十万考生。

每周都有模考,模考过后很多人会刷微博模考话题,这时候你会看到哪个用户裂开了觉得题目太难,哪个用户又很开心,模考成绩出得很快,自己又进步了,到处晒成绩单。在社交媒体上看到很多网友在讨论模考,知道自己做的技术对用户确实产生了帮助,非常有成就感。

Q:就没有过什么让你很受打击的情况吗?

最开始在百度是做机器学习的,经常跑模型迭代了一两个月都没有改进,这个时候会很气馁。做算法确实很难,在不确定性中探索,后来自己写了很多自动化跑模型调参的程序,适当提高迭代速度。再后来,觉得自己工程能力还不错,就专注于做后端架构了。

AKevin 对自己的评价是“过度谨慎”,他觉得这样的自己显得有点笨拙,做事情很慢,但也保证了代码很少出错。不过他还补充了一句:“个人对快和慢的理解都不一样,数理化我都拿过省一的奖,成绩也是专业第二,相信自己并不是真的笨,打算继续慢下去吧,思考清楚再行动。”

Q:过度谨慎的性格会让你做什么事都提前规划好么?生活上也这样么?

工作上的计划做得不错,生活技能却比较低级,比如旅游总是没计划,无端端地多花钱也没玩好。我见过能力强的人工作和生活都安排的明明白白,值得思考。努力工作的同时,也要安排好生活,高质量地陪伴家人。

工作学习 AKevin 都没落下,运动方面当然也要跟上,他很喜欢游泳,甚至不会觉得累。想要锻炼心肺能力时,他就加快速度,想要放下身心的时候他就慢慢游。AKevin 说:“很多人说游泳太累,我觉得一定是姿势不对。游泳重在放松,讲究人与环境的平衡,顺势而为,我觉得生活也是这样。”

Q:如果可以重新选择是否还会选择这个职业 ?

当然,有一台电脑就可以创造产品,没有比计算机更有趣的职业了。学航天的同学们,能凭一己之力在家造火箭?学材料的同学,能不顾成本做研发和实现流水生产?我学计算机,我有台电脑就可以造App,造PC端产品,批量处理可重复性的操作,撸脚本刷羊毛……这蕴涵着经典的经济学原理——“完美,在于一切伟大的生意,都具有 规模效应 和 边际成本低 的特点。”计算机兼具规模效应+低边际成本。无数青年终于有了做梦的权利,一个键盘或许真的可以改变世界。

Q:对编程初学者和怀抱梦想的年轻人有什么建议?

喜欢技术就去做!互联网技术有多好找工作、薪资大概多少、全球职位缺口……随便上网一查就有。当前的市场环境下,搞计算机涨薪还是很快的。拿二三线城市普通 70 后举例,当了十几年小学老师,工资从 2k 涨到现在 4k,终于实现了收入翻番。而普通的计算机从业者,从 1.5w 涨到 3w,大致只需要两年。如果你志在科研,计算机专业无论做体系结构研究,还是偏机器学习的理论、应用研究,都是当下比较有趣又具备现实意义的课题。或许你会说计算机没有社会资源,讲真,你觉得去当个基层公务员有资源?家里没有医疗系统的积淀,你去当医生就能 C 位出道?不惑之年就能当上主任医师?这个社会制度的设计是金字塔结构,普通人能有什么过硬的社会资源。但我学计算机,我可以结合一个我喜欢的行业去做创新,去赚相对多的、没有原罪的钱。少年你若喜欢玩游戏,大可学计算机去做游戏开发;你觉得人工智能很酷,大可以去学计算机再补点数学基础;你觉得金融能一夜暴富,也可以学点计算机去做量化模型自动化交易,去全球金融市场感受大庄家的壕,比一天到晚听理财经理瞎逼逼,买一些亏本的理财产品强多了。

AKevin 谈思否:

一开始在思否做了一个讲面试的课、后来做了一个 Python 课,收到不少好评。思否 CEO 和 CTO 在线下也多次邀请相聚,讨论怎么把课做得更好,帮助更多的开发者。思否的价值观是很正的,不会去骚扰用户推销昂贵的课,我们讨论得更多的是如何能帮助到开发者。我也很认可这样的价值观,就这样一直在思否迭代课程,现在在更新一门 Python 课,章节很多,从基础到实战例子都有,价格也很低,希望对社区用户有帮助。

小编有话说:

好羡慕这种会觉得学习太简单的人,要是我也有这脑子岂不是也能被北大录取了。

AKevin 老师一路从事业单位到技术岗位再到站上讲台,虽然一直没有脱离技术圈,但也算是看过不同风景有丰富工作阅历的人啦。上 AKevin 老师的课肯定没错!


欢迎有兴趣参与访谈的小伙伴踊跃报名,《思否有约》将把你与编程有关的故事记录下来。报名邮箱:mango@sifou.com

segmentfault 公众号

查看原文

赞 10 收藏 2 评论 2

蒋鹏飞 发布了文章 · 8月31日

深入Node.js的模块加载机制,手写require函数

模块是Node.js里面一个很基本也很重要的概念,各种原生类库是通过模块提供的,第三方库也是通过模块进行管理和引用的。本文会从基本的模块原理出发,到最后我们会利用这个原理,自己实现一个简单的模块加载机制,即自己实现一个require

本文完整代码已上传GitHub:https://github.com/dennis-jiang/Front-End-Knowledges/blob/master/Examples/Node.js/Module/MyModule/index.js

简单例子

老规矩,讲原理前我们先来一个简单的例子,从这个例子入手一步一步深入原理。Node.js里面如果要导出某个内容,需要使用module.exports,使用module.exports几乎可以导出任意类型的JS对象,包括字符串,函数,对象,数组等等。我们先来建一个a.js导出一个最简单的hello world:

// a.js 
module.exports = "hello world";

然后再来一个b.js导出一个函数:

// b.js
function add(a, b) {
  return a + b;
}

module.exports = add;

然后在index.js里面使用他们,即require他们,require函数返回的结果就是对应文件module.exports的值:

// index.js
const a = require('./a.js');
const add = require('./b.js');

console.log(a);      // "hello world"
console.log(add(1, 2));    // b导出的是一个加法函数,可以直接使用,这行结果是3

require会先运行目标文件

当我们require某个模块时,并不是只拿他的module.exports,而是会从头开始运行这个文件,module.exports = XXX其实也只是其中一行代码,我们后面会讲到,这行代码的效果其实就是修改模块里面的exports属性。比如我们再来一个c.js

// c.js
let c = 1;

c = c + 1;

module.exports = c;

c = 6;

c.js里面我们导出了一个c,这个c经过了几步计算,当运行到module.exports = c;这行时c的值为2,所以我们requirec.js的值就是2,后面将c的值改为了6并不影响前面的这行代码:

const c = require('./c.js');

console.log(c);  // c的值是2

前面c.js的变量c是一个基本数据类型,所以后面的c = 6;不影响前面的module.exports,那他如果是一个引用类型呢?我们直接来试试吧:

// d.js
let d = {
  num: 1
};

d.num++;

module.exports = d;

d.num = 6;

然后在index.js里面require他:

const d = require('./d.js');

console.log(d);     // { num: 6 }

我们发现在module.exports后面给d.num赋值仍然生效了,因为d是一个对象,是一个引用类型,我们可以通过这个引用来修改他的值。其实对于引用类型来说,不仅仅在module.exports后面可以修改他的值,在模块外面也可以修改,比如index.js里面就可以直接改:

const d = require('./d.js');

d.num = 7;
console.log(d);     // { num: 7 }

requiremodule.exports不是黑魔法

我们通过前面的例子可以看出来,requiremodule.exports干的事情并不复杂,我们先假设有一个全局对象{},初始情况下是空的,当你require某个文件时,就将这个文件拿出来执行,如果这个文件里面存在module.exports,当运行到这行代码时将module.exports的值加入这个对象,键为对应的文件名,最终这个对象就长这样:

{
  "a.js": "hello world",
  "b.js": function add(){},
  "c.js": 2,
  "d.js": { num: 2 }
}

当你再次require某个文件时,如果这个对象里面有对应的值,就直接返回给你,如果没有就重复前面的步骤,执行目标文件,然后将它的module.exports加入这个全局对象,并返回给调用者。这个全局对象其实就是我们经常听说的缓存。所以requiremodule.exports并没有什么黑魔法,就只是运行并获取目标文件的值,然后加入缓存,用的时候拿出来用就行。再看看这个对象,因为d.js是一个引用类型,所以你在任何地方获取了这个引用都可以更改他的值,如果不希望自己模块的值被更改,需要自己写模块时进行处理,比如使用Object.freeze()Object.defineProperty()之类的方法。

模块类型和加载顺序

这一节的内容都是一些概念,比较枯燥,但是也是我们需要了解的。

模块类型

Node.js的模块有好几种类型,前面我们使用的其实都是文件模块,总结下来,主要有这两种类型:

  1. 内置模块:就是Node.js原生提供的功能,比如fshttp等等,这些模块在Node.js进程起来时就加载了。
  2. 文件模块:我们前面写的几个模块,还有第三方模块,即node_modules下面的模块都是文件模块。

加载顺序

加载顺序是指当我们require(X)时,应该按照什么顺序去哪里找X,在官方文档上有详细伪代码,总结下来大概是这么个顺序:

  1. 优先加载内置模块,即使有同名文件,也会优先使用内置模块。
  2. 不是内置模块,先去缓存找。
  3. 缓存没有就去找对应路径的文件。
  4. 不存在对应的文件,就将这个路径作为文件夹加载。
  5. 对应的文件和文件夹都找不到就去node_modules下面找。
  6. 还找不到就报错了。

加载文件夹

前面提到找不到文件就找文件夹,但是不可能将整个文件夹都加载进来,加载文件夹的时候也是有一个加载顺序的:

  1. 先看看这个文件夹下面有没有package.json,如果有就找里面的main字段,main字段有值就加载对应的文件。所以如果大家在看一些第三方库源码时找不到入口就看看他package.json里面的main字段吧,比如jquerymain字段就是这样:"main": "dist/jquery.js"
  2. 如果没有package.json或者package.json里面没有main就找index文件。
  3. 如果这两步都找不到就报错了。

支持的文件类型

require主要支持三种文件类型:

  1. .js.js文件是我们最常用的文件类型,加载的时候会先运行整个JS文件,然后将前面说的module.exports作为require的返回值。
  2. .json.json文件是一个普通的文本文件,直接用JSON.parse将其转化为对象返回就行。
  3. .node.node文件是C++编译后的二进制文件,纯前端一般很少接触这个类型。

手写require

前面其实我们已经将原理讲的七七八八了,下面来到我们的重头戏,自己实现一个require。实现require其实就是实现整个Node.js的模块加载机制,我们再来理一下需要解决的问题:

  1. 通过传入的路径名找到对应的文件。
  2. 执行找到的文件,同时要注入modulerequire这些方法和属性,以便模块文件使用。
  3. 返回模块的module.exports

本文的手写代码全部参照Node.js官方源码,函数名和变量名尽量保持一致,其实就是精简版的源码,大家可以对照着看,写到具体方法时我也会贴上对应的源码地址。总体的代码都在这个文件里面:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js

Module类

Node.js模块加载的功能全部在Module类里面,整个代码使用面向对象的思想,如果你对JS的面向对象还不是很熟悉可以先看看这篇文章Module类的构造函数也不复杂,主要是一些值的初始化,为了跟官方Module名字区分开,我们自己的类命名为MyModule

function MyModule(id = '') {
  this.id = id;       // 这个id其实就是我们require的路径
  this.path = path.dirname(id);     // path是Node.js内置模块,用它来获取传入参数对应的文件夹路径
  this.exports = {};        // 导出的东西放这里,初始化为空对象
  this.filename = null;     // 模块对应的文件名
  this.loaded = false;      // loaded用来标识当前模块是否已经加载
}

require方法

我们一直用的require其实是Module类的一个实例方法,内容很简单,先做一些参数检查,然后调用Module._load方法,源码看这里:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L970。精简版的代码如下:

MyModule.prototype.require = function (id) {
  return Module._load(id);
}

MyModule._load

MyModule._load是一个静态方法,这才是require方法的真正主体,他干的事情其实是:

  1. 先检查请求的模块在缓存中是否已经存在了,如果存在了直接返回缓存模块的exports
  2. 如果不在缓存中,就new一个Module实例,用这个实例加载对应的模块,并返回模块的exports

我们自己来实现下这两个需求,缓存直接放在Module._cache这个静态变量上,这个变量官方初始化使用的是Object.create(null),这样可以使创建出来的原型指向null,我们也这样做吧:

MyModule._cache = Object.create(null);

MyModule._load = function (request) {    // request是我们传入的路劲参数
  const filename = MyModule._resolveFilename(request);

  // 先检查缓存,如果缓存存在且已经加载,直接返回缓存
  const cachedModule = MyModule._cache[filename];
  if (cachedModule !== undefined) {
    return cachedModule.exports;
  }

  // 如果缓存不存在,我们就加载这个模块
  // 加载前先new一个MyModule实例,然后调用实例方法load来加载
  // 加载完成直接返回module.exports
  const module = new MyModule(filename);
  
  // load之前就将这个模块缓存下来,这样如果有循环引用就会拿到这个缓存,但是这个缓存里面的exports可能还没有或者不完整
  MyModule._cache[filename] = module;
  
  module.load(filename);
  
  return module.exports;
}

上述代码对应的源码看这里:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L735

可以看到上述源码还调用了两个方法:MyModule._resolveFilenameMyModule.prototype.load,下面我们来实现下这两个方法。

MyModule._resolveFilename

MyModule._resolveFilename从名字就可以看出来,这个方法是通过用户传入的require参数来解析到真正的文件地址的,源码中这个方法比较复杂,因为按照前面讲的,他要支持多种参数:内置模块,相对路径,绝对路径,文件夹和第三方模块等等,如果是文件夹或者第三方模块还要解析里面的package.jsonindex.js。我们这里主要讲原理,所以我们就只实现通过相对路径和绝对路径来查找文件,并支持自动添加jsjson两种后缀名:

MyModule._resolveFilename = function (request) {
  const filename = path.resolve(request);   // 获取传入参数对应的绝对路径
  const extname = path.extname(request);    // 获取文件后缀名

  // 如果没有文件后缀名,尝试添加.js和.json
  if (!extname) {
    const exts = Object.keys(MyModule._extensions);
    for (let i = 0; i < exts.length; i++) {
      const currentPath = `${filename}${exts[i]}`;

      // 如果拼接后的文件存在,返回拼接的路径
      if (fs.existsSync(currentPath)) {
        return currentPath;
      }
    }
  }

  return filename;
}

上述源码中我们还用到了一个静态变量MyModule._extensions,这个变量是用来存各种文件对应的处理方法的,我们后面会实现他。

MyModule._resolveFilename对应的源码看这里:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L822

MyModule.prototype.load

MyModule.prototype.load是一个实例方法,这个方法就是真正用来加载模块的方法,这其实也是不同类型文件加载的一个入口,不同类型的文件会对应MyModule._extensions里面的一个方法:

MyModule.prototype.load = function (filename) {
  // 获取文件后缀名
  const extname = path.extname(filename);

  // 调用后缀名对应的处理函数来处理
  MyModule._extensions[extname](this, filename);

  this.loaded = true;
}

注意这段代码里面的this指向的是module实例,因为他是一个实例方法。对应的源码看这里: https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L942

加载js文件: MyModule._extensions['.js']

前面我们说过不同文件类型的处理方法都挂载在MyModule._extensions上面的,我们先来实现.js类型文件的加载:

MyModule._extensions['.js'] = function (module, filename) {
  const content = fs.readFileSync(filename, 'utf8');
  module._compile(content, filename);
}

可以看到js的加载方法很简单,只是把文件内容读出来,然后调了另外一个实例方法_compile来执行他。对应的源码看这里:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L1098

编译执行js文件:MyModule.prototype._compile

MyModule.prototype._compile是加载JS文件的核心所在,也是我们最常使用的方法,这个方法需要将目标文件拿出来执行一遍,执行之前需要将它整个代码包裹一层,以便注入exports, require, module, __dirname, __filename,这也是我们能在JS文件里面直接使用这几个变量的原因。要实现这种注入也不难,假如我们require的文件是一个简单的Hello World,长这样:

module.exports = "hello world";

那我们怎么来给他注入module这个变量呢?答案是执行的时候在他外面再加一层函数,使他变成这样:

function (module) { // 注入module变量,其实几个变量同理
  module.exports = "hello world";
}

所以我们如果将文件内容作为一个字符串的话,为了让他能够变成上面这样,我们需要再给他拼接上开头和结尾,我们直接将开头和结尾放在一个数组里面:

MyModule.wrapper = [
  '(function (exports, require, module, __filename, __dirname) { ',
  '\n});'
];

注意我们拼接的开头和结尾多了一个()包裹,这样我们后面可以拿到这个匿名函数,在后面再加一个()就可以传参数执行了。然后将需要执行的函数拼接到这个方法中间:

MyModule.wrap = function (script) {
  return MyModule.wrapper[0] + script + MyModule.wrapper[1];
};

这样通过MyModule.wrap包装的代码就可以获取到exports, require, module, __filename, __dirname这几个变量了。知道了这些就可以来写MyModule.prototype._compile了:

MyModule.prototype._compile = function (content, filename) {
  const wrapper = Module.wrap(content);    // 获取包装后函数体

  // vm是nodejs的虚拟机沙盒模块,runInThisContext方法可以接受一个字符串并将它转化为一个函数
  // 返回值就是转化后的函数,所以compiledWrapper是一个函数
  const compiledWrapper = vm.runInThisContext(wrapper, {
    filename,
    lineOffset: 0,
    displayErrors: true,
  });

  // 准备exports, require, module, __filename, __dirname这几个参数
  // exports可以直接用module.exports,即this.exports
  // require官方源码中还包装了一层,其实最后调用的还是this.require
  // module不用说,就是this了
  // __filename直接用传进来的filename参数了
  // __dirname需要通过filename获取下
  const dirname = path.dirname(filename);

  compiledWrapper.call(this.exports, this.exports, this.require, this,
    filename, dirname);
}

上述代码要注意我们注入进去的几个参数和通过call传进去的this:

  1. this:compiledWrapper是通过call调用的,第一个参数就是里面的this,这里我们传入的是this.exports,也就是module.exports,也就是说我们js文件里面this是对module.exports的一个引用。
  2. exports: compiledWrapper正式接收的第一个参数是exports,我们传的也是this.exports,所以js文件里面的exports也是对module.exports的一个引用。
  3. require: 这个方法我们传的是this.require,其实就是MyModule.prototype.require,也就是MyModule._load
  4. module: 我们传入的是this,也就是当前模块的实例。
  5. __filename:文件所在的绝对路径。
  6. __dirname: 文件所在文件夹的绝对路径。

到这里,我们的JS文件其实已经记载完了,对应的源码看这里:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L1043

加载json文件: MyModule._extensions['.json']

加载json文件就简单多了,只需要将文件读出来解析成json就行了:

MyModule._extensions['.json'] = function (module, filename) {
  const content = fs.readFileSync(filename, 'utf8');
  module.exports = JSONParse(content);
}

exportsmodule.exports的区别

网上经常有人问,node.js里面的exportsmodule.exports到底有什么区别,其实前面我们的手写代码已经给出答案了,我们这里再就这个问题详细讲解下。exportsmodule.exports这两个变量都是通过下面这行代码注入的。

compiledWrapper.call(this.exports, this.exports, this.require, this,
    filename, dirname);

初始状态下,exports === module.exports === {}exportsmodule.exports的一个引用,如果你一直是这样使用的:

exports.a = 1;
module.exports.b = 2;

console.log(exports === module.exports);   // true

上述代码中,exportsmodule.exports都是指向同一个对象{},你往这个对象上添加属性并没有改变这个对象本身的引用地址,所以exports === module.exports一直成立。

但是如果你哪天这样使用了:

exports = {
  a: 1
}

或者这样使用了:

module.exports = {
    b: 2
}

那其实你是给exports或者module.exports重新赋值了,改变了他们的引用地址,那这两个属性的连接就断开了,他们就不再相等了。需要注意的是,你对module.exports的重新赋值会作为模块的导出内容,但是你对exports的重新赋值并不能改变模块导出内容,只是改变了exports这个变量而已,因为模块始终是module,导出内容是module.exports

循环引用

Node.js对于循环引用是进行了处理的,下面是官方例子:

a.js:

console.log('a 开始');
exports.done = false;
const b = require('./b.js');
console.log('在 a 中,b.done = %j', b.done);
exports.done = true;
console.log('a 结束');

b.js:

console.log('b 开始');
exports.done = false;
const a = require('./a.js');
console.log('在 b 中,a.done = %j', a.done);
exports.done = true;
console.log('b 结束');

main.js:

console.log('main 开始');
const a = require('./a.js');
const b = require('./b.js');
console.log('在 main 中,a.done=%j,b.done=%j', a.done, b.done);

main.js 加载 a.js 时, a.js 又加载 b.js。 此时, b.js 会尝试去加载 a.js。 为了防止无限的循环,会返回一个 a.jsexports 对象的 未完成的副本b.js 模块。 然后 b.js 完成加载,并将 exports 对象提供给 a.js 模块。

那么这个效果是怎么实现的呢?答案就在我们的MyModule._load源码里面,注意这两行代码的顺序:

MyModule._cache[filename] = module;

module.load(filename);

上述代码中我们是先将缓存设置了,然后再执行的真正的load,顺着这个思路我能来理一下这里的加载流程:

  1. main加载aa在真正加载前先去缓存中占一个位置
  2. a在正式加载时加载了b
  3. b又去加载了a,这时候缓存中已经有a了,所以直接返回a.exports,即使这时候的exports是不完整的。

总结

  1. require不是黑魔法,整个Node.js的模块加载机制都是JS实现的。
  2. 每个模块里面的exports, require, module, __filename, __dirname五个参数都不是全局变量,而是模块加载的时候注入的。
  3. 为了注入这几个变量,我们需要将用户的代码用一个函数包裹起来,拼一个字符串然后调用沙盒模块vm来实现。
  4. 初始状态下,模块里面的this, exports, module.exports都指向同一个对象,如果你对他们重新赋值,这种连接就断了。
  5. module.exports的重新赋值会作为模块的导出内容,但是你对exports的重新赋值并不能改变模块导出内容,只是改变了exports这个变量而已,因为模块始终是module,导出内容是module.exports
  6. 为了解决循环引用,模块在加载前就会被加入缓存,下次再加载会直接返回缓存,如果这时候模块还没加载完,你可能拿到未完成的exports
  7. Node.js实现的这套加载机制叫CommonJS

本文完整代码已上传GitHub:https://github.com/dennis-jiang/Front-End-Knowledges/blob/master/Examples/Node.js/Module/MyModule/index.js

参考资料

Node.js模块加载源码:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js

Node.js模块官方文档:http://nodejs.cn/api/modules.html

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

作者博文GitHub项目地址: https://github.com/dennis-jiang/Front-End-Knowledges

查看原文

赞 30 收藏 19 评论 4

蒋鹏飞 赞了文章 · 8月27日

2020年中大厂前端面试总结

前言

本次面试面试了很多家公司,包括 360,美团,猿辅导,小米,腾讯地图,头条,新东方,快手,知乎等几家公司,刚开始去面试的时候那段时间状态不是很好(基本每天都加班到很晚,周六日也没有休息的那种,而且当时心态真的是差到爆,很多平时自己很会的东西,被问到居然答不上来),基本一面就挂的那种(360,美团,猿辅导),越面越失望,后来就直接不面试了,调整自己的状态,请假休息,好好睡了两天两夜之后,调整自己的心态,开始准备面试,接下来的面试就顺利的很多。

本篇面试题总结并没有按照公司那样分类而是按照知识点进行简单分类,很多面试题问的频率非常高,所以面试的时候如果第一次问完,没回答上来或者回答的不太好,一定要在面完的第一时间记录下来并且查找资料,否则就忘记了,或者之后再看就没有了当时迫切想知道具体答案的那种心情了(有迫切的想知道某些知识的心情的时候目标很明确,学东西也会印象深刻记得牢)。

本文链接地址较多,建议查看原文,阅读体验会好一些。下面给出的答案有的是自己总结的,有的是从网上找到写的很不错的相关文章,但是这些都仅供参考,不一定是最佳的答案,如果有很好的答案,欢迎留言一起讨论互相学习,有的还没有放上合适的链接,之后会不算补充进去,毕竟每道题涉及到的内容真的挺多的。

下面题目中标记有 【高频】 的至少被问过两次,标记有 【超高频】 的基本面试的每家公司都问到了。

笔试题

  1. 【超高频】 写一个深拷贝,考虑 正则,Date这种类型的数据
  2. 【高频】 Vue自定义指令懒加载
  1. 判断DOM标签的合法性,标签的闭合,span里面不能有div,写一个匹配DOM标签的正则
  1. 替换日期格式,xxxx-yy-zz 替换成 xxx-zz-yy

可以使用 正则的捕获组来实现

var reg = /(\d{2})\.(\d{2})\/(\d{4})/
var data = '10.24/2017'
data = data.replace(reg, '$3-$1-$2')
console.log(data)//2017-10-24
  1. 【高频】 实现Promise.all, Promise.allSettled
  2. 获取一段DOM节点中标签个数最多的标签
  1. 写一个简单的diff
  1. 【高频】 手写节流
  1. 手写ES6的继承
  2. 实现一个自定义hook - usePrevious
import { useRef } from 'react';

export type compareFunction<T> = (prev: T | undefined, next: T) => boolean;

export default <T>(state: T, compare?: compareFunction<T>): T | undefined => {
  const prevRef = useRef<T>();
  const curRef = useRef<T>();

  const needUpdate = typeof compare === 'function' ? compare(curRef.current, state) : true;
  if (needUpdate) {
    prevRef.current = curRef.current;
    curRef.current = state;
  }

  return prevRef.current;
};
更多自定义hook的写法可以参考 hooks
  1. 【高频】 实现一个vue的双向绑定
其他题目的答案之前做了整理,可以在 前端学习总结-手写代码系列中看到

笔试题中的算法题

  1. 二叉树的最大深度
  1. 另一个树的子树
  1. 相同的树
  1. 翻转二叉树
  1. 【高频】 斐波那契数列
  1. 【高频】 合并两个有序数组
  1. 【高频】 打乱数组
  1. 数组区间

webpack 和babel相关的问题

  1. babel的缓存是怎么实现的
  2. webapck的HMR,怎么配置:

    • 浏览器是如何更新的
    • 如何做到页面不刷新也就就自动更新的
    • webpack-dev-server webapck-dev-middleware
相关文章:Webpack Hot Module Replacement 的原理解析
  1. 自己有没有写过ast, webpack通过什么把公共的部分抽出来的,属性配置是什么
  2. webpack怎么配置mock转发代理,mock的服务,怎么拦截转换的
  3. webapck的plugin和loader的编写, webapck plugin和loader的顺序
  4. webpack的打包构建优化,体积和速度
  5. DLLPlugin原理,为什么不直接使用压缩版本的js

HTTP

  1. 【超高频】 缓存(强缓存),如何设置缓存
  1. 【高频】 HTTP2, HTTP2的性能优化方面,真的优化很多么?
  2. 【高频】 简单请求和复杂请求
  1. 【高频】 HTTPS的整个详细过程
  1. 301和302的区别
  1. 怎么用get实现post,就是使用get方法但是参数放到request body中
  2. TCP和UDP的区别
更多可以查看 【面试题】HTTP知识点整理(附答案)

CSS

  1. 【超高频】 flex相关的问题

    • 说一下flex
    • flex: 1具体代表什么, 有什么应用场景
    • flex-basic 是什么含义

相关文章:Flex 布局教程:语法篇

  1. css var 自定义变量的兼容性
  2. 行内元素和块级元素的区别
  3. position有哪些值,分别是什么含义
  4. 盒模型
  5. CSS的实现

  6. 【高频】 实现固定宽高比(width: height = 4: 3)的div,怎么设置
  7. 【高频】 伪类和伪元素
更多可以查看【面试题】CSS知识点整理(附答案)

JavaScript

  1. 单例的应用
  2. 【超高频】 什么是闭包,闭包的应用场景
  1. 如何判断 当前浏览器是否支持webp
  2. proxy除了拦截它的getter和setter外,还能做什么
  3. 同步阻塞,异步非阻塞
  4. 弱引用,WeakMap和Map的区别
  5. 【高频】 安全相关 XSS的反射型是什么,怎么避免
  1. 【超高频】 事件循环
  1. 【超高频】 promise相关的问题, 说一下你对Promise的了解
  1. 【超高频】 浏览器渲染(从输入url到页面渲染的完成过程)
  2. 【超高频】 首屏加载优化, 通过哪些指标去衡量性能优化的
  3. canvas和svg分别是干什么的
  1. 牛客网如何监听你调到了其他页面

document.hidden,监听 docuemnt.vibibleChange事件

document.addEventListener("visibilitychange", function() {
  console.log( document.hidden );
});
  1. JS原生3种绑定事件
// 1. 在标签中直接绑定
<button onclick="handleClick()" >自定义函数</button>

// 2. 在js事件中获取dom事件绑定

<button id="btn" onclick="handleClick()" >dom事件绑定</button>
document.getElementById('btn').onclick=handleClick();

// 3. 事件监听addEventListener
elementDOM.addEventListener(eventName,handle,useCapture);
  1. 简单说一下你对 websocket 了解多少?
  1. 实现复杂数据(去重元素是对象,数组)的数组去重 (* 3)
  2. 基本数据类型有哪些, 为什么symbol是一个函数, BigInt为什么可以用来存储大整数
  3. 什么是依赖注入
  4. JS类型转换

    • String([])’‘String({})结果是什么什么? 答案是:'[object object]'
    • 其他一些很经典的类型转换考察,当时没记那么清楚,大家可以去网上看一下
  5. 富文本编辑器相关的js知识
  1. cli工具的一些实现逻辑

Vue

  1. 【高频】 vue3.0的新特性,了解compose api和react hooks的区别
  2. new Vue做了什么
  3. 双向绑定原理
  4. vue组件通信方法

React

  1. 【高频】 React hooks 相关的问题
  • 为什么引入,什么原理
  • hooks如何监听响应的,内部是如何做到只有数据修改的时候才执行函数
  • 依赖的值发生变化,需要不停地监听和绑定事件
  • render props 和HOC相比的优缺点
  • 和mixin,hoc区别在哪儿
  1. 创建ref的几种方法
  2. context怎么使用,内部原理怎么做到的
  3. 【超高频】 React新的生命周期,为什么 getDrivedStatefromProps是静态的
  4. react中TS的声明
  5. redux相关的问题
  • redux使用方法,为什么action要返回一个函数,返回一个对象可以么
  • state为什么要设计成不可变的
相关文章 为什么redux要返回一个新的state引发的血案阮一峰-Redux 入门教程(一):基本用法
  1. 【高频】 diff算法
  2. 【高频】 key的作用
  3. immer和imutable的区别
  4. 【高频】 react性能优化, fiber架构
更多可以查看 【面试题】React知识点整理(附答案)

面试结果

大概说一下本人的大概情况,本科三年左右工作经验,非计算机专业,大三下学习决定转行学习前端,过程反正挺艰辛的,一直到现在还在恶补计算机的一些知识。毕业半年左右,一个偶然的机会,进入阿里文娱(哈哈,当时面试的时候也写过面经,感兴趣的可以看一下 当时写的面经 2017面末面试总结),现在因为个人原因,决定考虑新的机会。

面试差不多最开始是中3月中旬开始准备的,中间停了差不多小一个月又开始重新面试的,到最后拿到offer差不多5月底左右,历时近3个月吧,最近抽时间把这些题目总结了一下,算是给自己一个交代吧,上面很多题目自己回答的其实很多都不是很全面,标有 【高频】【超高频】 刚开始回答的不好,后来认真学习总结了一下,之后再被问到,基本都回答得差不多

一般提到面试,肯定都会想问一下面试结果,我就大概的说一下面试结果,哈哈,其实不太想说,因为挺惨的,并没有像很多大佬一样“已拿字节阿里腾讯各大厂offer”,但是毕竟是自己的经历,无论结果如何都要坦然接受,之前没好好学习,那之后多学习就是。360,美团,猿辅导最开始的一面挂,小米二面的时候面试官告知说要求招5年以上工作经验的,所以就直接告知不符合(哈哈,可能就是跟小米没有缘分吧,刚毕业的时候面试,终面被拒说要3年以上工作经验的,现在够3年工作经验了,却又要求5年工作年限),腾讯地图和头条都是hr直接找过来的,自己并没有投递,就顺便面了一下,二面面完之后,以为挂了,后来过了一周多(可能是作为备胎把),又打电话过来约面试,其实之前面试大概了解了一下部门相关的情况,感觉不是自己想去的,并不是说部门不好,可能做的事情跟现在的情况太像了,所以想做出一些改变。当时家里面又有好多事情处理,也没有太多的时间,就直接拒绝了,这件事儿也给自己以后提个醒,投简历之前要先想明白自己想要什么样的,可以列一些目标,而不是因为急于找工作,猎头和hr直接打电话过来就直接面试。

心得

面试公司的选择

本次面试有几家公司(腾讯地图,头条,360教育,新东方等)全部都是猎头和hr直接打电话过来让面试的,当时就抱着试试的态度,就直接面试了,面试的过程中感觉可能都不太合适(所以面试的时候要问一下公司部门的具体工作内容),换工作的时候尽量找相关部门的人内推,首先内推的部门你肯定会提前有所了解,而且还可以帮忙看看进度啥的,面试过了说不定还能成为好朋友,哈哈(所以平时要多结交一些大佬,一般大佬的人脉都很广泛,而且他们很可以给你内推,甚至可以把他们自己的经验分享给你)。

总是要想好自己现在出现什么问题了,为什么打算离职,下一份工作想要什么样儿的,毕竟一份工作要干很长时间。

面试准备

推荐一些很好的文章:

好文章真的太多了,哈哈,这里就不全部放出来了,关于面试,我也准备做了一些总结,可以查看 个人博客

算法

基本每家公司多多少少都会问很多算法题,算法题对于我这种基本没什么基础的人来说,碰到了就很恐惧,但是没有其他的办法,就是两个字 “多练”,这里推荐我看过的几篇文章:

其他的一些想法,之前也写了一篇文章 关于面试的一点心得,感兴趣的也可以看一下。也非常欢迎大家关注我的公众号 【牧码的星星】以及加我微信进行交流,公众号也会偶尔分享一些学习的一些心得。

查看原文

赞 143 收藏 118 评论 12

蒋鹏飞 赞了文章 · 8月24日

访问github太慢?我写了一个开源小工具一键变快

file

前言

GitHub应该是广大开发者最常去的站点,这里面有大量的优秀项目,是广大开发者寻找资源,交友学习的好地方。尤其是前段时间GitHub公布了一项代码存档计划——Arctic Code Vault,要把代码埋入地下250米深的永久冻土层,可以将代码保存一千年。此外,GitHub 还为开发者在配置文件中设计了纪念徽章。

想想自己的代码可以作为人类的技术瑰宝被保存一千年,是不是有点自豪呢。

好了,言归正传。

虽然GitHub没有被Q,但是由于CDN服务器都在国外,所以国内访问GitHub的速度实在是慢的一匹,有时候经常页面刷不出,在我获取知识的道路上增加了重重的阻碍。

所以,我肝了3小时,写了一个在不用T子的情况下,加速GitHub访问速度的小工具,最后会分享给大家。

同时,这篇文章也会分享其他加速GitHub访问的方法。算是一个比较全的整理吧。

Let's get it!

自动生成最快访问host的小工具

GitHub在国内访问速度慢的原因其实有很多,但最主要的原因就是GitHub的分发加速网络域名遭到DNS的污染。为了解决这个问题,网上有很多文章提供了一个解决方案,就是通过修改Hosts文件,绕过国内的DNS解析,直接访问GitHub的CDN节点,从而达到加速的目的。

但是我看大多数关于此方法的介绍,只提供3个github的相关域名,而且需要在ipaddress.com 一个个去查,根据查到的ip,再去自己ping,肉眼选取最快的ip,自行编辑成IP+域名格式,贴到hosts文件里。

其实GitHub用到相关域名有很多,我整理了下,一共有十几个

github.global.ssl.fastly.net
github.com
assets-cdn.github.com
documentcloud.github.com
gist.github.com
help.github.com
nodeload.github.com
codeload.github.com
raw.github.com
status.github.com
training.github.com
avatars0.githubusercontent.com
avatars1.githubusercontent.com
avatars2.githubusercontent.com
avatars3.githubusercontent.com

这要是一个个去查,一个个去选取,也是挺麻烦的。

为此我写了一个工具,能自动的根据你当前ip,去寻找这十几个域名所对应最快的CDN节点,如果一个ip对应多个CDN节点,工具会自动帮你去ping 10次,取到平均值最小的CDN的IP地址。

你需要做的,只是把最终生成的结果贴到你的hosts文件中即可。

这个小工具,关注「元人部落」输入github即可获取到。

用法很简单,只需要执行以下命令即可运行

java -jar githubhost.jar

运行起来后,浏览器输入127.0.0.1:8880即可自动进行根据你当前Ip进行分析:

file

分析大概需要十几秒,进度条会自动刷新,等进度条满了之后,即可看到生成内容:

file

每个地区每个运营商可能运行出来的都不一样,所以得出结果后,你就可以把这段内容追加到你hosts文件中(如果不知道hosts存放位置,可以自行baidu),然后根据提示让hosts文件生效。

指定了CDN的访问地址,可以让你的github访问至少无卡顿了。

码云GitHub镜像站

码云提供了一个”码云急速下载“站,每天从github上同步一些项目。

https://gitee.com/mirrors

个人感觉应该不是所有的github项目都会同步过来,看仓库数量,有大概15k的项目

file

如果你想clone一些项目去研究,可以先在这里找找有没有。码云因为是国内开源项目站点,git clone速度自然不用担心,但是很可惜的是

1.这个镜像站点不是所有的github项目,不过大多数热门项目都会有

2.issue和release包也没有,只有代码

3.有一天的延迟。即你看到的是一天前的项目状态

4.因为不是github,所以你也没法通过这个push到github上的项目

GitHub镜像站

这个镜像站为:

https://github.com.cnpmjs.org/

进入之后,完全和github没有任何区别,访问也很快。

尤其是clone代码,那是飞快啊。。。

比如,你原先要clone,这样写

git clone https://github.com/kubernetes/kubernetes.git

现在改成:

git clone https://github.com.cnpmjs.org/kubernetes/kubernetes.git

试一下:

file

这个速度,应该无欲无求了吧。。。

不过这个方法可惜的是:

1.这个镜像站很不稳定,你时常会看到:

file

2.你每次clone还需要自己去修改url,有点不方便

3.你依旧没法push

GitClone站点

在寻找解决之道的途中,我又发现一个站点:gitclone

https://gitclone.com/

file

这是一个GitHub的缓存加速节点,也大约缓存了15k个项目,但是gitclone单独做了一个站点,里面可以进行搜索项目,甚至于还可以创建仓库。

gitclone的clone提供了多种方式来clone

file

但是搜索到的项目,最终查看还是跳转到GitHub相应的页面。

所以其实和gitee镜像站都差不多。换汤不换药,问题和之前几个镜像站点差不多,不过你只是要clone,还是不错的选择。

总结

其实在不用T子的情况下,方式无非就两种:

  1. 修改hosts,直接访问最快的CDN节点,这种方式优势在于原汁原味。
  2. 通过镜像去访问和clone,这种方式优势在于clone的速度。

个人推荐如果主要浏览为主,还是用上文推荐的工具去生成hosts进行配置,毕竟原汁原味,clone大项目的话,可以考虑以上镜像站点去加速下载。

关注作者

最后把这个开源工具分享给大家,关注「元人部落」公众号,并回复github即可获取到这个工具jar包。启动后访问127.0.0.1:8880端口即可自动生成。

一个坚持做原创的技术科技分享号,希望你能关注我,我每周会出一篇实用的原创技术文章,陪着你一起走,不再害怕。

img

查看原文

赞 19 收藏 12 评论 1

蒋鹏飞 发布了文章 · 8月24日

不知道怎么封装代码?看看这几种设计模式吧!

为什么要封装代码?

我们经常听说:“写代码要有良好的封装,要高内聚,低耦合”。那怎样才算良好的封装,我们为什么要封装呢?其实封装有这样几个好处:

  1. 封装好的代码,内部变量不会污染外部。
  2. 可以作为一个模块给外部调用。外部调用者不需要知道实现的细节,只需要按照约定的规范使用就行了。
  3. 对扩展开放,对修改关闭,即开闭原则。外部不能修改模块,既保证了模块内部的正确性,又可以留出扩展接口,使用灵活。

怎么封装代码?

JS生态已经有很多模块了,有些模块封装得非常好,我们使用起来很方便,比如jQuery,Vue等。如果我们仔细去看这些模块的源码,我们会发现他们的封装都是有规律可循的。这些规律总结起来就是设计模式,用于代码封装的设计模式主要有工厂模式创建者模式单例模式原型模式四种。下面我们结合一些框架源码来看看这四种设计模式:

工厂模式

工厂模式的名字就很直白,封装的模块就像一个工厂一样批量的产出需要的对象。常见工厂模式的一个特征就是调用的时候不需要使用new,而且传入的参数比较简单。但是调用次数可能比较频繁,经常需要产出不同的对象,频繁调用时不用new也方便很多。一个工厂模式的代码结构如下所示:

function factory(type) {
  switch(type) {
    case 'type1':
      return new Type1();
    case 'type2':
      return new Type2();
    case 'type3':
      return new Type3();
  }
}

上述代码中,我们传入了type,然后工厂根据不同的type来创建不同的对象。

实例: 弹窗组件

下面来看看用工厂模式的例子,假如我们有如下需求:

我们项目需要一个弹窗,弹窗有几种:消息型弹窗,确认型弹窗,取消型弹窗,他们的颜色和内容可能是不一样的。

针对这几种弹窗,我们先来分别建一个类:

function infoPopup(content, color) {}
function confirmPopup(content, color) {}
function cancelPopup(content, color) {}

如果我们直接使用这几个类,就是这样的:

let infoPopup1 = new infoPopup(content, color);
let infoPopup2 = new infoPopup(content, color);
let confirmPopup1 = new confirmPopup(content, color);
...

每次用的时候都要去new对应的弹窗类,我们用工厂模式改造下,就是这样:

// 新加一个方法popup把这几个类都包装起来
function popup(type, content, color) {
  switch(type) {
    case 'infoPopup':
      return new infoPopup(content, color);
    case 'confirmPopup':
      return new confirmPopup(content, color);
    case 'cancelPopup':
      return new cancelPopup(content, color);
  }
}

然后我们使用popup就不用new了,直接调用函数就行:

let infoPopup1 = popup('infoPopup', content, color); 

改造成面向对象

上述代码虽然实现了工厂模式,但是switch始终感觉不是很优雅。我们使用面向对象改造下popup,将它改为一个类,将不同类型的弹窗挂载在这个类上成为工厂方法:

function popup(type, content, color) {
  // 如果是通过new调用的,返回对应类型的弹窗
  if(this instanceof popup) {
    return new this[type](content, color);
  } else {
    // 如果不是new调用的,使用new调用,会走到上面那行代码
    return new popup(type, content, color);
  }
}

// 各种类型的弹窗全部挂载在原型上成为实例方法
popup.prototype.infoPopup = function(content, color) {}
popup.prototype.confirmPopup = function(content, color) {}
popup.prototype.cancelPopup = function(content, color) {}

封装成模块

这个popup不仅仅让我们调用的时候少了一个new,他其实还把相关的各种弹窗都封装在了里面,这个popup可以直接作为模块export出去给别人调用,也可以挂载在window上作为一个模块给别人调用。因为popup封装了弹窗的各种细节,即使以后popup内部改了,或者新增了弹窗类型,或者弹窗类的名字变了,只要保证对外的接口参数不变,对外面都没有影响。挂载在window上作为模块可以使用自执行函数:

(function(){
     function popup(type, content, color) {
    if(this instanceof popup) {
      return new this[type](content, color);
    } else {
      return new popup(type, content, color);
    }
  }

  popup.prototype.infoPopup = function(content, color) {}
  popup.prototype.confirmPopup = function(content, color) {}
  popup.prototype.cancelPopup = function(content, color) {}
  
  window.popup = popup;
})()

// 外面就直接可以使用popup模块了
let infoPopup1 = popup('infoPopup', content, color); 

jQuery的工厂模式

jQuery也是一个典型的工厂模式,你给他一个参数,他就给你返回符合参数DOM对象。那jQuery这种不用new的工厂模式是怎么实现的呢?其实就是jQuery内部帮你调用了new而已,jQuery的调用流程简化了就是这样:

(function(){
  var jQuery = function(selector) {
    return new jQuery.fn.init(selector);   // new一下init, init才是真正的构造函数
  }

  jQuery.fn = jQuery.prototype;     // jQuery.fn就是jQuery.prototype的简写

  jQuery.fn.init = function(selector) {
    // 这里面实现真正的构造函数
  }

  // 让init和jQuery的原型指向同一个对象,便于挂载实例方法
  jQuery.fn.init.prototype = jQuery.fn;  

  // 最后将jQuery挂载到window上
  window.$ = window.jQuery = jQuery;
})();

上述代码结构来自于jQuery源码,从中可以看出,你调用时省略的new在jQuery里面帮你调用了,目的是为了使大量调用更方便。但是这种结构需要借助一个init方法,最后还要将jQueryinit的原型绑在一起,其实还有一种更加简便的方法可以实现这个需求:

var jQuery = function(selector) {
  if(!(this instanceof jQuery)) {
    return new jQuery(selector);
  }
  
  // 下面进行真正构造函数的执行
}

上述代码就简洁多了,也可以实现不用new直接调用,这里利用的特性是this在函数被new调用时,指向的是new出来的对象,new出来的对象自然是类的instance,这里的this instanceof jQuery就是true。如果是普通调用,他就是false,我们就帮他new一下。

建造者模式

建造者模式是用于比较复杂的大对象的构建,比如VueVue内部包含一个功能强大,逻辑复杂的对象,在构建的时候也需要传很多参数进去。像这种需要创建的情况不多,创建的对象本身又很复杂的时候就适用建造者模式。建造者模式的一般结构如下:

function Model1() {}   // 模块1
function Model2() {}   // 模块2

// 最终使用的类
function Final() {
  this.model1 = new Model1();
  this.model2 = new Model2();
}

// 使用时
var obj = new Final();

上述代码中我们最终使用的是Final,但是Final里面的结构比较复杂,有很多个子模块,Final就是将这些子模块组合起来完成功能,这种需要精细化构造的就适用于建造者模式。

实例:编辑器插件

假设我们有这样一个需求:

写一个编辑器插件,初始化的时候需要配置大量参数,而且内部的功能很多很复杂,可以改变字体颜色和大小,也可以前进后退。

一般一个页面就只有一个编辑器,而且里面的功能可能很复杂,可能需要调整颜色,字体等。也就是说这个插件内部可能还会调用其他类,然后将他们组合起来实现功能,这就适合建造者模式。我们来分析下做这样一个编辑器需要哪些模块:

  1. 编辑器本身肯定需要一个类,是给外部调用的接口
  2. 需要一个控制参数初始化和页面渲染的类
  3. 需要一个控制字体的类
  4. 需要一个状态管理的类
// 编辑器本身,对外暴露
function Editor() {
  // 编辑器里面就是将各个模块组合起来实现功能
  this.initer = new HtmlInit();
  this.fontController = new FontController();
  this.stateController = new StateController(this.fontController);
}

// 初始化参数,渲染页面
function HtmlInit() {
  
}
HtmlInit.prototype.initStyle = function() {}     // 初始化样式
HtmlInit.prototype.renderDom = function() {}     // 渲染DOM

// 字体控制器
function FontController() {
  
}
FontController.prototype.changeFontColor = function() {}    // 改变字体颜色
FontController.prototype.changeFontSize = function() {}     // 改变字体大小

// 状态控制器
function StateController(fontController) {
  this.states = [];       // 一个数组,存储所有状态
  this.currentState = 0;  // 一个指针,指向当前状态
  this.fontController = fontController;    // 将字体管理器注入,便于改变状态的时候改变字体
}
StateController.prototype.saveState = function() {}     // 保存状态
StateController.prototype.backState = function() {}     // 后退状态
StateController.prototype.forwardState = function() {}     // 前进状态

上面的代码其实就将一个编辑器插件的架子搭起来了,具体实现功能就是往这些方法里面填入具体的内容就行了,其实就是各个模块的相互调用,比如我们要实现后退状态的功能就可以这样写:

StateController.prototype.backState = function() {
  var state = this.states[this.currentState - 1];  // 取出上一个状态
  this.fontController.changeFontColor(state.color);  // 改回上次颜色
  this.fontController.changeFontSize(state.size);    // 改回上次大小
}

单例模式

单例模式适用于全局只能有一个实例对象的场景,单例模式的一般结构如下:

function Singleton() {}

Singleton.getInstance = function() {
  if(this.instance) {
    return this.instance;
  }
  
  this.instance = new Singleton();
  return this.instance;
}

上述代码中,Singleton类挂载了一个静态方法getInstance,如果要获取实例对象只能通过这个方法拿,这个方法会检测是不是有现存的实例对象,如果有就返回,没有就新建一个。

实例:全局数据存储对象

假如我们现在有这样一个需求:

我们需要对一个全局的数据对象进行管理,这个对象只能有一个,如果有多个会导致数据不同步。

这个需求要求全局只有一个数据存储对象,是典型的适合单例模式的场景,我们可以直接套用上面的代码模板,但是上面的代码模板获取instance必须要调getInstance才行,要是某个使用者直接调了Singleton()或者new Singleton()就会出问题,这次我们换一种写法,让他能够兼容Singleton()new Singleton(),使用起来更加傻瓜化:

function store() {
  if(store.instance) {
    return store.instance;
  }
  
  store.instance = this;
}

上述代码支持使用new store()的方式调用,我们使用了一个静态变量instance来记录是否有进行过实例化,如果实例化了就返回这个实例,如果没有实例化说明是第一次调用,那就把this赋给这个这个静态变量,因为是使用new调用,这时候的this指向的就是实例化出来的对象,并且最后会隐式的返回this

如果我们还想支持store()直接调用,我们可以用前面工厂模式用过的方法,检测this是不是当前类的实例,如果不是就帮他用new调用就行了:

function store() {
  // 加一个instanceof检测
  if(!(this instanceof store)) {
    return new store();
  }
  
  // 下面跟前面一样的
  if(store.instance) {
    return store.instance;
  }
  
  store.instance = this;
}

然后我们用两种方式调用来检测下:

image-20200521154322364

实例:vue-router

vue-router其实也用到了单例模式,因为如果一个页面有多个路由对象,可能造成状态的冲突,vue-router的单例实现方式又有点不一样,下列代码来自vue-router源码

let _Vue;

function install(Vue) {
  if (install.installed && _Vue === Vue) return;
  install.installed = true

  _Vue = Vue
}

每次我们调用vue.use(vueRouter)的时候其实都会去执行vue-router模块的install方法,如果用户不小心多次调用了vue.use(vueRouter)就会造成install的多次执行,从而产生不对的结果。vue-routerinstall在第一次执行时,将installed属性写成了true,并且记录了当前的Vue,这样后面在同一个Vue里面再次执行install就会直接return了,这也是一种单例模式。

可以看到我们这里三种代码都是单例模式,他们虽然形式不一样,但是核心思想都是一样的,都是用一个变量来标记代码是否已经执行过了,如果执行过了就返回上次的执行结果,这样就保证了多次调用也会拿到一样的结果。

原型模式

原型模式最典型的应用就是JS本身啊,JS的原型链就是原型模式。JS中可以使用Object.create指定一个对象作为原型来创建对象:

const obj = {
  x: 1,
  func: () => {}
}

// 以obj为原型创建一个新对象
const newObj = Object.create(obj);

console.log(newObj.__proto__ === obj);    // true
console.log(newObj.x);    // 1

上述代码我们将obj作为原型,然后用Object.create创建的新对象都会拥有这个对象上的属性和方法,这其实就算是一种原型模式。还有JS的面向对象其实更加是这种模式的体现,比如JS的继承可以这样写:

function Parent() {
  this.parentAge = 50;
}
function Child() {}

Child.prototype = new Parent();
Child.prototype.constructor = Child;      // 注意重置constructor

const obj = new Child();
console.log(obj.parentAge);    // 50

这里的继承其实就是让子类Child.prototype.__proto__的指向父类的prototype,从而获取父类的方法和属性。JS中面向对象的内容较多,我这里不展开了,有一篇文章专门讲这个问题

总结

  1. 很多用起来顺手的开源库都有良好的封装,封装可以将内部环境和外部环境隔离,外部用起来更顺手。
  2. 针对不同的场景可以有不同的封装方案。
  3. 需要大量产生类似实例的组件可以考虑用工厂模式来封装。
  4. 内部逻辑较复杂,外部使用时需要的实例也不多,可以考虑用建造者模式来封装。
  5. 全局只能有一个实例的需要用单例模式来封装。
  6. 新老对象之间可能有继承关系的可以考虑用原型模式来封装,JS本身就是一个典型的原型模式。
  7. 使用设计模式时不要生搬硬套代码模板,更重要的是掌握思想,同一个模式在不同的场景可以有不同的实现方案。

感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

本文主要素材来自于网易高级前端开发工程师微专业唐磊老师的设计模式视频课程。

作者博文GitHub项目地址: https://github.com/dennis-jiang/Front-End-Knowledges

查看原文

赞 55 收藏 46 评论 0

蒋鹏飞 发布了文章 · 8月19日

JavaScript中的函数式编程

函数式编程

函数式编程是一种编程范式,是一种构建计算机程序结构和元素的风格,它把计算看作是对数学函数的评估,避免了状态的变化和数据的可变,与函数式编程相对的是命令式编程。我们有这样一个需求,给数组的每个数字加一:

// 数组每个数字加一, 命令式编程
let arr = [1, 2, 3, 4];
let newArr = [];
for(let i = 0; i < arr.length; i++){
  newArr.push(arr[i] + 1);
}

console.log(newArr); // [2, 3, 4, 5]

这段代码结果没有问题,但是没法重用。我们换一个思维,这里面包含的操作其实就两个,一个是遍历数组,一个是成员加一。我们把这两个方法拆出来:

// 先拆加一出来
let add1 = x => x +1;

// 然后拆遍历方法出来,通过遍历返回一个操作后的新数组
// fn是我们需要对每个数组想进行的操作
let createArr = (arr, fn) => {
  const newArr = [];
  for(let i = 0; i < arr.length; i++){
    newArr.push(fn(arr[i]));
  }

  return newArr;
} 

// 用这两个方法来得到我们期望的结果
const arr = [1, 2, 3, 4];
const newArr = createArr(arr, add1);
console.log(newArr);  // [2, 3, 4, 5], 结果仍然是对的

这样拆分后,如果我们下次的需求是对数组每个元素乘以2,我们只需要写一个乘法的方法,然后复用之前的代码就行:

let multiply2 = x => x * 2;

// 调用之前的createArr
const arr2 = [1, 2, 3, 4];
const newArr2 = createArr(arr2, multiply2);
console.log(newArr2);  // [2, 4, 6, 8], 结果是对的

事实上我们的加一函数只能加一,也不好复用,它还可以继续拆:

// 先写一个通用加法,他接收第一个加数,返回一个方法
// 返回的这个方法接收第二个加数,第一个加数是上层方法的a
// 这样当我们需要计算1+2是,就是add(1)(2)
let add = (a) => {
  return (b) => {
    return a + b;
  }
}

// 我们也可以将返回的函数赋给一个变量,这个变量也就变成一个能特定加a的一个方法
let add1 = add(1);

let res = add1(4); 
console.log(res);  // 5

所以函数式编程就是将程序分解为一些更可重用、更可靠且更易于理解的部分,然后将他们组合起来,形成一个更易推理的程序整体。

纯函数

纯函数是指一个函数,如果它的调用参数相同,则永远返回相同的结果。它不依赖于程序执行期间函数外部任何状态或数据的变化,只依赖于其输入参数。同时函数的运行也不改变任何外部数据,它只通过它的返回值与外部通讯。

下面这个函数就不是纯函数,因为函数内部需要的discount需要从外部获取:

let discount = 0.8;
const calPrice = price => price * discount;
let price = calPrice(200);  // 160

// 当discount变了,calPrice传同样额参数,结果不一样,所以不纯
discount = 0.9;
price = calPrice(200);  // 180

要改为纯函数也很简单,将discount作为参数传递进去就行了

const calPrice = (price, discount) => price * discount;

纯函数可以保证代码的稳定性,因为相同的输入永远会得到相同结果。不纯的函数可能会带来副作用。

函数副作用

函数副作用是指调用函数时除了返回函数值之外,还对主调用函数产生附加的影响,比如修改全局变量或者外部变量,或者修改参数。这可能会带来难以查找的问题并降低代码的可读性。下面的foo就有副作用,当后面有其他地方需要使用a,可能就会拿到一个被污染的值

let a = 5;
let foo = () => a = a * 10;
foo();
console.log(a); // 50

除了我们自己写的函数有副作用外,一些原生API也可能有副作用,我们写代码时应该注意:

image-20200109232215022

我们的目标是尽可能的减少副作用,将函数写为纯函数,下面这个不纯的函数使用了new Date,每次运行结果不一样,是不纯的:

image-20200109232541307

要给为纯函数可以将依赖注入进去,所谓依赖注入就是将不纯的部分提取出来作为参数,这样我们可以让副作用代码集中在外部,远离核心代码,保证核心代码的稳定性

// 依赖注入
const foo = (d, log, something) => {
  const dt = d.toISOString();
  return log(`${dt}: ${something}`);
}

const something = 'log content';
const d = new Date();
const log = console.log.bind(console);
foo(d, log, something);

所以减少副作用一般的方法就是:

1. 函数使用参数进行运算,不要修改参数
2. 函数内部不修改外部变量
3. 运算结果通过返回值返回给外部

可变性和不可变性

  • 可变性:指一个变量创建以后可以任意修改
  • 不可变性: 指一个变量被创建后永远不会发生改变,不可变性是函数式编程的核心概念

下面是一个可变的例子:

image-20200109233313733

如果我们一定要修改这个参数,我们应该将这个参数进行深拷贝后再操作,这样就不会修改参数了:

image-20200109233515929

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

作者博文GitHub项目地址: https://github.com/dennis-jiang/Front-End-Knowledges

查看原文

赞 23 收藏 21 评论 2

蒋鹏飞 发布了文章 · 8月17日

从Generator入手读懂co模块源码

这篇文章是讲JS异步原理和实现方式的第四篇文章,前面三篇是:

setTimeout和setImmediate到底谁先执行,本文让你彻底理解Event Loop

从发布订阅模式入手读懂Node.js的EventEmitter源码

手写一个Promise/A+,完美通过官方872个测试用例

本文主要会讲Generator的运用和实现原理,然后我们会去读一下co模块的源码,最后还会提一下async/await。

本文全部例子都在GitHub上:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/JavaScript/Generator

Generator

异步编程一直是JS的核心之一,业界也是一直在探索不同的解决方法,从“回调地狱”到发布订阅模式,再到Promise,都是在优化异步编程。尽管Promise已经很优秀了,也不会陷入“回调地狱”,但是嵌套层数多了也会有一连串的then,始终不能像同步代码那样直接往下写就行了。Generator是ES6引入的进一步改善异步编程的方案,下面我们先来看看基本用法。

基本用法

Generator的中文翻译是“生成器”,其实他要干的事情也是一个生成器,一个函数如果加了*,他就会变成一个生成器函数,他的运行结果会返回一个迭代器对象,比如下面的代码:

// gen是一个生成器函数
function* gen() {
  let a = yield 1;
  let b = yield a + 2;
  yield b + 3;
}

let itor = gen();   // 生成器函数运行后会返回一个迭代器对象,即itor。

next

ES6规范中规定迭代器必须有一个next方法,这个方法会返回一个对象,这个对象具有donevalue两个属性,done表示当前迭代器内容是否已经执行完,执行完为true,否则为falsevalue表示当前步骤返回的值。在generator具体运用中,每次遇到yield关键字都会暂停执行,当调用迭代器的next时,会将yield后面表达式的值作为返回对象的value,比如上面生成器的执行结果如下:

image-20200419153257750

我们可以看到第一次调next返回的就是第一个yeild后面表达式的值,也就是1。需要注意的是,整个迭代器目前暂停在了第一个yield这里,给变量a赋值都没执行,要调用下一个next的时候才会给变量a赋值,然后一直执行到第二个yield。那应该给a赋什么值呢?从代码来看,a的值应该是yield语句的返回值,但是yield本身是没有返回值的,或者说返回值是undefined,如果要给a赋值需要下次调next的时候手动传进去,我们这里传一个4,4就会作为上次yield的返回值赋给a:

image-20200419154159553

可以看到第二个yield后面的表达式a + 2的值是6,这是因为我们传进去的4被作为上一个yield的返回值了,然后计算a + 2自然就是6了。

我们继续next,把这个迭代器走完:

image-20200419155225702

上图是接着前面运行的,图中第一个next返回的valueNaN是因为我们调next的时候没有传参数,也就是说bundefinedundefined + 3就为NaN了 。最后一个next其实是把函数体执行完了,这时候的value应该是这个函数return的值,但是因为我们没有写return,默认就是return undefined了,执行完后done会被置为true

throw

迭代器还有个方法是throw,这个方法可以在函数体外部抛出错误,然后在函数里面捕获,还是上面那个例子:

function* gen() {
  let a = yield 1;
  let b = yield a + 2;
  yield b + 3;
}

let itor = gen();  

我们这次不用next执行了,直接throw错误出来:

image-20200419160330384

这个错误因为我们没有捕获,所以直接抛到最外层来了,我们可以在函数体里面捕获他,稍微改下:

function* gen() {
  try {
    let a = yield 1;
    let b = yield a + 2;
    yield b + 3;
  } catch (e) {
    console.log(e);
  }
}

let itor = gen();  

然后再来throw下:

image-20200419160604004

这个图可以看出来,错误在函数里里面捕获了,走到了catch里面,这里面只有一个console同步代码,整个函数直接就运行结束了,所以done变成true了,当然catch里面可以继续写yield然后用next来执行。

return

迭代器还有个return方法,这个方法就很简单了,他会直接终止当前迭代器,将done置为true,这个方法的参数就是迭代器的value,还是上面的例子:

function* gen() {
  let a = yield 1;
  let b = yield a + 2;
  yield b + 3;
}

let itor = gen();  

这次我们直接调用return:

image-20200419161105691

yield*

简单理解,yield*就是在生成器里面调用另一个生成器,但是他并不会占用一个next,而是直接进入被调用的生成器去运行。

function* gen() {
  let a = yield 1;
  let b = yield a + 2;
}

function* gen2() {
  yield 10 + 5;
  yield* gen();
}

let itor = gen2();  

上面代码我们第一次调用next,值自然是10 + 5,即15,然后第二次调用next,其实就走到了yield*了,这其实就相当于调用了gen,然后执行他的第一个yield,值就是1。

image-20200419161624637

协程

其实Generator就是实现了协程,协程是一个比线程还小的概念。一个进程可以有多个线程,一个线程可以有多个协程,但是一个线程同时只能有一个协程在运行。这个意思就是说如果当前协程可以执行,比如同步代码,那就执行他,如果当前协程暂时不能继续执行,比如他是一个异步读文件的操作,那就将它挂起,然后去执行其他协程,等这个协程结果回来了,可以继续了再来执行他。yield其实就相当于将当前任务挂起了,下次调用再从这里开始。协程这个概念其实很多年前就已经被提出来了,其他很多语言也有自己的实现。Generator相当于JS实现的协程。

异步应用

前面讲了Generator的基本用法,我们用它来处理一个异步事件看看。我还是使用前面文章用到过的例子,三个网络请求,请求3依赖请求2的结果,请求2依赖请求1的结果,如果使用回调是这样的:

const request = require("request");

request('https://www.baidu.com', function (error, response) {
  if (!error && response.statusCode == 200) {
    console.log('get times 1');

    request('https://www.baidu.com', function(error, response) {
      if (!error && response.statusCode == 200) {
        console.log('get times 2');

        request('https://www.baidu.com', function(error, response) {
          if (!error && response.statusCode == 200) {
            console.log('get times 3');
          }
        })
      }
    })
  }
});

我们这次使用Generator来解决“回调地狱”:

const request = require("request");

function* requestGen() {
  function sendRequest(url) {
    request(url, function (error, response) {
      if (!error && response.statusCode == 200) {
        console.log(response.body);

        // 注意这里,引用了外部的迭代器itor
        itor.next(response.body);
      }
    })
  }

  const url = 'https://www.baidu.com';

  // 使用yield发起三个请求,每个请求成功后再继续调next
  const r1 = yield sendRequest(url);
  console.log('r1', r1);
  const r2 = yield sendRequest(url);
  console.log('r2', r2);
  const r3 = yield sendRequest(url);
  console.log('r3', r3);
}

const itor = requestGen();

// 手动调第一个next
itor.next();

这个例子中我们在生成器里面写了一个请求方法,这个方法会去发起网络请求,每次网络请求成功后又继续调用next执行后面的yield,最后是在外层手动调一个next触发这个流程。这其实就类似一个尾调用,这样写可以达到效果,但是在requestGen里面引用了外面的迭代器itor,耦合很高,而且不好复用。

thunk函数

为了解决前面说的耦合高,不好复用的问题,就有了thunk函数。thunk函数理解起来有点绕,我先把代码写出来,然后再一步一步来分析它的执行顺序:

function Thunk(fn) {
  return function(...args) {
    return function(callback) {
      return fn.call(this, ...args, callback)
    }
  }
}

function run(fn) {
  let gen = fn();
  
  function next(err, data) {
    let result = gen.next(data);
    
    if(result.done) return;
    
    result.value(next);
  }
  
  next();
}

// 使用thunk方法
const request = require("request");
const requestThunk = Thunk(request);

function* requestGen() {
  const url = 'https://www.baidu.com';
  
  let r1 = yield requestThunk(url);
  console.log(r1.body);
  
  let r2 = yield requestThunk(url);
  console.log(r2.body);
  
  let r3 = yield requestThunk(url);
  console.log(r3.body);
}

// 启动运行
run(requestGen);

这段代码里面的Thunk函数返回了好几层函数,我们从他的使用入手一层一层剥开看:

  1. requestThunk是Thunk运行的返回值,也就是第一层返回值,参数是request,也就是:

    function(...args) {
      return function(callback) {
        return request.call(this, ...args, callback);   // 注意这里调用的是request
      }
    }
  2. run函数的参数是生成器,我们看看他到底干了啥:

    1. run里面先调用生成器,拿到迭代器gen,然后自定义了一个next方法,并调用这个next方法,为了便于区分,我这里称这个自定义的next为局部next
    2. 局部next会调用生成器的next,生成器的next其实就是yield requestThunk(url),参数是我们传进去的url,这就调到我们前面的那个方法,这个yield返回的value其实是:

      function(callback) {
        return request.call(this, url, callback);   
      }
    3. 检测迭代器是否已经迭代完毕,如果没有,就继续调用第二步的这个函数,这个函数其实才真正的去request,这时候传进去的参数是局部next,局部next也作为了request的回调函数。
    4. 这个回调函数在执行时又会调gen.next,这样生成器就可以继续往下执行了,同时gen.next的参数是回调函数的data,这样,生成器里面的r1其实就拿到了请求的返回值。

Thunk函数就是这样一种可以自动执行Generator的函数,因为Thunk函数的包装,我们在Generator里面可以像同步代码那样直接拿到yield异步代码的返回值。

co模块

co模块是一个很受欢迎的模块,他也可以自动执行Generator,他的yield后面支持thunk和Promise,我们先来看看他的基本使用,然后再去分析下他的源码。
官方GitHub:https://github.com/tj/co

基本使用

支持thunk

前面我们讲了thunk函数,我们还是从thunk函数开始。代码还是用我们前面写的thunk函数,但是因为co支持的thunk是只接收回调函数的函数形式,我们使用时需要调整下:

// 还是之前的thunk函数
function Thunk(fn) {
  return function(...args) {
    return function(callback) {
      return fn.call(this, ...args, callback)
    }
  }
}

// 将我们需要的request转换成thunk
const request = require('request');
const requestThunk = Thunk(request);

// 转换后的requestThunk其实可以直接用了
// 用法就是 requestThunk(url)(callback)
// 但是我们co接收的thunk是 fn(callback)形式
// 我们转换一下
// 这时候的baiduRequest也是一个函数,url已经传好了,他只需要一个回调函数做参数就行
// 使用就是这样:baiduRequest(callback)
const baiduRequest = requestThunk('https://www.baidu.com');

// 引入co执行, co的参数是一个Generator
// co的返回值是一个Promise,我们可以用then拿到他的结果
const co = require('co');
co(function* () {
  const r1 = yield baiduRequest;
  const r2 = yield baiduRequest;
  const r3 = yield baiduRequest;
  
  return {
    r1,
    r2,
    r3,
  }
}).then((res) => {
  // then里面就可以直接拿到前面返回的{r1, r2, r3}
  console.log(res);
});

支持Promise

其实co官方是建议yield后面跟Promise的,虽然支持thunk,但是未来可能会移除。使用Promise,我们代码写起来其实更简单,直接用fetch就行,不用包装Thunk。

const fetch = require('node-fetch');
const co = require('co');
co(function* () {
  // 直接用fetch,简单多了,fetch返回的就是Promise
  const r1 = yield fetch('https://www.baidu.com');
  const r2 = yield fetch('https://www.baidu.com');
  const r3 = yield fetch('https://www.baidu.com');
  
  return {
    r1,
    r2,
    r3,
  }
}).then((res) => {
  // 这里同样可以拿到{r1, r2, r3}
  console.log(res);
});

源码分析

本文的源码分析基于co模块4.6.0版本,源码:https://github.com/tj/co/blob/master/index.js

仔细看源码会发现他代码并不多,总共两百多行,一半都是在进行yield后面的参数检测和处理,检测他是不是Promise,如果不是就转换为Promise,所以即使你yield后面传的thunk,他还是会转换成Promise处理。转换Promise的代码相对比较独立和简单,我这里不详细展开了,这里主要还是讲一讲核心方法co(gen)。下面是我复制的去掉了注释的简化代码:

function co(gen) {
  var ctx = this;
  var args = slice.call(arguments, 1);

  return new Promise(function(resolve, reject) {
    if (typeof gen === 'function') gen = gen.apply(ctx, args);
    if (!gen || typeof gen.next !== 'function') return resolve(gen);

    onFulfilled();

    function onFulfilled(res) {
      var ret;
      try {
        ret = gen.next(res);
      } catch (e) {
        return reject(e);
      }
      next(ret);
      return null;
    }

    function onRejected(err) {
      var ret;
      try {
        ret = gen.throw(err);
      } catch (e) {
        return reject(e);
      }
      next(ret);
    }

    function next(ret) {
      if (ret.done) return resolve(ret.value);
      var value = toPromise.call(ctx, ret.value);
      if (value && isPromise(value)) return value.then(onFulfilled, onRejected);
      return onRejected(new TypeError('You may only yield a function, promise, generator, array, or object, '
        + 'but the following object was passed: "' + String(ret.value) + '"'));
    }
  });
}
  1. 从整体结构看,co的参数是一个Generator,返回值是一个Promise,几乎所有逻辑代码都在这个Promise里面,这也是我们使用时用then拿结果的原因。
  2. Promise里面先把Generator拿出来执行,得到一个迭代器gen
  3. 手动调用一次onFulfilled,开启迭代

    1. onFulfilled接收一个参数res,第一次调用是没有传这个参数,这个参数主要是用来接收后面的then返回的结果。
    2. 然后调用gen.next,注意这个的返回值ret的形式是{value, done},然后将这个ret传给局部的next
  4. 然后执行局部next,他接收的参数是yield返回值{value, done}

    1. 这里先检测迭代是否完成,如果完成了,就直接将整个promise resolve。
    2. 这里的value是yield后面表达式的值,可能是thunk,也可能是promise
    3. 将value转换成promise
    4. 将转换后的promise拿出来执行,成功的回调是前面的onFulfilled
  5. 我们再来看下onFulfilled,这是第二次执行onFulfilled了。这次执行的时候传入的参数res是上次异步promise的执行结果,对应我们的fetch就是拿回来的数据,这个数据传给第二个gen.next,效果就是我们代码里面的赋值给了第一个yield前面的变量r1。然后继续局部next,这个next其实就是执行第二个异步Promise了。这个promise的成功回调又继续调用gen.next,这样就不断的执行下去,直到done变成true为止。
  6. 最后看一眼onRejected方法,这个方法其实作为了异步promise的错误分支,这个函数里面直接调用了gen.throw,这样我们在Generator里面可以直接用try...catch...拿到错误。需要注意的是gen.throw后面还继续调用了next(ret),这是因为在Generator的catch分支里面还可能继续有yield,比如错误上报的网络请求,这时候的迭代器并不一定结束了。

async/await

最后提一下async/await,先来看一下用法:

const fetch = require('node-fetch');

async function sendRequest () {
  const r1 = await fetch('https://www.baidu.com');
  const r2 = await fetch('https://www.baidu.com');
  const r3 = await fetch('https://www.baidu.com');
  
  return {
    r1,
    r2,
    r3,
  }
}

// 注意async返回的也是一个promise
sendRequest().then((res) => {
  console.log('res', res);
});

咋一看这个跟前面promise版的co是不是很像,返回值都是一个promise,只是Generator换成了一个async函数,函数里面的yield换成了await,而且外层不需要co来包裹也可以自动执行了。其实async函数就是Generator加自动执行器的语法糖,可以理解为从语言层面支持了Generator的自动执行。上面这段代码跟co版的promise其实就是等价的。

总结

  1. Generator是一种更现代的异步解决方案,在JS语言层面支持了协程
  2. Generator的返回值是一个迭代器
  3. 这个迭代器需要手动调next才能一条一条执行yield
  4. next的返回值是{value, done},value是yield后面表达式的值
  5. yield语句本身并没有返回值,下次调next的参数会作为上一个yield语句的返回值
  6. Generator自己不能自动执行,要自动执行需要引入其他方案,前面讲thunk的时候提供了一种方案,co模块也是一个很受欢迎的自动执行方案
  7. 这两个方案的思路有点类似,都是先写一个局部的方法,这个方法会去调用gen.next,同时这个方法本身又会传到回调函数或者promise的成功分支里面,异步结束后又继续调用这个局部方法,这个局部方法又调用gen.next,这样一直迭代,直到迭代器执行完毕。
  8. async/await其实是Generator和自动执行器的语法糖,写法和实现原理都类似co模块的promise模式。

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

作者博文GitHub项目地址: https://github.com/dennis-jiang/Front-End-Knowledges

查看原文

赞 16 收藏 16 评论 0

蒋鹏飞 赞了文章 · 8月14日

思否有约丨@洪光光:PHP 是世界上最好的语言,我搬着全世界最香的砖

image.png
洪光光(右二)

本期访谈嘉宾:洪光光
访谈编辑:芒果果

洪光光成为一个程序员已经 4 年了,仍然对编程充满热情,他说:“php 是世界上最好的语言,我搬着全世界最香的砖。”他应该是我接触的程序员中最直白表达对编程热爱的人。

虽然现在说起编程,我可以从洪光光的字里行间感受到他对自己工作的热爱,是那种把工作当成乐趣的享受。但其实,他走上编程之路却是因为大学毕业前英语四级没过,不得不靠参加比赛拿到毕业证书。因为只要获得全国开发奖项就可以代替四级证书顺利毕业。

人的潜能是无限的,在面临无法毕业的情况下,洪光光就像回到了高考冲刺阶段,捧着单片机在办公室一坐就是半年。终于,在全国蓝桥杯单片机比赛中拿了奖,他也成为了学校自建校以来第一个获得全国硬件类奖项的学生。一时间,洪光光竟成了学校的风云人物。

那时,他在学校宣传中讲的主题是《兴趣是最好的老师》,但是现在,他说:“如果再回到那个时候,我想改成《毕业是最好的压力》”。

洪光光的 4 年之坎儿

一开始只是希望能顺利毕业,没想到拿了个全国奖项,这就样洪光光顺理成章的成了一个程序员。

但与毕业前就拿奖的风光经历不同,入行 4 年,洪光光最后悔的就是没有把为了拿到毕业证那种学习的热情坚持下来。过了几年安逸的生活后,他意识到了如果不把落下的时间不上,就很难在工作中作出成绩了。

大学学制是 4 年,奥运会周期也是 4 年,4 年似乎是很多事情的一个砍。洪光光的大学 4 年决定了他的工作方向,入行后的 4 年他开始真正了解这个行业,也开始焦虑,因为现实和理想的差距,也因为自己的无能为力。

Q:介绍一下自己吧。

我叫洪光光。如果一句话介绍自己从入行到现在,我想我应该是一个在路上走了四年差点走丢,代表着绝大数的一个平凡普通的开发仔。

Q:为什么说自己是差点走丢的开发仔?

我是一个没有拿得出手的开源项目、没有比较拔尖的技术、没有让人惊叹的天赋、没有坚韧不拔的自律的却怀着大厂梦做了很多与梦想毫无帮助的事情的人。

2016 年毕业入行至今 4 年 前面 2 年一直保持高热情学习后面开始浑浑噩噩的混了 2 年,现在一直再偿还这两年欠下的窟窿债。因为自己过的太过平凡和平庸,也会在无数次的时候去问自己,自己还适合程序员吗?到了 30 岁如何保证自己不被淘汰?

我觉的自己代表了很多平凡的程序员,想再平凡的生活里去试着绽放一点微亮,我们就算注定成为不了多耀眼的大佬也要保证自己走在路上。

Q:入行 4 年了,你最大的感受是什么?

大家都说四年是一个坎,因为四年的时间足够去了解这个行业和足够了解自己,所以我开始感到焦虑。焦虑来源根本还是因为现实和理想的差距,说白还是来自现在的无能。

自律的养成除了高效完成工作还包括自律的生活,运动就是洪光光找到的适合自己的方式,他甚至把自己的微信昵称都改成了“运动光”。

也许,这也是他督促自己的一种方式吧,就像有人会把昵称改成“不瘦十斤不改名”,以此来督促自己减肥一样。

为了更健康的身体,也为了养成更好的习惯,洪光光会保证每周打两次羽毛球、一次网球、一次乒乓球。

无论是学习还是运动,都是为了提升自己,让自己的精神和身体都过的充实,不再浪费时间。就像那个被毕业逼出来的全国奖项,最后一刻的突击或许能取得好成绩,但绝不是长久之计。想要点亮自己胸前的勋章,就必须脚踏实地的努力。

Q:你如何化解焦虑的?

焦虑对我来说就像是痔疮,让我坐立难安。

是谁在无数次的夜晚提醒你不要再打游戏了 要学习

是谁在无数次的刷剧中提醒你不要再大游戏 要学习

是谁在无数次的麻木CURD提醒你不要沉迷CURD 要学习

除了学习,我也常常提醒自己要多运动,逐渐的慢慢从焦虑中怀疑人生到靠着焦虑逼着自己不断的往前走,焦虑是没办法彻底消除,但是可以尽可能的降低焦虑。

Q:用学习来缓解焦虑,那你有什么学习方法推荐吗?

失眠和焦虑是个好基友,可能很多人想着我白天工作很忙只能靠着晚上去学习充电,这里我并不是特别支持,因为我也尝试过每天学习到2点,白天变得特别疲惫,一段时间过后发现焦虑更加严重了。

可以试着可以早点睡觉,然后早晨早点起来看一些书和技术文章再去上班,事实上充电时间实际上特别多,比如想想如何提高工作效力加高摸鱼时间等。

其实程序员是一个你努力了一定就会有回报的职业,事实上很多人在无数个寂寞夜晚没能把持住自己学习的心。

所以还是要不断靠焦虑去逼自己学习,我会不断的收藏各种大佬的文章,毕竟收藏就是精通。也会不断的阅读各种书籍,最近就在看陈雷的《REDIS5涉及与源码分析》和分析`swoole`的源码,有兴趣的可以看看我的`swoole`系列。

如果真想让自己成为别人的口中的大佬,书和代码一样少不了。

Q:最近有没有尝试新的编程语言?一般通过什么方式和渠道提升自己的能力?

自己一直都再尝试的新的编程语言,4年以上的开发者都会明白编程语言只是一种业务的表达载体,这里跟人海茫茫的那些如同我一样的平凡开发仔说一句中肯的话,一定要用新语言多写项目,不然真的很容易忘记。

学新语言的最好的方式就是写项目,但是不是瞎写,可以找个具体的场景去用新语言是实现,如果只是走一遍 CURD (那duck不必)

学习语言的路径我一般都是会去买对应的书籍,然后放在床头枕着睡觉。所以建议大家直接上手撸哪里不会学哪里。等到入门了,再去深挖那些语言特性底层的一些东西。

Q:有什么个人的特别的工作习惯么?

我是一个做事效率特别高的人,所以我总有自己的时间去折腾一些工具和项目优化、甚至摸鱼。

我的工作习惯就是专注做一件事情,就算摸鱼也要专注的摸鱼。很多人工作习惯可能coding半小时、微信十分钟,其实这种特别不好,尤其在不断被产品大佬各种会议轰炸的情况下,所以我工作基本就是 coding 就认真 coding,多出来的时间当然就是摸鱼充电上,这四年我基本没有遇到过项目延期或者项目出问题的事情。

Q:与思否的故事?

一开始是公司要做个技术分享,每个季度的每个人都需要分享两个主题,但是碍于写`ppt`排版太浪费时间了,想着写一个技术文章分享,经常在朋友圈看到各位大佬发的思否的文章(swoole大部分文章都来源思否),所以就自己注册一个账号到发布一篇《【SWOOLE系列】浅谈SWOOLE协程篇》文章。

但是没想到一个用来公司分享的文章被`韩天峰`和`郭新华`给点赞和分享了,后来也被swoole的官方公众号转载了,瞬间感受了什么是平凡的生活绽放了一点微亮。尝到了一些甜头后又发布了几篇文章,真正的感受了社区的力量和一些信心。这里对那些点赞和收藏我文章的说了一句,你们都是码农届最靓的仔,也特别感谢思否作为一个平台让我感受到了自己的一些不不平凡。

Q:如何看待国内社区的环境和氛围?

我觉得国内的社区有一个很不好的现象就是一篇文章就算漏洞百出也能被不断的复制到各个论坛,导致大家搜索一个问题能搜索出各种无效的文章,还是需要大家有个自我判断的意识,就算复制粘贴也希望自己线下跑通和实践。

作为一个常年的用搜索引擎开发的工程师,这几年也有很强烈的感受到国内的社区的逐渐的活跃和大佬的涌出,我记得之前出问题总在`stackoverflow`上查,现在基本上可以国内很多社区找到自己想要的答案。

小编有话说:

洪光光很像我们上学时班里那个淘气的男生,凭着自己的聪明,平时不努力但是考试前总会突击学习,然后就能取得不错的成绩。

但是入行 4 年后他好像发生了不小的变化,25 岁的他却把“焦虑”挂在嘴上。

这不仅是来自工作和生活的压力,更多的源于是他开始思考未来,规划人生了。因为不想再“混日子”,因为想要在技术领域有所建树,所以他开始认真的思考该如何改变现状。

希望这份焦虑会像当初毕业时的压力一样,都成为他的动力。


欢迎有兴趣参与访谈的小伙伴踊跃报名,《思否有约》将把你与编程有关的故事记录下来。报名邮箱:mango@sifou.com

segmentfault公众号

查看原文

赞 36 收藏 4 评论 16

蒋鹏飞 发布了文章 · 8月12日

JavaScript中的compose函数和pipe函数

compose函数

compose函数可以将需要嵌套执行的函数平铺,嵌套执行就是一个函数的返回值将作为另一个函数的参数。我们考虑一个简单的需求:

给定一个输入值x,先给这个值加10,然后结果乘以10

这个需求很简单,直接一个计算函数就行:

const calculate = x => (x + 10) * 10;
let res = calculate(10);
console.log(res);    // 200

但是根据我们之前讲的函数式编程,我们可以将复杂的几个步骤拆成几个简单的可复用的简单步骤,于是我们拆出了一个加法函数和一个乘法函数:

const add = x => x + 10;
const multiply = x => x * 10;

// 我们的计算改为两个函数的嵌套计算,add函数的返回值作为multiply函数的参数
let res = multiply(add(10));
console.log(res);    // 结果还是200

上面的计算方法就是函数的嵌套执行,而我们compose的作用就是将嵌套执行的方法作为参数平铺,嵌套执行的时候,里面的方法也就是右边的方法最开始执行,然后往左边返回,我们的compose方法也是从右边的参数开始执行,所以我们的目标就很明确了,我们需要一个像这样的compose方法:

// 参数从右往左执行,所以multiply在前,add在后
let res = compose(multiply, add)(10);

在讲这个之前我们先来看一个需要用到的函数Array.prototype.reduce

Array.prototype.reduce

数组的reduce方法可以实现一个累加效果,它接收两个参数,第一个是一个累加器方法,第二个是初始化值。累加器接收四个参数,第一个是上次的计算值,第二个是数组的当前值,主要用的就是这两个参数,后面两个参数不常用,他们是当前index和当前迭代的数组:

const arr = [[1, 2], [3, 4], [5, 6]];
// prevRes的初始值是传入的[],以后会是每次迭代计算后的值
const flatArr = arr.reduce((prevRes, item) => prevRes.concat(item), []);

console.log(flatArr); // [1, 2, 3, 4, 5, 6]

Array.prototype.reduceRight

Array.prototype.reduce会从左往右进行迭代,如果需要从右往左迭代,用Array.prototype.reduceRight就好了

const arr = [[1, 2], [3, 4], [5, 6]];
// prevRes的初始值是传入的[],以后会是每次迭代计算后的值
const flatArr = arr.reduceRight((prevRes, item) => prevRes.concat(item), []);

console.log(flatArr); // [5, 6, 3, 4, 1, 2]

那这个compose方法要怎么实现呢,这里需要借助Array.prototype.reduceRight:

const compose = function(){
  // 将接收的参数存到一个数组, args == [multiply, add]
  const args = [].slice.apply(arguments);
  return function(x) {
    return args.reduceRight((res, cb) => cb(res), x);
  }
}

// 我们来验证下这个方法
let calculate = compose(multiply, add);
let res = calculate(10);
console.log(res);    // 结果还是200

上面的compose函数使用ES6的话会更加简洁:

const compose = (...args) => x => args.reduceRight((res, cb) => cb(res), x);

Redux的中间件就是用compose实现的,webpack中loader的加载顺序也是从右往左,这是因为他也是compose实现的。

pipe函数

pipe函数跟compose函数的左右是一样的,也是将参数平铺,只不过他的顺序是从左往右。我们来实现下,只需要将reduceRight改成reduce就行了:

const pipe = function(){
  const args = [].slice.apply(arguments);
  return function(x) {
    return args.reduce((res, cb) => cb(res), x);
  }
}

// 参数顺序改为从左往右
let calculate = pipe(add, multiply);
let res = calculate(10);
console.log(res);    // 结果还是200

ES6写法:

const pipe = (...args) => x => args.reduce((res, cb) => cb(res), x)

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

作者博文GitHub项目地址: https://github.com/dennis-jiang/Front-End-Knowledges

查看原文

赞 14 收藏 12 评论 0

蒋鹏飞 发布了文章 · 8月10日

手写React-Router源码,深入理解其原理

上一篇文章我们讲了React-Router的基本用法,并实现了常见的前端路由鉴权。本文会继续深入React-Router讲讲他的源码,套路还是一样的,我们先用官方的API实现一个简单的例子,然后自己手写这些API来替换官方的并且保持功能不变。

本文全部代码已经上传GitHub,大家可以拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/react-router-code

简单示例

本文用的例子是上篇文章开始那个不带鉴权的简单路由跳转例子,跑起来是这样子的:

Jul-10-2020 17-35-36

我们再来回顾下代码,在app.js里面我们用Route组件渲染了几个路由:

import React from 'react';
import {
  BrowserRouter as Router,
  Switch,
  Route,
} from "react-router-dom";
import Home from './pages/Home';
import Login from './pages/Login';
import Backend from './pages/Backend';
import Admin from './pages/Admin';


function App() {
  return (
    <Router>
      <Switch>
        <Route path="/login" component={Login}/>
        <Route path="/backend" component={Backend}/>
        <Route path="/admin" component={Admin}/>
        <Route path="/" component={Home}/>
      </Switch>
    </Router>
  );
}

export default App;

每个页面的代码都很简单,只有一个标题和回首页的链接,比如登录页长这样,其他几个页面类似:

import React from 'react';
import { Link } from 'react-router-dom';

function Login() {
  return (
    <>
      <h1>登录页</h1>
      <Link to="/">回首页</Link>
    </>
  );
}

export default Login;

这样我们就完成了一个最简单的React-Router的应用示例,我们来分析下我们用到了他的哪些API,这些API就是我们今天要手写的目标,仔细一看,我们好像只用到了几个组件,这几个组件都是从react-router-dom导出来的:

BrowserRouter: 被我们重命名为了Router,他包裹了整个React-Router应用,感觉跟以前写过的react-reduxProvider类似,我猜是用来注入context之类的。

Route: 这个组件是用来定义具体的路由的,接收路由地址path和对应渲染的组件作为参数。

Switch:这个组件是用来设置匹配模式的,不加这个的话,如果浏览器地址匹配到了多个路由,这几个路由都会渲染出来,加了这个只会渲染匹配的第一个路由组件。

Link:这个是用来添加跳转链接的,功能类似于原生的a标签,我猜他里面也是封装了一个a标签。

BrowserRouter源码

我们代码里面最外层的就是BrowserRouter,我们先去看看他的源码干了啥,地址传送门:https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/modules/BrowserRouter.js

看了他的源码,我们发现BrowserRouter代码很简单,只是一个壳:

import React from "react";
import { Router } from "react-router";
import { createBrowserHistory as createHistory } from "history";

class BrowserRouter extends React.Component {
  history = createHistory(this.props);

  render() {
    return <Router history={this.history} children={this.props.children} />;
  }
}

在这个壳里面还引用了两个库react-routerhistoryBrowserRouter仅仅是调用historycreateHistory得到一个history对象,然后用这个对象渲染了react-routerRouter组件。看起来我们要搞懂react-router-dom的源码还必须得去看react-routerhistory的源码,现在我们手上有好几个需要搞懂的库了,为了看懂他们的源码,我们得先理清楚他们的结构关系。

React-Router的项目结构

React-Router的结构是一个典型的monorepomonorepo这两年开始流行了,是一种比较新的多项目管理方式,与之相对的是传统的multi-repo。比如React-Router的项目结构是这样的:

image-20200727172427353

注意这里的packages文件夹下面有四个文件夹,这四个文件夹每个都可以作为一个单独的项目发布。之所以把他们放在一起,是因为他们之前有很强的依赖关系:

react-router:是React-Router的核心库,处理一些共用的逻辑

react-router-config:是React-Router的配置处理,我们一般不需要使用

react-router-dom:浏览器上使用的库,会引用react-router核心库

react-router-native:支持React-Native的路由库,也会引用react-router核心库

像这样多个仓库,发布多个包的情况,传统模式是给每个库都建一个git repo,这种方式被称为multi-repo。像React-Router这样将多个库放在同一个git repo里面的就是monorepo。这样做的好处是如果出了一个BUG或者加一个新功能,需要同时改react-routerreact-router-dommonorepo只需要一个commit一次性就改好了,发布也可以一起发布。如果是multi-repo则需要修改两个repo,然后分别发布两个repo,发布的时候还要协调两个repo之间的依赖关系。所以现在很多开源库都使用monorepo来将依赖很强的模块放在一个repo里面,比如React源码也是一个典型的monorepo

image-20200727174904352

yarn有一个workspaces可以支持monorepo,使用这个功能需要在package.json里面配置workspaces,比如这样:

"workspaces": {
    "packages": [
      "packages/*"
    ]
  }

扯远了,monorepo可以后面单独开一篇文章来讲,这里讲这个主要是为了说明React-Router分拆成了多个包,这些包之间是有比较强的依赖的。

前面我们还用了一个库是history,这个库没在React-Routermonorepo里面,而是单独的一个库,因为官方把他写的功能很独立了,不一定非要结合React-Router使用,在其他地方也可以使用。

React-Router架构思路

我之前另一篇文章讲Vue-Router的原理提到过,前端路由实现无非这几个关键点:

  1. 监听URL的改变
  2. 改变vue-router里面的current变量
  3. 监视current变量
  4. 获取对应的组件
  5. render新组件

其实React-Router的思路也是类似的,只是React-Router将这些功能拆分得更散,监听URL变化独立成了history库,vue-router里面的current变量在React里面是用Context API实现的,而且放到了核心库react-router里面,一些跟平台相关的组件则放到了对应的平台库react-router-dom或者react-router-native里面。按照这个思路,我们自己写的React-Router文件夹下面也建几个对应的文件夹:

image-20200728155030839

手写自己的React-Router

然后我们顺着这个思路一步一步的将我们代码里面用到的API替换成自己的。

BrowserRouter组件

BrowserRouter这个代码前面看过,直接抄过来就行:

import React from "react";
import { Router } from "react-router";
import { createBrowserHistory as createHistory } from "history";

class BrowserRouter extends React.Component {
  history = createHistory(this.props);

  render() {
    return <Router history={this.history} children={this.props.children} />;
  }
}

export default BrowserRouter;

react-router的Router组件

上面的BrowserRouter用到了react-routerRouter组件,这个组件在浏览器和React-Native端都有使用,主要获取当前路由并通过Context API将它传递下去:

import React from "react";

import HistoryContext from "./HistoryContext.js";
import RouterContext from "./RouterContext.js";

/**
 * The public API for putting history on context.
 */
class Router extends React.Component {
  // 静态方法,检测当前路由是否匹配
  static computeRootMatch(pathname) {
    return { path: "/", url: "/", params: {}, isExact: pathname === "/" };
  }

  constructor(props) {
    super(props);

    this.state = {
      location: props.history.location     // 将history的location挂载到state上
    };

    // 下面两个变量是防御性代码,防止根组件还没渲染location就变了
    // 如果location变化时,当前根组件还没渲染出来,就先记下他,等当前组件mount了再设置到state上
    this._isMounted = false;
    this._pendingLocation = null;

    // 通过history监听路由变化,变化的时候,改变state上的location
    this.unlisten = props.history.listen(location => {
      if (this._isMounted) {
        this.setState({ location });
      } else {
        this._pendingLocation = location;
      }
    });
  }

  componentDidMount() {
    this._isMounted = true;

    if (this._pendingLocation) {
      this.setState({ location: this._pendingLocation });
    }
  }

  componentWillUnmount() {
    if (this.unlisten) {
      this.unlisten();
      this._isMounted = false;
      this._pendingLocation = null;
    }
  }

  render() {
    // render的内容很简单,就是两个context
    // 一个是路由的相关属性,包括history和location等
    // 一个只包含history信息,同时将子组件通过children渲染出来
    return (
      <RouterContext.Provider
        value={{
          history: this.props.history,
          location: this.state.location,
          match: Router.computeRootMatch(this.state.location.pathname),
        }}
      >
        <HistoryContext.Provider
          children={this.props.children || null}
          value={this.props.history}
        />
      </RouterContext.Provider>
    );
  }
}

export default Router;

上述代码是我精简过的代码,原版代码可以看这里。这段代码主要是创建了两个context,将路由信息和history信息放到了这两个context上,其他也没干啥了。关于React的Context API我在另外一篇文章详细讲过,这里不再赘述了。

history

前面我们其实用到了history的三个API:

createBrowserHistory: 这个是用在BrowserRouter里面的,用来创建一个history对象,后面的listen和unlisten都是挂载在这个API的返回对象上面的。

history.listen:这个是用在Router组件里面的,用来监听路由变化。

history.unlisten:这个也是在Router组件里面用的,是listen方法的返回值,用来在清理的时候取消监听的。

下面我们来实现这个history:

// 创建和管理listeners的方法
function createEvents() {
  let handlers = [];

  return {
    push(fn) {
      handlers.push(fn);
      return function () {
        handlers = handlers.filter(handler => handler !== fn);
      };
    },
    call(arg) {
      handlers.forEach(fn => fn && fn(arg));
    }
  }
}

function createBrowserHistory() {
  const listeners = createEvents();
  let location = {
    pathname: '/',
  };

  // 路由变化时的回调
  const handlePop = function () {
    const currentLocation = {
      pathname: window.location.pathname
    }
    listeners.call(currentLocation);     // 路由变化时执行回调
  }

  // 监听popstate事件
  // 注意pushState和replaceState并不会触发popstate
  // 但是浏览器的前进后退会触发popstate
  // 我们这里监听这个事件是为了处理浏览器的前进后退
  window.addEventListener('popstate', handlePop);

  // 返回的history上有个listen方法
  const history = {
    listen(listener) {
      return listeners.push(listener);
    },
    location
  }

  return history;
}

export default createBrowserHistory;

上述history代码是超级精简版的代码,官方源码很多,还支持其他功能,我们这里只拎出来核心功能,对官方源码感兴趣的看这里:https://github.com/ReactTraining/history/blob/28c89f4091ae9e1b0001341ea60c629674e83627/packages/history/index.ts#L397

Route组件

我们前面的应用里面还有个很重要的组件是Route组件,这个组件是用来匹配路由和具体的组件的。这个组件看似是从react-router-dom里面导出来的,其实他只是相当于做了一个转发,原封不动的返回了react-routerRoute组件:

image-20200728173934453

这个组件其实只有一个作用,就是将参数上的path拿来跟当前的location做对比,如果匹配上了就渲染参数上的component就行。为了匹配pathlocation,还需要一个辅助方法matchPath我直接从源码抄这个方法了。大致思路是将我们传入的参数path转成一个正则,然后用这个正则去匹配当前的pathname

import pathToRegexp from "path-to-regexp";

const cache = {};
const cacheLimit = 10000;
let cacheCount = 0;

function compilePath(path, options) {
  const cacheKey = `${options.end}${options.strict}${options.sensitive}`;
  const pathCache = cache[cacheKey] || (cache[cacheKey] = {});

  if (pathCache[path]) return pathCache[path];

  const keys = [];
  const regexp = pathToRegexp(path, keys, options);
  const result = { regexp, keys };

  if (cacheCount < cacheLimit) {
    pathCache[path] = result;
    cacheCount++;
  }

  return result;
}

/**
 * Public API for matching a URL pathname to a path.
 */
function matchPath(pathname, options = {}) {
  if (typeof options === "string" || Array.isArray(options)) {
    options = { path: options };
  }

  const { path, exact = false, strict = false, sensitive = false } = options;

  const paths = [].concat(path);

  return paths.reduce((matched, path) => {
    if (!path && path !== "") return null;
    if (matched) return matched;

    const { regexp, keys } = compilePath(path, {
      end: exact,
      strict,
      sensitive
    });
    const match = regexp.exec(pathname);

    if (!match) return null;

    const [url, ...values] = match;
    const isExact = pathname === url;

    if (exact && !isExact) return null;

    return {
      path, // the path used to match
      url: path === "/" && url === "" ? "/" : url, // the matched portion of the URL
      isExact, // whether or not we matched exactly
      params: keys.reduce((memo, key, index) => {
        memo[key.name] = values[index];
        return memo;
      }, {})
    };
  }, null);
}

export default matchPath;

然后是Route组件,调用下matchPath来看下当前路由是否匹配就行了,当前路由记得从RouterContext里面拿:

import React from "react";

import RouterContext from "./RouterContext.js";
import matchPath from "./matchPath.js";

/**
 * The public API for matching a single path and rendering.
 */
class Route extends React.Component {
  render() {
    return (
      <RouterContext.Consumer>
        {context => {
          // 从RouterContext获取location
          const location = context.location;
          const match = matchPath(location.pathname, this.props);  // 调用matchPath检测当前路由是否匹配

          const props = { ...context, location, match };

          let { component } = this.props;

          // render对应的component之前先用最新的参数match更新下RouterContext
          // 这样下层嵌套的Route可以拿到对的值
          return (
            <RouterContext.Provider value={props}>
              {props.match
                ? React.createElement(component, props)
                : null}
            </RouterContext.Provider>
          );
        }}
      </RouterContext.Consumer>
    );
  }
}

export default Route;

上述代码也是精简过的,官方源码还支持函数组件和render方法等,具体代码可以看这里:https://github.com/ReactTraining/react-router/blob/master/packages/react-router/modules/Route.js

其实到这里,React-Router的核心功能已经实现了,但是我们开始的例子中还用到了SwitchLink组件,我们也一起来把它实现了吧。

Switch组件

我们上面的Route组件的功能是只要path匹配上当前路由就渲染组件,也就意味着如果多个Routepath都匹配上了当前路由,这几个组件都会渲染。所以Switch组件的功能只有一个,就是即使多个Routepath都匹配上了当前路由,也只渲染第一个匹配上的组件。要实现这个功能其实也不难,把Switchchildren拿出来循环,找出第一个匹配的child,给它添加一个标记属性computedMatch,顺便把其他的child全部干掉,然后修改下Route的渲染逻辑,先检测computedMatch,如果没有这个再使用matchPath自己去匹配:

import React from "react";

import RouterContext from "./RouterContext.js";
import matchPath from "./matchPath.js";

class Switch extends React.Component {
  render() {
    return (
      <RouterContext.Consumer>
        {context => {
          const location = context.location;     // 从RouterContext获取location

          let element, match;     // 两个变量记录第一次匹配上的子元素和match属性

          // 使用React.Children.forEach来遍历子元素,而不能使用React.Children.toArray().find()
          // 因为toArray会给每个子元素添加一个key,这会导致两个有同样component,但是不同URL的<Route>重复渲染
          React.Children.forEach(this.props.children, child => {
            // 先检测下match是否已经匹配到了
            // 如果已经匹配过了,直接跳过
            if (!match && React.isValidElement(child)) {
              element = child;

              const path = child.props.path;

              match = matchPath(location.pathname, { ...child.props, path });
            }
          });

          // 最终<Switch>组件的返回值只是匹配子元素的一个拷贝,其他子元素被忽略了
          // match属性会被塞给拷贝元素的computedMatch
          // 如果一个都没匹配上,返回null
          return match
            ? React.cloneElement(element, { location, computedMatch: match })   
            : null;
        }}
      </RouterContext.Consumer>
    );
  }
}

export default Switch;

然后修改下Route组件,让他先检查computedMatch

// ... 省略其他代码 ...
const match = this.props.computedMatch
              ? this.props.computedMatch
              : matchPath(location.pathname, this.props);  // 调用matchPath检测当前路由是否匹配

Switch组件其实也是在react-router里面,源码跟我们上面写的差不多:https://github.com/ReactTraining/react-router/blob/master/packages/react-router/modules/Switch.js

Link组件

Link组件功能也很简单,就是一个跳转,浏览器上要实现一个跳转,可以用a标签,但是如果直接使用a标签可能会导致页面刷新,所以不能直接使用它,而应该使用history APIhistory API具体文档可以看这里。我们这里要跳转URL可以直接使用history.pushState。使用history.pushState需要注意一下几点:

  1. history.pushState只会改变history状态,不会刷新页面。换句话说就是你用了这个API,你会看到浏览器地址栏的地址变化了,但是页面并没有变化。
  2. 当你使用history.pushState或者history.replaceState改变history状态的时候,popstate事件并不会触发,所以history里面的回调不会自动调用,当用户使用history.push的时候我们需要手动调用回调函数。
  3. history.pushState(state, title[, url])接收三个参数,第一个参数state是往新路由传递的信息,可以为空,官方React-Router会往里面加一个随机的key和其他信息,我们这里直接为空吧,第二个参数title目前大多数浏览器都不支持,可以直接给个空字符串,第三个参数url是可选的,是我们这里的关键,这个参数是要跳往的目标地址。
  4. 由于history已经成为了一个独立的库,所以我们应该将history.pushState相关处理加到history库里面。

我们先在history里面新加一个APIpush,这个API会调用history.pushState并手动执行回调:

// ... 省略其他代码 ...
push(url) {
  const history = window.history;
  // 这里pushState并不会触发popstate
  // 但是我们仍然要这样做,是为了保持state栈的一致性
  history.pushState(null, '', url);

  // 由于push并不触发popstate,我们需要手动调用回调函数
  location = { pathname: url };
  listeners.call(location);
}

上面说了我们直接使用a标签会导致页面刷新,但是如果不使用a标签,Link组件应该渲染个什么标签在页面上呢?可以随便渲染个spandiv什么的都行,但是可能会跟大家平时的习惯不一样,还可能导致一些样式失效,所以官方还是选择了渲染一个a标签在这里,只是使用event.preventDefault禁止了默认行为,然后用history api自己实现了跳转,当然你可以自己传component参数进去改变默认的a标签。因为是a标签,不能兼容native,所以Link组件其实是在react-router-dom这个包里面:

import React from "react";
import RouterContext from "../react-router/RouterContext";

// LinkAnchor只是渲染了一个没有默认行为的a标签
// 跳转行为由传进来的navigate实现
function LinkAnchor({navigate, ...rest}) {
  let props = {
    ...rest,
    onClick: event => {
      event.preventDefault();
      navigate();
    }
  }

  return <a {...props} />;
}

function Link({
  component = LinkAnchor,  // component默认是LinkAnchor
  to,
  ...rest
}) {
  return (
    <RouterContext.Consumer>
      {context => {
        const { history } = context;     // 从RouterContext获取history对象

        const props = {
          ...rest,
          href: to,
          navigate() {
            history.push(to);
          }
        };

        return React.createElement(component, props);
      }}
    </RouterContext.Consumer>
  );
}

export default Link;

上述代码是精简版的Link,基本逻辑跟官方源码一样:https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/modules/Link.js

到这里开头示例用到的全部API都换成了我们自己的,其实也实现了React-Router的核心功能。但是我们只实现了H5 history模式,hash模式并没有实现,其实有了这个架子,添加hash模式也比较简单了,基本架子不变,在react-router-dom里面添加一个HashRouter,他的基本结构跟BrowserRouter是一样的,只是他会调用historycreateHashHistorycreateHashHistory里面不仅仅会去监听popstate,某些浏览器在hash变化的时候不会触发popstate,所以还需要监听hashchange事件。对应的源码如下,大家可以自行阅读:

HashRouter: https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/modules/HashRouter.js

createHashHistory: https://github.com/ReactTraining/history/blob/28c89f4091ae9e1b0001341ea60c629674e83627/packages/history/index.ts#L616

总结

React-Router的核心源码我们已经读完了,下面我们来总结下:

  1. React-Router因为有跨平台的需求,所以分拆了好几个包,这几个包采用monorepo的方式管理:

    1. react-router是核心包,包含了大部分逻辑和组件,处理context和路由匹配都在这里。
    2. react-router-dom是浏览器使用的包,像Link这样需要渲染具体的a标签的组件就在这里。
    3. react-router-nativereact-native使用的包,里面包含了androidios具体的项目。
  2. 浏览器事件监听也单独独立成了一个包history,跟history相关的处理都放在了这里,比如pushreplace什么的。
  3. React-Router实现时核心逻辑如下:

    1. 使用不刷新的路由API,比如history或者hash
    2. 提供一个事件处理机制,让React组件可以监听路由变化。
    3. 提供操作路由的接口,当路由变化时,通过事件回调通知React
    4. 当路由事件触发时,将变化的路由写入到React的响应式数据上,也就是将这个值写到根routerstate上,然后通过context传给子组件。
    5. 具体渲染时将路由配置的path和当前浏览器地址做一个对比,匹配上就渲染对应的组件。
  4. 在使用popstate时需要注意:

    1. 原生history.pushStatehistory.replaceState并不会触发popstate,要通知React需要我们手动调用回调函数。
    2. 浏览器的前进后退按钮会触发popstate事件,所以我们还是要监听popstate,目的是兼容前进后退按钮。

本文全部代码已经上传GitHub,大家可以拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/react-router-code

参考资料

官方文档:https://reactrouter.com/web/guides/quick-start

GitHub源码地址:https://github.com/ReactTraining/react-router/tree/master/packages

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

作者博文GitHub项目地址: https://github.com/dennis-jiang/Front-End-Knowledges

查看原文

赞 23 收藏 21 评论 0

蒋鹏飞 收藏了文章 · 8月5日

从发布订阅模式入手读懂Node.js的EventEmitter源码

前面一篇文章setTimeout和setImmediate到底谁先执行,本文让你彻底理解Event Loop详细讲解了浏览器和Node.js的异步API及其底层原理Event Loop。本文会讲一下不用原生API怎么达到异步的效果,也就是发布订阅模式。发布订阅模式在面试中也是高频考点,本文会自己实现一个发布订阅模式,弄懂了他的原理后,我们就可以去读Node.js的EventEmitter源码,这也是一个典型的发布订阅模式。

本文所有例子已经上传到GitHub,同一个repo下面还有我所有博文和例子:

https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/DesignPatterns/PubSub

为什么要用发布订阅模式

在没有Promise之前,我们使用异步API的时候经常会使用回调,但是如果有几个互相依赖的异步API调用,回调层级太多可能就会陷入“回调地狱”。下面代码演示了假如我们有三个网络请求,第二个必须等第一个结束才能发出,第三个必须等第二个结束才能发起,如果我们使用回调就会变成这样:

const request = require("request");

request('https://www.baidu.com', function (error, response) {
  if (!error && response.statusCode == 200) {
    console.log('get times 1');

    request('https://www.baidu.com', function(error, response) {
      if (!error && response.statusCode == 200) {
        console.log('get times 2');

        request('https://www.baidu.com', function(error, response) {
          if (!error && response.statusCode == 200) {
            console.log('get times 3');
          }
        })
      }
    })
  }
});

由于浏览器端ajax会有跨域问题,上述例子我是用Node.js运行的。这个例子里面有三层回调,我们已经有点晕了,如果再多几层,那真的就是“地狱”了。

发布订阅模式

发布订阅模式是一种设计模式,并不仅仅用于JS中,这种模式可以帮助我们解开“回调地狱”。他的流程如下图所示:

image-20200323161211669

  1. 消息中心:负责存储消息与订阅者的对应关系,有消息触发时,负责通知订阅者
  2. 订阅者:去消息中心订阅自己感兴趣的消息
  3. 发布者:满足条件时,通过消息中心发布消息

有了这种模式,前面处理几个相互依赖的异步API就不用陷入"回调地狱"了,只需要让后面的订阅前面的成功消息,前面的成功后发布消息就行了。

自己实现一个发布订阅模式

知道了原理,我们自己来实现一个发布订阅模式,这次我们使用ES6的class来实现,如果你对JS的面向对象或者ES6的class还不熟悉,请看这篇文章:

class PubSub {
  constructor() {
    // 一个对象存放所有的消息订阅
    // 每个消息对应一个数组,数组结构如下
    // {
    //   "event1": [cb1, cb2]
    // }
    this.events = {}
  }

  subscribe(event, callback) {
    if(this.events[event]) {
      // 如果有人订阅过了,这个键已经存在,就往里面加就好了
      this.events[event].push(callback);
    } else {
      // 没人订阅过,就建一个数组,回调放进去
      this.events[event] = [callback]
    }
  }

  publish(event, ...args) {
    // 取出所有订阅者的回调执行
    const subscribedEvents = this.events[event];

    if(subscribedEvents && subscribedEvents.length) {
      subscribedEvents.forEach(callback => {
        callback.call(this, ...args);
      });
    }
  }

  unsubscribe(event, callback) {
    // 删除某个订阅,保留其他订阅
    const subscribedEvents = this.events[event];

    if(subscribedEvents && subscribedEvents.length) {
      this.events[event] = this.events[event].filter(cb => cb !== callback)
    }
  }
}

解决回调地狱

有了我们自己的PubSub,我们就可以用它来解决前面的回调地狱问题了:

const request = require("request");
const pubSub = new PubSub();

request('https://www.baidu.com', function (error, response) {
  if (!error && response.statusCode == 200) {
    console.log('get times 1');
    // 发布请求1成功消息
    pubSub.publish('request1Success');
  }
});

// 订阅请求1成功的消息,然后发起请求2
pubSub.subscribe('request1Success', () => {
  request('https://www.baidu.com', function (error, response) {
    if (!error && response.statusCode == 200) {
      console.log('get times 2');
      // 发布请求2成功消息
      pubSub.publish('request2Success');
    }
  });
})

// 订阅请求2成功的消息,然后发起请求3
pubSub.subscribe('request2Success', () => {
  request('https://www.baidu.com', function (error, response) {
    if (!error && response.statusCode == 200) {
      console.log('get times 3');
      // 发布请求3成功消息
      pubSub.publish('request3Success');
    }
  });
})

Node.js的EventEmitter

Node.js的EventEmitter思想跟我们前面的例子是一样的,不过他有更多的错误处理和更多的API,源码在GitHub上都有:https://github.com/nodejs/node/blob/master/lib/events.js。我们挑几个API看一下:

构造函数

代码传送门: https://github.com/nodejs/node/blob/master/lib/events.js#L64

image-20200323170909507

构造函数很简单,就一行代码,主要逻辑都在EventEmitter.init里面:

image-20200323171123339

EventEmitter.init里面也是做了一些初始化的工作,this._events跟我们自己写的this.events功能是一样的,用来存储订阅的事件。核心代码我在图上用箭头标出来了。这里需要注意一点,如果一个类型的事件只有一个订阅,this._events就直接是那个函数了,而不是一个数组,在源码里面我们会多次看到对这个进行判断,这样写是为了提高性能。

订阅事件

代码传送门: https://github.com/nodejs/node/blob/master/lib/events.js#L405

EventEmitter订阅事件的API是onaddListener,从源码中我们可以看出这两个方法是完全一样的:

image-20200323171656342

这两个方法都是调用了_addListener,这个方法对参数进行了判断和错误处理,核心代码仍然是往this._events里面添加事件:

image-20200323172045655

发布事件

代码传送门:https://github.com/nodejs/node/blob/master/lib/events.js#L263

EventEmitter发布事件的API是emit,这个API里面会对"error"类型的事件进行特殊处理,也就是抛出错误:

image-20200323172657760

如果不是错误类型的事件,就把订阅的回调事件拿出来执行:

image-20200323172822170

取消订阅

代码传送门:https://github.com/nodejs/node/blob/master/lib/events.js#L450

EventEmitter里面取消订阅的API是removeListeneroff,这两个是完全一样的。EventEmitter的取消订阅API不仅仅会删除对应的订阅,在删除后还会emit一个removeListener事件来通知外界。这里也会对this._events里面对应的type进行判断,如果只有一个,也就是说这个type的类型是function,会直接删除这个键,如果有多个订阅,就会找出这个订阅,然后删掉他。如果所有订阅都删完了,就直接将this._events置空:

image-20200323174111868

总结

本文讲解了发布订阅模式的原理,并自己实现了一个简单的发布订阅模式。在了解了原理后,还去读了Node.js的EventEmitter模块的源码,进一步学习了生产环境的发布订阅模式的写法。总结下来发布订阅模式有以下特点:

  1. 解决了“回调地狱”
  2. 将多个模块进行了解耦,自己执行时,不需要知道另一个模块的存在,只需要关心发布出来的事件就行
  3. 因为多个模块可以不知道对方的存在,自己关心的事件可能是一个很遥远的旮旯发布出来的,也不能通过代码跳转直接找到发布事件的地方,debug的时候可能会有点困难。

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

作者博文GitHub项目地址: https://github.com/dennis-jiang/Front-End-Knowledges

查看原文

蒋鹏飞 收藏了文章 · 8月5日

使用React-Router实现前端路由鉴权

React-Router是React生态里面很重要的一环,现在React的单页应用的路由基本都是前端自己管理的,而不像以前是后端路由,React管理路由的库常用的就是React-Router。本文想写一下React-Router的使用,但是光介绍API又太平淡了,而且官方文档已经写得很好了,我这里就用一个常见的开发场景来看看React-Router是怎么用的吧。我们一般的系统都会有用户访问权限的限制,某些页面可能需要用户具有一定的权限才能访问。本文就是用React-Router来实现一个前端鉴权模型。

本文全部代码已经上传GitHub,大家可以拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/react-router-usage

应用示例

本文要实现的功能是大家经常遇到的场景,就是要控制不同的用户角色来访问不同的页面,这里总共有四个页面:

  1. /index: 网站首页
  2. /login: 登录页
  3. /backend:后台页面
  4. /admin:管理页面

另外还有三种角色:

  1. 未登录用户:只能访问网站首页/index和登录页/login
  2. 普通用户:可以访问网站首页/index,登录页/login和后台页面/backend
  3. 管理员:可以访问管理页面/admin和其他所有页面

引入React-Router

要实现路由鉴权,我们还得一步一步来,我们先用React-Router搭建一个简单的带有这几个页面的项目。我们直接用create-react-app创建一个新项目,然后建了一个pages文件夹,里面放入我们前面说的那几个页面:

image-20200710152739352

我们页面先写简单点,先写个标题吧,比如这样:

import React from 'react';

function Admin() {
  return (
    <h1>管理员页面</h1>
  );
}

其他几个页面也是类似的。

然后我们就可以在App.js里面引入React-Router做路由跳转了,注意我们在浏览器上使用的是react-router-dom,新版的React-Router将核心逻辑层和展示层分开了,核心逻辑会处理路由匹配等,展示层会处理实际的跳转和路由变化的监听,之所以这么分,是因为React-Router不仅仅需要支持浏览器,还需要支持React Native,这两个平台的监听和跳转是不一样的,所以现在React-Router下面有好几个包了:

react-router:核心逻辑处理,提供一些公用的基类

react-router-dom:具体实现浏览器相关的路由监听和跳转

react-router-native:具体实现RN相关的路由监听和跳转

在实际使用时,我们一般不需要引用react-router,而是直接用react-router-dom就行,因为它自己会去引用react-router。下面我们在项目里面引入react-router-dom

import React from 'react';
import {
  BrowserRouter as Router,
  Switch,
  Route,
} from "react-router-dom";
import Home from './pages/Home';
import Login from './pages/Login';
import Backend from './pages/Backend';
import Admin from './pages/Admin';

function App() {
  return (
    <Router>
      <Switch>
        <Route path="/login" component={Login}/>
        <Route path="/backend" component={Backend}/>
        <Route path="/admin" component={Admin}/>
        <Route path="/" component={Home}/>
      </Switch>
    </Router>
  );
}

export default App;

然后可以在Home页面用Link加上跳转到其他页面的链接,这样就可以跳转了:

import React from 'react';
import { Link } from 'react-router-dom';

function Home() {
  return (
    <>
      <h1>首页</h1>
      <ul>
        <li><Link to="/login">登录</Link></li>
        <li><Link to="/backend">后台</Link></li>
        <li><Link to="/admin">管理员</Link></li>
      </ul>
    </>
  );
}

export default Home;

到现在我们的应用运行起来是这样的:

Jul-10-2020 17-35-36

模块划分

虽然我们的跳转实现了,但是所有人都可以访问任何页面,我们前面的需求是要根据登录的角色限制访问的页面的,在写代码前,我们先来思考下应该怎么做这个。当然最直观最简单的方法就是每个页面都检测下当前用户的角色,匹配不上就报错或者跳回首页。我们现在只有几个页面,这样做好像也还好,但是如果我们的应用变大了,页面变多了,每个页面都来一次检测就显得很重复了,所以我们应该换个角度来思考这个问题。

仔细一看,其实我们总共就三种角色,对应三种不同的权限,这三个权限还有层级关系,高级别的权限包含了低级别的权限,所以我们的页面也可以按照这些权限分为三种:

  1. 公共页面:所有人都可以访问,没登录也可以访问,包括网站首页和登录页
  2. 普通页面:普通登录用户可以访问的页面
  3. 管理员页面:只有管理员才能访问的页面

为了好管理这三种页面,我们可以将他们抽取成三个文件,放到一个独立的文件夹routes里面,三个文件分别命名为publicRoutes.jsprivateRoutes.jsadminRoutes.js

image-20200721170127221

对于每个路由文件,我们可以将这类路由组织成数组,然后export出去给外面调用,比如publicRoutes.js

import Login from '../pages';
import Home from '../pages/Home';

const publicRoutes = [
  {
    path: '/login',
    component: Login,
    exact: true,
  },
  {
    path: '/',
    component: Home,
    exact: true,
  },
];

export default publicRoutes;

然后我们外面使用的地方直接改为:

import publicRoutes from './routes/publicRoutes';

function App() {
  return (
    <Router>
      <Switch>
        {publicRoutes.map(
          ({path, component, ...routes}) => 
            <Route key={path} path={path} component={component} {...routes}/>
        )}
        <Route path="/backend" component={Backend}/>
        <Route path="/admin" component={Admin}/>
      </Switch>
    </Router>
  );
}

这样我们的App.js里面就不会有冗长的路由路由列表了,而是只需要循环一个数组就行了。但是对于需要登录才能访问的页面和管理员页面我们不能直接渲染Route组件,我们最好再封装一个高级组件,将鉴权的工作放到这个组件里面去,这样我们普通的页面在实现时就不需要关心怎么鉴权了。

封装高级组件

要封装这个鉴权组件思路也很简单,前面我们将publicRoutes直接拿来循环渲染了Route组件,我们的鉴权组件只需要在这个基础上再加一个逻辑就行了:在渲染真正的Route组件前先检查一下当前用户是否有对应的权限,如果有就直接渲染Route组件,如果没有就返回某个页面,可以是登录页或者后台首页,具体根据自己项目需求来。所以我们的路由配置文件privateRoutes.jsadminRoutes.js里面的路由会比publicRoutes.js的多两个参数:

// privateRoutes.js
import Backend from '../pages/Backend';

const privateRoutes = [
  {
    path: '/backend',
    component: Backend,
    exact: true,
    role: 'user',       // 当前路由需要的角色权限
    backUrl: '/login'   // 不满足权限跳转的路由
  },
];

export default privateRoutes;

adminRoutes.js是类似的写法:

// adminRoutes.js
import Admin from '../pages/Admin';

const adminRoutes = [
  {
    path: '/admin',
    component: Admin,
    exact: true,
    role: 'admin',       // 需要的权限是admin
    backUrl: '/backend'  // 不满足权限跳回后台页面
  },
];

export default adminRoutes;

然后就可以写我们的高级组件了,我们将它命名为AuthRoute吧,注意我们这里假设的用户登录时后端API会返回给我们当前用户的角色,一个用户可能有多个角色,比如普通用户的角色是['user'],管理员的角色是['user', 'admin'],具体的权限验证逻辑要看自己项目权限的设计,这里只是一个例子:

// AuthRoute.js
import React from 'react';
import { Route, Redirect } from 'react-router-dom';

function AuthRoute(props) {
  const {
    user: {
      role: userRole
    },
    role: routeRole,
    backUrl,
    ...otherProps
  } = props;

  // 如果用户有权限,就渲染对应的路由
  if (userRole && userRole.indexOf(routeRole) > -1) {
    return <Route {...otherProps} />
  } else {
    // 如果没有权限,返回配置的默认路由
    return <Redirect to={backUrl} />
  }
}

export default AuthRoute;

然后用我们的AuthRoute的渲染adminRoutesprivateRoutes:

// ... 省略其他代码 ...

{privateRoutes.map(
  (route) => <AuthRoute key={route.path} {...route}/>
)}
{adminRoutes.map(
  (route) => <AuthRoute key={route.path} {...route}/>
)}

登录设置权限

在我们的AuthRoute里面用到了user: { role }这个变量,但是我们还没设置它。真实项目中一般是登录的时候后端API会返回当前用户的角色,然后前端将这个权限信息保存在一些状态管理工具里面,比如Redux。我们这里直接在Login页面写死两个按钮来模拟这个权限了,用户的配置就用根组件的state来管理了,Login页面的两个按钮会改变对应的state

import React from 'react';
import { Link } from 'react-router-dom';

function Login(props) {
  const {loginAsUser, loginAsAdmin, history} = props;

  const userLoginHandler = () => {
    loginAsUser();      // 调用父级方法设置用户权限
    history.replace('/backend');     // 登录后跳转后台页面
  }

  const adminLoginHandler = () => {
    loginAsAdmin();     // 调用父级方法设置管理员权限
    history.replace('/admin');     // 登录后跳转管理员页面
  }

  return (
    <>
      <h1>登录页</h1>
      <button onClick={userLoginHandler}>普通用户登录</button>
      <br/><br/>
      <button onClick={adminLoginHandler}>管理员登录</button>
      <br/><br/>
      <Link to="/">回首页</Link>
    </>
  );
}

export default Login;

到这里我们这个简单的路由鉴权就完成了,具体跑起来效果如下:

Jul-23-2020 20-40-37

本文全部代码已经上传GitHub,大家可以拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/react-router-usage

总结

  1. React-Router可以用来管理前端的路由跳转,是React生态里面很重要的一个库。
  2. React-Router为了同时支持浏览器和React-Native,他分拆成了三个包react-router核心包,react-router-dom浏览器包,react-router-native支持React-Native。使用时不需要引入react-router,只需要引入需要的平台包就行。
  3. 对于需要不同权限的路由,我们可以将他们拎出来分好类,单独建成一个文件,如果路由不多,放在一个文件导出多个数组也行。
  4. 对于需要鉴权的路由,我们可以用一个高级组件将权限校验的逻辑封装在里面,其他页面只需要加好配置,完全不用关心鉴权的问题。

本文内容偏简单,作为熟悉React-Router的用法还不错,但是我们不能只会用,还要知道他的原理。下篇文章我们就来看看React-Router的源码里面蕴藏了什么奥秘,大家可以点个关注不迷路,哈哈~

参考资料

官方文档:https://reactrouter.com/web/guides/quick-start

GitHub源码地址:https://github.com/ReactTraining/react-router/tree/master/packages

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

作者博文GitHub项目地址: https://github.com/dennis-jiang/Front-End-Knowledges

查看原文

蒋鹏飞 收藏了文章 · 8月5日

手写一个React-Redux,玩转React的Context API

上一篇文章我们手写了一个Redux,但是单纯的Redux只是一个状态机,是没有UI呈现的,所以一般我们使用的时候都会配合一个UI库,比如在React中使用Redux就会用到React-Redux这个库。这个库的作用是将Redux的状态机和React的UI呈现绑定在一起,当你dispatch action改变state的时候,会自动更新页面。本文还是从它的基本使用入手来自己写一个React-Redux,然后替换官方的NPM库,并保持功能一致。

本文全部代码已经上传GitHub,大家可以拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/react-redux

基本用法

下面这个简单的例子是一个计数器,跑起来效果如下:

Jul-02-2020 16-44-04

要实现这个功能,首先我们要在项目里面添加react-redux库,然后用它提供的Provider包裹整个ReactApp的根组件:

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux'
import store from './store'
import App from './App';

ReactDOM.render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>,
  document.getElementById('root')
);

上面代码可以看到我们还给Provider提供了一个参数store,这个参数就是Redux的createStore生成的store,我们需要调一下这个方法,然后将返回的store传进去:

import { createStore } from 'redux';
import reducer from './reducer';

let store = createStore(reducer);

export default store;

上面代码中createStore的参数是一个reducer,所以我们还要写个reducer:

const initState = {
  count: 0
};

function reducer(state = initState, action) {
  switch (action.type) {
    case 'INCREMENT':
      return {...state, count: state.count + 1};
    case 'DECREMENT':
      return {...state, count: state.count - 1};
    case 'RESET':
      return {...state, count: 0};
    default:
      return state;
  }
}

export default reducer;

这里的reduce会有一个初始state,里面的count0,同时他还能处理三个action,这三个action对应的是UI上的三个按钮,可以对state里面的计数进行加减和重置。到这里其实我们React-Redux的接入和Redux数据的组织其实已经完成了,后面如果要用Redux里面的数据的话,只需要用connectAPI将对应的state和方法连接到组件里面就行了,比如我们的计数器组件需要count这个状态和加一,减一,重置这三个action,我们用connect将它连接进去就是这样:

import React from 'react';
import { connect } from 'react-redux';
import { increment, decrement, reset } from './actions';

function Counter(props) {
  const { 
    count,
    incrementHandler,
    decrementHandler,
    resetHandler
   } = props;

  return (
    <>
      <h3>Count: {count}</h3>
      <button onClick={incrementHandler}>计数+1</button>
      <button onClick={decrementHandler}>计数-1</button>
      <button onClick={resetHandler}>重置</button>
    </>
  );
}

const mapStateToProps = (state) => {
  return {
    count: state.count
  }
}

const mapDispatchToProps = (dispatch) => {
  return {
    incrementHandler: () => dispatch(increment()),
    decrementHandler: () => dispatch(decrement()),
    resetHandler: () => dispatch(reset()),
  }
};

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(Counter)

上面代码可以看到connect是一个高阶函数,他的第一阶会接收mapStateToPropsmapDispatchToProps两个参数,这两个参数都是函数。mapStateToProps可以自定义需要将哪些state连接到当前组件,这些自定义的state可以在组件里面通过props拿到。mapDispatchToProps方法会传入dispatch函数,我们可以自定义一些方法,这些方法可以调用dispatchdispatch action,从而触发state的更新,这些自定义的方法也可以通过组件的props拿到,connect的第二阶接收的参数是一个组件,我们可以猜测这个函数的作用就是将前面自定义的state和方法注入到这个组件里面,同时要返回一个新的组件给外部调用,所以connect其实也是一个高阶组件。

到这里我们汇总来看下我们都用到了哪些API,这些API就是我们后面要手写的目标:

Provider: 用来包裹根组件的组件,作用是注入Reduxstore

createStore: Redux用来创建store的核心方法,我们另一篇文章已经手写过了

connect:用来将statedispatch注入给需要的组件,返回一个新组件,他其实是个高阶组件。

所以React-Redux核心其实就两个API,而且两个都是组件,作用还很类似,都是往组件里面注入参数,Provider是往根组件注入storeconnect是往需要的组件注入statedispatch

在手写之前我们先来思考下,为什么React-Redux要设计这两个API,假如没有这两个API,只用Redux可以吗?当然是可以的!其实我们用Redux的目的不就是希望用它将整个应用的状态都保存下来,每次操作只用dispatch action去更新状态,然后UI就自动更新了吗?那我从根组件开始,每一级都把store传下去不就行了吗?每个子组件需要读取状态的时候,直接用store.getState()就行了,更新状态的时候就store.dispatch,这样其实也能达到目的。但是,如果这样写,子组件如果嵌套层数很多,每一级都需要手动传入store,比较丑陋,开发也比较繁琐,而且如果某个新同学忘了传store,那后面就是一连串的错误了。所以最好有个东西能够将store全局的注入组件树,而不需要一层层作为props传递,这个东西就是Provider!而且如果每个组件都独立依赖Redux会破坏React的数据流向,这个我们后面会讲到。

React的Context API

React其实提供了一个全局注入变量的API,这就是context api。假如我现在有一个需求是要给我们所有组件传一个文字颜色的配置,我们的颜色配置在最顶级的组件上,当这个颜色改变的时候,下面所有组件都要自动应用这个颜色。那我们可以使用context api注入这个配置:

先使用React.createContext创建一个context

// 我们使用一个单独的文件来调用createContext
// 因为这个返回值会被Provider和Consumer在不同的地方引用
import React from 'react';

const TestContext = React.createContext();

export default TestContext;

使用Context.Provider包裹根组件

创建好了context,如果我们要传递变量给某些组件,我们需要在他们的根组件上加上TestContext.Provider,然后将变量作为value参数传给TestContext.Provider:

import TestContext from './TestContext';

const setting = {
  color: '#d89151'
}

ReactDOM.render(
  <TestContext.Provider value={setting}>
      <App />
  </TestContext.Provider>,
  document.getElementById('root')
);

使用Context.Consumer接收参数

上面我们使用Context.Provider将参数传递进去了,这样被Context.Provider包裹的所有子组件都可以拿到这个变量,只是拿这个变量的时候需要使用Context.Consumer包裹,比如我们前面的Counter组件就可以拿到这个颜色了,只需要将它返回的JSXContext.Consumer包裹一下就行:

// 注意要引入同一个Context
import TestContext from './TestContext';

// ... 中间省略n行代码 ...
// 返回的JSX用Context.Consumer包裹起来
// 注意Context.Consumer里面是一个方法,这个方法就可以访问到context参数
// 这里的context也就是前面Provider传进来的setting,我们可以拿到上面的color变量
return (
    <TestContext.Consumer>
      {context => 
        <>
          <h3 style={{color:context.color}}>Count: {count}</h3>
          <button onClick={incrementHandler}>计数+1</button>&nbsp;&nbsp;
          <button onClick={decrementHandler}>计数-1</button>&nbsp;&nbsp;
          <button onClick={resetHandler}>重置</button>
        </>
      }
    </TestContext.Consumer>
  );

上面代码我们通过context传递了一个全局配置,可以看到我们文字颜色已经变了:

image-20200703171322676

使用useContext接收参数

除了上面的Context.Consumer可以用来接收context参数,新版React还有useContext这个hook可以接收context参数,使用起来更简单,比如上面代码可以这样写:

const context = useContext(TestContext);

return (
    <>
      <h3 style={{color:context.color}}>Count: {count}</h3>
      <button onClick={incrementHandler}>计数+1</button>&nbsp;&nbsp;
      <button onClick={decrementHandler}>计数-1</button>&nbsp;&nbsp;
      <button onClick={resetHandler}>重置</button>
    </>
);

所以我们完全可以用context api来传递redux store,现在我们也可以猜测React-ReduxProvider其实就是包装了Context.Provider,而传递的参数就是redux store,而React-ReduxconnectHOC其实就是包装的Context.Consumer或者useContext。我们可以按照这个思路来自己实现下React-Redux了。

手写Provider

上面说了Provider用了context api,所以我们要先建一个context文件,导出需要用的context

// Context.js
import React from 'react';

const ReactReduxContext = React.createContext();

export default ReactReduxContext;

这个文件很简单,新建一个context再导出就行了,对应的源码看这里

然后将这个context应用到我们的Provider组件里面:

import React from 'react';
import ReactReduxContext from './Context';

function Provider(props) {
  const {store, children} = props;

  // 这是要传递的context
  const contextValue = { store };

  // 返回ReactReduxContext包裹的组件,传入contextValue
  // 里面的内容就直接是children,我们不动他
  return (
    <ReactReduxContext.Provider value={contextValue}>
      {children}
    </ReactReduxContext.Provider>
  )
}

Provider的组件代码也不难,直接将传进来的store放到context上,然后直接渲染children就行,对应的源码看这里

手写connect

基本功能

其实connect才是React-Redux中最难的部分,里面功能复杂,考虑的因素很多,想要把它搞明白我们需要一层一层的来看,首先我们实现一个只具有基本功能的connect

import React, { useContext } from 'react';
import ReactReduxContext from './Context';

// 第一层函数接收mapStateToProps和mapDispatchToProps
function connect(mapStateToProps, mapDispatchToProps) {
  // 第二层函数是个高阶组件,里面获取context
  // 然后执行mapStateToProps和mapDispatchToProps
  // 再将这个结果组合用户的参数作为最终参数渲染WrappedComponent
  // WrappedComponent就是我们使用connext包裹的自己的组件
  return function connectHOC(WrappedComponent) {

    function ConnectFunction(props) {
      // 复制一份props到wrapperProps
      const { ...wrapperProps } = props;

      // 获取context的值
      const context = useContext(ReactReduxContext);

      const { store } = context;  // 解构出store
      const state = store.getState();   // 拿到state

      // 执行mapStateToProps和mapDispatchToProps
      const stateProps = mapStateToProps(state);
      const dispatchProps = mapDispatchToProps(store.dispatch);

      // 组装最终的props
      const actualChildProps = Object.assign({}, stateProps, dispatchProps, wrapperProps);

      // 渲染WrappedComponent
      return <WrappedComponent {...actualChildProps}></WrappedComponent>
    }

    return ConnectFunction;
  }
}

export default connect;

触发更新

用上面的Providerconnect替换官方的react-redux其实已经可以渲染出页面了,但是点击按钮还不会有反应,因为我们虽然通过dispatch改变了store中的state,但是这种改变并没有触发我们组件的更新。之前Redux那篇文章讲过,可以用store.subscribe来监听state的变化并执行回调,我们这里需要注册的回调是检查我们最终给WrappedComponentprops有没有变化,如果有变化就重新渲染ConnectFunction,所以这里我们需要解决两个问题:

  1. 当我们state变化的时候检查最终给到ConnectFunction的参数有没有变化
  2. 如果这个参数有变化,我们需要重新渲染ConnectFunction

检查参数变化

要检查参数的变化,我们需要知道上次渲染的参数和本地渲染的参数,然后拿过来比一下就知道了。为了知道上次渲染的参数,我们可以直接在ConnectFunction里面使用useRef将上次渲染的参数记录下来:

// 记录上次渲染参数
const lastChildProps = useRef();
useLayoutEffect(() => {
  lastChildProps.current = actualChildProps;
}, []);

注意lastChildProps.current是在第一次渲染结束后赋值,而且需要使用useLayoutEffect来保证渲染后立即同步执行。

因为我们检测参数变化是需要重新计算actualChildProps,计算的逻辑其实都是一样的,我们将这块计算逻辑抽出来,成为一个单独的方法childPropsSelector:

function childPropsSelector(store, wrapperProps) {
  const state = store.getState();   // 拿到state

  // 执行mapStateToProps和mapDispatchToProps
  const stateProps = mapStateToProps(state);
  const dispatchProps = mapDispatchToProps(store.dispatch);

  return Object.assign({}, stateProps, dispatchProps, wrapperProps);
}

然后就是注册store的回调,在里面来检测参数是否变了,如果变了就强制更新当前组件,对比两个对象是否相等,React-Redux里面是采用的shallowEqual,也就是浅比较,也就是只对比一层,如果你mapStateToProps返回了好几层结构,比如这样:

{
  stateA: {
    value: 1
  }
}

你去改了stateA.value是不会触发重新渲染的,React-Redux这样设计我想是出于性能考虑,如果是深比较,比如递归去比较,比较浪费性能,而且如果有循环引用还可能造成死循环。采用浅比较就需要用户遵循这种范式,不要传入多层结构,这点在官方文档中也有说明。我们这里直接抄一个它的浅比较:

// shallowEqual.js 
function is(x, y) {
  if (x === y) {
    return x !== 0 || y !== 0 || 1 / x === 1 / y
  } else {
    return x !== x && y !== y
  }
}

export default function shallowEqual(objA, objB) {
  if (is(objA, objB)) return true

  if (
    typeof objA !== 'object' ||
    objA === null ||
    typeof objB !== 'object' ||
    objB === null
  ) {
    return false
  }

  const keysA = Object.keys(objA)
  const keysB = Object.keys(objB)

  if (keysA.length !== keysB.length) return false

  for (let i = 0; i < keysA.length; i++) {
    if (
      !Object.prototype.hasOwnProperty.call(objB, keysA[i]) ||
      !is(objA[keysA[i]], objB[keysA[i]])
    ) {
      return false
    }
  }

  return true
}

在回调里面检测参数变化:

// 注册回调
store.subscribe(() => {
  const newChildProps = childPropsSelector(store, wrapperProps);
  // 如果参数变了,记录新的值到lastChildProps上
  // 并且强制更新当前组件
  if(!shallowEqual(newChildProps, lastChildProps.current)) {
    lastChildProps.current = newChildProps;

    // 需要一个API来强制更新当前组件
  }
});

强制更新

要强制更新当前组件的方法不止一个,如果你是用的Class组件,你可以直接this.setState({}),老版的React-Redux就是这么干的。但是新版React-Redux用hook重写了,那我们可以用React提供的useReducer或者useStatehook,React-Redux源码用了useReducer,为了跟他保持一致,我也使用useReducer:

function storeStateUpdatesReducer(count) {
  return count + 1;
}

// ConnectFunction里面
function ConnectFunction(props) {
  // ... 前面省略n行代码 ... 
  
  // 使用useReducer触发强制更新
  const [
    ,
    forceComponentUpdateDispatch
  ] = useReducer(storeStateUpdatesReducer, 0);
  // 注册回调
  store.subscribe(() => {
    const newChildProps = childPropsSelector(store, wrapperProps);
    if(!shallowEqual(newChildProps, lastChildProps.current)) {
      lastChildProps.current = newChildProps;
      forceComponentUpdateDispatch();
    }
  });
  
  // ... 后面省略n行代码 ...
}

connect这块代码主要对应的是源码中connectAdvanced这个类,基本原理和结构跟我们这个都是一样的,只是他写的更灵活,支持用户传入自定义的childPropsSelector和合并stateProps, dispatchProps, wrapperProps的方法。有兴趣的朋友可以去看看他的源码:https://github.com/reduxjs/react-redux/blob/master/src/components/connectAdvanced.js

到这里其实已经可以用我们自己的React-Redux替换官方的了,计数器的功能也是支持了。但是下面还想讲一下React-Redux是怎么保证组件的更新顺序的,因为源码中很多代码都是在处理这个。

保证组件更新顺序

前面我们的Counter组件使用connect连接了redux store,假如他下面还有个子组件也连接到了redux store,我们就要考虑他们的回调的执行顺序的问题了。我们知道React是单向数据流的,参数都是由父组件传给子组件的,现在引入了Redux,即使父组件和子组件都引用了同一个变量count,但是子组件完全可以不从父组件拿这个参数,而是直接从Redux拿,这样就打破了React本来的数据流向。在父->子这种单向数据流中,如果他们的一个公用变量变化了,肯定是父组件先更新,然后参数传给子组件再更新,但是在Redux里,数据变成了Redux -> 父,Redux -> 子完全可以根据Redux的数据进行独立更新,而不能完全保证父级先更新,子级再更新的流程。所以React-Redux花了不少功夫来手动保证这个更新顺序,React-Redux保证这个更新顺序的方案是在redux store外,再单独创建一个监听者类Subscription

  1. Subscription负责处理所有的state变化的回调
  2. 如果当前连接redux的组件是第一个连接redux的组件,也就是说他是连接redux的根组件,他的state回调直接注册到redux store;同时新建一个Subscription实例subscription通过context传递给子级。
  3. 如果当前连接redux的组件不是连接redux的根组件,也就是说他上面有组件已经注册到redux store了,那么他可以拿到上面通过context传下来的subscription,源码里面这个变量叫parentSub,那当前组件的更新回调就注册到parentSub上。同时再新建一个Subscription实例,替代context上的subscription,继续往下传,也就是说他的子组件的回调会注册到当前subscription上。
  4. state变化了,根组件注册到redux store上的回调会执行更新根组件,同时根组件需要手动执行子组件的回调,子组件回调执行会触发子组件更新,然后子组件再执行自己subscription上注册的回调,触发孙子组件更新,孙子组件再调用注册到自己subscription上的回调。。。这样就实现了从根组件开始,一层一层更新子组件的目的,保证了父->子这样的更新顺序。

Subscription

所以我们先新建一个Subscription类:

export default class Subscription {
  constructor(store, parentSub) {
    this.store = store
    this.parentSub = parentSub
    this.listeners = [];        // 源码listeners是用链表实现的,我这里简单处理,直接数组了

    this.handleChangeWrapper = this.handleChangeWrapper.bind(this)
  }

  // 子组件注册回调到Subscription上
  addNestedSub(listener) {
    this.listeners.push(listener)
  }

  // 执行子组件的回调
  notifyNestedSubs() {
    const length = this.listeners.length;
    for(let i = 0; i < length; i++) {
      const callback = this.listeners[i];
      callback();
    }
  }

  // 回调函数的包装
  handleChangeWrapper() {
    if (this.onStateChange) {
      this.onStateChange()
    }
  }

  // 注册回调的函数
  // 如果parentSub有值,就将回调注册到parentSub上
  // 如果parentSub没值,那当前组件就是根组件,回调注册到redux store上
  trySubscribe() {
      this.parentSub
        ? this.parentSub.addNestedSub(this.handleChangeWrapper)
        : this.store.subscribe(this.handleChangeWrapper)
  }
}

Subscription对应的源码看这里

改造Provider

然后在我们前面自己实现的React-Redux里面,我们的根组件始终是Provider,所以Provider需要实例化一个Subscription并放到context上,而且每次state更新的时候需要手动调用子组件回调,代码改造如下:

import React, { useMemo, useEffect } from 'react';
import ReactReduxContext from './Context';
import Subscription from './Subscription';

function Provider(props) {
  const {store, children} = props;

  // 这是要传递的context
  // 里面放入store和subscription实例
  const contextValue = useMemo(() => {
    const subscription = new Subscription(store)
    // 注册回调为通知子组件,这样就可以开始层级通知了
    subscription.onStateChange = subscription.notifyNestedSubs
    return {
      store,
      subscription
    }
  }, [store])

  // 拿到之前的state值
  const previousState = useMemo(() => store.getState(), [store])

  // 每次contextValue或者previousState变化的时候
  // 用notifyNestedSubs通知子组件
  useEffect(() => {
    const { subscription } = contextValue;
    subscription.trySubscribe()

    if (previousState !== store.getState()) {
      subscription.notifyNestedSubs()
    }
  }, [contextValue, previousState])

  // 返回ReactReduxContext包裹的组件,传入contextValue
  // 里面的内容就直接是children,我们不动他
  return (
    <ReactReduxContext.Provider value={contextValue}>
      {children}
    </ReactReduxContext.Provider>
  )
}

export default Provider;

改造connect

有了Subscription类,connect就不能直接注册到store了,而是应该注册到父级subscription上,更新的时候除了更新自己还要通知子组件更新。在渲染包裹的组件时,也不能直接渲染了,而是应该再次使用Context.Provider包裹下,传入修改过的contextValue,这个contextValue里面的subscription应该替换为自己的。改造后代码如下:

import React, { useContext, useRef, useLayoutEffect, useReducer } from 'react';
import ReactReduxContext from './Context';
import shallowEqual from './shallowEqual';
import Subscription from './Subscription';

function storeStateUpdatesReducer(count) {
  return count + 1;
}

function connect(
  mapStateToProps = () => {}, 
  mapDispatchToProps = () => {}
  ) {
  function childPropsSelector(store, wrapperProps) {
    const state = store.getState();   // 拿到state

    // 执行mapStateToProps和mapDispatchToProps
    const stateProps = mapStateToProps(state);
    const dispatchProps = mapDispatchToProps(store.dispatch);

    return Object.assign({}, stateProps, dispatchProps, wrapperProps);
  }

  return function connectHOC(WrappedComponent) {
    function ConnectFunction(props) {
      const { ...wrapperProps } = props;

      const contextValue = useContext(ReactReduxContext);

      const { store, subscription: parentSub } = contextValue;  // 解构出store和parentSub
      
      const actualChildProps = childPropsSelector(store, wrapperProps);

      const lastChildProps = useRef();
      useLayoutEffect(() => {
        lastChildProps.current = actualChildProps;
      }, [actualChildProps]);

      const [
        ,
        forceComponentUpdateDispatch
      ] = useReducer(storeStateUpdatesReducer, 0)

      // 新建一个subscription实例
      const subscription = new Subscription(store, parentSub);

      // state回调抽出来成为一个方法
      const checkForUpdates = () => {
        const newChildProps = childPropsSelector(store, wrapperProps);
        // 如果参数变了,记录新的值到lastChildProps上
        // 并且强制更新当前组件
        if(!shallowEqual(newChildProps, lastChildProps.current)) {
          lastChildProps.current = newChildProps;

          // 需要一个API来强制更新当前组件
          forceComponentUpdateDispatch();

          // 然后通知子级更新
          subscription.notifyNestedSubs();
        }
      };

      // 使用subscription注册回调
      subscription.onStateChange = checkForUpdates;
      subscription.trySubscribe();

      // 修改传给子级的context
      // 将subscription替换为自己的
      const overriddenContextValue = {
        ...contextValue,
        subscription
      }

      // 渲染WrappedComponent
      // 再次使用ReactReduxContext包裹,传入修改过的context
      return (
        <ReactReduxContext.Provider value={overriddenContextValue}>
          <WrappedComponent {...actualChildProps} />
        </ReactReduxContext.Provider>
      )
    }

    return ConnectFunction;
  }
}

export default connect;

到这里我们的React-Redux就完成了,跑起来的效果跟官方的效果一样,完整代码已经上传GitHub了:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/react-redux

下面我们再来总结下React-Redux的核心原理。

总结

  1. React-Redux是连接ReactRedux的库,同时使用了ReactRedux的API。
  2. React-Redux主要是使用了Reactcontext api来传递Reduxstore
  3. Provider的作用是接收Redux store并将它放到context上传递下去。
  4. connect的作用是从Redux store中选取需要的属性传递给包裹的组件。
  5. connect会自己判断是否需要更新,判断的依据是需要的state是否已经变化了。
  6. connect在判断是否变化的时候使用的是浅比较,也就是只比较一层,所以在mapStateToPropsmapDispatchToProps中不要反回多层嵌套的对象。
  7. 为了解决父组件和子组件各自独立依赖Redux,破坏了React父级->子级的更新流程,React-Redux使用Subscription类自己管理了一套通知流程。
  8. 只有连接到Redux最顶级的组件才会直接注册到Redux store,其他子组件都会注册到最近父组件的subscription实例上。
  9. 通知的时候从根组件开始依次通知自己的子组件,子组件接收到通知的时候,先更新自己再通知自己的子组件。

参考资料

官方文档:https://react-redux.js.org/

GitHub源码:https://github.com/reduxjs/react-redux/

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

作者博文GitHub项目地址: https://github.com/dennis-jiang/Front-End-Knowledges

查看原文

蒋鹏飞 回答了问题 · 8月3日

redux的原理是否源于闭包?

这里确实是有闭包哦,你return出去的dispatch,getState,subscribe方法都引用了变量state,subcribe,所以就形成了闭包,这两个变量不会被回收,会一直在内存里面。我也写了reduxreact-redux的文章,给楼主参考:
手写一个Redux,深入理解其原理
手写一个React-Redux,玩转React的Context API

关注 2 回答 1

蒋鹏飞 关注了用户 · 8月3日

若川 @lxchuan12

微信公众号:若川视野,欢迎关注呀~
某不那么知名的陶瓷大学毕业生,目前在杭州从事前端开发工作。常以若川为名混迹于江湖。

github blog
求个star^_^

微信号:ruochuan12,注明来源来者不拒

关注 489