Giter Site home page Giter Site logo

blog's Introduction

blog's People

Contributors

juliiii avatar

Stargazers

 avatar  avatar  avatar  avatar

Watchers

 avatar

Forkers

woaini521

blog's Issues

腾讯云SCF + Typescript实践

目的

​ 最近serverless愈来愈火,我刚好在培训,比较有时间去尝试一些新东西,所以趁这个时候去使用下serverless,尝试使用typescript和nodejs开发,部署在腾讯云scf上的一个小工具,探讨下typescript + nodejs + scf的最好实践模式,并同时抛钻引玉,希望有同学提供更好的方案。

项目介绍

想法

​ 由于本人平时会追一些小说,动漫,电视剧等,但是它们更新的时候,我一般是不感知的,都是等我得空或者睡前的时候,我才会去它们的网站去查看下它们是否更新。如果有这么一个工具,能够在它们更新的时候,告知我,它更新了,更新了啥,那么我就不需要老是用手机去查询,对我起到了一定的便利作用。

​ 这放在我没有接触到serverless之前,我的想法是这样的:写一个这么的程序是不难,但是我得去买个机器去部署啊;如果有问题不能及时发现同时又得上机器查日志;还得自己去控制程序定时爬取的逻辑等等等。总的来说就是,实现与维护一个这样的程序的成本远大于了其带给我的便利,让我有想法却懒于行动。

image

​ 但是了解serverless的概念后,以上提到阻碍我行动的问题变得不再是问题,例如部署难题,使用serverless就是使用云供应商提供的开发者工具创建函数,打包上传代码即部署成功;又例如定时爬取逻辑,使用其提供的定时触发器能力即可,都大大方便了我的开发,让我更专注于代码实现。这里我不会很官方地去说serverless的概念以及好处,仅是从一个开发者的角度去阐述我的想法。

image

