Giter Club home page Giter Club logo

blog's People

Contributors

js-hao avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar

Forkers

maaooo

blog's Issues

Node从入门到放弃——核心模块(施工中)

核心模块可谓是Node中的地位是重中之重的,它不仅作为底层模块不断为上层提供各种基础支持,同时在性能上的表现也是非常之优异;作为开发者来说,想要在Node上有更深入的了解,学习核心模块基础知识是必不可少的一环。

本文主要涉及核心模块的一些基础知识,其中一些比较难理解的地方我也尽量以通俗易懂的方式描述,仅作抛砖引玉,欢迎共同探讨~

Node.js版本:v10.x

什么是核心模块?

回答这个概念前我们先了解下Node的模块组成包括哪些部分,根据性质划分主要是三种:

  • 核心模块
    这是我们本文的主题,下面会详细说明,这里不细讲;
  • 文件模块
    ./..//为开头的路径引入的模块为文件模块,一般情况下我们自己写的模块就经常以相对/绝对路径引入,它们就属于文件模块的范畴;
  • 自定义模块
    一般情况下是指包,也就是常见的放在node_modules里面的模块啦(其实该描述不够严谨,这里不细讲,感兴趣的童鞋自己查阅~)

OK,回到主题,对于核心模块而言,从字面意思就可以知道它是node中最为核心的代码,通常以底层模块的形式提供各种功能。那我们能在Node项目里找到它吗?很显然不能,因为它早在Node的源代码编译过程中(安装Node)就已经编译成了可直接执行的二进制文件了,以mac os为例,它们最终整合成一个可直接运行的Node执行文件,而不是以文件目录形式输出各模块......
[图1]
因此想了解Node核心模块,可以查看最上面所提供的Node源码。

核心模块分类

一般而言,核心模块分为两种类型:一种是完全由C/C++实现的,称之为内置模块;而另外一种则是依赖了内置模块的由javascript编写的模块,我们就简单称之为javascript核心模块吧。

两种核心模块的特点

可能有读者很好奇,为什么在Node中需要使用多种语言去实现模块呢?都用javascript不好吗?其实这是有原因的。首先我们得知道javascript是一门解释型语言,要运行它就需要一个可以解释源码的宿主环境,比如浏览器就是一种宿主环境。在执行javascript代码的过程中,其实是采取读取一行源码,解释成机器码执行,再读下一行代码,解释成机器码再执行这种模式的,虽然这种解释型语言具有跨平台的优势,但执行效率真的是十分低下,在一些对性能要求高的场景下(如转码)则暴露出了运行低效的弊端。

而C/C++则是非常典型的编译型语言,在运行前需要将它们从源码编译成机器码,虽然编译的过程比较耗时,但带来的好处则是性能得到了非常大的提升,因此由C/C++编写的内置模块非常适合于处理对性能要求高的场景。

这样说的话那岂不是将所有核心模块都转成C/C++模块就好啦?其实这样也不行,javascript核心模块也有它的优势,比如javascript模块可以发挥V8引擎优秀的垃圾回收机制去管理内存,而不需要像C/C++那样需要人为手动分配/释放内存,实现一些实际的复杂操作会更便捷。

因此在Node核心模块中,往往性能相关的核心功能交给内置模块实现,非性能部分则由javascript模块实现

核心模块是如何编译的?

刚刚我们提到过,核心模块在Node源码编译的过程中就直接编译成二进制文件了,那对于两种实现方式不同的模块文件,node会区别对待吗?接下来我们就拿常用的Buffer模块作为例子,大家都知道Buffer是典型的javascript与C++相结合的模块,由javascript实现部分功能的模块是buffer.js ,由c++实现部分的则是node_buffer.cc,一同看下Node是如何处理它们的:

  • 内置模块的编译

    由于内置模块完全由C/C++实现,因此它就被直接编译成了二进制文件,当Node进程启动之后,便直接加载进了内存,以后访问的时候直接从内存中读取,速度非常快。

  • javascript核心模块的编译
    javascript核心模块的处理比较特殊,Node是这样做的:首先它将js文件通过js2c.py工具转换成了C/C++头文件,叫node_natives.hnode_natives.h将js源代码以字符串数组的形式保存在了node命名空间中,然后再编译成了二进制文件。当然,node_natives.h是在Node源码编译过程中生成的,在仓库源码中我们是找不到的,但从**文件可以看出它被使用到。在Node进程启动之后,执行该二进制文件时,就会将里面的字符串数组提取出来并转回相应的javascript普通字符串,通过编译、执行后便生成出js模块。

    【此时应该有个流程图】

核心模块的引入与导出

如何在Canvas中实现自定义路径动画

在最近的项目中笔者需要做一个新需求:在canvas中实现自定义的路径动画。这里所谓的自定义路径不单单包括一条直线,也许是多条直线的运动组合,甚至还包含了贝塞尔曲线,因此,这个动画也许是下面这个样子的:

animation.gif

那么如何才能在canvas中实现这种动画效果呢?其实很简单,对于路径的处理svg非常在行,因此在canvas中实现自定义路径动画,我们需要借助svg的力量。

创建Path

制作动画前,先要拿到动画的路径,对此我们可以直接使用svg的path定义规则,比如我们定义了一条较为复杂的路径(它到底长什么样大家可以自己试试,这里就不展示了),然后,我们需要将定义好的路径导入进一个新生成的path元素中(我们只是借助svg的api,因此并不需要将其插到页面内)

const path = 'M0,0 C8,33.90861 25.90861,16 48,16 C70.09139,16 88,33.90861 88,56 C88,78.09139 105.90861,92 128,92 C150.09139,92 160,72 160,56 C160,40 148,24 128,24 C108,24 96,40 96,56 C96,72 105.90861,92 128,92 C154,93 168,78 168,56 C168,33.90861 185.90861,16 208,16 C230.09139,16 248,33.90861 248,56 C248,78.09139 230.09139,96 208,96 L48,96 C25.90861,96 8,78.09139 8,56 Z';

const pathElement = document.createElementNS('http://www.w3.org/2000/svg',"path"); 
pathElement.setAttributeNS(null, 'd', path);

getTotalLength与getPointAtLength

SVGPathElement提供的这两个api很关键,可以说它是实现路径动画的最为核心的地方(在svg内实现自定义路径动画一般也是通过这两个api去解决)详情请戳:SVGPathElement MDN

getTotalLength 方法可以获取SVGPathElement的总长度
getPointAtLength方法,传入一个长度x,将返回距离SVGPathElement起点的长度为x的终点坐标。

利用这两个api,通过循环的方式不断去更新canvas内所绘制的图形坐标,即可实现路径动画:

const length = pathElement.getTotalLength();
const duration = 1000; // 动画总时长
const interval = length / duration;
const canvas = document.querySelector('canvas');
const context = canvas.getContext('2d');
let time = 0, step = 0; 

const timer = setInterval(function() {
  if (time <= duration) {
    const x = parseInt(pathElement.getPointAtLength(step).x);
    const y = parseInt(pathElement.getPointAtLength(step).y);
    move(x, y);  // 更新canvas所绘制图形的坐标
    step++;
  } else {
    clearInterval(timer)
  }
}, interval);

function move(x, y) {
   context.clearRect(0, 0, canvas.width, canvas.height);
   context.beginPath();
   context.arc(x, y, 25, 0, Math.PI*2, true);
   context.fillStyle = '#f0f';
   context.fill();
   context.closePath();
}

最后,我们把它封装一下,即可实现一个在canvas中实现自定义动画的简易函数啦:

function customizePath(path, func) {
    const pathElement = document.createElementNS('http://www.w3.org/2000/svg',"path"); 
    pathElement.setAttributeNS(null, 'd', path);
      const length = pathElement.getTotalLength();
    const duration = 1000; 
    const interval = length / duration;
    let time = 0, step = 0; 
  
      const timer = setInterval(function() {
        if (time <= duration) {
              const x = parseInt(pathElement.getPointAtLength(step).x);
              const y = parseInt(pathElement.getPointAtLength(step).y);
              func(x, y);
              step++;
        } else {
              clearInterval(timer)
        }
     }, interval);
}

const path = 'M0,0 C8,33.90861 25.90861,16 48,16 C70.09139,16 88,33.90861 88,56 C88,78.09139 105.90861,92 128,92 C150.09139,92 160,72 160,56 C160,40 148,24 128,24 C108,24 96,40 96,56 C96,72 105.90861,92 128,92 C154,93 168,78 168,56 C168,33.90861 185.90861,16 208,16 C230.09139,16 248,33.90861 248,56 C248,78.09139 230.09139,96 208,96 L48,96 C25.90861,96 8,78.09139 8,56 Z';
const canvas = document.querySelector('canvas');
const context = canvas.getContext('2d');
function move(x, y) {
      context.clearRect(0, 0, canvas.width, canvas.height);
    context.beginPath();
      context.arc(x, y, 25, 0, Math.PI*2, true);
      context.fillStyle = '#f0f';
      context.fill();
      context.closePath();
}
customizePath(path, move);

一段代码,带你理解js执行上下文的工作流程

我相信很多前端初学者一开始都会被执行上下文这个概念弄晕,或者说似懂非懂。对于工作两年的我来说,说来实在惭愧,虽然知道它大概是什么,但总觉得没有一个更为清晰的认识(无法把它的工作过程描述清楚),因此最近特意温习了一遍,写下了这篇文章

执行上下文

要说清它的大体工作流程,需要提前说明三个基本概念,分别是thread of exection(线程)variable envirnoment(变量环境)call Stack(调用栈),这些概念我们或多或少接触过,接下来我会通过一段示例代码,和一系列图片,进一步解释这三个概念在执行上下文的运作流程。

一段代码

const num = 2;

function addOne(input) {
  const output = input + 1;
  return output;
}

const result = addOne(2);

这段代码做了什么

在运行上面这些代码前,js 引擎做的第一件是就是创建一个global execution context,也就是全局执行上下文:


先看图中的黑色箭头,它表示线程thread的执行顺序,众所周知 js 是单线程的,它会一行行、从上往下去执行代码;而右边的global memory,它用于存储当前上下文中的数据,由于线程目前处于全局上下文环境,故加了个global的前缀。

在这段代码中,第一行我们声明了一个名为num的不可变变量,并赋值为4, 因此global memory中就会分配内存,存储这个变量:


接着继续,当线程执行到第二行时,问题就来了:我们创建了一个addOne的变量,并把一个函数赋值于它,那在global memory里,到底存的是个啥?为了解答这个问题,我特意打印了一下:

function addOne(input) {
  const output = input + 1;
  return output;
}
console.log(addOne);


看,我们竟然把函数里的内容完完整整打印出来了,很明显,它存的是一个函数内部的“文本信息”。

其实很容易理解,当执行第二行的时候,该函数并没有被调用,因此线程不会立刻解析里面的内容,而是把它内部的信息以“文本内容”的形式保存下来,当需要执行的时候,才去解析变量里的函数内容,这也很好地解析了为什么函数内的异常仅会在函数被调用时才抛出来。

因此这时global execution context长这样:


由于addOne里保存的是函数内容,目前对于线程而言它是未知的,因此我们这里特意用一个带有输入输出箭头的函数图标,代表它是一个未被解析的函数。

我们继续执行第三步:还是创建了一个变量result,但此时它被赋予undefined,因为线程暂时无法从addOne这个函数里获知它的返回值。


由于addOne函数被调用了,线程会从刚刚保存的addOne变量中取出内容,去解析、执行它。这时 js 就创建了一个新的执行上下文——local execution context,即当前的执行上下文,这是一个船新的上下文,因此我特意用一个新图片去描述它:


首先这个memory我加了个local前缀,表面当前存储的都是此上下文中的变量数据。无论是上述的global memory,亦或是现在、或未来的local memory,我们可以用一个更为专业的术语variable envirnoment去描述这种存储环境。

此外,这个黑箭头我特意画了个拐角,意味它是从全局上下文进来的,local memory首先会分配内存给变量input,它在调用时就被2赋值了,紧接着又创建了一个output标签并把计算结果3赋值给它。最后,当线程遇到离开上下文的标识——return,便离开上下文,并把ouput的结果一并返回出去。

此时,这个上下文就没用了(被执行完了),于是乎垃圾回收便盯上了它,选择一个恰当的时机把里面的local memory删光光。

这时候有同学会问道:你这个只是普通场景,那闭包怎么解释呢?

其实闭包是个比较大的话题,这里也可以简单描述下:return的是一个函数的话,它返回的不仅是函数本身,还会把local memory中被引用的变量作为此函数的附加属性一并返回出去,这就好比印鱼喜欢吸附在鲨鱼身上一般,鳝鱼无论去哪都带着它,因此,无论这个函数在哪里被调用,它都能在它本身附带的local memory中找到那个变量。如果你把返回的函数console.log出来,也是能够找到它的,这里就不详说,关于闭包的更多概念(包括在函数式编程中的使用),有兴趣的童鞋可以看看这篇文章:Partial & Curry - 函数式编程

go on,回到了global execution contextresult不再孤零零地undefined,而是拿到了可爱的3:

到这里,我们的线程就完成了所有工作,可以歇息了,等等.....好像漏了什么没讲.....对,就是Call Stack

刚刚我们全篇在讲解thread of exection多么努力地一行行解析执行代码,variable envirnoment多么勤快地存储变量,那call stack干了什么?是在偷懒吗?

其实并不是,call stack起到了非常关键的作用:有了它,线程随时可以知道自己目前处于哪个上下文。

试想一下,我们在写代码的过程中往往喜欢在各种函数内穿插着各种子函数,比如递归,因此勤劳的线程就得不断地进入上下文、退出上下文、再进入、再进入、再退出、再进入,久而久之线程根本就不知道自己处于哪个上下文中,也不知道应该在哪个memory中取数据,全都乱套了,因此必须通过一种方式去跟踪、记录目前线程所处的环境。

call stack就是一种数据结构,用于“存储”上下文,通过不断推入push、推出pop上下文的方式,跟踪线程目前所处的环境——线程无需刻意记住自己身处何方,只要永远处于最顶层执行上下文,就是当前函数执行的正确位置

程序一开始运行的时候,call stack先会pushglobal execution context:

接着我们在上述代码第三行调用了addOnecall stack立刻将addOne的上下文push进去,待执行到return标识后,再pop出来:


同样的,即使在复杂的情况,只要遵循pushpop以及时刻处于最顶层上下文的原则,线程就可以一直保持在正确的位置上:


值得一提的是,call stack层数是有上限的,因此稍加不注意,你写的递归可能会造成栈溢出了。

总结

简单来说,上下文就是个可以用于执行代码的环境,与它相关的有三个重要的概念:

  • thread of exection(执行线程) 它的主要职责是,从上到下,一行一行地解析和执行代码,不会同时多处执行,也就是我们常挂在嘴边的“js 是单线程的啦”
  • variable envirnoment(变量环境) 活跃的用于存储数据的内存
  • call stack(调用栈) 一种用于存储上下文,跟踪当前线程所属的上下文位置的数据结构

开局一张图,带你解读 redux 源码

作为 react 技术栈的同学来说,redux 可谓是老常客 —— 一个主要用于状态管理的框架,经常出现在各种大大小小的项目中,其重要性不言而喻。对于如此重要的状态管理库,如果能知其然,也知其所以然(了解其内部原理),想必能给我们的工作、学习带来很大的帮助。

笔者最近在解读 redux 源码时,发现其代码非常简练,利于理解,很适合阅读。如果你有解读各类源码的想法,不妨先从它入手。在本篇文章中,笔者将尝试用一张图描绘出 redux 最为本质的运作流程,并配合每个环节的文字 + 代码描述,帮助大家更好地了解 redux,同时如果你已经开始在学习其源码,相信也能给你带来些许帮助。

另外,本文所使用的版本为 4.0.5,感兴趣的同学可在这里下载源码。

4.0.5中,其./src下的目录结构如下所示,后面的解读同样基于此结构进行:

一张图

首先我们先看看,当在外部发起一个 action,直到 state 完成更新并下发通知订阅者的这个过程中 redux 做了什么,可以用一张图来概括:

总得来说流程并不复杂:

  1. 我们使用 actionCreator 发起 action (当然 actionCreator 的使用只是一种规范约束,你可以直接 dispatch(action) 或者使用 bindActionCreators,这取决于你及你的团队风格)
  2. action 首先经过一系列中间件 middlewareXX 的处理(可以留意下这些中间件的图形结构,它比较类似 koa 的洋葱模型,它们本身是层层包裹的,后面会详细说明)
  3. 纯函数 combination 同时接受传入的 action 及当前的 state 树,即 currentState,并分发给对应的 reducer 分别计算不同的 subState
  4. 完成计算后,生成新的 state 树,即 newState,然后赋值给 currentState,并通知所有的 listeners 本次更新完成

需要注意的是,上文中的中间件不是必须的,它只是个可选项,如果没了它,整个流程会更简单:action 直接进入 combination
大体流程就这样,我们再看看每个模块的具体实现

模块解读

我们将按照上图流程,依次介绍每个模块的实现。另外,为了确保文章的简明扼要,笔者对源码进行了部分删减,建议读者配合源码一起食用,效果更佳:)

createStore.js

createStore 方法可谓是 redux 的集大成者,绝大部分模块,最终都在此函数内发挥其作用,所以我们先以全局的视角看看它内部到底干了些什么。

首先我们先忽略 createStore 内部各具体函数的实现细节:

export default function createStore(reducer, preloadedState, enhancer) {
  // 参数校验
  if (typeof enhancer !== "undefined") {
    if (typeof enhancer !== "function") {
      throw new Error("Expected the enhancer to be a function.");
    }
    // 后面会具体讲解此实现
    return enhancer(createStore)(reducer, preloadedState);
  }

  if (typeof reducer !== "function") {
    throw new Error("Expected the reducer to be a function.");
  }

  let currentReducer = reducer;
  let currentState = preloadedState;
  let currentListeners = [];
  let nextListeners = currentListeners;
  let isDispatching = false;

  function ensureCanMutateNextListeners() {
    // some ignored code...
  }

  function getState() {
    // some ignored code...
  }

  function subscribe(listener) {
    // some ignored code...
  }

  function dispatch(action) {
    // some ignored code...
  }

  function replaceReducer(nextReducer) {
    // some ignored code...
  }

  function observable() {
    // some ignored code...
  }

  dispatch({ type: ActionTypes.INIT });

  return {
    dispatch,
    subscribe,
    getState,
    replaceReducer,
    [$$observable]: observable,
  };
}

从函数的参数定义上,我们可知道它需要传入一个必须参数 reducer 及两个可选参数 preloadedStateenhancer,这与文档 API 保持一致。它具体做了以下几件事:

  1. 校验参数的合法性,其中的 enhancer 需要注意下,后面关于 applyMiddlewares 的实现也与它相关;
  2. 定义了一系列的内部变量,如 currentState currentReducer
  3. 定义了内部私有方法 ensureCanMutateNextListeners,主要配合 currentListenersnextListeners 维护发布-订阅的可靠性与有序性;
  4. 定义了一系列对外输出的函数,如我们常用的 dispatchsubscribegetState,及在代码分割场景下需动态 rootReducer 替换用到的 replaceReducer 和在响应式编程上(如 rxjs)可配合使用的 observable
  5. 调用内部行为 ActionTypes.INIT 通知 store 被初始化
  6. 最终输出一个集成上述函数的对象 store

从输出的对象上看也印证了上面的观点 —— createStore 返回的 store 确实是集大成者,接下来我们看看这些内部方法的具体实现(考虑篇幅关系,笔者只挑选了比较常用的几种方法进行讲解)

dispatch

function dispatch(action) {
  if (!isPlainObject(action)) {
    throw new Error(
      "Actions must be plain objects. " +
        "Use custom middleware for async actions."
    );
  }

  if (typeof action.type === "undefined") {
    throw new Error(
      'Actions may not have an undefined "type" property. ' +
        "Have you misspelled a constant?"
    );
  }

  if (isDispatching) {
    throw new Error("Reducers may not dispatch actions.");
  }

  try {
    isDispatching = true;
    currentState = currentReducer(currentState, action);
  } finally {
    isDispatching = false;
  }

  const listeners = (currentListeners = nextListeners);
  for (let i = 0; i < listeners.length; i++) {
    const listener = listeners[i];
    listener();
  }

  return action;
}

