Showonne

Showonne 查看完整档案

杭州编辑杭州电子科技大学  |  软件工程 编辑阿里巴巴  |  前端开发工程师 编辑 www.showonne.com 编辑
编辑

考拉海购招前端专家,简历投递:showone896@gmail.com, wx: 918677896。

个人动态

Showonne 发布了文章 · 3月29日

从头写一个 React-like 框架:优化 diff

前言

从头写一个 React-like 框架:工程搭建中,我们将 mini React 进行了重构,这次我们来优化一下现有的 diff 逻辑,在 Fiber 架构中,主要有 reconcile 和 commit 两大阶段,diff 的过程发生在 reconcile 阶段。

现有功能

先看一下现有的 reconcileChildren 实现:

function reconcileChildren(WIPFiber: Fiber, children: VNode): void {
  let index = 0
  let prevSibling = null

  const oldChildren = WIPFiber.kids || []
  const newChildren = (WIPFiber.kids = arrayfy(children))

  const length = Math.max(oldChildren.length, newChildren.length)

  while (index < length) {

    const oldChild = oldChildren[index]
    const currentChild = newChildren[index]

    const sameType = oldChild && currentChild && oldChild.type === currentChild.type

    if (sameType) {
      currentChild.effectTag = 'UPDATE'
      // ...
    }

    if (currentChild && !sameType) {
      currentChild.effectTag = 'PLACEMENT'
      // ...
    }
    
    if (oldChild && !sameType) {
      oldChild.effectTag = 'DELETION'
      deletions.push(oldChild)
    }
    // ...
  }
}

因为新旧 children 的长短不定,我们取两者中较大的长度进行遍历,保证新旧 children 中至少有一个列表所有节点都能被遍历到。如果新旧节点的 type 相同,则认为新旧节点可以复用 DOM,否则不能复用。循环中一共有三种情况:

  1. 新旧节点 type 相同,说明新旧节点都存在,并且 type 相同,effectTag 记为 'UPDATE'
  2. 新节点存在且 type 不同,说明该新节点需要被创建,effectTag 记为 'PLACEMENT'
  3. 旧节点存在且 type 不同,说明该旧节点需要被删除,effectTag 记为 'DELETION'

随后在 commit 阶段,只需要根据不同的 effectTag 进行不同的 dom 操作即可:

// reconciler.js
function commitWork(fiber: Fiber): void {
  // ...
  if (fiber.effectTag === 'PLACEMENT') {
    parentDom.appendChild(fiber.dom)
  }

  if (fiber.effectTag === 'DELETION') {
    commitDeletion(fiber, parentDom as HTMLElement)
    if (fiber.ref) {
      fiber.ref.current = null
    }
    return
  }

  if (fiber.effectTag === 'UPDATE') {
    updateDom(
      fiber.dom,
      fiber.prevProps,
      fiber.props
    )
  }
  // ...
}

这是一种最简单的 diff 策略,仅根据 type 判断节点是否可以复用,比如在下面的例子中:

<ul>
  {
    keys.map(key =><li>{key}</li>)
  }
</ul>

如果 keys 是从 [1, 2, 3] 变为 [3, 2, 1],在 reconcile 过程中,li 节点会全部复用,因为他们的 fiber type 相同,但是稍微变一下就会大有不同:

<ul>
  {
    keys.map(key => key === 1 ? <div>key 1 div</div> : <li>{key}</li>)
  }
</ul>

现在 key === 1 时,渲染出的 dom 不再是 li 而是 div, 所以如果 keys 从 [1, 2, 3] 变为 [3, 2, 1] ,在 1 和 3 处分别会进行一次创建 dom 和一次删除 dom,我们需要对这种情况进行优化。

添加 key

首要问题是:仅通过 type 不能准确地找到可复用节点,所以需要额外属性建立新旧节点的映射关系,这个属性就是我们熟知的 key。首先在构建 fiber 节点时检查 key 属性,如果有直接挂载到节点上:

// h.js
export function h(type, props, ...children): VNode {
  props = props || {}
  const key = props.key || null

  while (children.some(child => Array.isArray(child))) {
    children = children.flat()
  }

  return {
    type,
    // 添加 key 属性
    key,
    props: {
      ...props,
      children: children.map(child => typeof child === 'object' ? child : createTextElement(child)).filter(e => e != null)
    }
  }
}

有了 key 之后,我们认为当新旧节点的 typekey 都相等时,新旧节点的 dom 可以复用。新的 diff 中用到如下变量,分别是新旧 children 的首尾元素:

const oldChildren = WIPFiber.kids || []
const newChildren = (WIPFiber.kids = arrayfy(children))
// 新旧 children 首尾下标
let oldStart = 0
let oldEnd = oldChildren.length - 1
let newStart = 0
let newEnd = newChildren.length - 1
// 新旧 children 的首尾元素
let oldStartNode = oldChildren[oldStart]
let oldEndNode = oldChildren[oldEnd]
let newStartNode = newChildren[newStart]
let newEndNode = newChildren[newEnd]

先从新旧 children 的两端尝试寻找可复用的节点,oldStartNode, newStartNode 分别是新旧 children 的第一个节点,如果 oldStartNode, newStartNodekeytype 相同,则认为这两个节点可以复用 dom,直接更新属性(effectTag = 'UPDATE')即可,更新完成后,oldStart, newStart 分别指向下一个位置,oldStartNode, newStartNode 也随之变成剩余未进行 diff 的新旧 children 的第一个元素;当 oldStartNode, newStartNode key 不同时,暂停首部的比较,同理再从新旧 children 尾部开始比较,这样就可以先将新旧 children 两端不需要移动的可复用节点优先更新,当 oldStart > oldEndnewStart > newEnd 时,证明 oldChildren, newChildren 其中一个已经全部参与过 diff,循环终止:

while (oldStart <= oldEnd && newStart <= newEnd) {
  // 首尾 key, type 相同的节点优先更新(effectTag = 'UPDATE')
  if (isSame(oldStartNode, newStartNode)) {
    clone(newStartNode, oldStartNode)
    newStartNode.effectTag = 'UPDATE'

    oldStartNode = oldChildren[++oldStart]
    newStartNode = newChildren[++newStart]
  } else if (isSame(oldEndNode, newEndNode)) {
    clone(newEndNode, oldEndNode)
    newEndNode.effectTag = 'UPDATE'

    oldEndNode = oldChildren[--oldEnd]
    newEndNode = newChildren[--newEnd]
  }
  // ...
}

两端的节点 diff 完成后,开始遍历 newChildren 中剩余的节点,因为现在有了 key,通过 findIndex 就可以判断 oldChildren 中有没有可复用的节点,如果有,对新旧节点进行 patch,这里新旧节点在各自 children 中的位置是不同的,后续需要移动节点,所以 effectTag 记为 INSERT,而且在 commit 阶段才会真正进行 dom 操作,这里先通过 after 属性先记录新节点要插入位置之后的节点(因为实际用到的是 insertBefore 方法),由于我们遍历的是 newChildren,说明当前节点在新的渲染中是剩余未 diff 列表中的第一个,所以该节点的 afteroldStartNode。如果 oldChildren 中没有可复用的节点,则将 newStartNodeeffectTag 置为 'INSERT' 表示当前位置需要新插入一个节点。 diff 完成后,将 oldChildren 中对应位置的节点置为 null,并将 newStart 指向下一个元素。因为在 diff 的过程中,每次对可复用节点完成更新操作后,都会将 oldChildren 中对应的元素置为 null,因此在循环的最开始,我们要判断一下 oldStartNodeoldEndNode 元素是否存在,不存在则指向下一个元素:

if (!oldStartNode) {
  oldStartNode = oldChildren[++oldStart]
} else if (!oldEndNode) {
  oldEndNode = oldChildren[--oldEnd]
} else if (isSame(oldStartNode, newStartNode)) {
  // ...
} else if (isSame(oldEndNode, newEndNode)) {
  // ...
} else {
  const indexInOld = oldChildren.findIndex(child => isSame(child, newStartNode))
  // 存在可复用节点,完成新旧节点的 patch 操作,此处的 'INSERT' 表示节点需要被移动
  if (indexInOld >= 0) {
    const oldNode = oldChildren[indexInOld]
    clone(newStartNode, oldNode)
    newStartNode.effectTag = 'INSERT'
    newStartNode.after = oldStartNode
    oldChildren[indexInOld] = undefined
  } else {
    // 无可复用节点,无需 patch,此处的 'INSERT' 表示节点需要被创建
    newStartNode.effectTag = 'INSERT'
    newStartNode.after = oldStartNode
  }
  newStartNode = newChildren[++newStart]
}

最后,当 while 循环完成后,我们需要检查一下 oldStart, oldEnd, newStart, newEnd 之间的关系:

  1. 如果 oldEnd < oldStart,说明旧节点全部参与 diff 后,还有新节点没参与 diff,这些节点是需要直接新增的节点。可以直接遍历剩余的 newChildren,将这些节点依次添加到新 dom 序列的末尾 newChildren[newEnd + 1]
  2. 如果 newEnd < newStart,说明新节点全部参与 diff 后,还有旧节点没参与 diff,这些节点时需要删除的节点,直接循环剩余的 oldChildren,依次删除即可:
if (oldEnd < oldStart) {
  for (let i = newStart; i <= newEnd; i++) {
    let node = newChildren[i]
    node.effectTag = 'INSERT'
    node.after = newChildren[newEnd + 1]
  }
} else if (newEnd < newStart) {
  for (let i = oldStart; i <= oldEnd; i++) {
    let node = oldChildren[i]
    if (node) {
      node.effectTag = 'DELETION'
      deletions.push(node)
    }
  }
}

到此,简单优化后的 diff 流程就已经完成了。

结语

相比于旧的 diff 方案,新的 diff 方案有以下改进:

  1. 可以通过 key 更准确地判断新旧 children 中是否有可复用的节点
  2. 会优先从两端处理可直接进行复用的节点,会减少一些 findIndex 的次数
  3. 新的 diff 中采用 'INSERT' (insertBefore) 而不是 'PLACEMENT' (appendChild), 灵活性更好

关于 diff 流程,我从这个博客中学到了很多,里面讲得很详细,还有很多配图帮助理解,最主要的是要想清楚 oldStart, oldEnd, newStart, newEnd 他们中携带的信息,博客中的示例代码是 diff 过程和更新 dom 一起进行的,这一点并不适用于 fiber 架构,所以我进行了一些小改造(先标记 effectTagafter,commit 阶段统一更新 dom),代码在 github 上,有兴趣的同学可以看看(顺手 star 一下也是极好的) ^_^

查看原文

赞 2 收藏 0 评论 0

Showonne 发布了文章 · 3月18日

从头写一个 React-like 框架:工程搭建

最近在网上看到了 Build your own React 这篇文章,作者从零开始实现了一个简易类 React 框架,虽然没有过多的优化,但 React 中的核心思想 Concurrent ModeFiber Reconciler 等都有实现,看完后对理解 React 有很大帮助,因此我想在 Build your own React 的基础上,对代码进行拆分,搭建起自己的框架工程,然后完善教程中没完成的其他功能,代码在 rac 中。

工程搭建