实践

  • 流程图

    程序的整个流程图如下图所示,逻辑很简单,这个项目的目的不在于实现一个多厉害的功能,而在于ts + node + scf的实践方式的探索。

    image

  • 开发

    开发能在scf运行的nodejs程序的其实与传统的开发nodejs程序在语言编写上并没有太大区别。比较明显的不同在于,我们开发时得有一个入口的函数,比如像这样:

    image

    更具体的入门文档,可以看此处,跟着文档一步步学习编写一个简单的函数。接下来回归正题。

    • 环境搭建

      • 首先为了方便开发,建议安装腾讯云scf提供的命令行工具或者vscode插件。但是这里我开发的时候vscode插件还没发布,所以这里主要使用命令行工具,命令行工具的安装与使用的文档,具体可以看此处

      • 安装好后,使用 scf init具体参数得去看文档填写,这个提个建议,scf init提供交互式操作,采取问答的模式去创建)创建一个项目、项目文件很简单,一共就四个文件,前三个,应该不多做介绍。第四个文件template.yaml称为模板文件,简单来说是描述这个函数的文件,比如函数的环境变量,触发器类型等等,具体还是前往文档处查看吧。

        image

      • 接下来,就是正常配置tsconfig.json,如果没有安装typescript的同学请去官网安装,然后tsc --init就可以快速生成一个tsconfig.json,然后根据自己的需求配置即可。

      • 然后,就是编写npm scripts。

        主要就三个操作,build,dev,deploy。

        可以使用npm scripts把typescript的编译和scf cli的本地调试,打包和部署串联在一起,使需要敲打的命令简洁和语义化

        image

      • 最后,将本地仓库与远程仓库关联起来。(这里提一个优化:有一种场景是用户已经创建了一个git仓库,现在需要将仓库里的代码写成scf模式下的代码,并配合scf cli使用,目前scf cli只支持init一个完整的项目,如果支持在一个已有项目中快速生成调试和部署的yaml,对开发者来说是一个比较方便的功能

    • 编码

      image

      我的主要逻辑代码分为上面的文件。

      • index,没什么好说的,就是一个入口文件,负责组合其余模块的逻辑。
      • config以及config_extra,config_extra文件放了我的隐私配置,例如redis的host,port和密码以及邮件服务的授权码等,这些配置通过配合.gitignore是不会提交到远程git仓库,而config文件则是引入config_extra文件中的配置,并与一些通用配置进行merge,然后输出到各个模块。(注:此处也可以好好利用scf提供的环境变量功能,很适合这种场景,具体文档
      • config_extra_demo,告诉别的开发者,config_extra文件应该如何编写。
      • mailer,封装邮件服务的初始化以及发送邮件方法
      • redis,封装redis的连接以及同步set以及get方法
      • task,暂时简单封装了下初始化以及执行的通用逻辑。但日后该工具扩展,此处仍得考虑如何抽象以及通用化。
      • util,封装了一些公用方法,例如封装了retry方法,来包装一些异步函数。

      上面简单介绍下主要逻辑代码的文件,具体的实现,有兴趣可以移步到 github地址 查看

  • 调试

    上面也有提到我编写的npm scripts里有npm run dev的一条。本人开发这个项目时,调试都执行npm run dev来进行调试。这里提一下,测试环境一般是需要和正式环境隔离的。所以可以新建一个 env.json文件,里面填写

    {
      "NODE_ENV": development
    }

    并将npm script中的dev命令改成 npm run build && scf local generate-event timer timeup | scf native invoke --template template.yaml --env-vars env.json

    然后在配置文件中根据process.env.NODE_ENV变量来判断是测试环境还是正式环境,并填写对应环境依赖的服务的配置即可。

  • 部署

    上面讲了这么多,其实都不是我最想表达的,因为我并没有在上面遇到一些很棘手的问题。而在部署的时候,我才发现在使用typescript时,无法在腾讯云scf目前的部署要求以及项目的文件目录管理中做到完美的配合

    image

    后面和同事讨论后,还是有不错的方法是达到两者的平衡。下面是我的多次尝试的一个过程

    如果不使用typescript,仅使用js编写nodejs程序,则不需要编译的过程,部署函数时,只需要打包然后部署即可;但是使用typescript后,则多了一步将ts代码编译成js代码的步骤。为了管理好项目的文件目录,我倾向于ts和js文件分别存放在不同的文件夹,例如,src文件夹存放ts文件,dist则是编译后得到的js文件。我一开始的文件目录便是如此。

    • 第一次尝试

      文件目录:

      image

      tsconfig.json

      指定编译src文件夹下的ts文件,输出到dist文件夹

      image

      template.yaml

      CodeUri指向dist文件夹

      image

      根据上面的配置,在本地调试是可以的。但是当部署到云上,测试是失败的。如果大家熟练的话可以立刻发现问题所在,打包没有把node_modules打包进去。主要逻辑代码依赖的第三方库全都找不到,测试当然失败了。

    • 第二次尝试

      根据第一次尝试,我使用npm scripts的pre钩子,在执行部署前,编辑ts代码,同时把node_modules拷贝到dist文件夹,然后再打包部署解决了这个问题。

      package.json

      image

      copy_node_modules.js

      image

      dist文件夹下的文件

      image

      虽然这样做可以运行了,在本地文件目录管理合理,但是提交到云上的代码是编译后的,基本没啥可读性,就是一坨能运行的东西,项目代码也不完整。所以个人认为,最完美的是本地开发的项目代码和交到云上的项目代码是一致的,不需要通过额外的脚本去阉割。虽然目前腾讯云scf控制台的webIDE还只是能看入口文件,不过之后会接入cloud studio,起码可以看到整个代码文件夹的每个文件,说不定以后就支持在线支持typescript编译(虽然不知道可不可能)。所以本人开始了第三次尝试。

    • 第三次尝试

      我有一个想法:template.yaml中指定的Handler,即入口函数,从index.main_handler 写成 文件夹/index.main_handler,即入口函数可以在某个文件夹里

      我在template.yaml处的Handler写成dist/index.main_handler,CodeUri写成了根目录,这样就可以打包整个文件夹,然后指定Handler为dist文件夹的index文件的main_handler函数。

      template.yaml

      image

      本地调试时,是成功的!

      但是在部署的时候,

      image

      额,好吧,我觉得是这个方案是不行的了,因为不符合scf的要求,通过不了校验。

    • 第四次尝试

      这是我第四次尝试。但是不是最完美的,在文件管理退了一步,允许ts和编译后的js放在一起。这样能做到把整个项目都打包上去,而且可运行,但是ts和js放在一起,文件管理不太合理。修改的地方如下:

      index.ts文件从src文件夹移动到根目录

      tsconfig.json

      编辑根目录下的index.ts和src文件夹下的ts文件,剔除node_modules,输出到根目录

      image

      image

      template.yaml

      CodeUri改成根目录,Handler改成 index.main_handler,即跟cli生成的一样

      image

      编译后结果

      image

      最后部署到云上scf,是可以运行的,而且是把整个项目都打包了上去,日后腾讯云scf接入了cloud studio,webIDE看到的文件架构和本地看到的文件架构是一致的。

    • 第五次尝试

      兜兜转转,有时候问题解决很简单。和组内同事讨论后,一位大佬同事点出:

      可不可以在根目录写一个index文件,然后调用编译后的index文件的入口方法?。

      一语惊醒梦中人!是的,一开始就没注意到,还可以这样解决,思维一直在一个圈子里绕来绕去,没有跳出来。这样做的成本很低,而且能达到了我之前说到的理想状态:

      本地开发的项目代码和交到云上的项目代码是一致的,不需要通过额外的脚本去阉割

      实施方法即是,把typescript文件放在src文件夹下,编辑后的js文件放在dist文件夹下,在根目录编写一个index.js文件,文件里的main_hanlder方法调用编译后的index文件的入口函数,下面是一些核心代码。

      index.js

      image

      tsconfig.json

      image

      template.json

      image

      编译后结果

      image

    成果

    简单展示下代码线上运行后的结果。

    image

    image

    总结

    上面说了这么多,这里给一个总结就是:

    虽然腾讯云scf没有原生支持typescript,但是经过一些方法还是可以做到两者的完美配合

    首先本地开发是没啥问题的,上面提到的尝试,都是为了能够在本地调试成功的同时可以部署到云上。

    主要是部署的问题,其中可行的三个尝试:

    第一个是通过一些额外的方法去适配,但是做不到云上的项目和实际的项目的一致,如第二次尝试。

    第二个是文件管理上退了一步,不做到极致的分明,如第四次尝试。

    第三个是在根目录写一个index.js文件,调用具有真正逻辑的入口函数,做个转发,如第五次尝试,也就是本人认为目前最好的实践方式。

    最后,以上的五个尝试,是本人开发的时候的想法与实践,也许不太正确,有误欢迎大家来批评。如果大家有更好的方法,欢迎讨论。五次尝试的源码都在github仓库,前四次尝试均有对应分支,master分支为第五次尝试。

redux-thunk源码解读

背景

最近刚把redux的源码阅读放了上来,那么再接再厉,咱们来看个redux中间件的源码,随便学习下怎么写个redux的中间件。额,为了简单点,所以选了redux-thunk。redux-thunk是什么?用过的同学应该知道是,redux处理异步action的一个中间件。然而他的源码也非常简单,不到20行,就完全可以实现处理异步action的需求,再一次感叹大牛的奇思妙想。

开始

  • 小二,上代码!

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

    先看看它的导出,哦,是先执行了createThunkMiddleware 这个函数,得到一个返回值,并将返回值赋值给thunk,并同时将这个函数挂载在thunk 上。最后导出给用户使用。那createThunkMiddleware 就是我们要剖析的核心了。下面为代码:

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

    这个函数,接受extraArgument 作为参数,并返回一个函数,而这个函数就是我们之前聊redux时,说到中间件的写法。就是形如 store => next => action => {}。所以这个函数会返回一个中间件给我们。而这个中间件,会判断action是不是function,如果是,就传入dispatch,getState等,执行,并返回;如果不是,就过,传给下一个中间件 。好了,redux-thunk的原理就这么简单。哈哈哈哈哈,额。为了避免有的同学没用过redux-thunk,那下面结合一个例子来讲解为毛原理就这么简单

    async function getUserInfo(dispatch, getState, userId) {
        try {
            const user = await axios.get(`/users/${userId}`);
            dispatch({
                type: 'GETUSERINFO_SUCCESS',
                data: user
            });
        } catch(e) {
            dispatch({
                type: 'GETUSERINFO_FAILED',
                error: e
            });        
        }
    }
    
    store.dispatch(getUserInfo);

    redux-thunk的用法,如果我没记错,应该是这么用(毕竟一直用redux-saga),正常的dispatch是接受一个简单对象的。但是redux-thunk允许我们传入一个函数,这个函数就是一个异步操作的函数。redux-thunk检测到是个函数的时候,就会执行该函数。函数会做异步操作。在未来的某个时间点,异步操作完成,会根据成功或者失败,去dispatch不同的action,而这次dispatch的action就是一个正常的action,即为一个简单对象,和正常使用redux的效果是一样的。所以,大家明白了redux的内部原理了吧。

  • 那么看了上面,懂得写一个中间件的套路了吗?

    看了redux和redux-thunk的源码后,我个人总结出一个redux的中间件应该具备以下两点

    • 形如 store => next => action => { };
    • {} 这部分要调用 next(action);

    第一点是因为,在redux的applyMiddleware中,对于中间件的处理,是先传入一个middlewareAPI,这个API包含了两样东西,一个是store.getState,一个是增强过的dispatch。然后得到一个形如 next => action => {} 的数组。然后对于这个数组,才有compose函数来处理,另其变成 (...args) => f(g(h(...args)))的一个函数。再然后再传入一个store.dispatch,这个函数就会执行,然后这个数组的函数就会从右往左执行,最后得到第一个action => {}的函数。当dispatch的时候,这个函数被执行,由于这个函数内调用了 next(action),所以中间件们就会以一种链式的结构被执行。所以个中间件只要具备以上两点,个人觉得就可以了。举了例子

    const logger = store => next => action => {
        console.log('currentState: ', store.getState());
        let result = next(action)
        console.log('nextState', store.getState());
        return result
    }

    这就是一个logger的中间件了,咱们看下效果:

    logger的demo

结语

这次也到这里了,这次篇幅不多,难度不难。大家也可以自行看看源码。老规矩,源码地址:https://github.com/Juliiii/source-plan 欢迎大家star或者fork也可以和我讨论学习

使用云函数快速打造一个智障微信公众号自动回复机器人

想法

于上周五发现了一个有趣的仓库: co-wechat。突发奇想,我不要你觉得,我要我觉得,腾讯云云函数配合该库,理论上可以快速搭建一个微信公众号的自动回复机器人。所以就开始查查文档,开搞了。

开搞

步骤一

先用Tencent Serverless Toolkit for VS Code这个VS Code插件生成一个云函数。并根据该文档修改下template.yaml,配置API网关触发器。

去到函数的面板,点击上传函数,等待上传完成,就可以在控制台看到了函数部署上去了。

那一个配有API网关触发器的云函数就创建完成了。访问API网关触发器下面的访问路径,即可触发该云函数运行。

步骤二

然后我们再看下微信公众号这边。

如果没有一个自己的订阅号,可以先去注册一个自己的订阅号,注册页面

注册完成后,我们还需要完成开发者配置。最完整的接入指南: 传送门

登录进去,可以在左侧菜单栏中看到开发,选择基本配置,可以看到下面的页面

重点看图片中的红框部分

服务器地址(URL): 填写 API网关触发器的访问路径

Token: 自己填写

EncodingAESKey:微信随机生成

消息加解密方式:这个大家看自己需求选择,我这里就选个明文模式。

点击修改配置后,可以进行配置的填写,然后可以点击提交试试,会报一个 token验证失败的错误。

看下文档,

哦,原来还得服务器返回echostr才能接入成功。那我们在云函数操作下。

可以在云函数的日志中,看到之前的失败的请求日志

可以知道,要返回的echostr就在event.queryString上,那我们return一下就完成接入了。

exports.main_handler = async (event, context, callback) => {
    console.log(event)
    return {
        "isBase64Encoded": false,
        "statusCode": 200,
        "headers": {"Content-Type":"plain/text"},
        "body": event.queryString.echostr
    }
};

一提交,成了。微信公众号平台到云函数这个请求链路就打通了

步骤三

我们就可以开始编写云函数代码(bug)了。

我是使用nodejs开发,使用了serverless-httpco-wechat两个库。根据两个库的github文档,我们可以写出下面的代码。

"use strict";
const Koa = require("koa");
const serverless = require("serverless-http");
const wechat = require("co-wechat");
const config = {
token: 'token',
 appid: 'appid',
 encodingAESKey: 'encodinAESKey',
};const app = new Koa();app.use(
 wechat(config).middleware(async (message, ctx) => {
   if (message.Content === "屌丝") {
     return "呵呵";
   }
})
);const handler = serverless(app);
exports.main_handler = async (event, context, callback) => {
  return await handler(
    { ...event, queryStringParameters: event.queryString },
    context
  );
};

简单解释下代码:

整个代码是使用了koa,这个nodejs开发web服务的框架。

serverless-http这个库是将api网关发送过来的请求参数做了一次封装,使我们在云函数中使用koa时,即使我们没有监听一个端口,也能像平时使用koa开发一样使用koa相关的api。

co-wechat这个库是封装好了微信公众号平台的自动回复api,然后将其作为一个koa中间件,提供给开发者使用。我们只需要将其当成koa的中间件来使用即可,同时传入微信要求传入的配置参数,例如token,appid等,这些在前面的开发者配置中提到过。更多使用详情,可以去该仓库查看文档哈。

代码编写完后,自己可以调试下,调试方式有以下几种:

1、使用微信提供的接口调试工具进行调试,同时代码中,wechat(config)需要改成wechat(config, true)来开启debug模式

2、拷贝微信的请求数据,以及新增一个api网关测试模板,使用VS Code插件进行调试,详情可以看 记一次使用腾讯云Serverless的VS Code插件来定位问题。

调试后没问题就可以部署到腾讯云Serverless。同时,在微信公众号平台那里启用服务器配置。

成果

看下成果,一个简单的自动回复机器人就完成了,剩余如何开(tiao)发(xi)这个机器人就看开发者自己的脑洞了。

总结

微信公众号开发的场景比较适合使用腾讯云云函数

看微信公众平台的接入指南,按照以往的接入方式,是需要自己去购买服务器,等配置完服务器,怕是没啥兴致继续捣鼓逻辑代码了。而腾讯云云函数配合api网关就很方便,看着我上面的步骤,创建一个可用的云函数就需要几分钟吧,同时,目前云函数还是免费:)。

其次就是调试,如果是传统的一个nodejs服务,我也许还得分析微信的请求信息,来使用postman或者curl去调试吧。使用VS Code插件我就需要拷贝下微信的请求信息,生成一个API的测试模板,然后本地运行函数就好了,甚至可以使用vscode的debugger进行断点调试。

最后就是部署,这么一个比较简单的代码,如果部署在服务器上,大概率是手动上传,再重新run(给我我就这样,别跟我说啥CICD)。但是部署在云函数上,使用VS Code插件部署简单方便省事。

总的来说,是腾讯云云函数一个比较合适的使用实践。

Github Actions自动部署云函数

Github Actions是什么

Github,从事IT行业的各位应该都知道,即一个代码托管的服务平台。

Github Actions是Github近来推出的新功能,能够自动化构建,部署我们的github项目。

个人认为,对标现成的工具,例如Travis CI,Jenkins,是Github官方的CI/CD工具。

具体的Github Actions的入门,这里不赘述,各位可以去以下的网址进行深入了解。

Github Help: https://help.github.com/en/categories/automating-your-workflow-with-github-actions

Github Actions 入门教程:http://www.ruanyifeng.com/blog/2019/09/getting-started-with-github-actions.html

腾讯云云函数与CI/CD

传统的架构一般需要把整个可运行的代码,部署到服务器。与传统的架构不同,Serverless架构需要把项目拆成多个云函数,然后进行部署。

个人理解,虽然架构模式不一样,但是软件开发的生命周期是一样的。无论是传统的架构还是Serverless,在部署前都得经过编译,构建,测试等。这些大多是重复的工作,所以接入CI/CD是解放生产力,提高效率,保证项目质量的一个必经之路。Serverless架构亦是需要CI/CD。

所以此篇文章旨在,使用Github Actions来自动部署腾讯云云函数来展示,Serverless如何和现有的CI/CD工具结合,来自动化整个发布流程。

前期准备

1、得注册一个Github的账户

2、得注册一个腾讯云的账户

3、Github Actions仍处于Beta阶段,需要体验得去 https://github.com/features/actions 进行排队申请使用资格。

4、了解 SCF CLI工具的使用,https://cloud.tencent.com/document/product/583/33445

实践过程

1、申请到使用资格后,可在github项目中看到新增了 Actions 的选项卡。可以点击进去看看里面会有什么。

2、使用SCF CLI工具部署云函数需要用到腾讯云的API 秘钥。

秘钥获取可以前往腾讯云控制台,https://console.cloud.tencent.com/cam/capi 获取。

然后Github Actions提供了secrets,可以将API秘钥此类隐私信息保存在secrets中。

点击Settings,可以看到Secrets,点击Secrets,可以来到如下页面,进行编辑。

3、github新建一个项目。

git clone到本地,进入到项目根目录,使用SCF CLI工具初始化一个函数。

eg:

git clone https://github.com/Juliiii/test-scf-github-actions.git

cd test-scf-github-actions

scf init --runtime nodejs8.9

4、点击github项目的Actions的选项卡,进行编辑自动化工作流的yml文件。

具体操作,请各位自行去实践。

下面是我实践时编写的一个yml文件。每一步的含义都提供给各位注释

eg:

name: CI

on: [push] # push时触发

jobs:
  build:
    runs-on: ubuntu-latest # 执行环境为ubuntu-latest
    steps:
    - uses: actions/checkout@v1 # 现成actions,获取源码
    - uses: actions/setup-node@v1 # 安装nodejs,版本指定10
      with:
        node-version: '10.x'
    - name: npm install # 安装函数的依赖
      run: cd hello_world && npm install
    - name: pip --version
      run: pip --version
    - name: install SCF CLI # 安装SCF CLI
      run: sudo pip install scf
    - name: scf configure # scf cofigure, 配置appid, 地域和api秘钥等信息
      env: 
        APPID: 1252618971
        REGION: ap-guangzhou
        SECRET_ID: ${{ secrets.TENCENTCLOUD_SECRET_ID }} # 值存放在第二步所说的secrets中
        SECRET_KEY: ${{ secrets.TENCENTCLOUD_SECRET_KEY }} # 同上
      run: scf configure set --appid $APPID --region $REGION --secret-id $SECRET_ID --secret-key $SECRET_KEY
    - name: scf deploy # scf deploy
      run: scf deploy -t ./hello_world/template.yaml -f
  

5、之后push函数代码到github仓库后,github会自动根据我们编写的yml文件进行代码的获取,安装依赖和部署。

以下是某次构建的全部流程。

总结

以上是本人了解到Github Actions后,考虑到和腾讯云云函数的结合可能性,做的一次简单的实践。大家可以看到流程是可以走通的。至于更多的丰富用法,例如增加自动化测试,增加代码检查等,大家都可以依托自己的阅读和实践能力去完善。其实大家也可以发现,与传统架构不同,其中一点在于部署的终点不一样,传统架构部署在虚拟机,docker等,而Serverless部署在云供应商提供的云服务上,只要借助云供应商提供的开发者工具,即可自动化Serverless项目整个的构建发布流程。

实践项目的github地址:https://github.com/Juliiii/test-scf-github-actions

Less is More。

记一次使用腾讯云Serverless的VS Code插件来定位问题

近日发现了一个比较有趣的库,co-wechat

该库封装了微信公共平台消息接口,并将其作为中间件的形式,配合express,koa等框架使用。

所以尝试着配合腾讯云Serverless,api网关以及该库,做一个自动回复的机器人。

根据微信公众平台技术文档中的指引进行了 个人订阅号的申请,开发者基本配置。以及根据腾讯云云函数文档完成云函数的创建以及Api网关触发器的配置,这里就不一一赘述。跟着文档做就行了。

一开始,跟着co-wechat以及serverless-http的文档写出来以下代码:

"use strict";
const Koa = require("koa");
const serverless = require("serverless-http");
const wechat = require("co-wechat");
const config = {
	token: 'token',
  appid: 'appid',
  encodingAESKey: 'encodinAESKey',
};

const app = new Koa();

app.use(
  wechat(config).middleware(async (message, ctx) => {
    console.log(message);
    // 微信输入信息就是这个 message
    if (message.FromUserName === "作者") {
      // 回复屌丝(普通回复)
      return "hehe";
    } else if (message.MsgType === "text") {
      //你也可以这样回复text类型的信息
      return {
        content: "diaosi",
        type: "text"
      };
    } else if (message.FromUserName === "hehe") {
      // 回复一段音乐
      return {
        type: "music",
        content: {
          title: "来段音乐吧",
          description: "一无所有",
          musicUrl: "http://mp3.com/xx.mp3",
          hqMusicUrl: "http://mp3.com/xx.mp3"
        }
      };
    } else if (message.FromUserName === "kf") {
      // 转发到客服接口
      return {
        type: "customerService",
        kfAccount: "test1@test"
      };
    } else {
      // 回复高富帅(图文回复)
      return [
        {
          title: "你来我家接我吧",
          description: "这是女神与高富帅之间的对话",
          picurl: "http://nodeapi.cloudfoundry.com/qrcode.jpg",
          url: "http://nodeapi.cloudfoundry.com/"
        }
      ];
    }
  })
);

const handler = serverless(app);
exports.main_handler = async (event, context, callback) => {
  return await handler(event, context);
};

并上传到腾讯云Serverless中,同时在微信公众号发送了一个词语,触发了一次云函数:

image-20190824144317024

但是,我却得到了这个错误。

错误可能情况有如下几种:

1、云函数执行错误,这个可以否掉了,因为看起来并不像

2、代码逻辑错误,这个也可以否掉了,基本都是看文档,CV文档的demo代码,流行库的demo代码基本没啥问题。

3、微信公众号的相关配置错误,这个也可以否掉,跟着文档一步步走的,除非我的阅读理解有问题。

4、依赖的第三方库,co-wechat以及serverless-http的处理有问题。

错误可能情况4比较有可能,但是线上的日志查看已经无法继续定位问题,不过幸好腾讯云Serverless还提供了另一个利器:

Tencent Serverless Toolkit for VS Code

所以,我开始使用该VS Code插件进行本地调试。

为了完全模拟微信公众号发来的请求信息,我现在控制台的日志中,CV了一份请求信息,并使用VS Code插件新增了一个Api网关的测试模板。

image-20190824145218107

image-20190824145326953

并在.vscode文件夹下,新建一个launch.json,使用快捷键生成了debugger的scf的调试配置,并加上了cwd,指定工作空间。

image-20190824145424122

由于怀疑是co-wechat库的问题,所以去node_modules下的co-wehcat的源码中看了看,发现他们源码中的确有返回:
image-20190824145920027

但是是哪里出了问题呢,所以我在这附近打了个 断点。

并F5,选择了我新建的测试模板,开始debug。

image-20190824150700529

跳到断点处,发现,这个库返回这个错误的原因在于这一句if代码判断微信发给云函数的签名,即signature与库自己算出来的不一致。然后我再在watch栏添加了这两个值,发现微信发给云函数的签名,不见了!

为什么会不见呢?

我的测试模板中,是明确带着的,大家可以回到上面的测试模板的图可以看到queryString中的signature是有值的。

我再在watch栏查看了ctx.query的值。

image

空对象!

那问题就基本可以定位在 serverless-http这个库了。

去到该库的源代码,在create-request文件中发现了如下代码:

image

一目了然。原因为:serverless-http并没有很好兼容腾讯云Api网关的请求格式。腾讯云Api网关的请求格式中,queryString存放着请求的queryString参数,但是serverless-http只取图中两个参数名的参数作为express,koa等的query参数,没有取queryString参数的值

靠着Tencent Serverless Toolkit for VS Code定位了到了问题,所以我在代码中做下兼容,即解决了这个问题。并运行成功,做了个简单的自动回复机器人~

exports.main_handler = async (event, context, callback) => {
  event.queryStringParameters = event.queryString;

  return await handler(
    { ...event, queryStringParameters: event.queryString },
    context
  );
};

image

记一次使用腾讯云Serverless的VS Code插件来定位问题

近日发现了一个比较有趣的库,co-wechat

该库封装了微信公共平台消息接口,并将其作为中间件的形式,配合express,koa等框架使用。

所以尝试着配合腾讯云Serverless,api网关以及该库,做一个自动回复的机器人。

根据微信公众平台技术文档中的指引进行了 个人订阅号的申请,开发者基本配置。以及根据腾讯云云函数文档完成云函数的创建以及Api网关触发器的配置,这里就不一一赘述。跟着文档做就行了。

一开始,跟着co-wechat以及serverless-http的文档写出来以下代码:

"use strict";
const Koa = require("koa");
const serverless = require("serverless-http");
const wechat = require("co-wechat");
const config = {
  token: 'token',
  appid: 'appid',
  encodingAESKey: 'encodinAESKey',
};

const app = new Koa();

app.use(
  wechat(config).middleware(async (message, ctx) => {
    console.log(message);
    // 微信输入信息就是这个 message
    if (message.FromUserName === "作者") {
      // 回复屌丝(普通回复)
      return "hehe";
    } else if (message.MsgType === "text") {
      //你也可以这样回复text类型的信息
      return {
        content: "diaosi",
        type: "text"
      };
    } else if (message.FromUserName === "hehe") {
      // 回复一段音乐
      return {
        type: "music",
        content: {
          title: "来段音乐吧",
          description: "一无所有",
          musicUrl: "http://mp3.com/xx.mp3",
          hqMusicUrl: "http://mp3.com/xx.mp3"
        }
      };
    } else if (message.FromUserName === "kf") {
      // 转发到客服接口
      return {
        type: "customerService",
        kfAccount: "test1@test"
      };
    } else {
      // 回复高富帅(图文回复)
      return [
        {
          title: "你来我家接我吧",
          description: "这是女神与高富帅之间的对话",
          picurl: "http://nodeapi.cloudfoundry.com/qrcode.jpg",
          url: "http://nodeapi.cloudfoundry.com/"
        }
      ];
    }
  })
);

const handler = serverless(app);
exports.main_handler = async (event, context, callback) => {
  return await handler(event, context);
};

并上传到腾讯云Serverless中,同时在微信公众号发送了一个词语,触发了一次云函数:

但是,我却得到了这个错误。

错误可能情况有如下几种:

1、云函数执行错误,这个可以否掉了,因为看起来并不像

2、代码逻辑错误,这个也可以否掉了,基本都是看文档,CV文档的demo代码,流行库的demo代码基本没啥问题。

3、微信公众号的相关配置错误,这个也可以否掉,跟着文档一步步走的,除非我的阅读理解有问题。

4、依赖的第三方库,co-wechat以及serverless-http的处理有问题。

错误可能情况4比较有可能,但是线上的日志查看已经无法继续定位问题,不过幸好腾讯云Serverless还提供了另一个利器:

Tencent Serverless Toolkit for VS Code

所以,我开始使用该VS Code插件进行本地调试。

为了完全模拟微信公众号发来的请求信息,我现在控制台的日志中,CV了一份请求信息,并使用VS Code插件新增了一个Api网关的测试模板。

并在.vscode文件夹下,新建一个launch.json,使用快捷键生成了debugger的scf的调试配置,并加上了cwd,指定工作空间。

由于怀疑是co-wechat库的问题,所以去node_modules下的co-wehcat的源码中看了看,发现他们源码中的确有返回:

但是是哪里出了问题呢,所以我在这附近打了个 断点。

并F5,选择了我新建的测试模板,开始debug。

跳到断点处,发现,这个库返回这个错误的原因在于这一句if代码判断微信发给云函数的签名,即signature与库自己算出来的不一致。然后我再在watch栏添加了这两个值,发现微信发给云函数的签名,不见了!

为什么会不见呢?

我的测试模板中,是明确带着的,大家可以回到上面的测试模板的图可以看到queryString中的signature是有值的。

我再在watch栏查看了ctx.query的值。

空对象!

那问题就基本可以定位在 serverless-http这个库了。

去到该库的源代码,在create-request文件中发现了如下代码:

一目了然。原因为:serverless-http并没有很好兼容腾讯云Api网关的请求格式。腾讯云Api网关的请求格式中,queryString存放着请求的query string,但是serverless-http只取图中两个参数名的值作为express,koa等的query参数的值,没有取queryString参数的值

靠着Tencent Serverless Toolkit for VS Code定位了到了问题,所以我在代码中做下兼容,即解决了这个问题。并运行成功,做了个简单的自动回复机器人~

exports.main_handler = async (event, context, callback) => {
  event.queryStringParameters = event.queryString;

  return await handler(
    { ...event, queryStringParameters: event.queryString },
    context
  );
};

Vue-Socket.io源码解读

背景

有一个项目,去年12月份开始重构,项目涉及到了socket。但是socket用的是以前一个开发人员封装的包(这个一直被当前的成员吐槽为什么不用已经千锤百炼的轮子)。因此,趁着这个重构的机会,将vue-socket.io引入,后端就用socket.io。我也好奇看了看vue-socket.io的源码(我不会说是因为这个库的文档实在太简略了,我为了稳点去看源码了解该怎么用)

开始

  • 文件架构
    文件架构我们主要看src下的三个文件,可以看出该库是用了观察者模式
  • Main.js
// 这里创建一个observe对象,具体做了什么可以看Observer.js文件
let observer = new Observer(connection, store)

// 将socket挂载到了vue的原型上,然后就可以
// 在vue实例中就可以this.$socket.emit('xxx', {})
Vue.prototype.$socket = observer.Socket;
import store from './yourstore'
Vue.use(VueSocketio, socketio('http://socketserver.com:1923'), store);

我们如果要使用这个库的时候,一般是这样写的代码(上图2)。上图一的connection和store就分别是图二的后两个参数。意思分别为socket连接的url和vuex的store啦。图一就是将这两个参数传进Observer,新建了一个observe对象,然后将observe对象的socket属性挂载在Vue原型上。那么我们在Vue的实例中就可以直接 this.$sockets.emit('xxx', {})

// 👇就是在vue实例的生命周期做一些操作
Vue.mixin({
    created(){
        let sockets = this.$options['sockets']

        this.$options.sockets = new Proxy({}, {
            set: (target, key, value) => {
                Emitter.addListener(key, value, this)
                target[key] = value
                return true;
            },
            deleteProperty: (target, key) => {
                Emitter.removeListener(key, this.$options.sockets[key], this)
                delete target.key;
                return true
            }
        })

        if(sockets){
            Object.keys(sockets).forEach((key) => {
                this.$options.sockets[key] = sockets[key];
            });
        }
    },
    /**
     * 在beforeDestroy的时候,将在created时监听好的socket事件,全部取消监听
     * delete this.$option.sockets的某个属性时,就会将取消该信号的监听
     */
    beforeDestroy(){
        let sockets = this.$options['sockets']

        if(sockets){
            Object.keys(sockets).forEach((key) => {
                delete this.$options.sockets[key]
            });
        }
    }

下面就是在Vue实例的生命周期做一些操作。创建的时候,将实例中的$options.sockets的值先缓存下来,再将$options.sockets指向一个proxy对象,这个proxy对象会拦截外界对它的赋值和删除属性操作。这里赋值的时候,键就是socket事件,值就是回调函数。赋值时,就会监听该事件,然后将回调函数,放进该socket事件对应的回调数组里。删除时,就是取消监听该事件了,将赋值时压进回调数组的那个回调函数,删除,表示,我不监听了。这样写法,其实就跟vue的响应式一个道理。也因此,我们就可以动态地添加和移除监听socket事件了,比如this.$option.sockets.xxx = () => ()delete this.$option.sockets.xxx。最后将缓存的值,依次赋值回去,那么如下图的写法就会监听到事件并执行回调函数了:

var vm = new Vue({
  sockets:{
    connect: function(){
      console.log('socket connected')
    },
    customEmit: function(val){
      console.log('this method was fired by the socket server. eg: io.emit("customEmit", data)')
    }
  },
  methods: {
    clickButton: function(val){
        // $socket is socket.io-client instance
        this.$socket.emit('emit_method', val);
    }
  }
})
  • Emitter.js
    Emitter.js主要是写了一个Emitter对象,该对象提供了三个方法:
addListener
addListener(label, callback, vm) {
    // 回调函数类型是回调函数才对
    if(typeof callback == 'function'){
        // 这里就很常见的写法了,判断map中是否已经注册过该事件了
        // 如果没有,就初始化该事件映射的值为空数组,方便以后直接存入回调函数
        // 反之,直接将回调函数放入数组即可
        this.listeners.has(label) || this.listeners.set(label, []);
        this.listeners.get(label).push({callback: callback, vm: vm});

        return true
    }

    return false
}

其实很常规啦,实现发布订阅者模式或者观察者模式代码的同学都很清楚这段代码的意思。Emiiter用一个map来存储事件以及它对应的回调事件数组。这段代码先判断map中是否之前已经存储过了该事件,如果没有,初始化该事件对应的值为空数组,然后将当前的回调函数,压进去,反之,直接压进去。

removeListener
if (listeners && listeners.length) {
    index = listeners.reduce((i, listener, index) => {
        return (typeof listener.callback == 'function' && listener.callback === callback && listener.vm == vm) ?
            i = index :
            i;
    }, -1);

    if (index > -1) {
        listeners.splice(index, 1);
        this.listeners.set(label, listeners);
        return true;
    }
}
return false;

这里也很简单啦,获取该事件对应的回调数组。如果不为空,就去寻找需要移除的回调,找到后,直接删除,然后将新的回调数组覆盖原来的那个就可以了

emit

if (listeners && listeners.length) {
    listeners.forEach((listener) => {
        listener.callback.call(listener.vm,...args)
    });
    return true;
}
return false;

这里就是监听到事件后,执行该事件对应的回调函数,注意这里的call,因为监听到事件后我们可能要修改下vue实例的数据或者调用一些方法,用过vue的同学都知道我们都是this.xxx来调用的,所以一定得将回调函数的this指向vue实例,这也是为什么存回调事件时也要把vue实例存下来的原因。

  • Observer.js
constructor(connection, store) {
    // 这里很明白吧,就是判断这个connection是什么类型
    // 这里的处理就是你可以传入一个连接好的socket实例,也可以是一个url
    if(typeof connection == 'string'){
        this.Socket = Socket(connection);
    }else{
        this.Socket = connection
    }

    // 如果有传进vuex的store可以响应在store中写的mutations和actions
    // 这里只是挂载在这个oberver实例上
    if(store) this.store = store;

    // 监听,启动!
    this.onEvent()

}

这个Observer.js里也主要是写了一个Observer的class,以上是它的构造函数,构造函数第一件事是判断connection是不是字符串,如果是就构建一个socket实例,如果不是,就大概是个socket的实例了,然后直接挂载在它的对象实例上。其实这里我觉得可以参数检查严格点, 比如字符串被人搞怪地可能会传入一个非法的url,对吧。这个时候判断下,抛出一个error提醒下也好,不过应该也没人这么无聊吧,2333。然后如果传入了store,也挂在对象实例上吧。最后就启动监听事件啦。我们看看onEvent的逻辑

    onEvent(){
        // 监听服务端发来的事件,packet.data是一个数组
        // 第一项是事件,第二个是服务端传来的数据
        // 然后用emit通知订阅了该信号的回调函数执行
        // 如果有传入了vuex的store,将该事件和数据传入passToStore,执行passToStore的逻辑
        var super_onevent = this.Socket.onevent;
        this.Socket.onevent = (packet) => {
            super_onevent.call(this.Socket, packet);

            Emitter.emit(packet.data[0], packet.data[1]);

            if(this.store) this.passToStore('SOCKET_'+packet.data[0],  [ ...packet.data.slice(1)])
        };

        // 这里跟上面意思应该是一样的,我很好奇为什么要分开写,难道上面的写法不会监听到下面的信号?
        // 然后这里用一个变量暂存this
        // 但是下面都是箭头函数了,我觉得没必要,毕竟箭头函数会自动绑定父级上下文的this
        let _this = this;

        ["connect", "error", "disconnect", "reconnect", "reconnect_attempt", "reconnecting", "reconnect_error", "reconnect_failed", "connect_error", "connect_timeout", "connecting", "ping", "pong"]
            .forEach((value) => {
                _this.Socket.on(value, (data) => {
                    Emitter.emit(value, data);
                    if(_this.store) _this.passToStore('SOCKET_'+value, data)
                })
            })
    }

这里就是有点类似重载onevent这个函数了,监听到事件后,将数据拆包,然后通知执行回调和传递给store。大体的逻辑是这样子。然后这代码实现有两部分,第一部分和第二部分逻辑基本一样。只是分开写。(其实我也不是很懂啦,如果很有必要的话,我猜第一部分的写法还监听不了第二部分的事件吧,所以要另外监听)。最后只剩下一个passToStore了,其实也很容易懂

 passToStore(event, payload){
     // 如果事件不是以SOCKET_开头的就不用管了
     if(!event.startsWith('SOCKET_')) return

     // 这里遍历vuex的store中的mutations
     for(let namespaced in this.store._mutations) {
         // 下面的操作是因为,如果store中有module是开了namespaced的,会在mutation的名字前加上 xxx/
         // 这里将mutation的名字拿出来
         let mutation = namespaced.split('/').pop()
         // 如果名字和事件是全等的,那就发起一个commit去执行这个mutation
         // 也因此,mutation的名字一定得是 SOCKET_开头的了
         if(mutation === event.toUpperCase()) this.store.commit(namespaced, payload)
     }
     // 这里类似上面
     for(let namespaced in this.store._actions) {
         let action = namespaced.split('/').pop()

         // 这里强制要求了action的名字要以 socket_ 开头
         if(!action.startsWith('socket_')) continue

         // 这里就是将事件转成驼峰式
         let camelcased = 'socket_'+event
                 .replace('SOCKET_', '')
                 .replace(/^([A-Z])|[\W\s_]+(\w)/g, (match, p1, p2) => p2 ? p2.toUpperCase() : p1.toLowerCase())

         // 如果action和事件全等,那就发起这个action
         if(action === camelcased) this.store.dispatch(namespaced, payload)
     }
 }

passToStore嘛其实就是做两个事情,一个是获取与该事件对应的mutation,然后发起一个commit,一个是获取与该事件对应的action,然后dispatch。只是这里的实现对mutations和actions的命名有了要求,比如mutations的命名一定得是SOCKET_开头,action就是一个得socket_开头,然后还得是驼峰式命名。

最后

  • 源码的详细的注释在这里,欢迎大家提issue,如果能star和fork就更好了。以后我尽量更新自己阅读源码的感悟,大家一起学习。

redux源码解读

背景

最近不想写业务代码了,因为就得去实习了。所以打算开始补补坑。比如自己阅读源码的计划。所以今天来聊聊redux的源码。后续会有redux-thunkreact-redux的源码阅读。搞定这些的话,就开始阅读一个node的库的源码了,比如eventproxyanywhere

开始

  • 总览, redux的文件结构

    文件架构

文件看起来貌似不少,其实,要理解redux的内部实现,主要就看 createStore.js

,applyMiddleware.js ,combineReducers.js和compose.js。下面从createStore.js开始看。

  • createStore.js

    export default function createStore(reducer, preloadedState, enhancer) {
      // 如果第二个参数没有传入初始的state,而是传入了enhancer(为applyMiddleware调用的返回值), 那就将第二个参数,即preloadedState赋值给enhancer
      if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') {
        enhancer = preloadedState
        preloadedState = undefined
      }
      // 如果传入了enhancer,但enhancer不是一个函数,报错
      if (typeof enhancer !== 'undefined') {
        if (typeof enhancer !== 'function') {
          throw new Error('Expected the enhancer to be a function.')
        }
    	// 反之, 执行。注意此处。此处意味着,如果createStore的时候传入了enhancer,是会将createStore传入enhancer中,执行enhancer, 而enhancer的返回值也是一个函数。具体的可以等到下面我们讲解applyMiddleware,看完你就知道到底发生了什么。
        return enhancer(createStore)(reducer, preloadedState)
      }
    
      // 如果没传入enhancer,就继续下面的逻辑
      
      // reducer是要求为一个函数的,如果不是一个函数,报错
      if (typeof reducer !== 'function') {
        throw new Error('Expected the reducer to be a function.')
      }    
      
      .....
      .....
      
      // 最后createStore就会返回dispatch,subscribe, getState等几个常用的api
      return {
        dispatch,
        subscribe,
        getState,
        replaceReducer,
        [$$observable]: observable
      };    
    }

    上面的代码给大家展览了下createStore这个函数大概做了什么,其实就是封装了一些api,最后暴露给用户使用。接下来看一下各个api的实现:

    先看一下私有变量的定义

      let currentReducer = reducer // 就是reducer嘛
      let currentState = preloadedState // 就是传入的初始state嘛
      let currentListeners = [] // 当前的监听器队列
      let nextListeners = currentListeners // 未来的监听器队列
      let isDispatching = false //  标志是否正在dispatch

    getState : 用来获取store中的state的。因为redux是不允许用户直接操作state,对于state的获取,是得通过getState的api来获取store内部的state。

      function getState() {
        // 如果正在dispatch的话, 说明新的state正在计算中,现在的state是旧的,为了确保用户能获得新的
        // state,所以要加一个判断,如果是正在dispatch的话,就报错,反之,返回现在的state
        if (isDispatching) {
          throw new Error(
            'You may not call store.getState() while the reducer is executing. ' +
              'The reducer has already received the state as an argument. ' +
              'Pass it down from the top reducer instead of reading it from the store.'
          )
        }
    
        return currentState
      }

    subscribe :redux提供了用户一个监听state变化的api,这个尤为重要,如果没有这个api的暴露,react-redux应该就比较实现了。

      function subscribe(listener) {
        // listener是state变化时的回调,必须是个函数
        if (typeof listener !== 'function') {
          throw new Error('Expected the listener to be a function.')
        }
    	// 如果是正在dispatch中,就报错。因为要确保state变化时,监听器的队列也必须是最新的。所以监听器的注册要在计算新的state之前。
        if (isDispatching) {
          throw new Error(
            'You may not call store.subscribe() while the reducer is executing. ' +
              'If you would like to be notified after the store has been updated, subscribe from a ' +
              'component and invoke store.getState() in the callback to access the latest state. ' +
              'See https://redux.js.org/api-reference/store#subscribe(listener) for more details.'
          )
        }
    
        // 标志是否注册,额,其实个人感觉没啥必要。不过仔细想想,应该是防止用户多次调用取消监听的函数。
        let isSubscribed = true
    
        // 其实这个函数就是判断当前的监听器队列和未来的是否一样,如果不一样那就将当前的赋值给未来的,额,还是不是很理解为什么得这么实现,可能是为了达到数据不可变的效果,避免压进新的回调时,导致当前的监听器队列也有这个回调
        ensureCanMutateNextListeners()
        // 将回调压进未来的监听器队列中
        nextListeners.push(listener)
    
        // 注册监听器后会返回一个取消监听的函数
        return function unsubscribe() {
          // 如果是已经调用该函数取消监听了,就返回
          if (!isSubscribed) {
            return
          }
    
          if (isDispatching) {
            throw new Error(
              'You may not unsubscribe from a store listener while the reducer is executing. ' +
                'See https://redux.js.org/api-reference/store#subscribe(listener) for more details.'
            )
          }
    
          // 标志已经取消了
          isSubscribed = false
    
          ensureCanMutateNextListeners()
            
          // 删除
          const index = nextListeners.indexOf(listener)
          nextListeners.splice(index, 1)
        }
      }

    dispatch : 该函数是与getState对应的,getState是读,那dispatch就是写。redux的改动state,只能通过发起一个dispatch,传达一个action给reducer,reducer会根据action和currentState以及自己的内部实现逻辑,来计算出新的state,从而达到写的目的。

      function dispatch(action) {
        // action要求是一个简单对象,而一个简单对象就是指通过对象字面量和new Object()创建的对象,如果不是就报错。
        if (!isPlainObject(action)) {
          throw new Error(
            'Actions must be plain objects. ' +
              'Use custom middleware for async actions.'
          )
        }
    
        // reducer内部是根据action的type属性来switch-case,决定用什么逻辑来计算state的,所以type属性是必须的。
        if (typeof action.type === 'undefined') {
          throw new Error(
            'Actions may not have an undefined "type" property. ' +
              'Have you misspelled a constant?'
          )
        }
    
        // 如果是已经在dispatch的,就报错,避免不一致
        if (isDispatching) {
          throw new Error('Reducers may not dispatch actions.')
        }
    
        // 这里就是计算新的state,并赋值给currentState
        try {
          isDispatching = true
          currentState = currentReducer(currentState, action)
        } finally {
          isDispatching = false
        }
    
        // state更新了后,就如之前我们所说的subscribe,将注册的回调都触发一遍。大家要注意这里,是都触发一遍哦!这个点了解,react-redux的一些原理会比较容易理解。
        const listeners = (currentListeners = nextListeners)
        for (let i = 0; i < listeners.length; i++) {
          const listener = listeners[i]
          listener()
        }
    
        return action
      }

    以上就是createStore的大致实现。这个函数难度不大,更多的是一个了解redux的入口。咱们从这个入口来一点点挖掘别的代码的实现。下面先从combineReducers开始

  • combineReducers

    • 这个函数是用来整合多个reducers的, 因为createStore只接受一个reducer。
    • 这个函数分为两部分,第一部分是检验用户传入的reducers的准确性。第二部分就是计算state的逻辑。第一部分大家看一看也就了解了为什么,咱们主要看看第二部分
    export default function combineReducers(reducers) {
      ........
      ........
      return function combination(state = {}, action) {
        if (shapeAssertionError) {
          throw shapeAssertionError
        }
    
        if (process.env.NODE_ENV !== 'production') {
          const warningMessage = getUnexpectedStateShapeWarningMessage(
            state,
            finalReducers,
            action,
            unexpectedKeyCache
          )
          if (warningMessage) {
            warning(warningMessage)
          }
        }
    
        // hasChanged来标志是否计算出了新的state
        let hasChanged = false
        // 这个就是存储新的state的
        const nextState = {}
        // emmmm, 就是遍历每一个reducer,把action传进去,计算state
        for (let i = 0; i < finalReducerKeys.length; i++) {
          const key = finalReducerKeys[i]
          const reducer = finalReducers[key]
          const previousStateForKey = state[key]
          const nextStateForKey = reducer(previousStateForKey, action)
          // 如果某个reducer没有返回新的state,就报错
          if (typeof nextStateForKey === 'undefined') {
            const errorMessage = getUndefinedStateErrorMessage(key, action)
            throw new Error(errorMessage)
          }
          nextState[key] = nextStateForKey
          // 此处来判断是否有新的state
          hasChanged = hasChanged || nextStateForKey !== previousStateForKey
        }
        // 根据该标志,决定返回原来的state, 还是新的state
        return hasChanged ? nextState : state
      }
    }

    这个整合的过程就是将所有的reducer存在一个对象里。当dispatch一个action的时候,通过遍历每一个reducer, 来计算出每个reducer的state, 其中用到的优化就是每遍历一个reducer就会判断新旧的state是否发生了变化, 最后决定是返回旧state还是新state。最后得到的state的数据结构类似存reducer的数据结构,就是键为reducer的名字,值为对应reducer的值。这个部分其实也不难。下面继续,我们看看applyMiddleware的实现

  • applyMiddleware

    这个部分就是用来扩展redux的功能的。因为redux的最原始的功能就是操作state,管理state。如果我们需要在这个基础上,根据需求扩展一些功能,就需要通过使用别人编写好的中间件,或者自己编写的中间件来达到需求。比如,发起一个dispatch时,我们为了方便调试,不愿意每次自己手动console.log出这个action,这个时候编写一个logger中间件,就可以自动打印出每次dispatch发起的action,就很容易方便我们测试。又比如,我们要处理异步的action,就可以使用redux-thunk和redux-saga这类中间件。总之,该函数为我们提供了无限的可能。

    我们一点点来看代码:

    export default function applyMiddleware(...middlewares) {
      return createStore => (...args) => {
        const store = createStore(...args)
        ....
        ....
        ....
        return {
          ...store,
          dispatch
        }
      }

    先看个总览的,注意到applyMiddleware接受不定数量的中间件,然后返回一个接受一个creatStore作为参数,返回一个函数的函数。还记得我们在creatStore的时候么?那里有个场景就是

      if (typeof enhancer !== 'undefined') {
        if (typeof enhancer !== 'function') {
          throw new Error('Expected the enhancer to be a function.')
        }
    
        return enhancer(createStore)(reducer, preloadedState)
      }

    当enhancer不为空且为函数时,就执行该函数,并return回去,作为creatStore的返回值。而这个enhancer是什么呢?翻看redux的文档:

    const store = createStore(reducers, applyMiddleware(a, b, c));

    哦!enhancer就是applyMiddleware的结果,就是一个 creatStore => (...args) = {}的函数。那看下enhancer的代码:

      return createStore => (...args) => {
        const store = createStore(...args)
        // 1、也许有的同学一开始看到这个会有点蒙蔽, 我当时看到也是觉得奇怪, 这个dispatch的逻辑不对劲
        // 而且, 还把这个dispatch作为middleware的参数传了进去,代表在中间件时使用dispatch的逻辑是这个
        // 但是看到下面, dispatch = compose(...chain)(store.dispatch)
        // 还行, 根据作用域链, 我们可以知道在中间件中调用dispatch的时候, 其实就是调用了这个dispatch, 而不是一开始声明的逻辑
        // 而这个dispatch是已经经过compose的包装的了.逻辑到这里的时候就很清楚了
        let dispatch = () => {
          throw new Error(
            `Dispatching while constructing your middleware is not allowed. ` +
              `Other middleware would not be applied to this dispatch.`
          )
        }
    
        const middlewareAPI = {
          getState: store.getState,
          dispatch: (...args) => dispatch(...args)
        }
    
        // 2、compose是如何将中间件串联在一起的?
        // 首先一个最简单的中间件的格式: store => next => action => {}
        // 这一行代码就是传入了store, 获得了 next => action => {} 的函数
        const chain = middlewares.map(middleware => middleware(middlewareAPI))
    
        // 这一行代码其实拆分成两行
        // const composeRes = compose(...chain);
        // dispatch = composeRes(store.dispatch);
        // 第一行是通过compose, 将一个 这样 next => action => {} 的数组组合成 (...args) => f(g(b(...args))) 这么一个函数
        // 第二行通过传入store.dispatch, 这个store.dispatch就是最后一个 next => action => {}的next参数
        // 传入后 (...args) => f(g(b(...args)) 就会执行, 执行时, store.dispacth作为b的next传入, b函数结果action => {}会作为
        // g的next传入, 以此类推. 所以最后dispatch作为有中间件的store的dispatch属性输出, 当用户调用dispatch时, 中间件就会一个一个
        // 执行完逻辑后, 将执行权给下一个, 直到原始的store.dispacth, 最后计算出新的state
        dispatch = compose(...chain)(store.dispatch)
    
        return {
          ...store,
          dispatch
        }
      }

    跟着上面的注释,大家应该能弄懂enhancer的原理。我这里总结一下,enhancer接收一个creatStore,会在内部创建一个store,然后对该store进行增强,增强的部位在于dispatch。增强的具体方式是通过compose来构造一个dispatch链,链的具体形式就是**[中间件1,中间件2, ......, 中间件N, store.dispatch]** ,然后将增强的dispatch作为store新的dispatch暴露给用户。那用户每次dispatch的时候,就会依次执行每个中间件,执行完当前的,会将执行权交给下一个,直到reducer中,计算出新的state。

结语

网上讲解redux的源码很多,我这篇也是其中一个,主要是我个人学习源码后的,一种记录方式,加深自己印象,也为了之后忘了可以快速重温。redux其实实现上不难,但是**上真是精髓。程序员的编码能力是一个刚需,但是设计**是要借他山之玉,来攻石的。站在巨人的肩膀上看远方,希望自己多阅读他人的源码,在能了解原理更好运用的同时,以后自己也能创造出好用的轮子。谢谢大家花时间观看。另外,附源码地址:https://github.com/Juliiii/source-plan ,欢迎大家star和fork ,也欢迎大家和我讨论

reselect源码解读

背景

最近偶然想起了reselect这个库,因为面试今日头条的时候,面试官有问到,当时也回答上来了。只是有点好奇这个库是怎么做到记忆,从而达到缓存的。所以打开它的github看了一下,发现代码量不多,而且实现逻辑不难。所以就趁热写下这篇reselect源码阅读。

开始

  • reselect是什么?

    开始讲解代码前,我觉得还是得介绍下reselect是什么。因为其实不少react的初学者,很少会了解到这个库。我也是之前偶然看到的。引用它的github的readme的话:

    Simple “selector” library for Redux (and others) inspired by getters in NuclearJS,subscriptionsin re-frame and this proposal from speedskater .

    • Selectors can compute derived data, allowing Redux to store the minimal possible state.
    • Selectors are efficient. A selector is not recomputed unless one of its arguments changes.
    • Selectors are composable. They can be used as input to other selectors.

    英文好的同学可以自己看看,我个人的理解reselect就是一个根据redux的state,来计算衍生数据的库,并且这个库是当衍生数据依赖的state发生了变化,才会被重新计算,不然就继续用原来的。换句话说,这个库有缓存,记忆的作用。

    举个例子:

    import { createSelector } from 'reselect'
    
    const shopItemsSelector = state => state.shop.items
    const taxPercentSelector = state => state.shop.taxPercent
    
    const subtotalSelector = createSelector(
      shopItemsSelector,
      items => items.reduce((acc, item) => acc + item.value, 0)
    )
    
    const taxSelector = createSelector(
      subtotalSelector,
      taxPercentSelector,
      (subtotal, taxPercent) => subtotal * (taxPercent / 100)
    )
    
    export const totalSelector = createSelector(
      subtotalSelector,
      taxSelector,
      (subtotal, tax) => ({ total: subtotal + tax })
    )
    
    let exampleState = {
      shop: {
        taxPercent: 8,
        items: [
          { name: 'apple', value: 1.20 },
          { name: 'orange', value: 0.95 },
        ]
      }
    }
    
    console.log(subtotalSelector(exampleState)) // 2.15
    console.log(taxSelector(exampleState))      // 0.172
    console.log(totalSelector(exampleState))    // { total: 2.322 }

    举了官网例子,redux是只要维护items和 taxPercent这两个数据,根据这两个数据,可以计算出很多别的衍生数据。可能你也会说,不用这么做也行,还有别的做法。这个说法没错是没错,比如我们可以在reducer或mapStateToProps这些地方,写一个计算的函数,传入items和taxPercent就可以了。但是这个缺点在于,每次state变化,就会导致计算执行一次。这样就会导致很多无用的计算。如果计算不复杂,性能上的确没多大的区别,反之,就会造成性能上的不足。而reselect帮我们做好了记忆缓存的工作。即使state变化了,但是衍生数据的依赖的state中的数据没有发生变化,计算是不会执行的。所以下面,我们讲讲它的源码,不过重点会讲reselect是如何做到记忆化的。

  • 源码

    先介绍几个基本的函数

    这个为默认的比较函数,采用===的比较方式。

    // 比较函数,采用全等的比较方式
    function defaultEqualityCheck(a, b) {
      return a === b
    }

    这个函数是用来比较前后的依赖值是否发生变化

    /**
     * 比较前后的参数是否相等
     * 
     * @param {any} equalityCheck 比较函数,默认采用上面说到的全等比较函数
     * @param {any} prev 上一份参数
     * @param {any} next 当前份参数
     * @returns 比较的结果,布尔值
     */
    function areArgumentsShallowlyEqual(equalityCheck, prev, next) {
      // 先简单比较下是否为null和参数个数是不是一致
      if (prev === null || next === null || prev.length !== next.length) {
        return false
      }
    
      // 这里就用比较函数做一层比较,👇的那个源码注释,说用for循环,而不用forEach这些,因为forEach, return后还是会继续循环, 而for会终止。当数据量大的时候,性能提升明显
      // Do this in a for loop (and not a `forEach` or an `every`) so we can determine equality as fast as possible.
      const length = prev.length
      for (let i = 0; i < length; i++) {
        // 不相等就return false
        // 这里提一下,官方Readme里的一些F&Q中,基于使用了redux和默认的比较函数
        // (1) 有问到为什么state发生变化了,却不更新数据。那是因为用户
        // 的reducer没有返回一个新的state。这里使用默认的比较函数比较就会得出先后数据是一致的,所以就不会更新。
        // 比如往todolist里插入一个todo,如果只是 state.todos.push(todo)的话,那prev.todos和
        // state.todos还是指向同一个引用,所以===比较是true, 故不会更新
        // (2) 也有问到为什么state没有变化,但老是重新计算一次。那是因为state中某个属性经过filter或者别的操作后
        // 与原来的属性还是一样,但由于是不同的引用了,所以===比较还是会返回false,就会导致重新计算。
        // 所以源头都是默认的比较函数,如果大家需要根据业务需求自定义自己的比较函数的话,也是可以的。下面会继续说
        if (!equalityCheck(prev[i], next[i])) {
          return false
        }
      }
    
      return true
    }

    这个函数,我感觉就是判断传入的inputSelector是不是函数,如果不是,就报错。。。

    /**
     * 这个感觉就是拿来判断传入的inputSelector(reselect如是说,个人感觉就是获取依赖的函数)
     * 的类型是不是函数,如果有误就抛错误。反之就,直接返回func
     * 
     * @param {any} funcs 
     * @returns 
     */
    function getDependencies(funcs) {
      const dependencies = Array.isArray(funcs[0]) ? funcs[0] : funcs
    
      if (!dependencies.every(dep => typeof dep === 'function')) {
        // 报错的内容类似 function,string,function....
        const dependencyTypes = dependencies.map(
          dep => typeof dep
        ).join(', ')
        throw new Error(
          'Selector creators expect all input-selectors to be functions, ' +
          `instead received the following types: [${dependencyTypes}]`
        )
      }
    
      return dependencies
    }

    这里要重点说说了,defaultMemoize这个函数接受两个参数,第一个是根据依赖值计算出衍生值的方法,也就是我们createSelector时传入的最后一个函数,第二个就是比较函数,如果不传入话,就默认使用我们之前说的defaultEqualityCheck,即采用全等的方式去比较。然后这个函数返回了一个闭包,这个闭包能记住该函数作用域定义的两个变量,lastArgs和lastResult,一个是上一份的依赖值 ,一个是上一次计算得到的结果。而这个闭包的作用就是根据传入的新的依赖值,通过我们之前说的areArgumentsShallowlyEqual来比较新旧的依赖值,如果依赖值发生了变化,就调用func,来计算出新的衍生值,并存储到lastResult中,自然,lastArgs存储这次的依赖值,方便下一次比较使用。那么从这里就可以看到,reselect的记忆化的根本做法就是闭包,通过闭包的特性,来记忆上一次的依赖值和计算结果,根据比较结果,来决定是重新计算,还是使用缓存。那这个库,最核心的代码,就是👇的了,**就是闭包。

    /**
     * 默认的记忆函数
     * 
     * @export
     * @param {any} func 根据依赖的值,计算出新的值的函数
     * @param {any} [equalityCheck=defaultEqualityCheck] 比较函数,这里可以自定义
     * @returns function
     */
    export function defaultMemoize(func, equalityCheck = defaultEqualityCheck) {
      // 存储上一次计算得到的结果和依赖的参数
      let lastArgs = null
      let lastResult = null
      // we reference arguments instead of spreading them for performance reasons
      // 返回一个函数
      return function () {
        // 该函数执行的时候,会先对上一份参数和当前的参数做个比较,比较方式由equalityCheck决定,如果用户不自定义的话,默认采用全等比较
        if (!areArgumentsShallowlyEqual(equalityCheck, lastArgs, arguments)) {
          // apply arguments instead of spreading for performance.
          // 如果是发生了改变,重新计算值,并存到lastResult中,下次如果没变的话可以直接返回
          lastResult = func.apply(null, arguments)
        }
    
        // 将当前的参数存储到lastArgs中,下次使用
        lastArgs = arguments
    
        // 返回结果
        return lastResult
      }
    }

    这个函数就是能让我们自定义的函数,比如自定义记忆函数memoize,或者自定义比较函数。而我们使用该库的createSelector就是默认只传入defaultMemoize,执行该函数得到的返回值。该函数内部用了两次记忆函数,一个是我们传入的,一个是defaultMemoize。第一个是为了根据我们传入的记忆函数来缓存数据,第二个是这个库内部做一个优化。举个例子,这个库和redux一起使用,而我们使用redux的都知道,reducer是根据action.type来更新state的,如果reducer中没有某个action.type的更新逻辑,那就会返回旧的state。所以这个时候通过defaultMemoize来加一层优化,可以针对该情况,减少计算的次数。

    /**
     * createSelector的创建函数
     * 
     * @export
     * @param {any} memoize 记忆函数
     * @param {any} memoizeOptions 其余的一些option,比如比较函数
     * @returns function
     */
    export function createSelectorCreator(memoize, ...memoizeOptions) {
      return (...funcs) => {
        // 重新计算的次数
        let recomputations = 0
        // 取出计算的函数
        const resultFunc = funcs.pop()
        // 将所有获取依赖的函数传入getDependencies,判断是不是都是函数
        const dependencies = getDependencies(funcs)
    
        // 这里调用了memoize,传入一个func和传入的option,所以这里是生成真正核心的计算代码
        // 而这个func就是我们自己定义的根据依赖,计算出数据的方法,也是我们createSelector时
        // 传入的最后一个参数,同时也传入memoizeOptions,一般是传入自定义的比较函数
        // 
        // 而这个memoize返回的函数,我称为真正的记忆函数,当被调用时,传入的是我们传入的inputSelector的返回值,
        // 而这个inputSelector一般是从store的state中取值,所以每次dispatch一个redux时
        // 会导致组件和store都会被connect一遍,而这个函数会被调用,比较上次的state和这次
        // 是不是一样,是一样就不计算了,返回原来的值,反之返回新计算的值。
        const memoizedResultFunc = memoize(
          function () {
            recomputations++
            // apply arguments instead of spreading for performance.
            return resultFunc.apply(null, arguments)
          },
          ...memoizeOptions
        )
    
        // 这里是默认使用defaultMemoize,额,这里传入arguments应该是state和props,算是又做了一层优化
        // 因为reducer是不一定会返回一个新的state,所以state没变的时候,真正的记忆函数就不用被调用。
        // If a selector is called with the exact same arguments we don't need to traverse our dependencies again.
        const selector = defaultMemoize(function () {
          const params = []
          const length = dependencies.length
    
          // 根据传入的inputSelector来从state中获取依赖值
          for (let i = 0; i < length; i++) {
            // apply arguments instead of spreading and mutate a local list of params for performance.
            params.push(dependencies[i].apply(null, arguments))
          }
          // 调用真正的记忆函数
          // apply arguments instead of spreading for performance.
          return memoizedResultFunc.apply(null, params)
        })
    
        // 最后返回
        selector.resultFunc = resultFunc
        selector.recomputations = () => recomputations
        selector.resetRecomputations = () => recomputations = 0
        return selector
      }
    }

结语

以上就是reselect的源码解读。这个库也是比较容易阅读的,因为代码总数就100来行,而且逻辑上不是很难理解。总结一句话,reselect是起到计算衍生值和优化性能的作用,它有点类似vue中的computed功能,而它的实现核心就是闭包。具体一点,就是比较前后的store的state,来决定是否更新衍生值,是,那就执行我们给予的更新逻辑来更新,不是,那就返回之前计算好的结果。源码地址:https://github.com/Juliiii/source-plan, 欢迎大家star和fork,如有不对,请issue,谢谢

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.