可以看到此函数的最核心之处在于调用纯函数 currentReducer 生成新的 currentState, 并在结束后遍历 listeners 触发订阅回调。这里需要主要下标识变量 isDispatching,在 createStore 内部各处都会出现它的身影,它充当“锁”的作用 —— 当在调用 reducer 重新计算 store 时,会锁住以下功能:

  • 不允许再次触发 dispatch
  • 不允许新增或取消订阅
  • 不允许 getState

以上行为当且仅当在非 dispatching 阶段才开放

subscribe

function subscribe(listener) {
  if (typeof listener !== "function") {
    throw new Error("Expected the listener to be a function.");
  }

  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#subscribelistener 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#subscribelistener for more details."
      );
    }

    isSubscribed = false;

    ensureCanMutateNextListeners();
    const index = nextListeners.indexOf(listener);
    nextListeners.splice(index, 1);
    currentListeners = null;
  };
}

subscribe 主要用于新增订阅者,并返回一个取消订阅的函数 unsubscribe。大家需要注意下这里的两个订阅者列表 currentListenersnextListeners,也许你会很好奇为什么 redux 需要在内部维护两个订阅者列表呢?因为 redux dispatch 完后、在遍历 listeners 并触发订阅回调时,它并不清楚订阅者中会不会存在新增或取消订阅的行为,为了保证发布-订阅的可靠性与有序性,它通过两个列表来实现“快照”功能 —— currentListeners 表示当前的订阅者列表,store 更新完毕后,redux 是遍历此订阅列表进行消息通知,而如果在此期间发生了新增或取消订阅,则会把这部分的变化更新到 nextListeners 中 —— 这意味着 nextListeners 始终存放着变动的、未来的订阅者,关于这一点的设计,在返回的 unsubscribedispatch 函数中都均有体现

PS: 在此期间 ensureCanMutateNextListeners 函数也发挥重要作用,用于确保两个订阅者列表是不同的引用,一旦两者引用相等时,则使用浅拷贝的方式再次分开两者

getState

function getState() {
  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;
}

getState 就非常简单了,直接返回内部的 currentState 即可

createStore 的介绍到这里基本就结束了,接下来我们再一一介绍其他相关模块。我们就从一个 action 的发起开始吧,与 action 发起相关的函数为 bindActionCreator,一起看看它的内部实现。

bindActionCreator.js

从 redux 官方文档可知,函数 bindActionCreator 的作用在于绑定 action
dispatch,这样就无须单独引入 dispatch 并手动调用,其在项目开发中的好处不言而喻:例如对于 react 展示型组件,内部则无须引入 dispatch,取而代之的是由父组件传递而来的经过 bindActionCreators 包装过后的函数,使其充分解耦。

我们来看看 redux 是怎么实现它的:

function bindActionCreator(actionCreator, dispatch) {
  return function () {
    return dispatch(actionCreator.apply(this, arguments));
  };
}

export default function bindActionCreators(actionCreators, dispatch) {
  if (typeof actionCreators === "function") {
    return bindActionCreator(actionCreators, dispatch);
  }

  // some ignored code here...

  const boundActionCreators = {};
  for (const key in actionCreators) {
    const actionCreator = actionCreators[key];
    if (typeof actionCreator === "function") {
      boundActionCreators[key] = bindActionCreator(actionCreator, dispatch);
    }
  }
  return boundActionCreators;
}

上述代码最为核心的是 bindActionCreator 函数,它借助闭包,使得返回的函数拥有访问 dispatch 的权限,以此实现绑定。由于代码过于简单,这里就不再赘述,我们继续往下看,当一个 action 发起后,首先它会经过哪个模块呢?是直接进入 reducer 重新计算 store 吗?当项目没有应用任何中间件时,确实是这样的,但一旦传入了中间件后,它首先需要经过一层层中间件的“洗礼”,因此在介绍 combineReducers 前,首先看下与中间件机制相关的函数 applyMiddleware 吧~

applyMiddleware.js

当 redux 引入中间件后,action 首先要经过的是层层包裹的中间件,关于 redux 对中间件的实现思路,其实官网上已经给出了自己的推演说明,讲解得非常精彩,有兴趣的同学可直接戳 中文文档 进行查看

import compose from "./compose";

export default function applyMiddleware(...middlewares) {
  return (createStore) => (...args) => {
    const store = createStore(...args);
    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),
    };
    const chain = middlewares.map((middleware) => middleware(middlewareAPI));
    dispatch = compose(...chain)(store.dispatch);

    return {
      ...store,
      dispatch,
    };
  };
}

首先 redux 引入了工具函数 compose,它是用来干什么的呢?我们先看看其代码:

export default function compose(...funcs) {
  if (funcs.length === 0) {
    return (arg) => arg;
  }

  if (funcs.length === 1) {
    return funcs[0];
  }

  return funcs.reduce((a, b) => (...args) => a(b(...args)));
}

从这段代码可以看出,compose 的作用是:传入一组任意数量的函数,比如 funcA, funcBfuncC,可生成一个新的函数 (...args) => funcA(funcB(funcC(...args))),**它的含义是每个函数均以上一个函数的返回值为参数传入,并将自己计算得到的返回值作为下一个函数的参数。**对于第一次接触 compose 概念的童鞋来说可能比较绕,可多花思考体会一下。

回到代码本身,当引入了 compose 之后,下面则为 applyMiddleware 函数的实现:当我们执行 applyMiddleware(...middlewares) 时,它返回了一个诸如 (createStore) => (...args) => { ... } 的柯里化函数,等等...这是什么鬼!?从代码实现上看,它确实有点绕,因此对于后面的讲解希望大家能打起十二分精神,结合源码,多看几次

化简后的 applyMiddleware

笔者将上述代码进行了化简,同时截取了部分 createStore 的代码,结合阅读更便于理解它的工作原理:

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

    // some ignored code...

    dispatch = compose(...chain)(store.dispatch);

    return {
      ...store,
      dispatch,
    };
  };
}

截取的部分 createStore

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

    return enhancer(createStore)(reducer, preloadedState);
  }
  1;
  // some ignored code...
}

createStore 函数的第三个参数 enhancer,其实就是 applyMiddleware() 执行后返回的函数,显而易见,当存在 enhancer 时,redux 会做一层递归 —— 把 createStore 及传入的 reducerpreloadedState 分别传递给 enhancer,由它的内部完成 store 的创建工作,并返回出去供业务使用;

由于 enhancer 获得了创建并返回 store 的权利,因此可以偷偷给 store “做手脚”:比如在 applyMiddlewares 内部它篡改了 dispatch 函数 —— 它借助 compose + middlewares 对最初始的 dispatch 函数进行层层包装,以实现各种丰富功能;

看到这里,我们已经对 applyMiddlewares 的大致功能有了了解,接下来我们再看看它是如何通过包装 dispatch 以实现中间件功能的

dispatch 的层层包装

光看 applyMiddleware 的内部实现可能还无法完全理解,需要加上一个简易的中间件配合阅读,这里笔者实现了个非常简单的类似 redux-thunk 的功能 —— 赋予 redux 处理异步函数的能力:

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),
};
const chain = middlewares.map((middleware) => middleware(middlewareAPI));
dispatch = compose(...chain)(store.dispatch);

一个简易 redux-thunk 的具体实现

const myThunk = ({ getState, dispatch }) => (next) => (action) => {
  if (typeof action === "function") {
    action(dispatch, getState);
  } else {
    next(action);
  }
};

它们的执行流程如下:

  1. 首先执行 middleware(middlewareAPI) 返回格式为 next => action => {...} 的函数,函数通过闭包,可随时访问 dispatchgetState
  2. 借助上述 compose 机制,next => action => {...} 函数中的 next 为上个函数的执行结果,而自身计算的结果将作为下一个函数的参数使用,因此效果类似如下代码:
    const finalDispatch = funcA(funcB(funcC(store.dispatch)));
    store.dispatch = finalDispatch;
  3. 由于 finalDispatch 最终会触发原本的 store.dispatch,不仅机制上来说结果不变,而且中间件们还能在这期间实现自己的功能:比如处理异步函数、promise、输出日志等等;

以上代码可以得出 redux 实现中间件的核心**在于 dispatch 函数的包装与复写 —— 通过一系列中间件的层层包装,我们最终在外边拿到的,并非是最初在 createStore 内部定义的原汁原味的 dispatch,它早已被 middleware 们“动了手脚”,也正是因为改写了 dispatch,诸如 redux-thunkredux-promise 等中间价赋予了 redux 处理异步的能力,actionCreator 的返回值得到了拓展,它不再必须为纯对象,返回函数或 promise 也是可以的

combineReducers

其实我们在使用 reducer 时,不一定非要使用 combineReducers 函数,它只是提供了组合多个子 reducer 的一种方式。关于 combineReducers 的实现,官方已经在文档上给出了大概的实现,但我们还是来看看真实的内部代码是怎样的吧:

export default function combineReducers(reducers) {
  const reducerKeys = Object.keys(reducers);
  const finalReducers = {};
  for (let i = 0; i < reducerKeys.length; i++) {
    const key = reducerKeys[i];

    if (process.env.NODE_ENV !== "production") {
      if (typeof reducers[key] === "undefined") {
        warning(`No reducer provided for key "${key}"`);
      }
    }

    if (typeof reducers[key] === "function") {
      finalReducers[key] = reducers[key];
    }
  }
  const finalReducerKeys = Object.keys(finalReducers);

  // This is used to make sure we don't warn about the same
  // keys multiple times.
  let unexpectedKeyCache;
  if (process.env.NODE_ENV !== "production") {
    unexpectedKeyCache = {};
  }

  let shapeAssertionError;
  try {
    assertReducerShape(finalReducers);
  } catch (e) {
    shapeAssertionError = e;
  }

  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);
      }
    }

    let hasChanged = false;
    const nextState = {};
    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);
      if (typeof nextStateForKey === "undefined") {
        const errorMessage = getUndefinedStateErrorMessage(key, action);
        throw new Error(errorMessage);
      }
      nextState[key] = nextStateForKey;
      hasChanged = hasChanged || nextStateForKey !== previousStateForKey;
    }
    hasChanged =
      hasChanged || finalReducerKeys.length !== Object.keys(state).length;
    return hasChanged ? nextState : state;
  };
}