技术栈上我选择用 TypeScript 开发,Rollup 打包, 都是平时用的不多的技术,顺带一起练练手,而且相比 webpack, rollup 配置更简单一些。在工程中创建一个 tsconfig.json 和一个 rollup.config.js, 然后安装一下需要的 rollup 插件,比如 rollup-plugin-typescript2rollup-plugin-terser。另外准备一个 examples 文件夹,创建一个小型的 demo 工程,使用 tsx 开发

支持 jsx

如果想让 TypeScript 支持 jsx,需要在 tsconfig 中开启 jsx TypeScript 自带了三种模式: preservereact,和 react-native,我们设置为 react, TypeScript 就会将代码中的 jsx 翻译成 React.createElement,这也是在使用 jsx 时,React 必须要在作用域中的原因。

但是我们要自己实现一个 React-like 框架,完全可以给 React.createElement 换个名字。在 Build your own React 中,作者通过 /** @jsx Didact.createElement */ 注释,告诉编译器将 jsx 的输出函数改为 Didact.createElement,这个方法只对当前文件生效,如果是在工程中使用为每个文件都加一行注释就麻烦了。我们通过另一种办法,在 tsconfig 中通过 jsxFactory 属性指定,我们这里叫 h,除了 React.createEmenent,还有个特殊元素 - Fragment,TypeScript 默认会翻译成 React.Fragment,我们通过 jsxFragmentFactory 直接改为 Fragment

tsconfig.json:

{
  "compilerOptions": {
    "target": "esnext",
    "module": "commonjs",
    "moduleResolution": "node",
    "jsx": "react", // enable jsx
    "jsxFactory": "h", // React.createElement => h
    "jsxFragmentFactory": "Fragment", // React.Fragment => Fragment
    "rootDir": "./src",
    "lib": ["dom", "es2015"]
  }
}

Rollup 配置

Rollup 的配置比较简单,除了 input,output,再额外加一些插件就可以了:

const path = require('path')
const typescript = require('rollup-plugin-typescript2')
const { terser } = require('rollup-plugin-terser')
const eslint = require('@rollup/plugin-eslint')

export default {
  input: 'src/index.ts',
  output: [
    { file: 'dist/rac.umd.js', format: 'umd', name: 'rac' }
  ],
  plugins: [
    terser(),
    eslint({
      throwOnError: true,
      include: ['src/**/*.ts']
    }),
    typescript({
      verbosity: 0,
      tsconfig: path.resolve(__dirname, 'tsconfig.json'),
      useTsconfigDeclarationDir: true
    })
  ]
}

Eslint in TypeScript

为了能让 Eslint 支持 TypeScript,需要给 Eslint 一些额外配置:

module.exports = {
  parser: '@typescript-eslint/parser',
  env: {
    es6: true,
    browser: true
  },
  plugins: [
    '@typescript-eslint'
  ],
  extends: [
    'eslint:recommended',
  ],
  parserOptions: {
    sourceType: 'module'
  },
  rules: {
    ...
  }
}

项目结构
React 新的 Fiber 架构有几个核心概念,在 Build your own React 中,作者依照

  • Step I: The createElement Function
  • Step II: The render Function
  • Step III: Concurrent Mode
  • Step IV: Fibers
  • Step V: Render and Commit Phases
  • Step VI: Reconciliation
  • Step VII: Function Components
  • Step VIII: Hooks

这几步逐步实现了一个 mini React,为了提高代码可读性和可维护性,会把这些功能划分到不同的文件中:

.
├── README.md
├── examples  // demo目录
├── package.json
├── rollup.config.js
├── src
│   ├── dom.ts
│   ├── h.ts
│   ├── hooks.ts
│   ├── index.ts
│   ├── reconciler.ts
│   ├── scheduler.ts
│   └── type.ts
└── tsconfig.json
  • dom.ts 中处理 DOM 相关工作
  • h.ts 中是对 jsxFactory, jsxFragmentFactory 的实现
  • hooks.ts 中是对 hooks 的实现
  • reconciler.ts 是 reconcile 阶段和 commit 阶段的实现
  • shceduler.ts 是任务调度器的实现
  • type.ts 是一些类型定义

到这工程就搭建起来了,整个工程的结构和一些代码实现上借鉴了 fre 这个框架。

查看原文

赞 10 收藏 4 评论 0

Showonne 赞了文章 · 2019-08-29

你真的会使用XMLHttpRequest吗?

看到标题时,有些同学可能会想:“我已经用xhr成功地发过很多个Ajax请求了,对它的基本操作已经算挺熟练了。” 我之前的想法和你们一样,直到最近我使用xhr时踩了不少坑儿,我才突然发现其实自己并不够了解xhr,我知道的只是最最基本的使用。
于是我决定好好地研究一番xhr的真面目,可拜读了不少博客后都不甚满意,于是我决定认真阅读一遍W3C的XMLHttpRequest标准。看完标准后我如同醍醐灌顶一般,感觉到了从未有过的清澈。这篇文章就是参考W3C的XMLHttpRequest标准和结合一些实践验证总结而来的。

AjaxXMLHttpRequest

我们通常将Ajax等同于XMLHttpRequest,但细究起来它们两个是属于不同维度的2个概念。

以下是我认为对Ajax较为准确的解释:(摘自what is Ajax
AJAX stands for Asynchronous JavaScript and XML. AJAX is a new technique for creating better, faster, and more interactive web applications with the help of XML, HTML, CSS, and Java Script.

AJAX is based on the following open standards:

  • Browser-based presentation using HTML and Cascading Style Sheets (CSS).

  • Data is stored in XML format and fetched from the server.

  • Behind-the-scenes data fetches using XMLHttpRequest objects in the browser.

  • JavaScript to make everything happen.

从上面的解释中可以知道:ajax是一种技术方案,但并不是一种新技术。它依赖的是现有的CSS/HTML/Javascript,而其中最核心的依赖是浏览器提供的XMLHttpRequest对象,是这个对象使得浏览器可以发出HTTP请求与接收HTTP响应。

所以我用一句话来总结两者的关系:我们使用XMLHttpRequest对象来发送一个Ajax请求。

XMLHttpRequest的发展历程

XMLHttpRequest一开始只是微软浏览器提供的一个接口,后来各大浏览器纷纷效仿也提供了这个接口,再后来W3C对它进行了标准化,提出了XMLHttpRequest标准XMLHttpRequest标准又分为Level 1Level 2
XMLHttpRequest Level 1主要存在以下缺点:

  • 受同源策略的限制,不能发送跨域请求;

  • 不能发送二进制文件(如图片、视频、音频等),只能发送纯文本数据;

  • 在发送和获取数据的过程中,无法实时获取进度信息,只能判断是否完成;

那么Level 2Level 1 进行了改进,XMLHttpRequest Level 2中新增了以下功能:

  • 可以发送跨域请求,在服务端允许的情况下;

  • 支持发送和接收二进制数据;

  • 新增formData对象,支持发送表单数据;

  • 发送和获取数据时,可以获取进度信息;

  • 可以设置请求的超时时间;

当然更详细的对比介绍,可以参考阮老师的这篇文章,文章中对新增的功能都有具体代码示例。

XMLHttpRequest兼容性

关于xhr的浏览器兼容性,大家可以直接查看“Can I use”这个网站提供的结果XMLHttpRequest兼容性,下面提供一个截图。

clipboard.png

从图中可以看到:

  • IE8/IE9、Opera Mini 完全不支持xhr对象

  • IE10/IE11部分支持,不支持 xhr.responseTypejson

  • 部分浏览器不支持设置请求超时,即无法使用xhr.timeout

  • 部分浏览器不支持xhr.responseTypeblob

细说XMLHttpRequest如何使用

先来看一段使用XMLHttpRequest发送Ajax请求的简单示例代码。

function sendAjax() {
  //构造表单数据
  var formData = new FormData();
  formData.append('username', 'johndoe');
  formData.append('id', 123456);
  //创建xhr对象 
  var xhr = new XMLHttpRequest();
  //设置xhr请求的超时时间
  xhr.timeout = 3000;
  //设置响应返回的数据格式
  xhr.responseType = "text";
  //创建一个 post 请求,采用异步
  xhr.open('POST', '/server', true);
  //注册相关事件回调处理函数
  xhr.onload = function(e) { 
    if(this.status == 200||this.status == 304){
        alert(this.responseText);
    }
  };
  xhr.ontimeout = function(e) { ... };
  xhr.onerror = function(e) { ... };
  xhr.upload.onprogress = function(e) { ... };
  
  //发送数据
  xhr.send(formData);
}

上面是一个使用xhr发送表单数据的示例,整个流程可以参考注释。


接下来我将站在使用者的角度,以问题的形式介绍xhr的基本使用。
我对每一个问题涉及到的知识点都会进行比较细致地介绍,有些知识点可能是你平时忽略关注的。

如何设置request header

在发送Ajax请求(实质是一个HTTP请求)时,我们可能需要设置一些请求头部信息,比如content-typeconnectioncookieaccept-xxx等。xhr提供了setRequestHeader来允许我们修改请求 header。

void setRequestHeader(DOMString header, DOMString value);

注意点

  • 方法的第一个参数 header 大小写不敏感,即可以写成content-type,也可以写成Content-Type,甚至写成content-Type;

  • Content-Type的默认值与具体发送的数据类型有关,请参考本文【可以发送什么类型的数据】一节;

  • setRequestHeader必须在open()方法之后,send()方法之前调用,否则会抛错;

  • setRequestHeader可以调用多次,最终的值不会采用覆盖override的方式,而是采用追加append的方式。下面是一个示例代码:

var client = new XMLHttpRequest();
client.open('GET', 'demo.cgi');
client.setRequestHeader('X-Test', 'one');
client.setRequestHeader('X-Test', 'two');
// 最终request header中"X-Test"为: one, two
client.send();

如何获取response header

xhr提供了2个用来获取响应头部的方法:getAllResponseHeadersgetResponseHeader。前者是获取 response 中的所有header 字段,后者只是获取某个指定 header 字段的值。另外,getResponseHeader(header)header参数不区分大小写。

DOMString getAllResponseHeaders();
DOMString getResponseHeader(DOMString header);

这2个方法看起来简单,但却处处是坑儿。

你是否遇到过下面的坑儿?——反正我是遇到了。。。

  1. 使用getAllResponseHeaders()看到的所有response header与实际在控制台 Network 中看到的 response header 不一样

  2. 使用getResponseHeader()获取某个 header 的值时,浏览器抛错Refused to get unsafe header "XXX"

经过一番寻找最终在 Stack Overflow找到了答案

"simple response header"包括的 header 字段有:Cache-Control,Content-Language,Content-Type,Expires,Last-Modified,Pragma;
"Access-Control-Expose-Headers":首先得注意是"Access-Control-Expose-Headers"进行跨域请求时响应头部中的一个字段,对于同域请求,响应头部是没有这个字段的。这个字段中列举的 header 字段就是服务器允许暴露给客户端访问的字段。

所以getAllResponseHeaders()只能拿到限制以外(即被视为safe)的header字段,而不是全部字段;而调用getResponseHeader(header)方法时,header参数必须是限制以外的header字段,否则调用就会报Refused to get unsafe header的错误。

如何指定xhr.response的数据类型

有些时候我们希望xhr.response返回的就是我们想要的数据类型。比如:响应返回的数据是纯JSON字符串,但我们期望最终通过xhr.response拿到的直接就是一个 js 对象,我们该怎么实现呢?
有2种方法可以实现,一个是level 1就提供的overrideMimeType()方法,另一个是level 2才提供的xhr.responseType属性。

xhr.overrideMimeType()

overrideMimeTypexhr level 1就有的方法,所以浏览器兼容性良好。这个方法的作用就是用来重写responsecontent-type,这样做有什么意义呢?比如:server 端给客户端返回了一份document或者是 xml文档,我们希望最终通过xhr.response拿到的就是一个DOM对象,那么就可以用xhr.overrideMimeType('text/xml; charset = utf-8')来实现。

再举一个使用场景,我们都知道xhr level 1不支持直接传输blob二进制数据,那如果真要传输 blob 该怎么办呢?当时就是利用overrideMimeType方法来解决这个问题的。

下面是一个获取图片文件的代码示例:

var xhr = new XMLHttpRequest();
//向 server 端获取一张图片
xhr.open('GET', '/path/to/image.png', true);

// 这行是关键!
//将响应数据按照纯文本格式来解析,字符集替换为用户自己定义的字符集
xhr.overrideMimeType('text/plain; charset=x-user-defined');

xhr.onreadystatechange = function(e) {
  if (this.readyState == 4 && this.status == 200) {
    //通过 responseText 来获取图片文件对应的二进制字符串
    var binStr = this.responseText;
    //然后自己再想方法将逐个字节还原为二进制数据
    for (var i = 0, len = binStr.length; i < len; ++i) {
      var c = binStr.charCodeAt(i);
      //String.fromCharCode(c & 0xff);
      var byte = c & 0xff; 
    }
  }
};

xhr.send();

代码示例中xhr请求的是一张图片,通过将 responsecontent-type 改为'text/plain; charset=x-user-defined',使得 xhr 以纯文本格式来解析接收到的blob 数据,最终用户通过this.responseText拿到的就是图片文件对应的二进制字符串,最后再将其转换为 blob 数据。

xhr.responseType

responseTypexhr level 2新增的属性,用来指定xhr.response的数据类型,目前还存在些兼容性问题,可以参考本文的【XMLHttpRequest的兼容性】这一小节。那么responseType可以设置为哪些格式呢,我简单做了一个表,如下:

xhr.response 数据类型说明
""String字符串默认值(在不设置responseType时)
"text"String字符串
"document"Document对象希望返回 XML 格式数据时使用
"json"javascript 对象存在兼容性问题,IE10/IE11不支持
"blob"Blob对象
"arrayBuffer"ArrayBuffer对象

下面是同样是获取一张图片的代码示例,相比xhr.overrideMimeType,用xhr.response来实现简单得多。

var xhr = new XMLHttpRequest();
xhr.open('GET', '/path/to/image.png', true);
//可以将`xhr.responseType`设置为`"blob"`也可以设置为`" arrayBuffer"`
//xhr.responseType = 'arrayBuffer';
xhr.responseType = 'blob';

xhr.onload = function(e) {
  if (this.status == 200) {
    var blob = this.response;
    ...
  }
};

xhr.send();

小结

虽然在xhr level 2中,2者是共同存在的。但其实不难发现,xhr.responseType就是用来取代xhr.overrideMimeType()的,xhr.responseType功能强大的多,xhr.overrideMimeType()能做到的xhr.responseType都能做到。所以我们现在完全可以摒弃使用xhr.overrideMimeType()了。

如何获取response数据

xhr提供了3个属性来获取请求返回的数据,分别是:xhr.responsexhr.responseTextxhr.responseXML

  • xhr.response

    • 默认值:空字符串""

    • 当请求完成时,此属性才有正确的值

    • 请求未完成时,此属性的值可能是""或者 null,具体与 xhr.responseType有关:当responseType"""text"时,值为""responseType为其他值时,值为 null

  • xhr.responseText

    • 默认值为空字符串""

    • 只有当 responseType"text"""时,xhr对象上才有此属性,此时才能调用xhr.responseText,否则抛错

    • 只有当请求成功时,才能拿到正确值。以下2种情况下值都为空字符串"":请求未完成、请求失败

  • xhr.responseXML

    • 默认值为 null

    • 只有当 responseType"text""""document"时,xhr对象上才有此属性,此时才能调用xhr.responseXML,否则抛错

    • 只有当请求成功且返回数据被正确解析时,才能拿到正确值。以下3种情况下值都为null:请求未完成、请求失败、请求成功但返回数据无法被正确解析时

如何追踪ajax请求的当前状态

在发一个ajax请求后,如果想追踪请求当前处于哪种状态,该怎么做呢?

xhr.readyState这个属性即可追踪到。这个属性是只读属性,总共有5种可能值,分别对应xhr不同的不同阶段。每次xhr.readyState的值发生变化时,都会触发xhr.onreadystatechange事件,我们可以在这个事件中进行相关状态判断。

  xhr.onreadystatechange = function () {
    switch(xhr.readyState){
      case 1://OPENED
        //do something
            break;
      case 2://HEADERS_RECEIVED
        //do something
        break;
      case 3://LOADING
        //do something
        break;
      case 4://DONE
        //do something
        break;
    }
状态描述
0UNSENT (初始状态,未打开)此时xhr对象被成功构造,open()方法还未被调用
1OPENED (已打开,未发送)open()方法已被成功调用,send()方法还未被调用。注意:只有xhr处于OPENED状态,才能调用xhr.setRequestHeader()xhr.send(),否则会报错
2HEADERS_RECEIVED (已获取响应头)send()方法已经被调用, 响应头和响应状态已经返回
3LOADING (正在下载响应体)响应体(response entity body)正在下载中,此状态下通过xhr.response可能已经有了响应数据
4DONE (整个数据传输过程结束)整个数据传输过程结束,不管本次请求是成功还是失败

如何设置请求的超时时间

如果请求过了很久还没有成功,为了不会白白占用的网络资源,我们一般会主动终止请求。XMLHttpRequest提供了timeout属性来允许设置请求的超时时间。

xhr.timeout

单位:milliseconds 毫秒
默认值:0,即不设置超时

很多同学都知道:从请求开始 算起,若超过 timeout 时间请求还没有结束(包括成功/失败),则会触发ontimeout事件,主动结束该请求。

【那么到底什么时候才算是请求开始 ?】
——xhr.onloadstart事件触发的时候,也就是你调用xhr.send()方法的时候。
因为xhr.open()只是创建了一个连接,但并没有真正开始数据的传输,而xhr.send()才是真正开始了数据的传输过程。只有调用了xhr.send(),才会触发xhr.onloadstart

【那么什么时候才算是请求结束 ?】
—— xhr.loadend事件触发的时候。

另外,还有2个需要注意的坑儿:

  1. 可以在 send()之后再设置此xhr.timeout,但计时起始点仍为调用xhr.send()方法的时刻。

  2. xhr为一个sync同步请求时,xhr.timeout必须置为0,否则会抛错。原因可以参考本文的【如何发一个同步请求】一节。

如何发一个同步请求

xhr默认发的是异步请求,但也支持发同步请求(当然实际开发中应该尽量避免使用)。到底是异步还是同步请求,由xhr.open()传入的async参数决定。

open(method, url [, async = true [, username = null [, password = null]]])

  • method: 请求的方式,如GET/POST/HEADER等,这个参数不区分大小写

  • url: 请求的地址,可以是相对地址如example.php,这个相对是相对于当前网页的url路径;也可以是绝对地址如http://www.example.com/example.php

  • async: 默认值为true,即为异步请求,若async=false,则为同步请求

在我认真研读W3C 的 xhr 标准前,我总以为同步请求和异步请求只是阻塞和非阻塞的区别,其他什么事件触发、参数设置应该是一样的,事实证明我错了。

W3C 的 xhr标准中关于open()方法有这样一段说明:

Throws an "InvalidAccessError" exception if async is false, the JavaScript global environment is a document environment, and either the timeout attribute is not zero, the withCredentials attribute is true, or the responseType attribute is not the empty string.

从上面一段说明可以知道,当xhr为同步请求时,有如下限制:

  • xhr.timeout必须为0

  • xhr.withCredentials必须为 false

  • xhr.responseType必须为""(注意置为"text"也不允许)

若上面任何一个限制不满足,都会抛错,而对于异步请求,则没有这些参数设置上的限制。

之前说过页面中应该尽量避免使用sync同步请求,为什么呢?
因为我们无法设置请求超时时间(xhr.timeout0,即不限时)。在不限制超时的情况下,有可能同步请求一直处于pending状态,服务端迟迟不返回响应,这样整个页面就会一直阻塞,无法响应用户的其他交互。

另外,标准中并没有提及同步请求时事件触发的限制,但实际开发中我确实遇到过部分应该触发的事件并没有触发的现象。如在 chrome中,当xhr为同步请求时,在xhr.readyState2变成3时,并不会触发 onreadystatechange事件,xhr.upload.onprogressxhr.onprogress事件也不会触发。

如何获取上传、下载的进度

在上传或者下载比较大的文件时,实时显示当前的上传、下载进度是很普遍的产品需求。
我们可以通过onprogress事件来实时显示进度,默认情况下这个事件每50ms触发一次。需要注意的是,上传过程和下载过程触发的是不同对象的onprogress事件:

  • 上传触发的是xhr.upload对象的 onprogress事件

  • 下载触发的是xhr对象的onprogress事件

xhr.onprogress = updateProgress;
xhr.upload.onprogress = updateProgress;
function updateProgress(event) {
    if (event.lengthComputable) {
      var completedPercent = event.loaded / event.total;
    }
 }

可以发送什么类型的数据

void send(data);

xhr.send(data)的参数data可以是以下几种类型:

  • ArrayBuffer

  • Blob

  • Document

  • DOMString

  • FormData

  • null

如果是 GET/HEAD请求,send()方法一般不传参或传 null。不过即使你真传入了参数,参数也最终被忽略,xhr.send(data)中的data会被置为 null.

xhr.send(data)中data参数的数据类型会影响请求头部content-type的默认值:

  • 如果dataDocument 类型,同时也是HTML Document类型,则content-type默认值为text/html;charset=UTF-8;否则为application/xml;charset=UTF-8

  • 如果dataDOMString 类型,content-type默认值为text/plain;charset=UTF-8

  • 如果dataFormData 类型,content-type默认值为multipart/form-data; boundary=[xxx]

  • 如果data是其他类型,则不会设置content-type的默认值

当然这些只是content-type的默认值,但如果用xhr.setRequestHeader()手动设置了中content-type的值,以上默认值就会被覆盖。

另外需要注意的是,若在断网状态下调用xhr.send(data)方法,则会抛错:Uncaught NetworkError: Failed to execute 'send' on 'XMLHttpRequest'。一旦程序抛出错误,如果不 catch 就无法继续执行后面的代码,所以调用 xhr.send(data)方法时,应该用 try-catch捕捉错误。

try{
    xhr.send(data)
  }catch(e) {
    //doSomething...
  };

xhr.withCredentialsCORS 什么关系

我们都知道,在发同域请求时,浏览器会将cookie自动加在request header中。但大家是否遇到过这样的场景:在发送跨域请求时,cookie并没有自动加在request header中。

造成这个问题的原因是:在CORS标准中做了规定,默认情况下,浏览器在发送跨域请求时,不能发送任何认证信息(credentials)如"cookies"和"HTTP authentication schemes"。除非xhr.withCredentialstruexhr对象有一个属性叫withCredentials,默认值为false)。

所以根本原因是cookies也是一种认证信息,在跨域请求中,client端必须手动设置xhr.withCredentials=true,且server端也必须允许request能携带认证信息(即response header中包含Access-Control-Allow-Credentials:true),这样浏览器才会自动将cookie加在request header中。

另外,要特别注意一点,一旦跨域request能够携带认证信息,server端一定不能将Access-Control-Allow-Origin设置为*,而必须设置为请求页面的域名。

xhr相关事件

事件分类

xhr相关事件有很多,有时记起来还挺容易混乱。但当我了解了具体代码实现后,就容易理清楚了。下面是XMLHttpRequest的部分实现代码:

interface XMLHttpRequestEventTarget : EventTarget {
  // event handlers
  attribute EventHandler onloadstart;
  attribute EventHandler onprogress;
  attribute EventHandler onabort;
  attribute EventHandler onerror;
  attribute EventHandler onload;
  attribute EventHandler ontimeout;
  attribute EventHandler onloadend;
};

interface XMLHttpRequestUpload : XMLHttpRequestEventTarget {

};

interface XMLHttpRequest : XMLHttpRequestEventTarget {
  // event handler
  attribute EventHandler onreadystatechange;
  readonly attribute XMLHttpRequestUpload upload;
};

从代码中我们可以看出:

  1. XMLHttpRequestEventTarget接口定义了7个事件:

    • onloadstart

    • onprogress

    • onabort

    • ontimeout

    • onerror

    • onload

    • onloadend

  2. 每一个XMLHttpRequest里面都有一个upload属性,而upload是一个XMLHttpRequestUpload对象

  3. XMLHttpRequestXMLHttpRequestUpload都继承了同一个XMLHttpRequestEventTarget接口,所以xhrxhr.upload都有第一条列举的7个事件

  4. onreadystatechangeXMLHttpRequest独有的事件

所以这么一看就很清晰了:
xhr一共有8个相关事件:7个XMLHttpRequestEventTarget事件+1个独有的onreadystatechange事件;而xhr.upload只有7个XMLHttpRequestEventTarget事件。

事件触发条件

下面是我自己整理的一张xhr相关事件触发条件表,其中最需要注意的是 onerror 事件的触发条件。

事件触发条件
onreadystatechange每当xhr.readyState改变时触发;但xhr.readyState由非0值变为0时不触发。
onloadstart调用xhr.send()方法后立即触发,若xhr.send()未被调用则不会触发此事件。
onprogressxhr.upload.onprogress在上传阶段(即xhr.send()之后,xhr.readystate=2之前)触发,每50ms触发一次;xhr.onprogress在下载阶段(即xhr.readystate=3时)触发,每50ms触发一次。
onload当请求成功完成时触发,此时xhr.readystate=4
onloadend当请求结束(包括请求成功和请求失败)时触发
onabort当调用xhr.abort()后触发
ontimeoutxhr.timeout不等于0,由请求开始即onloadstart开始算起,当到达xhr.timeout所设置时间请求还未结束即onloadend,则触发此事件。
onerror在请求过程中,若发生Network error则会触发此事件(若发生Network error时,上传还没有结束,则会先触发xhr.upload.onerror,再触发xhr.onerror;若发生Network error时,上传已经结束,则只会触发xhr.onerror)。注意,只有发生了网络层级别的异常才会触发此事件,对于应用层级别的异常,如响应返回的xhr.statusCode4xx时,并不属于Network error,所以不会触发onerror事件,而是会触发onload事件。

事件触发顺序

当请求一切正常时,相关的事件触发顺序如下:

  1. 触发xhr.onreadystatechange(之后每次readyState变化时,都会触发一次)

  2. 触发xhr.onloadstart
    //上传阶段开始:

  3. 触发xhr.upload.onloadstart

  4. 触发xhr.upload.onprogress

  5. 触发xhr.upload.onload

  6. 触发xhr.upload.onloadend
    //上传结束,下载阶段开始:

  7. 触发xhr.onprogress

  8. 触发xhr.onload

  9. 触发xhr.onloadend

发生abort/timeout/error异常的处理

在请求的过程中,有可能发生 abort/timeout/error这3种异常。那么一旦发生这些异常,xhr后续会进行哪些处理呢?后续处理如下:

  1. 一旦发生aborttimeouterror异常,先立即中止当前请求

  2. readystate 置为4,并触发 xhr.onreadystatechange事件

  3. 如果上传阶段还没有结束,则依次触发以下事件:

    • xhr.upload.onprogress

    • xhr.upload.[onabort或ontimeout或onerror]

    • xhr.upload.onloadend

  4. 触发 xhr.onprogress事件

  5. 触发 xhr.[onabort或ontimeout或onerror]事件

  6. 触发xhr.onloadend 事件

在哪个xhr事件中注册成功回调?

从上面介绍的事件中,可以知道若xhr请求成功,就会触发xhr.onreadystatechangexhr.onload两个事件。 那么我们到底要将成功回调注册在哪个事件中呢?我倾向于 xhr.onload事件,因为xhr.onreadystatechange是每次xhr.readyState变化时都会触发,而不是xhr.readyState=4时才触发。

xhr.onload = function () {
    //如果请求成功
    if(xhr.status == 200){
      //do successCallback
    }
  }

上面的示例代码是很常见的写法:先判断http状态码是否是200,如果是,则认为请求是成功的,接着执行成功回调。这样的判断是有坑儿的,比如当返回的http状态码不是200,而是201时,请求虽然也是成功的,但并没有执行成功回调逻辑。所以更靠谱的判断方法应该是:当http状态码为2xx304时才认为成功。

  xhr.onload = function () {
    //如果请求成功
    if((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304){
      //do successCallback
    }
  }

结语

终于写完了......
看完那一篇长长的W3C的xhr 标准,我眼睛都花了......
希望这篇总结能帮助刚开始接触XMLHttpRequest的你。

最后给点扩展学习资料,如果你:

查看原文

赞 672 收藏 1209 评论 98

Showonne 赞了文章 · 2019-08-03

antv g2的理解总结

G2

G2本身是一门图形语法,G2和传统的图表系统(HighCharts,ACharts等)不同,G2是一个基于统计分析的语义化数据可视化系统。它真正做到了让数据驱动图形,让你在使用它时候不用关心绘图细节,只需要知道你想通过它怎么展示你关心的数据。echarts更多的是配置options来显示图片,出发点不同。(g2也同样支持配置项声明)

G2构成

一个可视化框架需要四部分:

  • 数据处理模块,对数据进行加工的模块,包括一些数据处理方法。例如:合并、分组、排序、过滤、计算统计信息等
  • 图形映射模块,将数据映射到图形视觉通道的过程。例如:将数据映射成颜色、位置、大小等
  • 图形展示模块,决定使用何种图形来展示数据,点、线、面等图形标记
  • 辅助信息模块,用于说明视觉通道跟数据的映射关系,例如:坐标轴、图例、辅助文本等

clipboard.png

  1. 在数据处理模块上,dataSet主要通过state状态管理多个dataview视图,实现多图联动,或者关联视图。dataView则是对应的是每一个数据源,通过connector来接入不同类型的数据,通过tranform进行数据的转换或者过滤。最后输出我们理想的数据,dataSet是与g2分离的,需要用到的时候可以加载
  2. 在图形映射模块上,度量 Scale,是数据空间到图形空间的转换桥梁,负责原始数据到 [0, 1] 区间数值的相互转换工作,从原始数据到 [0, 1] 区间的转换我们称之为归一化操作。我们可以通过chart.source或者chart.scale('field', defs)来实现列定义,我们可以在这对数据进行起别名,更换显示类型(time,cat类型等)
  3. 辅助信息,就是标记数据,方便理解数据
  4. 图形展示 chart图表是一个大画布,可以有多个view视图,geom则是数据映射的图形标识,就是指的点,线,面,通过对其操作,从而展示图形,

这是大体步骤:
clipboard.png

//代码实现
const data = [
        { genre: 'Sports', sold: 275 },
        { genre: 'Strategy', sold: 115 },
        { genre: 'Action', sold: 120 },
        { genre: 'Shooter', sold: 350 },
        { genre: 'Other', sold: 150 }
  ]; 
  // G2 对数据源格式的要求,仅仅是 JSON 数组,数组的每个元素是一个标准 JSON 对象。
  // Step 1: 创建 Chart 对象
  const chart = new G2.Chart({
    container: 'c1', // 指定图表容器 ID
    width : 600, // 指定图表宽度
    height : 300 // 指定图表高度
  });
  // Step 2: 载入数据源
  chart.source(data);
  // Step 3:创建图形语法,绘制柱状图,由 genre 和 sold 两个属性决定图形位置,genre 映射至 x 轴,sold 映射至 y 轴
  chart.interval().position('genre*sold').color('genre')
  // Step 4: 渲染图表

dataSet

负责数据处理,使得数据驱动视图, 可以包含多个dataView,每个view对应一套数据

clipboard.png

通过connector接入数据(把各种数据类型转成一定的形式),再通过transform进行过滤聚合等操作

// 以下是通过state过滤数据
// step1 创建 dataset 指定状态量
const ds = new DataSet({
  state: {
    year: '2010'
  }
});
// step2 创建 DataView
const dv = ds.createView().source(data);
dv.transform({
  type: 'filter',
  callback(row) {
    return row.year === ds.state.year;
  }
});
// step3 引用 DataView
chart.source(dv);
// step4 更新状态量
ds.setState('year', '2012');
// transform例子
const data = [
  { country: "USA", gold: 10, silver: 20 },
  { country: "Canada", gold: 7, silver: 26 }
];
const dv = ds.createView()
  .source(data)
  .transform({
    type: 'fold',
    fields: [ 'gold', 'silver' ], // 展开字段集
    key: 'key',                   // key字段
    value: 'value',               // value字段
    retains: [ 'country' ]        // 保留字段集,默认为除 fields 以外的所有字段
  });
/*
 dv.rows 变为
[
  { key: gold, value: 10, country: "USA" },
  { key: silver, value: 20, country: "USA" },
  { key: gold, value: 7, country: "Canada" },
  { key: silver, value: 26, country: "Canada" }
]
 */
// connector例子
const testCSV = `Expt,Run,Speed
 1,1,850
 1,2,740
 1,3,900
 1,4,1070`;

const dv = new DataSet.View().source(testCSV, {
  type: 'csv'
});

console.log(dv.rows);
/*
 * dv.rows:
 * [
 *   {Expt: " 1", Run: "1", Speed: "850"}
 *   {Expt: " 1", Run: "2", Speed: "740"}
 *   {Expt: " 1", Run: "3", Speed: "900"}
 *   {Expt: " 1", Run: "4", Speed: "1070"}
 * ]
 */

度量scale

就是从数据到图形的转化,使得数据在展示的时候可以自定义
所谓的列定义,即是对度量 scale 的操作

列定义上的操作可以理解为直接修改数据源中的数据属性,因此它会影响坐标轴、tooltip 提示信息、图例、辅助元素 guide 以及几何标记的标签文本 label 的数据内容显示。
//以下是关于数据映射scale的demo
const data = [
  { month: 0, value: 1 },
  { month: 1, value: 2 },
  { month: 2, value: 3 }
];
chart.scale('month', {
  type: 'cat', // 声明 type 字段为分类类型
  values: [ '一月', '二月', '三月' ], // 重新显示的值
  alias: '月份' // 设置属性的别名
});
// 这时候映射的month就变成了 月份:一月
// 这时坐标轴,tooltip等关于month的数据显示都改变了

view

视图,由 Chart 生成和管理,拥有自己独立的数据源、坐标系和图层,用于异构数据的可视化以及图表组合,一个 Chart 由一个或者多个视图 View 组成。

因此 view 上的 api 同 chart 基本相同。
view绘制的图形是在chart上的,Tooltip(提示信息)和 Legend(图例)仅在 Chart 上支持,所以view共用一套tooltip和legentd, 可以进行图形的叠加展示,如果需要不同图形完全隔离开的联动展示,可以再new一个chart,然后通过state联动起来

geom

g2对图形进行了抽象,我们通过对点,线,面操作使得可以我们可以画出各种图形

clipboard.png

clipboard.png
也可以自定义shape来实现图形

// line画出折线图,position分别从x轴和Y轴取数据,通过city的不同画出不同的折线
chart.line().position('month*temperature').color('city');  
//size表示的是点的大小,shape为点的类型
chart.point().position('month*temperature').color('city').size(4).shape('circle').style({
    stroke: '#fff',
    lineWidth: 1
  });

clipboard.png

shape

而shape正是自定义形状,通过在Shape 上注册图形,实现自定义 Shape 的功能。
通过对点,线,面的描绘实现自定义图形

const Shape = G2.Shape;
const shapeObj = Shape.registerShape('geomType', 'shapeName', { 
  getPoints(pointInfo) {
    // 获取每种 shape 绘制的关键点
  },
  draw(cfg, container) {
    // 自定义最终绘制的逻辑
  }
});

coord坐标系

chart.coord('coordTpye'[, cfg]);主要就是更改坐标系,笛卡尔坐标系(直角坐标系)和 极坐标系,例如通过改成极坐标系来画饼图

clipboard.png

辅助信息

axis坐标轴

clipboard.png
在这里,你可以进行一些针对坐标轴的操作,例如x轴显示的点的个数,坐标轴点的间距

chart.axis('xField', {
  line: {
    lineWidth: 2, // 设置线的宽度
    stroke: 'red', // 设置线的颜色
    lineDash: [ 3, 3 ] // 设置虚线样式
  }
});

实现多Y轴的绘制非常简单,用户完全不需要做任何配置。只要做到各个 geom 的 X 轴属性相同,Y 轴属性不同,G2 就会为您自动生成。

legend图例

chart.legend({ 
  position: 'bottom', // 设置图例的显示位置
  itemGap: 20 // 图例项之间的间距
});

chart.legend('cut', false); // 不显示 cut 字段对应的图例

chart.legend('price', {
  title: null // 不展示图例 title
});

chart.legend(false); //所有的图例都不显示

clipboard.png
当然,也可以使用html渲染图例,只需要useHtml:true就可以了

tooltip提示信息

clipboard.png
分为两种配置

  • 在chart上配置
chart.tooltip(true, cfg); // 开启 tooltip,并设置 tooltip 配置信息
chart.tooltip(cfg); // 省略 true, 直接设置 tooltip 配置信息
chart.tooltip(false); // 关闭 tooltip
  • 在geom对象上配置,粒度更小
chart.<geom>.tooltip('field1*field2...*fieldN');

支持各种自定义操作,对于复杂的场景,可以监听 chart 对象上的 tooltip:change 事件,或者通过回调进行自定义操作

guide辅助元素

chart.guide()
可以画辅助线或者辅助图案
支持line线,image图片,html,text等内容
通过chart.guide().line({...})来使用
clipboard.png

label图形文本

label在geom上调用
chart.point().position(x*y).label('x', {})
clipboard.png

slider

需要额外引入

clipboard.png

Slider 组件是完全基于数据的交互组件,同 chart 并无任何关联,无论是你的滑动条想要操纵多少个 chart 或者 view 都没有关系。其滑动时与图表的联动行为,需要同 DataSet 中的状态量相结合,通过定义每个 Slider 对象的 onChange 回调函数,在其中动态更新 DataSet 的状态量来实现数据过滤
 // !!! 创建 slider 对象
const slider = new Slider({
  container: 'slider', 
  start: '2004-01-01',
  end: '2007-09-24',
  data, // !!! 注意是原始数据,不要传入 dv
  xAxis: 'date',
  yAxis: 'aqi',
  onChange: ({ startText, endText }) => {
    // !!! 更新状态量
    ds.setState('start', startText);
    ds.setState('end', endText);
  }
});
slider.render(); 

facet分面

分面,将一份数据按照某个维度分隔成若干子集,然后创建一个图表的矩阵,将每一个数据子集绘制到图形矩阵的窗格中。

总结起来,分面其实提供了两个功能:
1.按照指定的维度划分数据集;
2.对图表进行排版。
主要就是降低维度,把数据拆分开,帮助分析
clipboard.png

chart.facet('list', {
  fileds: [ 'cut', 'carat' ],
  padding: 20 // 各个分面之间的间距,也可以是数组 [top, right, bottom, left]
});

animate

可以自定义animate动画

const { Animate } = G2;
/**
 * @param  {String} animationType      动画场景类型 appear enter leave update
 * @param  {String} 动画名称,用户自定义即可
 * @param  {Function} 动画执行函数
 **/
Animate.registerAnimation(animationType, animationName, animationFun);

其他封装

antv g2也提供了高层封装,BizCharts和Viser
BizCharts 地址:https://alibaba.github.io/Biz...
Viser 地址:https://viserjs.github.io/

Viser 并不是针对 React 做的适配,它是对 G2 3.0 通用的抽象。通过基于 Viser 封装,现在已经支持对 React、 Angular 和 Vue 三个常用框架的深度整合,对应的是 viser-react、viser-ng 和 viser-vue。

viser在react的使用,类似于新版的react-router,一切皆是组件

export default class App extends React.Component {
  render() {
    return (
      <Chart forceFit height={400} data={data} scale={scale}>
        <Tooltip />
        <Axis />
        <Line position="year*value" />
        <Point position="year*value" shape="circle"/>
      </Chart>
    );
  }
}

在vue中也类似

<template>
  <div>
    <v-chart :forceFit="true" :height="height" :data="data" :scale="scale">
      <v-tooltip />
      <v-axis />
      <v-line position="year*value" />
      <v-point position="year*value" shape="circle" />
    </v-chart>
  </div>
</template>

<script>
const data = [
  { year: '1991', value: 3 },
  { year: '1992', value: 4 },
  { year: '1993', value: 3.5 },
  { year: '1994', value: 5 },
  { year: '1995', value: 4.9 },
  { year: '1996', value: 6 },
  { year: '1997', value: 7 },
  { year: '1998', value: 9 },
  { year: '1999', value: 13 },
];

const scale = [{
  dataKey: 'value',
  min: 0,
},{
  dataKey: 'year',
  min: 0,
  max: 1,
}];

export default {
  data() {
    return {
      data,
      scale,
      height: 400,
    };
  }
};
</script>

另外,g2同样支持配置项声明的方式编写,通过编写options来

如果有错误的地方,欢迎指出~~~
感谢收看~~

参考文献:
https://antv.alipay.com/zh-cn...
https://antv.alipay.com/zh-cn...
https://segmentfault.com/a/11...

查看原文

赞 36 收藏 25 评论 8

Showonne 赞了文章 · 2018-08-22

koa-router 源码浅析

代码结构

执行流程

上面两张图主要将koa-router的整体代码结构和大概的执行流程画了出来,画的不够具体。那下面主要讲koa-router中的几处的关键代码解读一下。

读代码首先要找到入口文件,那几乎所有的node模块的入口文件都会在package.json文件中的main属性指明了。koa-router的入口文件就是lib/router.js

第三方模块

首先先讲几个第三方的node模块了解一下,因为后面的代码讲解中会用到,不去看具体实现,只要知道其功能就行:
koa-compose:
提供给它一个中间件数组, 返回一个顺序执行所有中间件的执行函数。
methods
node中支持的http动词,就是http.METHODS,可以在终端输出看看。
path-to-regexp
将路径字符串转换成强大的正则表达式,还可以输出路径参数。

Router & Layer

RouterLayer 分别是两个构造函数,分别在router.jslayer.js中,koa-router的所有代码也就在这两个文件中,可以知道它的代码量并不是很多。

Router: 创建管理整个路由模块的实例

function Router(opts) {
  if (!(this instanceof Router)) {
    return new Router(opts);
  }

  this.opts = opts || {};
  this.methods = this.opts.methods || [
    'HEAD',
    'OPTIONS',
    'GET',
    'PUT',
    'PATCH',
    'POST',
    'DELETE'
  ];

  this.params = {};
  this.stack = [];
};

首先是

if (!(this instanceof Router)) {
  return new Router(opts);
}

这是常用的去new的方式,所以我们可以在引入koa-router时:

const router = require('koa-router')()

而不用:

const router = new require('koa-router')() // 这样也是没问题的

this.methods:
在后面要讲的allowedMethods方法中要用到的,目的是响应options请求和请求出错的处理。

this.params:
全局的路由参数处理的中间件组成的对象。

this.stack:
其实就是各个路由(Layer)实例组成的数组。每次处理请求时都需要循环这个数组找到匹配的路由。

Layer: 创建各个路由实例

function Layer(path, methods, middleware, opts) {
  ...

  this.stack = Array.isArray(middleware) ? middleware : [middleware];

  // 为给后面的allowedMthods处理
  methods.forEach(function(method) {
    var l = this.methods.push(method.toUpperCase());
    if (this.methods[l-1] === 'GET') {
      // 如果是get请求,则支持head请求
      this.methods.unshift('HEAD');
    }
  }, this);

  // 确保路由的每个中间件都是函数
  this.stack.forEach(function(fn) {
    var type = (typeof fn);
    if (type !== 'function') {
      throw new Error(
        methods.toString() + " `" + (this.opts.name || path) +"`: `middleware` "
        + "must be a function, not `" + type + "`"
      );
    }
  }, this);
  this.path = path;
  // 利用path-to-rege模块生产的路径的正则表达式
  this.regexp = pathToRegExp(path, this.paramNames, this.opts);

  ...
};

这里的this.stackRouter中的不同,这里的是路由所有的中间件的数组。(一个路由可以有多个中间件)

router.register()

作用:注册路由

从上一篇的代码结构图中可以看出,Router的几个实例方法都直接或简介地调用了register方法,可见,它应该是比较核心的函数, 代码不长,我们一行行看一下:

Router.prototype.register = function (path, methods, middleware, opts) {
  opts = opts || {};
  var router = this;

  // 全部路由
  var stack = this.stack;

  // 说明路由的path是支持数组的
  // 如果是数组的话,需要递归调用register来注册路由
  // 因为一个path对应一个路由
  if (Array.isArray(path)) {
    path.forEach(function (p) {
      router.register.call(router, p, methods, middleware, opts);
    });

    return this;
  }

  // 创建路由,路由就是Layer的实例
  // mthods 是路由处理的http方法
  // 最后一个参数对象最终是传给Layer模块中的path-to-regexp模块接口调用的
  var route = new Layer(path, methods, middleware, {
    end: opts.end === false ? opts.end : true,
    name: opts.name,
    sensitive: opts.sensitive || this.opts.sensitive || false,
    strict: opts.strict || this.opts.strict || false,
    prefix: opts.prefix || this.opts.prefix || "",
    ignoreCaptures: opts.ignoreCaptures
  });

  // 处理路径前缀
  if (this.opts.prefix) {
    route.setPrefix(this.opts.prefix);
  }

  // 将全局的路由参数添加到每个路由中
  Object.keys(this.params).forEach(function (param) {
    route.param(param, this.params[param]);
  }, this);

  // 往路由数组中添加新创建的路由
  stack.push(route);

  return route;
};

router.verb()

verb => get|put|post|patch|delete
作用:注册路由

这是koa-router提供的直接注册相应http方法的路由,但最终还是会调用register方法如:

router.get('/user', function(ctx, next){...})

和下面利用register方法等价:

router.register('/user', ['get'], [function(ctx, next){...}])

可以看到直接使用router.verb注册路由会方便很多。来看看代码:
你会发现router.js的代码里并没有Router.prototype.get的代码出现,原因是它还依赖了上面提到的methods模块来实现。

// 这里的methods就是上面的methods模块提供的数组
methods.forEach(function (method) {
  Router.prototype[method] = function (name, path, middleware) {
    var middleware;

    // 这段代码做了两件事:
    // 1.name 参数是可选的,所以要做一些参数置换的处理
    // 2.将所有路由中间件合并成一个数组
    if (typeof path === 'string' || path instanceof RegExp) {
      middleware = Array.prototype.slice.call(arguments, 2);
    } else {
      middleware = Array.prototype.slice.call(arguments, 1);
      path = name;
      name = null;
    }

    // 调用register方法
    this.register(path, [method], middleware, {
      name: name
    });

    return this;
  };
});

router.routes()

作用:启动路由

这是在koa中配置路由的重要一步:

var router = require('koa-router')();
...
app.use(router.routes())

就这样,koa-router就启动了,所以我们也一定会很好奇这个routes函数到底做了什么,但可以肯定router.routes()返回了一个中间件函数。
函数体长了一点,简化一下看下整体轮廓:

Router.prototype.routes = Router.prototype.middleware = function () {
  var router = this;
  var dispatch = function dispatch(ctx, next) {
    ...
  }
  dispatch.router = this;
  return dispatch;
};

这里形成了一个闭包,在routes函数内部返回了一个dispatch函数作为中间件。
接下来看下dispatch函数的实现:

var dispatch = function dispatch(ctx, next) {
    var path = router.opts.routerPath || ctx.routerPath || ctx.path;

    // router.match函数内部遍历所有路由(this.stach),
    // 根据路径和请求方法找到对应的路由
    // 返回的matched对象为: 
    /* 
      var matched = {
        path: [], // 保存了path匹配的路由数组
        pathAndMethod: [], // 保存了path和methods都匹配的路由数组
        route: false // 是否有对应的路由
      };
    */
    var matched = router.match(path, ctx.method);
    var layerChain, layer, i;
    if (ctx.matched) {
      ctx.matched.push.apply(ctx.matched, matched.path);
    } else {
      ctx.matched = matched.path;
    }

    // 如果没有对应的路由,则直接进入下一个中间件
    if (!matched.route) return next();

    // 找到正确的路由的path
    var mostSpecificPath = matched.pathAndMethod[matched.pathAndMethod.length - 1].path;
    ctx._matchedRoute = mostSpecificPath;

    // 使用reduce方法将路由的所有中间件形成一条链
    layerChain = matched.pathAndMethod.reduce(function(memo, layer) {

      // 在每个路由的中间件执行之前,根据参数不同,设置 ctx.captures 和 ctx.params
      // 这就是为什么我们可以直接在中间件函数中直接使用 ctx.params 来读取路由参数信息了
      memo.push(function(ctx, next) {

        // 返回路由的参数的key 
        ctx.captures = layer.captures(path, ctx.captures);

        // 返回参数的key和对应的value组成的对象
        ctx.params = layer.params(path, ctx.captures, ctx.params);

        // 执行下一个中间件
        return next();
      });

      // 将上面另外加的中间件和已有的路由中间件合并到一起
      // 所以最终 layerChain 将会是一个中间件的数组
      return memo.concat(layer.stack);
    }, []);

    // 最后调用上面提到的 compose 模块提供的方法,返回将 layerChain (中间件的数组) 
    // 顺序执行所有中间件的执行函数, 并立即执行。
    return compose(layerChain)(ctx, next);
  };

router.allowMethods()

作用: 当请求出错时的处理逻辑

同样也是koa中配置路由的中一步:

var router = require('koa-router')();
...
app.use(router.routes())
app.use(router.allowMethods())

可以看出,该方法也是闭包内返回了中间件函数。我们将代码简化一下:

Router.prototype.allowedMethods = function (options) {
  options = options || {};
  var implemented = this.methods;
  return function allowedMethods(ctx, next) {
    return next().then(function() {
      var allowed = {};

      if (!ctx.status || ctx.status === 404) {
        ...

        if (!~implemented.indexOf(ctx.method)) {
          if (options.throw) {
            ...
          } else {
            ctx.status = 501;
            ctx.set('Allow', allowedArr);
          }
        } else if (allowedArr.length) {
          if (ctx.method === 'OPTIONS') {
            ctx.status = 204;
            ctx.set('Allow', allowedArr);
          } else if (!allowed[ctx.method]) {
            if (options.throw) {
              ...
            } else {
              ctx.status = 405;
              ctx.set('Allow', allowedArr);
            }
          }
        }
      }
    });
  };
};

眼尖的同学可能会看到一些http code404, 501, 204, 405
那这个函数其实就是当所有中间件函数执行完了,并且请求出错了进行相应的处理:

  1. 如果请求的方法koa-router不支持并且没有设置throw选项,则返回 501(未实现)

  2. 如果是options请求,则返回 204(无内容)

  3. 如果请求的方法支持但没有设置throw选项,则返回 405(不允许此方法 )

总结

粗略浅析了这么些,能大概知道了koa-router的工作原理。笔者能力有限,有错误还请指出。

查看原文

赞 21 收藏 41 评论 3

Showonne 赞了文章 · 2018-08-22

koa-router 源码浅析

代码结构

执行流程

上面两张图主要将koa-router的整体代码结构和大概的执行流程画了出来,画的不够具体。那下面主要讲koa-router中的几处的关键代码解读一下。

读代码首先要找到入口文件,那几乎所有的node模块的入口文件都会在package.json文件中的main属性指明了。koa-router的入口文件就是lib/router.js

第三方模块

首先先讲几个第三方的node模块了解一下,因为后面的代码讲解中会用到,不去看具体实现,只要知道其功能就行:
koa-compose:
提供给它一个中间件数组, 返回一个顺序执行所有中间件的执行函数。
methods
node中支持的http动词,就是http.METHODS,可以在终端输出看看。
path-to-regexp
将路径字符串转换成强大的正则表达式,还可以输出路径参数。

Router & Layer

RouterLayer 分别是两个构造函数,分别在router.jslayer.js中,koa-router的所有代码也就在这两个文件中,可以知道它的代码量并不是很多。

Router: 创建管理整个路由模块的实例

function Router(opts) {
  if (!(this instanceof Router)) {
    return new Router(opts);
  }

  this.opts = opts || {};
  this.methods = this.opts.methods || [
    'HEAD',
    'OPTIONS',
    'GET',
    'PUT',
    'PATCH',
    'POST',
    'DELETE'
  ];

  this.params = {};
  this.stack = [];
};

首先是

if (!(this instanceof Router)) {
  return new Router(opts);
}

这是常用的去new的方式,所以我们可以在引入koa-router时:

const router = require('koa-router')()

而不用:

const router = new require('koa-router')() // 这样也是没问题的

this.methods:
在后面要讲的allowedMethods方法中要用到的,目的是响应options请求和请求出错的处理。

this.params:
全局的路由参数处理的中间件组成的对象。

this.stack:
其实就是各个路由(Layer)实例组成的数组。每次处理请求时都需要循环这个数组找到匹配的路由。

Layer: 创建各个路由实例

function Layer(path, methods, middleware, opts) {
  ...

  this.stack = Array.isArray(middleware) ? middleware : [middleware];

  // 为给后面的allowedMthods处理
  methods.forEach(function(method) {
    var l = this.methods.push(method.toUpperCase());
    if (this.methods[l-1] === 'GET') {
      // 如果是get请求,则支持head请求
      this.methods.unshift('HEAD');
    }
  }, this);

  // 确保路由的每个中间件都是函数
  this.stack.forEach(function(fn) {
    var type = (typeof fn);
    if (type !== 'function') {
      throw new Error(
        methods.toString() + " `" + (this.opts.name || path) +"`: `middleware` "
        + "must be a function, not `" + type + "`"
      );
    }
  }, this);
  this.path = path;
  // 利用path-to-rege模块生产的路径的正则表达式
  this.regexp = pathToRegExp(path, this.paramNames, this.opts);

  ...
};

这里的this.stackRouter中的不同,这里的是路由所有的中间件的数组。(一个路由可以有多个中间件)

router.register()

作用:注册路由

从上一篇的代码结构图中可以看出,Router的几个实例方法都直接或简介地调用了register方法,可见,它应该是比较核心的函数, 代码不长,我们一行行看一下:

Router.prototype.register = function (path, methods, middleware, opts) {
  opts = opts || {};
  var router = this;

  // 全部路由
  var stack = this.stack;

  // 说明路由的path是支持数组的
  // 如果是数组的话,需要递归调用register来注册路由
  // 因为一个path对应一个路由
  if (Array.isArray(path)) {
    path.forEach(function (p) {
      router.register.call(router, p, methods, middleware, opts);
    });

    return this;
  }

  // 创建路由,路由就是Layer的实例
  // mthods 是路由处理的http方法
  // 最后一个参数对象最终是传给Layer模块中的path-to-regexp模块接口调用的
  var route = new Layer(path, methods, middleware, {
    end: opts.end === false ? opts.end : true,
    name: opts.name,
    sensitive: opts.sensitive || this.opts.sensitive || false,
    strict: opts.strict || this.opts.strict || false,
    prefix: opts.prefix || this.opts.prefix || "",
    ignoreCaptures: opts.ignoreCaptures
  });

  // 处理路径前缀
  if (this.opts.prefix) {
    route.setPrefix(this.opts.prefix);
  }

  // 将全局的路由参数添加到每个路由中
  Object.keys(this.params).forEach(function (param) {
    route.param(param, this.params[param]);
  }, this);

  // 往路由数组中添加新创建的路由
  stack.push(route);

  return route;
};

router.verb()

verb => get|put|post|patch|delete
作用:注册路由

这是koa-router提供的直接注册相应http方法的路由,但最终还是会调用register方法如:

router.get('/user', function(ctx, next){...})

和下面利用register方法等价:

router.register('/user', ['get'], [function(ctx, next){...}])

可以看到直接使用router.verb注册路由会方便很多。来看看代码:
你会发现router.js的代码里并没有Router.prototype.get的代码出现,原因是它还依赖了上面提到的methods模块来实现。

// 这里的methods就是上面的methods模块提供的数组
methods.forEach(function (method) {
  Router.prototype[method] = function (name, path, middleware) {
    var middleware;

    // 这段代码做了两件事:
    // 1.name 参数是可选的,所以要做一些参数置换的处理
    // 2.将所有路由中间件合并成一个数组
    if (typeof path === 'string' || path instanceof RegExp) {
      middleware = Array.prototype.slice.call(arguments, 2);
    } else {
      middleware = Array.prototype.slice.call(arguments, 1);
      path = name;
      name = null;
    }

    // 调用register方法
    this.register(path, [method], middleware, {
      name: name
    });

    return this;
  };
});

router.routes()

作用:启动路由

这是在koa中配置路由的重要一步:

var router = require('koa-router')();
...
app.use(router.routes())

就这样,koa-router就启动了,所以我们也一定会很好奇这个routes函数到底做了什么,但可以肯定router.routes()返回了一个中间件函数。
函数体长了一点,简化一下看下整体轮廓:

Router.prototype.routes = Router.prototype.middleware = function () {
  var router = this;
  var dispatch = function dispatch(ctx, next) {
    ...
  }
  dispatch.router = this;
  return dispatch;
};

这里形成了一个闭包,在routes函数内部返回了一个dispatch函数作为中间件。
接下来看下dispatch函数的实现:

var dispatch = function dispatch(ctx, next) {
    var path = router.opts.routerPath || ctx.routerPath || ctx.path;

    // router.match函数内部遍历所有路由(this.stach),
    // 根据路径和请求方法找到对应的路由
    // 返回的matched对象为: 
    /* 
      var matched = {
        path: [], // 保存了path匹配的路由数组
        pathAndMethod: [], // 保存了path和methods都匹配的路由数组
        route: false // 是否有对应的路由
      };
    */
    var matched = router.match(path, ctx.method);
    var layerChain, layer, i;
    if (ctx.matched) {
      ctx.matched.push.apply(ctx.matched, matched.path);
    } else {
      ctx.matched = matched.path;
    }

    // 如果没有对应的路由,则直接进入下一个中间件
    if (!matched.route) return next();

    // 找到正确的路由的path
    var mostSpecificPath = matched.pathAndMethod[matched.pathAndMethod.length - 1].path;
    ctx._matchedRoute = mostSpecificPath;

    // 使用reduce方法将路由的所有中间件形成一条链
    layerChain = matched.pathAndMethod.reduce(function(memo, layer) {

      // 在每个路由的中间件执行之前,根据参数不同,设置 ctx.captures 和 ctx.params
      // 这就是为什么我们可以直接在中间件函数中直接使用 ctx.params 来读取路由参数信息了
      memo.push(function(ctx, next) {

        // 返回路由的参数的key 
        ctx.captures = layer.captures(path, ctx.captures);

        // 返回参数的key和对应的value组成的对象
        ctx.params = layer.params(path, ctx.captures, ctx.params);

        // 执行下一个中间件
        return next();
      });

      // 将上面另外加的中间件和已有的路由中间件合并到一起
      // 所以最终 layerChain 将会是一个中间件的数组
      return memo.concat(layer.stack);
    }, []);

    // 最后调用上面提到的 compose 模块提供的方法,返回将 layerChain (中间件的数组) 
    // 顺序执行所有中间件的执行函数, 并立即执行。
    return compose(layerChain)(ctx, next);
  };

router.allowMethods()

作用: 当请求出错时的处理逻辑

同样也是koa中配置路由的中一步:

var router = require('koa-router')();
...
app.use(router.routes())
app.use(router.allowMethods())

可以看出,该方法也是闭包内返回了中间件函数。我们将代码简化一下:

Router.prototype.allowedMethods = function (options) {
  options = options || {};
  var implemented = this.methods;
  return function allowedMethods(ctx, next) {
    return next().then(function() {
      var allowed = {};

      if (!ctx.status || ctx.status === 404) {
        ...

        if (!~implemented.indexOf(ctx.method)) {
          if (options.throw) {
            ...
          } else {
            ctx.status = 501;
            ctx.set('Allow', allowedArr);
          }
        } else if (allowedArr.length) {
          if (ctx.method === 'OPTIONS') {
            ctx.status = 204;
            ctx.set('Allow', allowedArr);
          } else if (!allowed[ctx.method]) {
            if (options.throw) {
              ...
            } else {
              ctx.status = 405;
              ctx.set('Allow', allowedArr);
            }
          }
        }
      }
    });
  };
};

眼尖的同学可能会看到一些http code404, 501, 204, 405
那这个函数其实就是当所有中间件函数执行完了,并且请求出错了进行相应的处理:

  1. 如果请求的方法koa-router不支持并且没有设置throw选项,则返回 501(未实现)

  2. 如果是options请求,则返回 204(无内容)

  3. 如果请求的方法支持但没有设置throw选项,则返回 405(不允许此方法 )

总结

粗略浅析了这么些,能大概知道了koa-router的工作原理。笔者能力有限,有错误还请指出。

查看原文

赞 21 收藏 41 评论 3

Showonne 赞了文章 · 2018-08-22

koa源码

写在前面

本文将会大家来看下koa的源码,当然本文需要大家了解koa的中间件机制,如果大家之前没有了解过其实现原理,可以关注下这篇文章
koa的源码非常的精简,与express不同,koa只是为开发者搭起了一个架子,没有任何的功能,包括路由,全部由中间件实现;下面就来看下koa的实现:

koa

创建应用时,一般都会利用app.listen指定一个端口号,这个方法的本质就是http.createServer

listen() {
    debug('listen');
    const server = http.createServer(this.callback());
    return server.listen.apply(server, arguments);
}

最为关键的就是这个callback的实现:

clipboard.png

callback() {
    const fn = compose(this.middleware);

    if (!this.listeners('error').length) this.on('error', this.onerror);

    const handleRequest = (req, res) => {
        res.statusCode = 404;
        const ctx = this.createContext(req, res);
        const onerror = err => ctx.onerror(err);
        const handleResponse = () => respond(ctx);
        onFinished(res, onerror);
        return fn(ctx).then(handleResponse).catch(onerror);
    };

    return handleRequest;
}

需要注意下面几点:

  • onFinished(res, onerror),应对的是返回的bodyStream的情况,为其添加一个finished事件。

  • respond()根据ctxstatus,body,method来决定如何响应这次请求:

    • status204,304,不需要有响应体,res.end()就好

    • methodHEADHEAD的意义是不请求资源内容但是需要了解资源情况,所以只需要请求头,指定了资源lengthres.end()就好

    • 加入body为空,则bodystatuses包中status对应的文字描述,如404 => Not Found

context对象

koarequest对象response封装成了一个对象,提供了一些别名,具体可以参见context对象,例如:当访问ctx.url实则是访问的ctx.request.url。具体的实现利用了tj写的delegates这个npm包来对context对象添加属性,koa中利用了其中三个api:

  • method:添加方法引用

  • getter:利用__defineGetter__,添加getter属性

  • access:添加gettersetter

对于context创建的代码:

  createContext(req, res) {
    const context = Object.create(this.context);
    const request = context.request = Object.create(this.request);
    const response = context.response = Object.create(this.response);
    context.app = request.app = response.app = this;
    context.req = request.req = response.req = req;
    context.res = request.res = response.res = res;
    request.ctx = response.ctx = context;
    request.response = response;
    response.request = request;
    context.originalUrl = request.originalUrl = req.url;
    context.cookies = new Cookies(req, res, {
      keys: this.keys,
      secure: request.secure
    });
    request.ip = request.ips[0] || req.socket.remoteAddress || '';
    context.accept = request.accept = accepts(req);
    context.state = {};
    return context;
  }

函数的参数req, res为node本身的对象,requestresponse分别是对于reqres的封装,读取ctx.url的过程如下:

clipboard.png

context就是一个顶层对象,koa中,所有的属性和操作基本会基于这个对象,这个对象的组成如下图:

clipboard.png

写在最后

个人感觉koa就像是一个架子,提供了基础的方法和属性,如ctx.redirect等,具体的功能主要利用中间件来实现,与express相比,koa去除内置路由,views等,变得更加的轻量;当然我认为更加重要的是避免了层层回调的出现。以上内容如有出错,欢迎大家指出。

查看原文

赞 1 收藏 1 评论 0

Showonne 评论了文章 · 2018-07-24

对 SegmentFault 社区提问标准的一些解释

有心的用户应该发现最近 SegmentFault 问答的审核趋向严格,甚至一些已经正常展示的问题都会因质量问题提示作者修改。随着社区用户的增长,新进入用户的习惯正逐渐冲击着之前社区形成的默契,我们的问答质量出现了一定程度的下降。这对整个社区的运营提出了挑战,我们不希望发生劣币驱逐良币的状况,因此有必要在这个问题上达成新的共识。

应该说 SegmentFault 的提问一直都是有具体的标准的(https://segmentfault.com/faq#...),但是在具体理解的时候每个人都会产生偏差,为了尽量缩小这个偏差,我们约定如下几个提问的原则:

  1. 回答者优先
  2. 考虑后来者

回答者优先

当你理解了回答者优先的原则,就会自然而然地理解我们的运营规范,甚至你都不需要时刻记住这些规范,因为它们只是保证这一原则的最低要求。

什么是回答者优先?简而言之,就是你在提问的时候要优先考虑回答者能否清晰准确地知晓你要表达的意思,我们在审核的时候也是以这一条标准做为最优先的准则。提问者怎么判断呢?很简单,把自己置于回答者的位子上去审视一下你的问题,看看做为回答者的你是否可以通过这些表述知晓题意。

以这条原则为出发点,我们会对存在以下情况的问题说不:

  1. 问题表述过于简略,往往就一句话甚至一个标题的。(举例:标题是“如何实现一个淘宝一样的网站?”,内容是:“如题”)
  2. 问题中完全没有自己的观点,也就是传说中的伸手党。伸手党的存在主要有两大害处,第一,你没有说出已经尝试过哪些方法,没有尽量为回答者排除错误情况,会大大降低回答者的答题效率。第二,你的付出过少,无法达到回答者的心里预期,会大大影响回答者的答题积极性。用通俗的话说就是,你自己都不重视自己的事情,其他人又凭什么去帮你呢?
  3. 问题的排版过于混乱。从语法上讲,我们并不认为 Markdown 语法比你手上要写的任何编程语言语法更加复杂。而混乱的排版至少表明你并不重视这个问题,也不重视回答者的感受。很多人没有把代码用 Markdown 包裹起来,我们也视为排版混乱。
  4. 没有代码或者用图片代替了代码。这是一个最近比较突出的问题,代码胜千言,准确简短的描述配上必要的代码,比你说一大堆废话要好得多,我们已经看到了无数可爱的回答者在问题下方的评论中呼唤代码。与不贴代码相比,用代码截图来代替代码走入了另一个误区,让我们还是站在回答者的角度,当你面对上百行没头没尾的代码时,怎么去调试它们呢?你想让回答者浪费自己宝贵的时间,照着你们的图片一个字一个字的敲进去么?所以,当你要这么做的时候,想一想本章的标题“回答者优先”。在这里,还有一个比较特殊的情况,就是错误信息算不算代码,可不可以用截图代替?在这里,给出明确的答复:算。大部分的错误信息,包括浏览器的出错,c, java等预编译语言的运行时错误,都是一个简单的文本,你可以直接用鼠标选中复制,用 Markdown 的代码块语法包裹后附加到问题里。这样可以大大方便回答者定位错误。

考虑后来者

考虑后来者可以说是我们创建这个社区的一大目的,我们之所以让大家的问题可以公开讨论,就是为了降低在开发领域的信息不对称,让后来者少走弯路。为了做到这一点,我们提倡大家:

  1. 标题应该直接地表达问题的中心思想,如果你是因为运行时抛出某些错误而提问,你可以直接写“为什么JAVA运行时抛出xxxx异常?”。而不要写什么“一个关于JAVA的问题?”,请问做为一个后来者,我能从你的标题里获得什么重要的信息呢?如果这则问题被搜索引擎索引了,后来者遇到同类问题是怎么搜索的呢?大家想想你们搜问题,是不是喜欢把错误信息直接丢到搜索框里,那么怎样才算一个有用的问题就不言而喻了。
  2. 不要用图片代替代码,不要用图片代替代码,不要用图片代替代码!图片里的内容不能被任何搜索引擎检索到,你的问题会变成信息海洋里的垃圾沉没水底,这不是我们做为社区所提倡的。
  3. 用好标签。标签的作用在于更好地组织内容,这也是为了方便后来者。所以首先不要滥用,你的标签一定要跟问题相关。其次,标签不是用来描述问题的,不要自己创造一些描述性的语言做为标签。通常选择标签就选择这个问题所涉及到的技术就可以了,而且尽量至少使用一个大的语言标签,比如“php, java, c, javascript” 等等。

一些措施

俗话说“用霹雳手段,显菩萨心肠”,我们的菩萨心肠在上面已经告诉大家了。为了保证这些目的能够达到,我们将采取一系列措施。除了在审核时我们会严格按照标准来执行之外,我们还鼓励大家共同维护社区的秩序。大家可以通过评论来提醒一些违规的内容,或者使用举报和建议关闭功能。

我们针对把代码截图到图片里的行为,专门开发了自动扫描机器人,它会最大程度地去监控这一行为,一旦发现这一情况会提醒你修改问题。如果在一小时内没有修改的话,这个问题会被提交人工审核后处理。注意:机器人可能存在误判行为,如果你确定你的内容没有存在这种情况,请放心交给我们人工审核即可,我们会及时处理。

写在最后

当我们在6年前创立 SegmentFault 的时候,愿景是做一个高质量的中文技术问答社区。当然现在 SegmentFault 上承载的不止有问答的内容,但它依然是整个社区重要的组成部分。经常有人向我们抱怨国内技术社区的讨论氛围,思想浮躁,问题质量差,伸手党盛行等等。当我们体量比较小的时候,我们总是以提高素质还需要时间之类的理由来安慰自己或者他人。而当我们逐渐成长为国内技术问答领域一支重要力量之后,我们已经无法逃避肩上的责任,因此我们希望带领整个社区一起进步,共同打造一个属于我们自己的技术家园。

更多阅读

查看原文

Showonne 赞了回答 · 2018-07-17

解决webpck是不是不能编译这个属性-webkit-box-orient: vertical

解决方案如下

 /* autoprefixer: off */
  -webkit-box-orient: vertical; // 参考 https://github.com/postcss/autoprefixer/issues/776
  /* autoprefixer: on */

关注 18 回答 14

Showonne 收藏了文章 · 2018-03-26

一篇文章教会你Event loop——浏览器和Node

最近对Event loop比较感兴趣,所以了解了一下。但是发现整个Event loop尽管有很多篇文章,但是没有一篇可以看完就对它所有内容都了解的文章。大部分的文章都只阐述了浏览器或者Node二者之一,没有对比的去看的话,认识总是浅一点。所以才有了这篇整理了百家之长的文章。

1. 定义

Event loop:为了协调事件(event),用户交互(user interaction),脚本(script),渲染(rendering),网络(networking)等,用户代理(user agent)必须使用事件循环(event loops)。(3月29修订)

那什么是事件?

事件:事件就是由于某种外在或内在的信息状态发生的变化,从而导致出现了对应的反应。比如说用户点击了一个按钮,就是一个事件;HTML页面完成加载,也是一个事件。一个事件中会包含多个任务。

我们在之前的文章中提到过,JavaScript引擎又称为JavaScript解释器,是JavaScript解释为机器码的工具,分别运行在浏览器和Node中。而根据上下文的不同,Event loop也有不同的实现:其中Node使用了libuv库来实现Event loop; 而在浏览器中,html规范定义了Event loop,具体的实现则交给不同的厂商去完成。

所以,浏览器的Event loop和Node的Event loop是两个概念,下面分别来看一下。

2. 意义

在实际工作中,了解Event loop的意义能帮助你分析一些异步次序的问题(当然,随着es7 async和await的流行,这样的机会越来越少了)。除此以外,它还对你了解浏览器和Node的内部机制有积极的作用;对于参加面试,被问到一堆异步操作的执行顺序时,也不至于两眼抓瞎。

3. 浏览器上的实现

在JavaScript中,任务被分为Task(又称为MacroTask,宏任务)和MicroTask(微任务)两种。它们分别包含以下内容:

MacroTask: script(整体代码), setTimeout, setInterval, setImmediate(node独有), I/O, UI rendering
MicroTask: process.nextTick(node独有), Promises, Object.observe(废弃), MutationObserver

需要注意的一点是:在同一个上下文中,总的执行顺序为同步代码—>microTask—>macroTask[6]。这一块我们在下文中会讲。

浏览器中,一个事件循环里有很多个来自不同任务源的任务队列(task queues),每一个任务队列里的任务是严格按照先进先出的顺序执行的。但是,因为浏览器自己调度的关系,不同任务队列的任务的执行顺序是不确定的。

具体来说,浏览器会不断从task队列中按顺序取task执行,每执行完一个task都会检查microtask队列是否为空(执行完一个task的具体标志是函数执行栈为空),如果不为空则会一次性执行完所有microtask。然后再进入下一个循环去task队列中取下一个task执行,以此类推。

图片描述

注意:图中橙色的MacroTask任务队列也应该是在不断被切换着的。

本段大批量引用了《什么是浏览器的事件循环(Event Loop)》的相关内容,想看更加详细的描述可以自行取用。

4. Node上的实现

nodejs的event loop分为6个阶段,它们会按照顺序反复运行,分别如下:

  1. timers:执行setTimeout() 和 setInterval()中到期的callback。
  2. I/O callbacks:上一轮循环中有少数的I/Ocallback会被延迟到这一轮的这一阶段执行
  3. idle, prepare:队列的移动,仅内部使用
  4. poll:最为重要的阶段,执行I/O callback,在适当的条件下会阻塞在这个阶段
  5. check:执行setImmediate的callback
  6. close callbacks:执行close事件的callback,例如socket.on("close",func)

不同于浏览器的是,在每个阶段完成后,而不是MacroTask任务完成后,microTask队列就会被执行。这就导致了同样的代码在不同的上下文环境下会出现不同的结果。我们在下文中会探讨。

另外需要注意的是,如果在timers阶段执行时创建了setImmediate则会在此轮循环的check阶段执行,如果在timers阶段创建了setTimeout,由于timers已取出完毕,则会进入下轮循环,check阶段创建timers任务同理。

图片描述

5. 示例

5.1 浏览器与Node执行顺序的区别

setTimeout(()=>{
    console.log('timer1')

    Promise.resolve().then(function() {
        console.log('promise1')
    })
}, 0)

setTimeout(()=>{
    console.log('timer2')

    Promise.resolve().then(function() {
        console.log('promise2')
    })
}, 0)



浏览器输出:
time1
promise1
time2
promise2

Node输出:
time1
time2
promise1
promise2

在这个例子中,Node的逻辑如下:

最初timer1和timer2就在timers阶段中。开始时首先进入timers阶段,执行timer1的回调函数,打印timer1,并将promise1.then回调放入microtask队列,同样的步骤执行timer2,打印timer2;
至此,timer阶段执行结束,event loop进入下一个阶段之前,执行microtask队列的所有任务,依次打印promise1、promise2。

而浏览器则因为两个setTimeout作为两个MacroTask, 所以先输出timer1, promise1,再输出timer2,promise2。

更加详细的信息可以查阅《深入理解js事件循环机制(Node.js篇)

为了证明我们的理论,把代码改成下面的样子:

setImmediate(() => {
  console.log('timer1')

  Promise.resolve().then(function () {
    console.log('promise1')
  })
})

setTimeout(() => {
  console.log('timer2')

  Promise.resolve().then(function () {
    console.log('promise2')
  })
}, 0)

Node输出:
timer1               timer2
promise1    或者     promise2
timer2               timer1
promise2             promise1

按理说setTimeout(fn,0)应该比setImmediate(fn)快,应该只有第二种结果,为什么会出现两种结果呢?
这是因为Node 做不到0毫秒,最少也需要1毫秒。实际执行的时候,进入事件循环以后,有可能到了1毫秒,也可能还没到1毫秒,取决于系统当时的状况。如果没到1毫秒,那么 timers 阶段就会跳过,进入 check 阶段,先执行setImmediate的回调函数。

另外,如果已经过了Timer阶段,那么setImmediate会比setTimeout更快,例如:

const fs = require('fs');

fs.readFile('test.js', () => {
  setTimeout(() => console.log(1));
  setImmediate(() => console.log(2));
});

上面代码会先进入 I/O callbacks 阶段,然后是 check 阶段,最后才是 timers 阶段。因此,setImmediate才会早于setTimeout执行。

具体可以看《Node 定时器详解》。

5.2 不同异步任务执行的快慢

setTimeout(() => console.log(1));
setImmediate(() => console.log(2));

Promise.resolve().then(() => console.log(3));
process.nextTick(() => console.log(4));


输出结果:4 3 1 2或者4 3 2 1

因为我们上文说过microTask会优于macroTask运行,所以先输出下面两个,而在Node中process.nextTick比Promise更加优先[3],所以4在3前。而根据我们之前所说的Node没有绝对意义上的0ms,所以1,2的顺序不固定。

5.3 MicroTask队列与MacroTask队列

   setTimeout(function () {
       console.log(1);
   },0);
   console.log(2);
   process.nextTick(() => {
       console.log(3);
   });
   new Promise(function (resolve, rejected) {
       console.log(4);
       resolve()
   }).then(res=>{
       console.log(5);
   })
   setImmediate(function () {
       console.log(6)
   })
   console.log('end');

Node输出:
2 4 end 3 5 1 6

这个例子来源于《JavaScript中的执行机制》。Promise的代码是同步代码,then和catch才是异步的,所以4要同步输出,然后Promise的then位于microTask中,优于其他位于macroTask队列中的任务,所以5会优于1,6输出,而Timer优于Check阶段,所以1,6。

6. 总结

综上,关于最关键的顺序,我们要依据以下几条规则:

  1. 同一个上下文下,MicroTask会比MacroTask先运行
  2. 然后浏览器按照一个MacroTask任务,所有MicroTask的顺序运行,Node按照六个阶段的顺序运行,并在每个阶段后面都会运行MicroTask队列
  3. 同个MicroTask队列下process.tick()会优于Promise

Event loop还是比较深奥的,深入进去会有很多有意思的东西,有任何问题还望不吝指出。

参考文档:

  1. 什么是浏览器的事件循环(Event Loop)
  2. 不要混淆nodejs和浏览器中的event loop
  3. Node 定时器详解
  4. 浏览器和Node不同的事件循环(Event Loop)
  5. 深入理解js事件循环机制(Node.js篇)
  6. JavaScript中的执行机制
查看原文

认证与成就

  • 获得 205 次点赞
  • 获得 77 枚徽章 获得 4 枚金徽章, 获得 30 枚银徽章, 获得 43 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2014-12-24
个人主页被 3.6k 人浏览