笔者上面所贴的代码,其实删减了一些跟告警、校验相关的函数。combineReducers 做了以下几件事情:

  1. 校验 reducers 的合法性,包括:是否存在 undefined 的值、剔除假 reducer(即判定是否为函数),以及判定每个 reducer 能否正常处理未知 action、能否在初始化正常返回 initialState 等等;
  2. 返回了一个新函数 combination,也就是我们常说的 rootReducer
  3. 当执行 combination 后,combination 先进行参数校验,随后通过遍历 finalReducerKeys 获取每个 state 的 key 及对应的 reducer,并计算出新的 state 即 nextStateForKey
  4. 当存在 nextStateForKey !== previousStateForKey 时,则返回新的 state, 否则返
    回旧的 state (这里需要注意下,它是通过标志位 hasChanged 来判断 state 是否有更新的)

结尾

写到这里,redux 的代码就基本读完了,总的来说,如果要让笔者用一个词来形容它,那就是“精炼”——会有一种麻雀虽小五脏俱全的感觉,而且有些地方的实现也非常巧妙,还是很推荐大家有时间读一读源码,相信会非常有收获的:) 由于笔者能力有限,可能存在部分解读不当的情况,欢迎大家拍砖~

canvas 进阶 —— 如何实现 canvas 的事件系统

众所周知,canvas 是前端进军可视化领域的一大利器,借助 canvas 画布我们不仅可以实现许多 dom 和 css 难以实现的、各种绚丽多彩的视觉效果,而且在渲染数量繁多、内容复杂的场景下,其性能表现及优化空间也占据一定优势。

然而 canvas 却存在一个缺陷:由于 canvas 是作为一个整体画布存在,所有的内容只不过是其内部渲染的结果,我们不能像在 dom 元素上监听事件一样,在 canvas 所渲染的图形内绑定各种事件,因此基于 canvas 画布开发出一套交互式应用是件复杂的事情。虽然 gayhub 上很多 canvas 框架自带了事件系统,但如果想深入学习 canvas,笔者认为还是有必要了解其实现原理,因此本篇文章将实现一个简易版的 canvas 事件系统。

正文开始前,先贴下仓库地址,各位按需取用 canvas-event-system

环境搭建

要在 canvas 上实现事件系统,我们必须先做些准备工作 —— 首先我们得往 canvas 上填充些“内容”,没有内容,谈何事件监听,下文我们将这些可绑定事件的内容称之为元素。同时,为简明扼要,笔者这里仅实现了形状(Shape) 这一类元素;当我们有了一个个元素后,我们还需要一个容器去管理它们,这个容器则是 —— 舞台(Stage),舞台如同上帝一般,负责元素们的渲染、事件管理及事件触发,接下来我们先初始化这两大类

API 设计

在实现细节前,笔者是这样设想事件系统的:我们可以通过 new 操作符生成一个个的 Shape 实例,并可在实例上监听各类事件,然后再将它们addStage即可,就像这样:

const stage = new Stage(myCanvas);

// 生成形状
const rect = new Rect(props); // 矩形
const circle = new Rect(props); // 圆形

// 监听点击事件
rect.on('click', () => console.log('click rect!'));
circle.on('click', () => console.log('click circle!'));

// 将形状添加至舞台,即可渲染到画布上
stage.add(rect);
stage.add(circle);

构建 Shape

由于不同形状间有许多相似的逻辑,因此我们先实现一个Base基类,然后让诸如RectCircle等形状继承此类:

import { Listener, EventName, Shape } from './types';

export default class Base implements Shape {
  private listeners: { [eventName: string]: Listener[] };

  constructor() {
    this.listeners = {};
  }

  draw(ctx: CanvasRenderingContext2D): void {
    throw new Error('Method not implemented.');
  }

  on(eventName: EventNames, listener: Listener): void {
    if (this.listeners[eventName]) {
      this.listeners[eventName].push(listener);
    } else {
      this.listeners[eventName] = [listener];
    }
  }

  getListeners(): { [name: string]: Listener[] } {
    return this.listeners;
  }
}

Base有三个对外暴露的 api:

  • draw 用于绘制内容,需要将 canvas 上下文 CanvasRenderingContext2D 传入

  • on 用于事件监听,收集到的事件回调会以事件名eventName为 key,回调函数数组为 value 的形式存放在一个对象当中,此外我们还用了枚举类型定义了所有事件

    export enum EventNames {
      click = 'click',
      mousedown = 'mousedown',
      mousemove = 'mousemove',
      mouseup = 'mouseup',
      mouseenter = 'mouseenter',
      mouseleave = 'mouseleave',
    }
  • getListeners 获取此形状上所有的监听事件

有了Base基类,我们就可以轻松定义其他具体的形状:

比如Rect

import Base from './Base';

interface RectProps {
  x: number;
  y: number;
  width: number;
  height: number;
  strokeWidth?: number;
  strokeColor?: string;
  fillColor?: string;
}

export default class Rect extends Base {
  constructor(private props: RectProps) {
    super();
    this.props.fillColor = this.props.fillColor || '#fff';
    this.props.strokeColor = this.props.strokeColor || '#000';
    this.props.strokeWidth = this.props.strokeWidth || 1;
  }

  draw(ctx: CanvasRenderingContext2D) {
    const { x, y, width, height, strokeColor, strokeWidth, fillColor } = this.props;

    ctx.save();
    ctx.beginPath();
    ctx.strokeStyle = strokeColor;
    ctx.lineWidth = strokeWidth;
    ctx.fillStyle = fillColor;
    ctx.rect(x, y, width, height);
    ctx.fill();
    ctx.stroke();
    ctx.restore();
  }
}

又比如原型 Circle

import Base from './Base';

interface RectProps {
  x: number;
  y: number;
  radius: number;
  strokeWidth?: number;
  strokeColor?: string;
  fillColor?: string;
}

export default class Circle extends Base {
  constructor(private props: RectProps) {
    super();
    this.props.fillColor = this.props.fillColor || '#fff';
    this.props.strokeColor = this.props.strokeColor || '#000';
    this.props.strokeWidth = this.props.strokeWidth || 1;
  }

  draw(ctx: CanvasRenderingContext2D) {
    const { x, y, radius, strokeColor, strokeWidth, fillColor } = this.props;

    ctx.save();
    ctx.beginPath();
    ctx.fillStyle = fillColor;
    ctx.strokeStyle = strokeColor;
    ctx.lineWidth = strokeWidth;
    ctx.arc(x, y, radius, 0, Math.PI * 2);
    ctx.fill();
    ctx.stroke();
    ctx.restore();
  }
}

构建 Stage

Stage 的代码如下

import { Shape } from './shapes/types';

export class Stage {
  private canvas: HTMLCanvasElement;
  private ctx: CanvasRenderingContext2D;
  private dpr: number;
  private shapes: Set<string>;

  constructor(canvas: HTMLCanvasElement) {
    // 解决 canvas 在高清屏上的模糊问题
    const dpr = window.devicePixelRatio;
    canvas.width = parseInt(canvas.style.width) * dpr;
    canvas.height = parseInt(canvas.style.height) * dpr;

    this.canvas = canvas;
    this.ctx = this.canvas.getContext('2d');
    this.ctx.scale(dpr, dpr);
    this.dpr = dpr;

    this.canvas.addEventListener('mousedown', this.handleCreator(ActionType.Down));
    this.canvas.addEventListener('mouseup', this.handleCreator(ActionType.Up));
    this.canvas.addEventListener('mousemove', this.handleCreator(ActionType.Move));

    this.shapes = new Set();
  }

  add(shape: Shape) {
    shape.draw(this.ctx);
  }

  private handleCreator = (type: ActionType) => (evt: MouseEvent) => {};
}

Stage 的实现中,我们做了三件事情:

  • 解决了 canvas 在高清屏上的模糊问题,由于不是本文讨论范围,这里略过;
  • 对画布监听了三个事件mousedownmouseupmousemove,之所以监听是因为后面其他事件的模拟判断均和它们有关,此外,由于三者接下来的处理逻辑相似度非常高,为代码复用,故使用handleCreator统一逻辑处理,并使用ActionType加以区分不同类型
  • 实现了add方法,在案例中我们通过调用stage.add(rect)实现内容绘制,本质上add会在内部调用形状的draw方法,并把绘制上下文传入,由具体形状控制内部的内容展示

当我们实现到这,一个基本的 canvas 绘图系统就完成了,起码页面上能初现雏形,demo 代码如下:

import { Stage, Rect, Circle, EventNames } from './canvas-event-system';

const canvas = document.querySelector('#canvas') as HTMLCanvasElement;
const stage = new Stage(canvas);

const rect = new Rect({
  x: 50,
  y: 50,
  width: 250,
  height: 175,
  fillColor: 'green',
});

const circle = new Circle({
  x: 200,
  y: 200,
  radius: 100,
  fillColor: 'red',
});

rect.on(EventNames.mousedown, () => console.log('rect mousedown'));
rect.on(EventNames.mouseup, () => console.log('rect mouseup'));
rect.on(EventNames.mouseenter, () => console.log('rect mouseenter'));

circle.on(EventNames.click, () => console.log('circle click!!'));
circle.on(EventNames.mouseleave, () => console.log('circle mouseleave!'));

stage.add(rect);
stage.add(circle);

鼠标的命中问题

内容框架搭建好了,接下来就到了构建事件系统的环节,要实现事件绑定及触发,首要解决的问题是:如何判断当前鼠标选择的是哪个元素?这个问题放在 dom 上并不复杂,每个 dom 元素占据的空间均是矩形,我们完全可以通过鼠标的坐标x, y,加上每个矩形的x y width height 四个值,简单判断它处于哪个矩形内部:

if (mouse.x > rect.x && mouse.x < rect.x + rect.width && mouse.y > rect.y && mouse.y < rect.y + rect.height) {
  // 在 rect 内部
} else {
  // 不在 rect 内部
}

但 canvas 内的形状各种各样,不仅有圆形、椭圆形、多边形、不规则多边形形状,甚至还存在由曲线构成的不规则形状。比如像下面这种类似肥皂的形状:

虽然从数学意义上,我们可以通过诸如 射线法 等算法判断,但由于内容千变万化,在非常复杂的图形上,难以依靠数学计算得以解决,因此这里我们将利用 canvas 本身的特性,使用一种取巧的方式,解决鼠标的命中问题,思路如下:

我们先对 canvas 画布内的每个元素添加唯一的 id,并设计一种 id 与 rgba 互相转换的算法,然后再建立一个与当前画布等大的“影子画布”(不必显示在页面上),我们将用户能看见的画布称为 A,影子画布为 B,每当在 A 上渲染一个元素的时候,同步在 B 上的相同位置渲染一个等大的元素,并以其 id 所转换的 rgba 值填充。这样,当鼠标处于 A 上时,可通过当前坐标和 getImageData 可找到 B 上对应点的 rgba 值,将 rgba 反转为 id,即可知晓被选中的元素

为此,首先我们需要一个函数 createId 生成 id,两个转换函数 idToRgbargbaToId

const idPool = {}; // 避免 id 重复

export function createId(): string {
  let id = createOnceId();

  while (idPool[id]) {
    id = createOnceId();
  }

  return id;
}

function createOnceId(): string {
  return Array(3)
    .fill(0)
    .map(() => Math.ceil(Math.random() * 255))
    .concat(255)
    .join('-');
}
export function idToRgba(id: string) {
  return id.split('-');
}

export function rgbaToId(rgba: [number, number, number, number]) {
  return rgba.join('-');
}

原理很简单,rgba 的 r、g、b 的范围是 0255,我们生成 3 个 0255 的随机数即可。对于透明度 a 值则必须为 1(不透明),否则当两个形状重叠时,重叠部分的 rgba 将被混合,会影响命中的判断,这里为方便转换,a 默认给了 255

接着需要对 Base 进行调整:

export default class Base implements Shape {
  public id: string;

  constructor() {
    // ...other code
    this.id = createId();
  }

  // ...other code

  draw(ctx: CanvasRenderingContext2D, osCtx: OffscreenCanvasRenderingContext2D): void {
    throw new Error('Method not implemented.');
  }

  getId(): string {
    return this.id;
  }
}

每当创建一个实例时,实例内部均会默认添加一个 id,可通过 getId 获取,此外,draw 方法也进行了调整,需要多传入一个影子画布的上下文

对于继承自 BaseRectCircle,也要进行相应的改造,这里就以 Rect 为例,更多内容可详见源码:

import { idToRgba } from '../helpers';
import Base from './Base';

export default class Rect extends Base {
  // other code...

  draw(ctx: CanvasRenderingContext2D, osCtx: OffscreenCanvasRenderingContext2D) {
    const { x, y, width, height, strokeColor, strokeWidth, fillColor } = this.props;

    ctx.save();
    ctx.beginPath();
    ctx.strokeStyle = strokeColor;
    ctx.lineWidth = strokeWidth;
    ctx.fillStyle = fillColor;
    ctx.rect(x, y, width, height);
    ctx.fill();
    ctx.stroke();
    ctx.restore();

    const [r, g, b, a] = idToRgba(this.id);

    osCtx.save();
    osCtx.beginPath();
    osCtx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${a})`;
    osCtx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a})`;
    osCtx.rect(x, y, width, height);
    osCtx.fill();
    osCtx.stroke();
    osCtx.restore();
  }
}

看,为保持两个画布的同步,每当在 ctx 绘制一个矩形时,也要在 osCtx 同步绘制。

接下来则需要对 Stage 进行调整:它需要根据传入的 canvas 复刻一个 OffscreenCanvas,同时还需要在根据当前鼠标的位置判断元素的命中:

export class Stage {
  private canvas: HTMLCanvasElement;
  private osCanvas: OffscreenCanvas;
  private ctx: CanvasRenderingContext2D;
  private osCtx: OffscreenCanvasRenderingContext2D;
  private dpr: number;
  private shapes: Set<string>;

  constructor(canvas: HTMLCanvasElement) {
    // other codes...

    this.osCanvas = new OffscreenCanvas(canvas.width, canvas.height);
    this.osCtx = this.osCanvas.getContext('2d');
    this.osCtx.scale(dpr, dpr);
    this.dpr = dpr;
    this.shapes = new Set(); // 通过一个 Set 保存所有 add 进来的形状元素
  }

  add(shape: Shape) {
    const id = shape.getId();
    this.shapes.add(id);
    shape.draw(this.ctx, this.osCtx);
  }

  private handleCreator = (type: ActionType) => (evt: MouseEvent) => {
    const x = evt.offsetX;
    const y = evt.offsetY;
    // 根据 x, y 拿到当前被选中的 id
    const id = this.hitJudge(x, y);
  };

  /**
   * Determine whether the current position is inside a certain shape, if it is, then return its id
   * @param x
   * @param y
   */
  private hitJudge(x: number, y: number): string {
    const rgba = Array.from(this.osCtx.getImageData(x * this.dpr, y * this.dpr, 1, 1).data);
    const id = rgbaToId(rgba as [number, number, number, number]);
    return this.shapes.has(id) ? id : undefined;
  }
}

事件模拟

由于 handleCreator(actionType) 同时处理了三个鼠标事件,因此只要鼠标在 canvas 上,它的一举一动、经过了哪些元素都会被捕获到,当然,要实现事件的触发,我们必须通过一些操作“组合”,去判断当前的事件类型,由于篇幅关系,笔者主要模拟了以下几种事件:

  • mousedown = mousedown
  • mouesmove = mousemove
  • mouseup = mouseup
  • click = mousedown + mouseup
  • mouseenter(id1) = mousemove(id2) + mousemove(id1)
  • mouseleave(id2) = mousemove(id2) + mousemove(id1)

于是我们创建一个 EventSimulator 类,它将根据传入的当前鼠标动作类型,预判此时应该发生的事件:

import { Listener, EventNames } from './shapes';

export interface Action {
  type: ActionType;
  id: string;
}

export enum ActionType {
  Down = 'DOWN',
  Up = 'Up',
  Move = 'MOVE',
}

export default class EventSimulator {
  private listenersMap: {
    [id: string]: {
      [eventName: string]: Listener[];
    };
  } = {};

  private lastDownId: string;
  private lastMoveId: string;

  addAction(action: Action, evt: MouseEvent) {
    const { type, id } = action;

    // mousemove
    if (type === ActionType.Move) {
      this.fire(id, EventNames.mousemove, evt);
    }

    // mouseover
    // mouseenter
    if (type === ActionType.Move && (!this.lastMoveId || this.lastMoveId !== id)) {
      this.fire(id, EventNames.mouseenter, evt);
      this.fire(this.lastMoveId, EventNames.mouseleave, evt);
    }

    // mousedown
    if (type === ActionType.Down) {
      this.fire(id, EventNames.mousedown, evt);
    }

    // mouseup
    if (type === ActionType.Up) {
      this.fire(id, EventNames.mouseup, evt);
    }

    // click
    if (type === ActionType.Up && this.lastDownId === id) {
      this.fire(id, EventNames.click, evt);
    }

    if (type === ActionType.Move) {
      this.lastMoveId = action.id;
    } else if (type === ActionType.Down) {
      this.lastDownId = action.id;
    }
  }

  addListeners(
    id: string,
    listeners: {
      [eventName: string]: Listener[];
    },
  ) {
    this.listenersMap[id] = listeners;
  }

  fire(id: string, eventName: EventNames, evt: MouseEvent) {
    if (this.listenersMap[id] && this.listenersMap[id][eventName]) {
      this.listenersMap[id][eventName].forEach((listener) => listener(evt));
    }
  }
}

接着我们继续完善下 Stage,将实现好的 EventSimulator 放入进去,完整代码如下

import { rgbaToId } from './helpers';
import { Shape } from './shapes/types';
import EventSimulator, { ActionType } from './EventSimulator';
export * from './shapes';

export class Stage {
  private canvas: HTMLCanvasElement;
  private osCanvas: OffscreenCanvas;
  private ctx: CanvasRenderingContext2D;
  private osCtx: OffscreenCanvasRenderingContext2D;
  private dpr: number;
  private shapes: Set<string>;
  private eventSimulator: EventSimulator;

  constructor(canvas: HTMLCanvasElement) {
    const dpr = window.devicePixelRatio;
    canvas.width = parseInt(canvas.style.width) * dpr;
    canvas.height = parseInt(canvas.style.height) * dpr;

    this.canvas = canvas;
    this.osCanvas = new OffscreenCanvas(canvas.width, canvas.height);

    this.ctx = this.canvas.getContext('2d');
    this.osCtx = this.osCanvas.getContext('2d');

    this.ctx.scale(dpr, dpr);
    this.osCtx.scale(dpr, dpr);
    this.dpr = dpr;

    this.canvas.addEventListener('mousedown', this.handleCreator(ActionType.Down));
    this.canvas.addEventListener('mouseup', this.handleCreator(ActionType.Up));
    this.canvas.addEventListener('mousemove', this.handleCreator(ActionType.Move));

    this.shapes = new Set();
    this.eventSimulator = new EventSimulator();
  }

  add(shape: Shape) {
    const id = shape.getId();
    this.eventSimulator.addListeners(id, shape.getListeners());
    this.shapes.add(id);

    shape.draw(this.ctx, this.osCtx);
  }

  private handleCreator = (type: ActionType) => (evt: MouseEvent) => {
    const x = evt.offsetX;
    const y = evt.offsetY;
    const id = this.hitJudge(x, y);
    this.eventSimulator.addAction({ type, id }, evt);
  };

  /**
   * Determine whether the current position is inside a certain shape, if it is, then return its id
   * @param x
   * @param y
   */
  private hitJudge(x: number, y: number): string {
    const rgba = Array.from(this.osCtx.getImageData(x * this.dpr, y * this.dpr, 1, 1).data);

    const id = rgbaToId(rgba as [number, number, number, number]);
    return this.shapes.has(id) ? id : undefined;
  }
}

结尾

到此为止,到此为止,整个 canvas 事件系统就搭建完成啦,一起看看运行时的效果吧~

看起来效果不错,我们可以在 RectCircle 等形状通过 Rect.on('xxx', func) 的形式实现事件监听,满足了基本需求。

然而,由于篇幅关系,本文做了许多内容的缩减,要想真正实现一个能用于生产环境的事件系统,还需要很多工作,比如 元素嵌套关系所带来的额外处理:其实元素之间不仅存在层级关系,还有嵌套关系,如果元素存在嵌套,那必然要处理事件捕获、冒泡相关问题,比如如何取消冒泡;此外本文并未模拟 mouseovermouseout等与嵌套关系相关的事件等......当你看到这,不妨一起思考下要如何解决以上的场景:)(PS:如果有机会的话,也许会单独写篇文章讨论这些问题)

以上便是本文的所有内容,欢迎交流讨论~😊

从0到1,开发一个动画库(1)

如今市面上关于动画的开源库多得数不胜数,有关于CSS、js甚至是canvas渲染的,百花齐放,效果炫酷。但你是否曾想过,自己亲手去实现(封装)一个简单的动画库?

本文将从零开始,讲授如何搭建一个简单的动画库,它将具备以下几个特征:

从实际动画中抽象出来,根据给定的动画速度曲线,完成“由帧到值”的计算过程,而实际渲染则交给开发者决定,更具拓展性
支持基本的事件监听,如onPlay、onStop、onReset 、onEnd,及相应的回调函数
支持手动式触发动画的各种状态,如play、stop、reset、end
支持自定义路径动画
支持多组动画的链式触发

完整的项目在这里,欢迎各种吐槽和指正^_^

OK,话不多说,现在正式开始。

作为开篇,本节将介绍的是最基本、最核心的步骤——构建“帧-值”对应的函数关系,完成“由帧到值”的计算过程。

目录结构

首先介绍下我们的项目目录结构:

/timeline
    /index..js
    /core.js
    /tween.js

/timeline是本项目的根目录,各文件的作用分别如下:

  • index.js 项目入口文件
  • core.js 动画核心文件
  • easing.js 存放基本缓动函数

引入缓动函数

所谓动画,简单来说,就是在一段时间内不断改变目标某些状态的结果。这些状态值在运动过程中,随着时间不断发生变化,状态值与时间存在一一对应的关系,这就是所谓的“帧-值”对应关系,常说的动画缓动函数也是相同的道理

有了这种函数关系,给定任意一个时间点,我们都能计算出对应的状态值。OK,那如何在动画中引入缓动函数呢?不说废话,直接上代码:

首先我们在core.js中创建了一个Core类:

class Core {
  constructor(opt) {
    // 初始化,并将实例当前状态设置为'init'
    this._init(opt);
    this.state = 'init';
  }
  
  _init(opt) {
    this._initValue(opt.value);
    // 保存动画总时长、缓动函数以及渲染函数
    this.duration = opt.duration || 1000;
    this.timingFunction = opt.timingFunction || 'linear';
    this.renderFunction = opt.render || this._defaultFunc;

    // 未来会用到的事件函数
    this.onPlay = opt.onPlay;
    this.onEnd = opt.onEnd;
    this.onStop = opt.onStop;
    this.onReset = opt.onReset;
  }

  _initValue(value) {
    // 初始化运动值
      this.value = [];
      value.forEach(item => {
        this.value.push({
          start: parseFloat(item[0]),
          end: parseFloat(item[1]),
        });
      });
  }
}

我们在构造函数中对实例调用_init函数,对其初始化:将传入的参数保存在实例属性中。

当你看到_initValue的时候可能不大明白:外界传入的value到底是啥?其实value是一个数组,**它的每一个元素都保存着独立动画的起始与结束两种状态。**这样说好像有点乱,举个栗子好了:假设我们要创建一个动画,让页面上的div同时往右、左分别平移300px、500px,此外还同时把自己放大1.5倍。在这个看似复杂的动画过程中,其实可以拆解成三个独立的动画,每一动画都有自己的起始与终止值:

  • 对于往右平移,就是把css属性的translateX的0px变成了300px
  • 同理,往下平移,就是把tranlateY的0px变成500px
  • 放大1.5倍,也就是把scale从1变成1.5

因此传入的value应该长成这样:[[0, 300], [0, 500], [1, 1.5]] 。我们将数组的每一个元素依次保存在实例的value属性中。

此外,renderFunction是由外界提供的渲染函数,即opt.render,它的作用是:

动画运动的每一帧,都会调用一次该函数,并把计算好的当前状态值以参数形式传入,有了当前状态值,我们就可以自由地选择渲染动画的方式啦。

接下来我们给Core类添加一个循环函数:

_loop() {
  const t = Date.now() - this.beginTime,
        d = this.duration,
        func = Tween[this.timingFunction] || Tween['linear'];

  if (t >= d) {
    this.state = 'end';
    this._renderFunction(d, d, func);
  } else {
    this._renderFunction(t, d, func);
    window.requestAnimationFrame(this._loop.bind(this));
  }
}

_renderFunction(t, d, func) {
  const values = this.value.map(value => func(t, value.start, value.end - value.start, d));
  this.renderFunction.apply(this, values);
}

_loop的作用是:倘若当前时间进度t还未到终点,则根据当前时间进度计算出目标现在的状态值,并以参数的形式传给即将调用的渲染函数,即renderFunction,并继续循环。如果大于duration,则将目标的运动终止值传给renderFunction,运动结束,将状态设为end

代码中的Tween是从tween.js文件引入的缓动函数,tween.js的代码如下(网上搜搜基本都差不多= =):

/*
 * t: current time(当前时间);
 * b: beginning value(初始值);
 * c: change in value(变化量);
 * d: duration(持续时间)。
 * Get effect on 'http://easings.net/zh-cn'
 */

const Tween = {
    linear: function (t, b, c, d) {
        return c * t / d + b;
    },
    // Quad
    easeIn: function (t, b, c, d) {
        return c * (t /= d) * t + b;
    },
    easeOut: function (t, b, c, d) {
        return -c * (t /= d) * (t - 2) + b;
    },
    easeInOut: function (t, b, c, d) {
        if ((t /= d / 2) < 1) return c / 2 * t * t + b;
        return -c / 2 * ((--t) * (t - 2) - 1) + b;
    },
    // Cubic
    easeInCubic: function (t, b, c, d) {
        return c * (t /= d) * t * t + b;
    },
    easeOutCubic: function (t, b, c, d) {
        return c * ((t = t / d - 1) * t * t + 1) + b;
    },
    easeInOutCubic: function (t, b, c, d) {
        if ((t /= d / 2) < 1) return c / 2 * t * t * t + b;
        return c / 2 * ((t -= 2) * t * t + 2) + b;
    },
    // Quart
    easeInQuart: function (t, b, c, d) {
        return c * (t /= d) * t * t * t + b;
    },
    easeOutQuart: function (t, b, c, d) {
        return -c * ((t = t / d - 1) * t * t * t - 1) + b;
    },
    easeInOutQuart: function (t, b, c, d) {
        if ((t /= d / 2) < 1) return c / 2 * t * t * t * t + b;
        return -c / 2 * ((t -= 2) * t * t * t - 2) + b;
    },
    // Quint
    easeInQuint: function (t, b, c, d) {
        return c * (t /= d) * t * t * t * t + b;
    },
    easeOutQuint: function (t, b, c, d) {
        return c * ((t = t / d - 1) * t * t * t * t + 1) + b;
    },
    easeInOutQuint: function (t, b, c, d) {
        if ((t /= d / 2) < 1) return c / 2 * t * t * t * t * t + b;
        return c / 2 * ((t -= 2) * t * t * t * t + 2) + b;
    },
    // Sine
    easeInSine: function (t, b, c, d) {
        return -c * Math.cos(t / d * (Math.PI / 2)) + c + b;
    },
    easeOutSine: function (t, b, c, d) {
        return c * Math.sin(t / d * (Math.PI / 2)) + b;
    },
    easeInOutSine: function (t, b, c, d) {
        return -c / 2 * (Math.cos(Math.PI * t / d) - 1) + b;
    },
    // Expo
    easeInExpo: function (t, b, c, d) {
        return (t == 0) ? b : c * Math.pow(2, 10 * (t / d - 1)) + b;
    },
    easeOutExpo: function (t, b, c, d) {
        return (t == d) ? b + c : c * (-Math.pow(2, -10 * t / d) + 1) + b;
    },
    easeInOutExpo: function (t, b, c, d) {
        if (t == 0) return b;
        if (t == d) return b + c;
        if ((t /= d / 2) < 1) return c / 2 * Math.pow(2, 10 * (t - 1)) + b;
        return c / 2 * (-Math.pow(2, -10 * --t) + 2) + b;
    },
    // Circ
    easeInCirc: function (t, b, c, d) {
        return -c * (Math.sqrt(1 - (t /= d) * t) - 1) + b;
    },
    easeOutCirc: function (t, b, c, d) {
        return c * Math.sqrt(1 - (t = t / d - 1) * t) + b;
    },
    easeInOutCirc: function (t, b, c, d) {
        if ((t /= d / 2) < 1) return -c / 2 * (Math.sqrt(1 - t * t) - 1) + b;
        return c / 2 * (Math.sqrt(1 - (t -= 2) * t) + 1) + b;
    },
    // Elastic
    easeInElastic: function (t, b, c, d, a, p) {
        let s;
        if (t == 0) return b;
        if ((t /= d) == 1) return b + c;
        if (typeof p == "undefined") p = d * .3;
        if (!a || a < Math.abs(c)) {
            s = p / 4;
            a = c;
        } else {
            s = p / (2 * Math.PI) * Math.asin(c / a);
        }
        return -(a * Math.pow(2, 10 * (t -= 1)) * Math.sin((t * d - s) * (2 * Math.PI) / p)) + b;
    },
    easeOutElastic: function (t, b, c, d, a, p) {
        let s;
        if (t == 0) return b;
        if ((t /= d) == 1) return b + c;
        if (typeof p == "undefined") p = d * .3;
        if (!a || a < Math.abs(c)) {
            a = c;
            s = p / 4;
        } else {
            s = p / (2 * Math.PI) * Math.asin(c / a);
        }
        return (a * Math.pow(2, -10 * t) * Math.sin((t * d - s) * (2 * Math.PI) / p) + c + b);
    },
    easeInOutElastic: function (t, b, c, d, a, p) {
        let s;
        if (t == 0) return b;
        if ((t /= d / 2) == 2) return b + c;
        if (typeof p == "undefined") p = d * (.3 * 1.5);
        if (!a || a < Math.abs(c)) {
            a = c;
            s = p / 4;
        } else {
            s = p / (2 * Math.PI) * Math.asin(c / a);
        }
        if (t < 1) return -.5 * (a * Math.pow(2, 10 * (t -= 1)) * Math.sin((t * d - s) * (2 * Math.PI) / p)) + b;
        return a * Math.pow(2, -10 * (t -= 1)) * Math.sin((t * d - s) * (2 * Math.PI) / p) * .5 + c + b;
    },
    // Back
    easeInBack: function (t, b, c, d, s) {
        if (typeof s == "undefined") s = 1.70158;
        return c * (t /= d) * t * ((s + 1) * t - s) + b;
    },
    easeOutBack: function (t, b, c, d, s) {
        if (typeof s == "undefined") s = 1.70158;
        return c * ((t = t / d - 1) * t * ((s + 1) * t + s) + 1) + b;
    },
    easeInOutBack: function (t, b, c, d, s) {
        if (typeof s == "undefined") s = 1.70158;
        if ((t /= d / 2) < 1) return c / 2 * (t * t * (((s *= (1.525)) + 1) * t - s)) + b;
        return c / 2 * ((t -= 2) * t * (((s *= (1.525)) + 1) * t + s) + 2) + b;
    },
    // Bounce
    easeInBounce: function (t, b, c, d) {
        return c - Tween.easeOutBounce(d - t, 0, c, d) + b;
    },
    easeOutBounce: function (t, b, c, d) {
        if ((t /= d) < (1 / 2.75)) {
            return c * (7.5625 * t * t) + b;
        } else if (t < (2 / 2.75)) {
            return c * (7.5625 * (t -= (1.5 / 2.75)) * t + .75) + b;
        } else if (t < (2.5 / 2.75)) {
            return c * (7.5625 * (t -= (2.25 / 2.75)) * t + .9375) + b;
        } else {
            return c * (7.5625 * (t -= (2.625 / 2.75)) * t + .984375) + b;
        }
    },
    easeInOutBounce: function (t, b, c, d) {
        if (t < d / 2) {
            return Tween.easeInBounce(t * 2, 0, c, d) * .5 + b;
        } else {
            return Tween.easeOutBounce(t * 2 - d, 0, c, d) * .5 + c * .5 + b;
        }
    }
};

export default Tween;

最后,给Core类增加play方法:

_play() {
  this.state = 'play';
  this.beginTime = Date.now();
  // 执行动画循环
  const loop = this._loop.bind(this);
  window.requestAnimationFrame(loop);
}

play() {
  this._play();
}

core.js的完整代码如下:

import Tween from './tween';

class Core {
    constructor(opt) {
        this._init(opt);
        this.state = 'init';
    }

    _init(opt) {
    this._initValue(opt.value);
    this.duration = opt.duration || 1000;
    this.timingFunction = opt.timingFunction || 'linear';
    this.renderFunction = opt.render || this._defaultFunc;

    /* Events */
    this.onPlay = opt.onPlay;
    this.onEnd = opt.onEnd;
    this.onStop = opt.onStop;
    this.onReset = opt.onReset;
  }

  _initValue(value) {
      this.value = [];
      value.forEach(item => {
          this.value.push({
              start: parseFloat(item[0]),
              end: parseFloat(item[1]),
          });
      })
  }

  _loop() {
      const t = Date.now() - this.beginTime,
          d = this.duration,
          func = Tween[this.timingFunction] || Tween['linear'];

    if (t >= d) {
        this.state = 'end';
        this._renderFunction(d, d, func);
    } else {
        this._renderFunction(t, d, func);
        window.requestAnimationFrame(this._loop.bind(this));
    }
  }

  _renderFunction(t, d, func) {
      const values = this.value.map(value => func(t, value.start, value.end - value.start, d));
      this.renderFunction.apply(this, values);
  }

  _play() {
      this.state = 'play';
      this.beginTime = Date.now();
      const loop = this._loop.bind(this);
    window.requestAnimationFrame(loop);
  }

  play() {
      this._play();
  }
}

window.Timeline = Core;

在html中引入它后就可以愉快地调用啦^ _ ^

PS:该项目是用webpack打包并以timeline.min.js作为输出文件,由于暂时没用到index.js文件,因此暂时以core.js作为打包入口啦~

<!DOCTYPE html>
<html>
<head>
    <title></title>
    <style type="text/css">
        #box {
            width: 100px;
            height: 100px;
            background: green;
        }
    </style>
</head>
<body>
<div id="box"></div>
<script type="text/javascript" src="timeline.js"></script>
<script type="text/javascript">
    const box = document.querySelector('#box');
    const timeline = new Timeline({
        duration: 3000,
        value: [[0, 400], [0, 600]],
        render: function(value1, value2) {
            box.style.transform = `translate(${ value1 }px, ${ value2 }px)`;
        },
        timingFunction: 'easeOut',
    })
    timeline.play();
</script>
</body>
</html>

看到这里,本文就差不多结束了,下节将介绍如何在项目中加入各类事件监听及触发方式。

本系列文章将会继续不定期更新,欢迎各位大大指正^_^

一个播放器引发的思考——谈谈React跨组件通信

在我们react项目日常开发中,往往会遇到这样一个问题:如何去实现跨组件通信?

为了更好的理解此问题,接下来我们通过一个简单的栗子说明。

实现一个视频播放器

假设有一个这样的需求,需要我们去实现一个简易的视频播放器,基于对播放器的理解,我们可以把这个视频播放器大致分为如下几个部分:

  • 视频窗口组件Screen
  • 底部播放控件BottomCtrl

对于视频窗口组件,它包含一个播放/暂停按钮CenterPlayBtn;而底部播放控件又是由以下几种组件组合而成:

  • 播放/暂停按钮BottomPlayBtn
  • 进度控制条ProgressCtrl
  • 音量按钮Volume

于是乎它的构成应该如下图所示:

同样的,我们的组件组织方式应该也长这样:(这里简化了代码实现)

class MyVideo {
  render() {
    return (
      <div>
        <Screen />
        <BottomCtrl />
      </div>
    )
  }
}

// 底部视频控件
class BottomCtrl {
  render() {
    return (
      <div>
        <BottomPlayBtn />
        <ProgressCtrl />
        <Volume />
      </div>
    )
  }
}

// 视频窗口组件
class Screen {
  render() {
    return (
      <div>
        <video />
        <ScreenPlayBtn />
      </div>
    )
  }
}

对于视频播放器而言,有一个很常见的交互,即当我们点击屏幕中心的播放按钮CenterPlayBtn时,不仅需要改变自身的状态(隐藏起来),而且还要更新底部播放按钮BottomPlayBtn的样式

由于中心播放按钮与底部控件按钮分别属于ScreenBottomCtrl组件的部分,因此这就是一个很常见的跨组件通信问题:如何将CenterPlayBtn的状态同步到BottomPlayBtn?

方案一:祖先组件的状态管理

一个非常常用的方式,就是让祖先组件通过状态管理的方式把信息同步到其他子组件中:

class MyVideo {
    constructor(props) {
        super(props);
        this.state = {
            isPlay: false,
        }
    }
    
    updatePlayState = isPlay => {
        this.setState({ isPlay });
    }
    
    render() {
        const { isPlay } = this.state;
        return (
            <div>
                <Screen updatePlayState={this.updatePlayState} isPlay={isPlay} />
                <BottomCtrl updatePlayState={this.updatePlayState} isPlay={isPlay} />
            </div>
        )
    }
}

我们通过在祖先组件的state定义相应的状态,并把修改state的方法传递给了子组件,那么当一个子组件通过调用updatePlayState后,它所设置的新状态亦可通过react本身的state更新机制传递给其他的子组件,实现跨组件通信。

这种方案虽然简单,但在一些复杂的场景下却显得不够友好:

  1. 状态和方法需要通过层层props传递到相应的子组件,一旦组件嵌套过深,不好编写与维护,且对于中间传递的组件而言,增加了不必要的逻辑;
  2. 管理状态的祖先组件将变得更加臃肿。试想一下,假设我们为了实现两个嵌套很深的子组件的通信,却需要在祖先组件上去额外添加状态和方法,这增加了祖先组件的维护成本。

方案二:redux提供的跨组件通信能力

熟悉redux的童鞋都知道,redux提供的订阅发布机制,可以让我们实现任何两个组件的通信:首先我们需要在state上去添加一个key,在两个需要通信的组件上通过connect的封装,即可订阅key值的改变。

// CenterPlayBtn
class CenterPlayBtn {
    play() {
        this.props.updatePlayStatus();
    }
}

const mapDispatchToProps = dispatch => {
  return {
    updatePlayStatus: isPlay => {
      dispatch(updatePlayStatus(isPlay))
    }
  }
}

export default connect(null, mapDispatchToProps)(BottomPlayBtn)
class BottomPlayBtn {
    componentWillReceiveProps(nextProps) {
        if (this.props.isPlay !== nextProps.isPlay) {
            // do something
        }
    }
}

const mapStateToProps = state => ({
    isPlay: state.isPlay
})

export default connect(mapStateToProps, null)(BottomPlayBtn)

使用redux的方式去实现跨组件通信是一种很常见的方式,在项目开发中也经常用到。那问题又来了,由于使用这种方案的前提是必须得在项目中加入redux,如果我的项目本来就比较简单,不需要使用到redux,难道为了实现两个组件简单的通信而要去做一系列redux的配置工作吗?这显然把简单的问题又复杂化了。

方案三:EventEmitter

EventEmitter也可以实现跨组件通信,当然这种基于事件订阅的设计模式本身也与react关系不大,但我们的项目很小的时候,使用EventEmitter也不失为一种简单且高效的方式:

class CenterPlayBtn {

    constructor(props) {
        super(props);
        event.on('pause', () => {
            // do something
        })
    }

    play() {
        event.emit('play');
    }
}

class BottomPlayBtn {

    constructor(props) {
        super(props);
        event.on('play', () => {
            // do something
        })
    }

    pause() {
        event.emit('pause');
    }
}

当然这种方案也是有缺陷的:

  • 组织方式过于离散。发送者emit与接收者on分散在各个组件里,如果不细看每个组件的代码,我们难以从整体去观察、跟踪、管理这些事件;
  • 有可能出现错过某个事件的情况。如果某个组件订阅该事件太晚,那发布者之前所发布的该类事件,它都接收不到,而方案一和二的优点则在于,无论如何,组件都能拿到该key的最终状态值;
  • 有存在内存泄漏的风险。如果组件销毁了而不及时取消订阅,那就有内存泄漏的风险;

方案四:利用react原生的context实现跨组件通信

原生react提供了context,它的原文描述是这样的:

Context provides a way to pass data through the component tree without having to pass props down manually at every level.

简单来说就是react提供了一种方式,让你可以跨多层嵌套组件去访问数据,而不需要手动的将props一个一个地传递下去。通过这种方式我们也可以实现跨组件通信方式,这个方案和方案一很相似,但区别在于我们无需手动将props传递给经历的每一个中间层组件。更为具体的用法可以直接参考官网示例,下面只是抛砖引玉,给出个简单示例:

首先我们定义一个player-context.js文件

import { createContext } from 'react';
const PlayerContext = createContext();
export default PlayerContext;

然后在MyVideo组件中使用PlayerContext.Provider:

import PlayerContext from './player-context';

class MyVideo {
    constructor(props) {
        super(props);
        this.state = {
            isPlay: false,
            updatePlayState: this.updatePlayState,
        }
    }
    
    updatePlayState = isPlay => {
        this.setState({ isPlay });
    }
    
    render() {
        return (
            <PlayerContext.Provider value={this.state}>
                <Screen />
                <BottomCtrl />
            </PlayerContext.Provider>
        )
    }
}

接着在需要消费数据的地方CenterPlayBtnBottomPlayBtn中使用到它,这里只给出CenterPlayBtn的示例:

import PlayerContext from './player-context';

class CenterPlayBtn {

    constructor(props) {
        super(props);
    }

    play() {
        this.props.updatePlayStatus(!this.props.isPlay);
    }
    
    componentWillReceiveProps(nextProps) {
        if (this.props.isPlay !== nextProps.isPlay) {
            // do something...
        }
    }
}

export default props => (<PlayerContext.Consumer>
    {
        ({isPlay, updatePlayStatus}) => <CenterPlayBtn {...props} isPlay={isPlay} updatePlayStatus={updatePlayStatus} />
    } 
</PlayerContext.Consumer>)

其实个人认为这种方案是方案一的“增强版”:

  • 首先它像方案一一样,对数据作了集中控制管理,即把提供数据内容和修改数据的能力集中到了上层组件身上,使得上层组件成为唯一的Provider,供下层各处的消费者Consumer使用;
  • 其次它无须像方案一一样繁琐地将props手动向下传递;

总得来说,如果你的项目没有使用到redux的话,使用context是个不错的选择。

总结

上面列举的方案各有优劣,我们很难去判定哪种方案是最好的,而真正重要的,是要学会分析哪个场景下使用哪种方案更佳。

btw,其实跨组件通信的方式多种多样,远不止这些,本人才疏学浅,这里只能列举出一些自己常用的解决方案,希望此文能抛砖引玉,引出更棒的方案和见解:)

canvas进阶——如何画出平滑的曲线?

canvas进阶——如何画出平滑的曲线?

背景概要

相信大家平时在学习canvas 或 项目开发中使用canvas的时候应该都遇到过这样的需求:实现一个可以书写的画板小工具。

嗯,相信这对canvas使用较熟的童鞋来说仅仅只是几十行代码就可以搞掂的事情,以下demo就是一个再也简单不过的例子了:

<!DOCTYPE html>
<html>
<head>
	<title>Sketchpad demo</title>
	<style type="text/css">
		canvas {
			border: 1px blue solid; 
		}
	</style>
</head>
<body>
	<canvas id="canvas" width="800" height="500"></canvas>
	<script type="text/javascript">
		let isDown = false;
		let beginPoint = null;
		const canvas = document.querySelector('#canvas');
		const ctx = canvas.getContext('2d');

		// 设置线条颜色
		ctx.strokeStyle = 'red';
		ctx.lineWidth = 1;
		ctx.lineJoin = 'round';
		ctx.lineCap = 'round';

		canvas.addEventListener('mousedown', down, false);
		canvas.addEventListener('mousemove', move, false);
		canvas.addEventListener('mouseup', up, false);
		canvas.addEventListener('mouseout', up, false);

		function down(evt) {
			isDown = true;
			beginPoint = getPos(evt);
		}

		function move(evt) {
			if (!isDown) return;
			const endPoint = getPos(evt);
			drawLine(beginPoint, endPoint);
			beginPoint = endPoint;
		}

		function up(evt) {
			if (!isDown) return;
			
			const endPoint = getPos(evt);
			drawLine(beginPoint, endPoint);

			beginPoint = null;
			isDown = false;
		}

		function getPos(evt) {
			return {
				x: evt.clientX,
				y: evt.clientY
			}
		}

		function drawLine(beginPoint, endPoint) {
			ctx.beginPath();
			ctx.moveTo(beginPoint.x, beginPoint.y);
			ctx.lineTo(endPoint.x, endPoint.y);
			ctx.stroke();
			ctx.closePath();
		}
	</script>
</body>
</html>

它的实现逻辑也很简单:

  1. 我们在canvas画布上主要监听了三个事件:mousedownmouseupmousemove,同时我们也创建了一个isDown变量;
  2. 当用户按下鼠标(mousedown,即起笔)时将isDown置为true,而放下鼠标(mouseup)的时候将它置为false,这样做的好处就是可以判断用户当前是否处于绘画状态;
  3. 通过mousemove事件不断采集鼠标经过的坐标点,当且仅当isDowntrue(即处于书写状态)时将当前的点通过canvas的lineTo方法与前面的点进行连接、绘制;

通过以上几个步骤我们就可以实现基本的画板功能了,然而事情并没那么简单,仔细的童鞋也许会发现一个很严重的问题——通过这种方式画出来的线条存在锯齿,不够平滑,而且你画得越快,折线感越强。表现如下图所示:

howToDrawLineSmoothly_6

为什么会这样呢?

问题分析

出现该现象的原因主要是:

  • 我们是以canvas的lineTo方法连接点的,连接相邻两点的是条直线,非曲线,因此通过这种方式绘制出来的是条折线;
    howToDrawLineSmoothly_1.png
  • 受限于浏览器对mousemove事件的采集频率,大家都知道在mousemove时,浏览器是每隔一小段时间去采集当前鼠标的坐标的,因此鼠标移动的越快,采集的两个临近点的距离就越远,故“折线感越明显“;

如何才能画出平滑的曲线?

要画出平滑的曲线,其实也是有方法的,lineTo靠不住那我们可以采用canvas的另一个绘图API——quadraticCurveTo ,它用于绘制二次贝塞尔曲线。

二次贝塞尔曲线

quadraticCurveTo(cp1x, cp1y, x, y)

调用quadraticCurveTo方法需要四个参数,cp1xcp1y描述的是控制点,而xy则是曲线的终点:

howToDrawLineSmoothly_7

更多详细的信息可移步MDN

既然要使用贝塞尔曲线,很显然我们的数据是不够用的,要完整描述一个二次贝塞尔曲线,我们需要:起始点、控制点和终点,这些数据怎么来呢?

有一个很巧妙的算法可以帮助我们获取这些信息

获取二次贝塞尔关键点的算法

这个算法并不难理解,这里我直接举例子吧:

  1. 假设我们在一次绘画**采集到6个鼠标坐标,分别是A, B, C, D, E, F
  2. 取前面的A, B, C三点,计算出BC的中点B1,以A为起点,B为控制点,B1为终点,利用quadraticCurveTo绘制一条二次贝塞尔曲线线段;
    howToDrawLineSmoothly_2
  3. 接下来,计算得出CD点的中点C1,以B1为起点、C为控制点、C1为终点继续绘制曲线;
    howToDrawLineSmoothly_3
  4. 依次类推不断绘制下去,当到最后一个点F时,则以DE的中点D1为起点,以E为控制点,F为终点结束贝塞尔曲线。
    howToDrawLineSmoothly_4

OK,算法就是这样,那我们基于该算法再对现有代码进行一次升级改造:

let isDown = false;
let points = [];
let beginPoint = null;
const canvas = document.querySelector('#canvas');
const ctx = canvas.getContext('2d');

// 设置线条颜色
ctx.strokeStyle = 'red';
ctx.lineWidth = 1;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';

canvas.addEventListener('mousedown', down, false);
canvas.addEventListener('mousemove', move, false);
canvas.addEventListener('mouseup', up, false);
canvas.addEventListener('mouseout', up, false);

function down(evt) {
    isDown = true;
    const { x, y } = getPos(evt);
    points.push({x, y});
    beginPoint = {x, y};
}

function move(evt) {
    if (!isDown) return;

    const { x, y } = getPos(evt);
    points.push({x, y});

    if (points.length > 3) {
        const lastTwoPoints = points.slice(-2);
        const controlPoint = lastTwoPoints[0];
        const endPoint = {
            x: (lastTwoPoints[0].x + lastTwoPoints[1].x) / 2,
            y: (lastTwoPoints[0].y + lastTwoPoints[1].y) / 2,
        }
        drawLine(beginPoint, controlPoint, endPoint);
        beginPoint = endPoint;
    }
}

function up(evt) {
    if (!isDown) return;
    const { x, y } = getPos(evt);
    points.push({x, y});

    if (points.length > 3) {
        const lastTwoPoints = points.slice(-2);
        const controlPoint = lastTwoPoints[0];
        const endPoint = lastTwoPoints[1];
        drawLine(beginPoint, controlPoint, endPoint);
    }
    beginPoint = null;
    isDown = false;
    points = [];
}

function getPos(evt) {
    return {
        x: evt.clientX,
        y: evt.clientY
    }
}

function drawLine(beginPoint, controlPoint, endPoint) {
    ctx.beginPath();
    ctx.moveTo(beginPoint.x, beginPoint.y);
    ctx.quadraticCurveTo(controlPoint.x, controlPoint.y, endPoint.x, endPoint.y);
    ctx.stroke();
    ctx.closePath();
}

在原有的基础上,我们创建了一个变量points用于保存之前mousemove事件中鼠标经过的点,根据该算法可知要绘制二次贝塞尔曲线起码需要3个点以上,因此我们只有在points中的点数大于3时才开始绘制。接下来的处理就跟该算法一毛一样了,这里不再赘述。

代码更新后我们的曲线也变得平滑了许多,如下图所示:

howToDrawLineSmoothly_5

本文到这里就结束了,希望大家在canvas画板中“画”得愉快~我们下次再见:)

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.