Jason

Jason 查看完整档案

上海编辑长江大学  |  计算机科学与技术 编辑扬讯科技  |  软件工程师 编辑 github.com/hovenjay 编辑
编辑

我喜欢程序员,他们单纯、固执、容易体会到成就感;面对压力,能够挑灯夜战不眠不休;面对困难,能够迎难而上挑战自我。他们也会感到困惑与傍徨,但每个程序员的心中都有一个比尔盖茨或是乔布斯的梦想“用智慧开创属于自己的事业”。我想说的是,其实我是一个程序员。

个人动态

Jason 收藏了文章 · 9月16日

React系列 --- virtualdom diff算法实现分析(三)

React系列

React系列 --- 简单模拟语法(一)
React系列 --- Jsx, 合成事件与Refs(二)
React系列 --- virtualdom diff算法实现分析(三)
React系列 --- 从Mixin到HOC再到HOOKS(四)
React系列 --- createElement, ReactElement与Component部分源码解析(五)
React系列 --- 从使用React了解Css的各种使用方案(六)
React系列 --- 从零构建状态管理及Redux源码解析(七)
React系列 --- 扩展状态管理功能及Redux源码解析(八)

完整代码可查看virtualdom-diff

渲染DOM

经历过PHP模板开发或者JQuery的洗礼的人都知道,它们实现重新渲染采用最简单粗暴的办法就是重新构建DOM替换旧DOM,问题也很明显

  • 性能消耗高
  • 无法保存状态(聚焦,滚动等)

我们先看看创建一个元素所包含的实例属性有多少个

const div = document.createElement('div');
let num = 0;
for (let k in div) {
  num++;
}
console.log(num); // 241

然后浏览器根据CSS规则查找匹配节点,计算合并样式布局,为了避免重新计算一般浏览器会保存这些数据.但这是整个过程下来依然会耗费大量的内存和 CPU 资源.

Virtual DOM

实际也是操作Dom树进行渲染更新,但是它只是针对修改部分进行局部渲染,将影响降到最低,虽然实现方式各有不同,但是大体步骤如下:

  1. 用Javascript对象结构描述Dom树结构,然后用它来构建真正的Dom树插入文档
  2. 当状态发生改变之后,重新构造新的Javascript对象结构和旧的作对比得出差异
  3. 针对差异之处进行重新构建更新视图

无非就是利用Js做一层映射比较,操作简单并且速度远远高于直接比较Dom树

基础工具函数

无非就是一些类型判断,循环遍历的简化函数

function type(obj) {
  return Object.prototype.toString.call(obj).replace(/\[object\s|\]/g, "");
}

function isArray(list) {
  return type(list) === "Array";
}

function isObject(obj) {
  return type(obj) === "Object";
}

function isString(str) {
  return type(str) === "String";
}

function isNotEmptyObj(obj) {
  return isObject(obj) && JSON.stringify(obj) != "{}";
}

function objForEach(obj, fn) {
  isNotEmptyObj(obj) && Object.keys(obj).forEach(fn);
}

function aryForEach(ary, fn) {
  ary.length && ary.forEach(fn);
}

function setAttr(node, key, value) {
  switch (key) {
    case "style":
      node.style.cssText = value;
      break;
    case "value":
      var tagName = node.tagName || "";
      tagName = tagName.toLowerCase();
      if (tagName === "input" || tagName === "textarea") {
        node.value = value;
      } else {
        // if it is not a input or textarea, use `setAttribute` to set
        node.setAttribute(key, value);
      }
      break;
    default:
      node.setAttribute(key, value);
      break;
  }
}

function toArray(data) {
  if (!data) {
    return [];
  }
  const ary = [];
  aryForEach(data, item => {
    ary.push(item);
  });

  return ary;
}

export {
  isArray,
  isObject,
  isString,
  isNotEmptyObj,
  objForEach,
  aryForEach,
  setAttr,
  toArray
};

相关代码可以查看util.js

Javascript对象结构描述

我之前讲JSX的时候举过这么个例子,然后我们就以这个来实现效果吧

<div className="num" index={1}>
  <span>123456</span>
</div>
"use strict";

React.createElement("div", {
  className: "num",
  index: 1
}, React.createElement("span", null, "123456"));

创建一个Element类负责将Javascript对象结构转换为Dom树结构

import {
  isObject,
  isString,
  isArray,
  isNotEmptyObj,
  objForEach,
  aryForEach
} from "./util";
import { NOKEY } from "./common";

class Element {
  constructor(tagName, props, children) {
    // 解析参数
    this.tagName = tagName;
    // 字段处理,可省略参数
    this.props = isObject(props) ? props : {};
    this.children =
      children ||
      (!isNotEmptyObj(this.props) &&
        ((isString(props) && [props]) || (isArray(props) && props))) ||
      [];
    // 无论void后的表达式是什么,void操作符都会返回undefined
    this.key = props ? props.key : void NOKEY;

    // 计算节点数
    let count = 0;
    aryForEach(this.children, (item, index) => {
      if (item instanceof Element) {
        count += item.count;
      } else {
        this.children[index] = "" + item;
      }
      count++;
    });
    this.count = count;
  }

  render() {
    // 根据tagName构建
    const dom = document.createElement(this.tagName);

    // 设置props
    objForEach(this.props, propName =>
      dom.setAttribute(propName, this.props[propName])
    );

    // 渲染children
    aryForEach(this.children, child => {
      const childDom =
        child instanceof Element
          ? child.render() // 如果子节点也是虚拟DOM,递归构建DOM节点
          : document.createTextNode(child); // 如果字符串,只构建文本节点
      dom.appendChild(childDom);
    });
    return dom;
  }
}

// 改变传参方式,免去手动实例化
export default function CreateElement(tagName, props, children) {
  return new Element( tagName, props, children );
}

新建示例,调用方式如下

// 1. 构建虚拟DOM
const tree = createElement("div", { id: "root" }, [
  createElement("h1", { style: "color: blue" }, ["Tittle1"]),
  createElement("p", ["Hello, virtual-dom"]),
  createElement("ul", [
    createElement("li", { key: 1 }, ["li1"]),
    createElement("li", { key: 2 }, ["li2"]),
    createElement("li", { key: 3 }, ["li3"]),
    createElement("li", { key: 4 }, ["li4"])
  ])
]);

// 2. 通过虚拟DOM构建真正的DOM
const root = tree.render();
document.body.appendChild(root);

运行之后能正常得出结果了,那么第一步骤算是完成了,具体还有更多不同类型标签,对应事件状态先略过.

界面如图
图片描述

Javascript结构如图
图片描述

结构原型如下
图片描述
相关代码可以查看element.js

diff算法

这是整个实现里面最关键的一步,因为这决定了计算的速度和操作Dom的数量

我们创建新的Dom树作对比

// 3. 生成新的虚拟DOM
const newTree = createElement("div", { id: "container" }, [
  createElement("h1", { style: "color: red" }, ["Title2"]),
  createElement("h3", ["Hello, virtual-dom"]),
  createElement("ul", [
    createElement("li", { key: 3 }, ["li3"]),
    createElement("li", { key: 1 }, ["li1"]),
    createElement("li", { key: 2 }, ["li2"]),
    createElement("li", { key: 5 }, ["li5"])
  ])
]);

Javascript结构如图
图片描述

tree diff

传统 diff 算法的复杂度为 O(n^3),但是一般Dom跨层级的情况是非常少见的。所以React 只针对同层级Dom节点做比较,将 O(n^3) 复杂度的问题转换成 O(n) 复杂度的问题。
图片描述

比较大的问题就是当节点跨层级移动并不会进行移动而是直接替换整个节点,所以切记这点性能问题

component diff

  • 某个组件发生变化,会导致自其从上往下整体替换
  • 同一类型组件会进行Virtual DOM进行比较
  • React提供了一个shouldComponentUpdate决定是否更新

尽可能将动态组件往底层节点迁移,有利于提高性能

element diff

元素操作无非就是几种,我们定义几个类型做状态标记

const REPLACE = "replace";
const REORDER = "reorder";
const PROPS = "props";
const TEXT = "text";
const NOKEY = "no_key"

export {
  REPLACE,
  REORDER,
  PROPS,
  TEXT,
  NOKEY
}

其中NOKEY就是专门给那些没有定义key的组件做默认,React对同一层级的同组子节点,添加唯一 key 进行区分进行位移而不是直接替换,这点对于整体性能尤为关键

我们首先针对不同类型做些区分处理

import { isString, objForEach, aryForEach, isNotEmptyObj } from "./util";
import { REPLACE, REORDER, PROPS, TEXT } from "./common";
import listDiff from "list-diff2";

/**
 *
 * @param {旧Dom树} oTree
 * @param {新Dom树} nTree
 * 返回差异记录
 */
function diff(oTree, nTree) {
  // 节点位置
  let index = 0;
  // 差异记录
  const patches = {};
  dfsWalk(oTree, nTree, index, patches);
  return patches;
}

function dfsWalk(oNode, nNode, index, patches) {
  const currentPatch = [];

  // 首次渲染
  if (nNode === null) return;

  // 都是字符串形式并且不相同直接替换文字
  if (isString(oNode) && isString(nNode)) {
    oNode !== nNode &&
      currentPatch.push({
        type: TEXT,
        content: nNode
      });
    // 同种标签并且key相同
  } else if (oNode.tagName === nNode.tagName && oNode.key === nNode.key) {
    // 至少一方有值
    if (isNotEmptyObj(oNode.props) || isNotEmptyObj(nNode.props)) {
      // 计算props结果
      const propsPatches = diffProps(oNode, nNode);
      // 有差异则重新排序
      propsPatches &&
        currentPatch.push({
          type: PROPS,
          props: propsPatches
        });
    }
    // children对比
    if (
      !(!isNotEmptyObj(nNode.props) && nNode.props.hasOwnProperty("ignore"))
    ) {
      (oNode.children.length || nNode.children.length) &&
        diffChildren(
          oNode.children,
          nNode.children,
          index,
          patches,
          currentPatch
        );
    }
  } else {
    // 都不符合上面情况就直接替换
    currentPatch.push({ type: REPLACE, node: nNode });
  }

  // 最终对比结果
  currentPatch.length && (patches[index] = currentPatch);
}

新旧节点的props属性比较

/**
 *
 * @param {旧节点} oNode
 * @param {新节点} nNode
 */
function diffProps(oNode, nNode) {
  let isChange = false;
  const oProps = oNode.props;
  const nProps = nNode.props;
  // 节点属性记录
  const propsPatched = {};

  // 替换/新增属性
  objForEach(oProps, key => {
    if (nProps[key] !== oProps[key] || !oProps.hasOwnProperty(key)) {
      !isChange && (isChange = true);
      propsPatched[key] = nProps[key];
    }
  });

  return !isChange ? null : propsPatched;
}

新旧节点的子元素对比

/**
 *  同级对比
 * @param {*} oChildren
 * @param {*} nChildren
 * @param {*} index
 * @param {*} patches
 * @param {*} currentPatch
 */
function diffChildren(oChildren, nChildren, index, patches, currentPatch) {
  // 得出相对简化移动路径
  const diffs = listDiff(oChildren, nChildren, "key");

  // 保留元素
  nChildren = diffs.children;

  // 记录排序位移
  diffs.moves.length &&
    currentPatch.push({ type: REORDER, moves: diffs.moves });

  // 深度遍历
  let leftNode = null;
  let currentNodeIndex = index;
  aryForEach(oChildren, (_item, _index) => {
    const nChild = nChildren[_index];
    currentNodeIndex =
      leftNode && leftNode.count
        ? currentNodeIndex + leftNode.count + 1
        : currentNodeIndex + 1;
    _item !== nChild && dfsWalk(_item, nChild, currentNodeIndex, patches);
    leftNode = _item;
  });
}

深度遍历的原型图如下
图片描述

其中的listDiff来自于list-diff,能通过关键属性获得最小移动量,moves就是给第三步更新视图做铺垫指示,官方介绍如下

Diff two lists in time O(n). I The algorithm finding the minimal amount of moves is Levenshtein distance which is O(n*m). This algorithm is not the best but is enougth for front-end DOM list manipulation.

This project is mostly influenced by virtual-dom algorithm.

调用对比方式

// 4. 比较两棵虚拟DOM树的不同
const patches = diff(tree, newTree);

得出差异如下
图片描述
相关代码可以查看diff.js

更新视图

进行深度遍历

import {
  isString,
  isObject,
  objForEach,
  aryForEach,
  setAttr,
  toArray
} from "./util";
import { REPLACE, REORDER, PROPS, TEXT, NOKEY } from "./common";

function patch(node, patches) {
  const walker = { index: 0 };
  dfsWalk(node, walker, patches);
}

// 深度遍历更新
function dfsWalk(node, walker, patches) {
  const currentPatches = patches[walker.index];

  node.childNodes &&
    aryForEach(node.childNodes, item => {
      walker.index++;
      dfsWalk(item, walker, patches);
    });

  currentPatches && applyPatches(node, currentPatches);
}

针对不同标志做对应处理

// 更新类型
function applyPatches(node, currentPatches) {
  aryForEach(currentPatches, item => {
    switch (item.type) {
      case REPLACE:
        const nNode = isString(item.node)
          ? document.createTextNode(item.node)
          : item.node.render();
        node.parentNode.replaceChild(nNode, node);
        break;
      case REORDER:
        reorderChildren(node, item.moves);
        break;
      case PROPS:
        setProps(node, item.props);
        break;
      case TEXT:
        if (node.textContent) {
          // 使用纯文本
          node.textContent = item.content;
        } else {
          // 仅仅对CDATA片段,注释comment,Processing Instruction节点或text节点有效
          node.nodeValue = item.content;
        }
        break;
      default:
        throw new Error("Unknown patch type " + item.type);
    }
  });
}

先说简单的属性替换

// 修改属性
function setProps(node, props) {
  objForEach(props, key => {
    if (props[key] === void NOKEY) {
      node.removeAttribute(key);
    } else {
      setAttr(node, key, props[key]);
    }
  });
}

最后就是列表渲染

// 列表排序渲染
function reorderChildren(node, moves) {
  const staticNodeList = toArray(node.childNodes);
  const maps = {};

  aryForEach(staticNodeList, node => {
    // Element
    if (node.nodeType === 1) {
      const key = node.getAttribute("key");
      key && (maps[key] = node);
    }
  });

  aryForEach(moves, move => {
    const index = move.index;
    // 0:删除 1:替换
    if (move.type === 0) {
      // 找到对应节点删除
      staticNodeList[index] === node.childNodes[index] &&
        node.removeChild(node.childNodes[index]);
      staticNodeList.splice(index, 1);
    } else if (move.type === 1) {
      let insertNode;
      if (maps[move.item.key]) {
        // 删除并返回节点
        insertNode = node.removeChild(maps[move.item.key]);
        // 获取删除节点位置
        staticNodeList.splice(Array.prototype.indexOf.call(node.childNodes, maps[move.item.key]), 1);
      } else {
        // 创建节点
        insertNode = isObject(move.item)
          ? move.item.render()
          : document.createTextNode(move.item);
      }
      // 同步staticNodeList信息
      staticNodeList.splice(index, 0, insertNode);
      // 操作Dom
      node.insertBefore(insertNode, node.childNodes[index] || null);
    }
  });
}

export default patch;

当这一步完成以后我们可以直接应用查看效果

// 4. 比较两棵虚拟DOM树的不同
const patches = diff(tree, newTree);

// 5. 在真正的DOM元素上应用变更
patch(root, patches);

结果如图
图片描述
相关代码可以查看patch.js

参考

深度剖析:如何实现一个 Virtual DOM 算法

查看原文

Jason 赞了文章 · 8月4日

为什么你需要避免使用ORM(含Node.js示例)

图片描述

在这篇文章里,我们将讨论为什么在项目中不应该使用ORM(对象关系映射)。
虽然本文讨论的概念适用于所有的语言和平台,代码示例还是使用了Javascript编写的Nodejs来说明,并从NPM库中获取包。

首先,我无意diss任何在本文中提到的任何模块。它们的作者都付诸了大量的辛勤劳动。同时,它们被很多应用程序用在生产环境,并且每天都响应大量的请求。我也用ORM部署过应用程序,并不觉得后悔。

快跟上!

ORM 是强大的工具。我们将在本文中研究的ORM能够与SQL后端进行通信,例如SQLite, PostgreSQL, MySQLMSSQL。 本篇示例将会使用PostgreSQL,它是一种强大的SQL服务器。另外还有一些ORM可以和NoSQL通讯,例如由MongoDB支持的Mongoose ORM,这些ORM不在本篇讨论范围之内。

首先,运行下述命令启动一个本地的PostgreSQL实例,该实例将以这种方式被配置:对本地5432端口(localhost:5432)的请求将被转发到容器。同时,文件将会储存至根目录,随后的实例化将保存我们已经创建的数据。

mkdir -p ~/data/pg-node-orms
docker run 
  --name pg-node-orms 
  -p 5432:5432 
  -e POSTGRES_PASSWORD=hunter12 
  -e POSTGRES_USER=orm-user 
  -e POSTGRES_DB=orm-db 
  -v ~/data/pg-node-orms:/var/lib/postgresql/data 
  -d 
  postgres

现在我们将拥有一个数据库,可供我们新建表和插入数据。这将使我们能够查询数据并更好地理解各个抽象层,运行下一个命令以进入PostgreSQL交互。

docker run 
  -it --rm 
  --link pg-node-orms:postgres 
  postgres 
  psql 
  -h postgres 
  -U orm-user 
  orm-db


在提示符下,输入上一个代码块中的密码,hunter12。连接成功后,复制下述查询代码并执行。

CREATE TYPE item_type AS ENUM (
  'meat', 'veg', 'spice', 'dairy', 'oil'
);

CREATE TABLE item (
  id    SERIAL PRIMARY KEY,
  name  VARCHAR(64) NOT NULL,
  type  item_type
);

CREATE INDEX ON item (type);

INSERT INTO item VALUES
  (1, 'Chicken', 'meat'), (2, 'Garlic', 'veg'), (3, 'Ginger', 'veg'),
  (4, 'Garam Masala', 'spice'), (5, 'Turmeric', 'spice'),
  (6, 'Cumin', 'spice'), (7, 'Ground Chili', 'spice'),
  (8, 'Onion', 'veg'), (9, 'Coriander', 'spice'), (10, 'Tomato', 'veg'),
  (11, 'Cream', 'dairy'), (12, 'Paneer', 'dairy'), (13, 'Peas', 'veg'),
  (14, 'Ghee', 'oil'), (15, 'Cinnamon', 'spice');

CREATE TABLE dish (
  id     SERIAL PRIMARY KEY,
  name   VARCHAR(64) NOT NULL,
  veg    BOOLEAN NOT NULL
);

CREATE INDEX ON dish (veg);

INSERT INTO dish VALUES
  (1, 'Chicken Tikka Masala', false), (2, 'Matar Paneer', true);

CREATE TABLE ingredient (
  dish_id   INTEGER NOT NULL REFERENCES dish (id),
  item_id   INTEGER NOT NULL REFERENCES item (id),
  quantity  FLOAT DEFAULT 1,
  unit      VARCHAR(32) NOT NULL
);

INSERT INTO ingredient VALUES
  (1, 1, 1, 'whole breast'), (1, 2, 1.5, 'tbsp'), (1, 3, 1, 'tbsp'),
  (1, 4, 2, 'tsp'), (1, 5, 1, 'tsp'),
  (1, 6, 1, 'tsp'), (1, 7, 1, 'tsp'), (1, 8, 1, 'whole'),
  (1, 9, 1, 'tsp'), (1, 10, 2, 'whole'), (1, 11, 1.25, 'cup'),
  (2, 2, 3, 'cloves'), (2, 3, 0.5, 'inch piece'), (2, 13, 1, 'cup'),
  (2, 6, 0.5, 'tsp'), (2, 5, 0.25, 'tsp'), (2, 7, 0.5, 'tsp'),
  (2, 4, 0.5, 'tsp'), (2, 11, 1, 'tbsp'), (2, 14, 2, 'tbsp'),
  (2, 10, 3, 'whole'), (2, 8, 1, 'whole'), (2, 15, 0.5, 'inch stick');

你现在拥有一个填充的数据库,你现在可以输入quit和psql断开连接,并重新控制终端。如果你需要再次输入原始SQL语句,你可以再次运行docker run命令。

最后,你还需要创建一个connection.json文件,如下所示。这个文件稍后将会被Node应用用于连接数据库。

{
  "host": "localhost",
  "port": 5432,
  "database": "orm-db",
  "user": "orm-user",
  "password": "hunter12"
}

抽象层

在深入研究过多代码之前,让我们先弄清楚一些不同的抽象层。就像其他所有的计算机科学一样,在我们增加抽象层时也要进行权衡。在每增加一个抽象层时,我们都尝试以降低性能为代价,以提高开发人员生产力(尽管并非总是如此)。

底层:数据库驱动程序

基本上是我们所能达到的最低级别,再往下就是手动生成TCP包并发送至数据库了。数据库驱动将处理连接到数据库(有时是连接池)的操作。在这一层,我们将编写原始SQL语句发送至数据库,并接收响应。在Node.js生态系统中,有许多库在此层运行,下面是三个最流行的库:

  • mysql: MySQL (13k stars / 330k weekly downloads)
  • pg: PostgreSQL (6k stars / 520k weekly downloads)
  • sqlite3: SQLite (3k stars / 120k weekly downloads)

这些库基本上都是以相同的方式工作:

  • 获取数据库凭据,
  • 实例化一个新的数据库实例,
  • 连接到数据库,
  • 然后以字符串形式向其发送查询并异步处理结果

下面是一个简单的示例,使用pg模块获取做Chicken Tikka Masala所需的原料清单:

#!/usr/bin/env node

// $ npm install pg

const { Client } = require('pg');
const connection = require('./connection.json');
const client = new Client(connection);

client.connect();

const query = `SELECT
  ingredient.*, item.name AS item_name, item.type AS item_type
FROM
  ingredient
LEFT JOIN
  item ON item.id = ingredient.item_id
WHERE
  ingredient.dish_id = $1`;

client
  .query(query, [1])
  .then(res => {
    console.log('Ingredients:');
    for (let row of res.rows) {
      console.log(`${row.item_name}: ${row.quantity} ${row.unit}`);
    }

    client.end();
});

中层:查询构造器

该层是介于使用简单的数据库驱动和成熟的ORM之间的一层,
在此层运行的最著名的模块是Knex。该模块能够为几种不同的SQL语言生成查询语句。这个模块依赖上面提到的几个数据库驱动库--你需要安装特定的库来使用Knex

  • Knex:Query Builder (8k stars / 170k weekly downloads)

创建Knex实例时,提供连接详细信息以及计划使用的sql语言,然后便可以开始进行查询。你编写的查询将与基础SQL查询非常相似。一个好处是,与将字符串连接在一起形成SQL相比(通常会引发安全漏洞),你能够以一种更加方便的方式-以编程方式生成动态查询。

下面是一个使用Knex模块获取烹饪Chicken Tikka Masala材料清单的一个示例:

#!/usr/bin/env node

// $ npm install pg knex

const knex = require('knex');
const connection = require('./connection.json');
const client = knex({
  client: 'pg',
  connection
});

client
  .select([
    '*',
    client.ref('item.name').as('item_name'),
    client.ref('item.type').as('item_type'),
  ])
  .from('ingredient')
  .leftJoin('item', 'item.id', 'ingredient.item_id')
  .where('dish_id', '=', 1)
  .debug()
  .then(rows => {
    console.log('Ingredients:');
    for (let row of rows) {
      console.log(`${row.item_name}: ${row.quantity} ${row.unit}`);
    }

    client.destroy();
});

上层:ORM

这是我们要讨论的最高抽象级别。当我们使用ORM时,都要在使用前进行一大堆的配置。顾名思义,ORM的要点是将关系数据库中的记录映射到应用程序中的对象(一般来说是一个类实例,但并非全部)。这意味着我们在应用程序代码中定义这些对象的结构及其关系。

  • sequelize: (16k stars / 270k weekly downloads)
  • bookshelf: Knex based (5k stars / 23k weekly downloads)
  • waterline: (5k stars / 20k weekly downloads)
  • objection: Knex based (3k stars / 20k weekly downloads)

在下面的示例中,我们将研究最受欢迎的ORMSequelize。我们还将使用Sequelize对原始PostgreSQL模式中表示的关系进行建模,下面是一个使用Sequelize模块获取烹饪Chicken Tikka Masala材料清单的一个示例:

#!/usr/bin/env node

// $ npm install sequelize pg

const Sequelize = require('sequelize');
const connection = require('./connection.json');
const DISABLE_SEQUELIZE_DEFAULTS = {
  timestamps: false,
  freezeTableName: true,
};

const { DataTypes } = Sequelize;
const sequelize = new Sequelize({
  database: connection.database,
  username: connection.user,
  host: connection.host,
  port: connection.port,
  password: connection.password,
  dialect: 'postgres',
  operatorsAliases: false
});

const Dish = sequelize.define('dish', {
  id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
  name: { type: DataTypes.STRING },
  veg: { type: DataTypes.BOOLEAN }
}, DISABLE_SEQUELIZE_DEFAULTS);

const Item = sequelize.define('item', {
  id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
  name: { type: DataTypes.STRING },
  type: { type: DataTypes.STRING }
}, DISABLE_SEQUELIZE_DEFAULTS);

const Ingredient = sequelize.define('ingredient', {
  dish_id: { type: DataTypes.INTEGER, primaryKey: true },
  item_id: { type: DataTypes.INTEGER, primaryKey: true },
  quantity: { type: DataTypes.FLOAT },
  unit: { type: DataTypes.STRING }
}, DISABLE_SEQUELIZE_DEFAULTS);

Item.belongsToMany(Dish, {
  through: Ingredient, foreignKey: 'item_id'
});

Dish.belongsToMany(Item, {
  through: Ingredient, foreignKey: 'dish_id'
});

Dish.findOne({where: {id: 1}, include: [{model: Item}]}).then(rows => {
  console.log('Ingredients:');
  for (let row of rows.items) {
    console.log(
      `${row.dataValues.name}: ${row.ingredient.dataValues.quantity} ` +
      row.ingredient.dataValues.unit
    );
  }

  sequelize.close();
});

你已经看到了如何使用不同的抽象层执行类似查询的示例,现在,让我们深入了解您应该谨慎使用ORM的原因。

理由一:你在学习错误的东西

许多人选择ORM是因为他们不想花时间学习基础SQL,人们通常认为SQL很难学习,并且通过学习ORM,我们可以使用一种语言而不是两种来编写应用程序。乍一看,这似乎是一个好理由。ORM将使用与应用程序其余部分相同的语言编写,而SQL是完全不同的语法。

但是,这种思路存在问题。问题是ORM代表了你可以使用的一些最复杂的库。ORM的体积很大,从内到外学习它不是一件容易的事。

一旦你掌握了特定的ORM,这些知识可能无法很好地应用在其他语言中。假设你从一种平台切换到另一种平台(例如JS / Node.js到C#/NET)。但也许更不易被考虑到的是,如果您在同一平台上从一个ORM切换到另一个,例如在Nodejs中从Sequelize切换到Bookshelf。例如:

Sequelize

#!/usr/bin/env node

// $ npm install sequelize pg

const Sequelize = require('sequelize');
const { Op, DataTypes } = Sequelize;
const connection = require('./connection.json');
const DISABLE_SEQUELIZE_DEFAULTS = {
  timestamps: false,
  freezeTableName: true,
};

const sequelize = new Sequelize({
  database: connection.database,
  username: connection.user,
  host: connection.host,
  port: connection.port,
  password: connection.password,
  dialect: 'postgres',
  operatorsAliases: false
});

const Item = sequelize.define('item', {
  id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
  name: { type: DataTypes.STRING },
  type: { type: DataTypes.STRING }
}, DISABLE_SEQUELIZE_DEFAULTS);

// SELECT "id", "name", "type" FROM "item" AS "item"
//     WHERE "item"."type" = 'veg';
Item
  .findAll({where: {type: 'veg'}})
  .then(rows => {
    console.log('Veggies:');
    for (let row of rows) {
      console.log(`${row.dataValues.id}t${row.dataValues.name}`);
    }
    sequelize.close();
  });

Bookshelf:

#!/usr/bin/env node

// $ npm install bookshelf knex pg

const connection = require('./connection.json');
const knex = require('knex')({
  client: 'pg',
  connection,
  // debug: true
});
const bookshelf = require('bookshelf')(knex);

const Item = bookshelf.Model.extend({
  tableName: 'item'
});

// select "item".* from "item" where "type" = ?
Item
  .where('type', 'veg')
  .fetchAll()
  .then(result => {
    console.log('Veggies:');
    for (let row of result.models) {
      console.log(`${row.attributes.id}t${row.attributes.name}`);
    }
    knex.destroy();
  });

Waterline:

#!/usr/bin/env node

// $ npm install sails-postgresql waterline

const pgAdapter = require('sails-postgresql');
const Waterline = require('waterline');
const waterline = new Waterline();
const connection = require('./connection.json');

const itemCollection = Waterline.Collection.extend({
  identity: 'item',
  datastore: 'default',
  primaryKey: 'id',
  attributes: {
    id: { type: 'number', autoMigrations: {autoIncrement: true} },
    name: { type: 'string', required: true },
    type: { type: 'string', required: true },
  }
});

waterline.registerModel(itemCollection);

const config = {
  adapters: {
    'pg': pgAdapter
  },

  datastores: {
    default: {
      adapter: 'pg',
      host: connection.host,
      port: connection.port,
      database: connection.database,
      user: connection.user,
      password: connection.password
    }
  }
};

waterline.initialize(config, (err, ontology) => {
  const Item = ontology.collections.item;
  // select "id", "name", "type" from "public"."item"
  //     where "type" = $1 limit 9007199254740991
  Item
    .find({ type: 'veg' })
    .then(rows => {
      console.log('Veggies:');
      for (let row of rows) {
        console.log(`${row.id}t${row.name}`);
      }
      Waterline.stop(waterline, () => {});
    });
});

Objection:

#!/usr/bin/env node

// $ npm install knex objection pg

const connection = require('./connection.json');
const knex = require('knex')({
  client: 'pg',
  connection,
  // debug: true
});
const { Model } = require('objection');

Model.knex(knex);

class Item extends Model {
  static get tableName() {
    return 'item';
  }
}

// select "item".* from "item" where "type" = ?
Item
  .query()
  .where('type', '=', 'veg')
  .then(rows => {
    for (let row of rows) {
      console.log(`${row.id}t${row.name}`);
    }
    knex.destroy();
  });


在这些示例之间,简单读取操作的语法差异巨大。随着你尝试执行的操作的复杂性增加,例如涉及多个表的操作,ORM语法在不同的实现之间差异会更大。

仅Node.js就有至少几十个ORM,而所有平台至少有数百个ORM。学习所有这些工具将是一场噩梦!

对我们来说幸运的是,实际上只需要学习有限的几种SQL语言。通过学习如何使用原始SQL生成查询,可以轻松地在不同平台之间传递此知识。

理由二:复杂的ORM调用效率低下

回想一下,ORM的目的是获取存储在数据库中的基础数据并将其映射到我们可以在应用程序中进行交互的对象中。当我们使用ORM来获取某些数据时,这通常会带来一些效率低下的情况。

例如,看一下我们在抽象层章节中做的查询。在该查询中,我们只需要特定配方的成分及其数量的列表。首先,我们通过手工编写SQL进行查询。接下来,我们使用查询构造器Knex进行查询。最后,我们使用Sequelize进行了查询。
让我们来看一下由这三个命令生成的查询:

用"pg"驱动手工编写SQL

第一个查询正是我们手工编写的查询。它代表了获取所需数据的最简洁方法。

SELECT
  ingredient.*, item.name AS item_name, item.type AS item_type
FROM
  ingredient
LEFT JOIN
  item ON item.id = ingredient.item_id
WHERE
ingredient.dish_id = ?;

当我们为该查询添加EXPLAIN前缀并将其发送到PostgreSQL服务器时,花费为34.12。

用“ knex”查询构造器生成

下一个查询主要是Knex帮我们生成的,但是由于Knex查询构造器的明确特性,性能上应该有一个很好的预期。

select
  *, "item"."name" as "item_name", "item"."type" as "item_type"
from
  "ingredient"
left join
  "item" on "item"."id" = "ingredient"."item_id"
where
"dish_id" = ?;

为了便于阅读,我添加了换行符。除了我手写的示例中的一些次要格式和不必要的表名外,这些查询是相同的。实际上,运行EXPLAIN查询后,我们得到的分数是34.12。

用“ Sequelize” ORM生成

现在,让我们看一下由ORM生成的查询:

SELECT
  "dish"."id", "dish"."name", "dish"."veg", "items"."id" AS "items.id",
  "items"."name" AS "items.name", "items"."type" AS "items.type",
  "items->ingredient"."dish_id" AS "items.ingredient.dish_id",
  "items->ingredient"."item_id" AS "items.ingredient.item_id",
  "items->ingredient"."quantity" AS "items.ingredient.quantity",
  "items->ingredient"."unit" AS "items.ingredient.unit"
FROM
  "dish" AS "dish"
LEFT OUTER JOIN (
  "ingredient" AS "items->ingredient"
  INNER JOIN
  "item" AS "items" ON "items"."id" = "items->ingredient"."item_id"
) ON "dish"."id" = "items->ingredient"."dish_id"
WHERE
"dish"."id" = ?;

为了便于阅读,我添加了换行符。如你所见,此查询与前两个查询有很大不同。为什么行为如此不同?由于我们已定义的关系,Sequelize试图获得比我们要求的更多的信息。直白讲就是,当我们只在乎属于该菜的配料时,会获得有关菜本身的信息。根据EXPLAIN的结果,此查询的花费为42.32

理由三:ORM不是万能的

并非所有查询都可以表示为ORM操作。当我们需要生成这些查询时,我们必须回过头来手动生成SQL查询。这通常意味着使用大量ORM的代码库仍然会有一些手写查询。意思是,作为从事这些项目之一的开发人员,我们最终需要同时了解ORM语法和一些基础SQL语法。一种普遍的情况是,当查询包含子查询时,ORM通常不能很好的工作。考虑一下这种情况,我想在数据库中查询1号菜所需的所有配料,但不包含2号菜的配料。为了实现这个需求,我可能会运行以下查询:

SELECT *
FROM item
WHERE
  id NOT IN
    (SELECT item_id FROM ingredient WHERE dish_id = 2)
  AND id IN
(SELECT item_id FROM ingredient WHERE dish_id = 1);

据我所知,无法使用上述ORM清晰地表示此查询。为了应对这些情况,ORM通常会提供将原始SQL注入到查询接口的功能。Sequelize提供了一个.query()方法来执行原始SQL,就像您正在使用基础数据库驱动程序一样。通过BookshelfObjection,你可以访问在实例化期间提供的原始Knex对象,并将其用于查询构造器功能。Knex对象还具有.raw()方法来执行原始SQL。使用Sequelize,你还可以使用Sequelize.literal()方法,将原始SQL散布在Sequelize调用的各个部分中。但是在每种情况下,你仍然需要了解一些基础SQL才能生成这些查询。

查询构造器:最佳选择

使用底层的数据库驱动程序模块很有吸引力。生成数据库查询时没有多余的开销,因为SQL语句是我们手动编写的。我们项目的依赖也得以最小化。但是,生成动态查询可能非常繁琐,我认为这是使用数据库驱动最大的缺点。

例如,在一个Web界面中,用户可以在其中选择想要检索项目的条件。如果用户只能输入一个选项(例如颜色),我们的查询可能如下所示:

SELECT * FROM things WHERE color = ?;

这个简单的查询在驱动程序下工作的非常好。但是,如果颜色是可选的,还有另一个名为is_heavy的可选字段。现在,我们需要支持此查询的一些不同排列:

SELECT * FROM things; -- Neither
SELECT * FROM things WHERE color = ?; -- 仅Color
SELECT * FROM things WHERE is_heavy = ?; -- 仅Is Heavy
SELECT * FROM things WHERE color = ? AND is_heavy = ?; -- 两者

但是,由于上章节提到的种种原因,功能齐全的ORM并不是我们想要的工具。

在这些情况下,查询构造器最终成为一个非常不错的工具。Knex开放的接口非常接近基础SQL查询,以至于我们最终还是能大概知道SQL语句是怎样的。
这种关系类似于TypeScript转换为JavaScript的方式。

只要你完全理解生成的基础SQL,使用查询构造器是一个很好的解决方案。切勿使用它作为隐藏底层的工具,而是用于方便起见并且在你确切了解它在做什么的情况下。如果对生成的SQL语句有疑问,可以在用Knex()实例化时添加调试字段。像这样:

const knex = require('knex')({
  client: 'pg',
  connection,
  debug: true // Enable Query Debugging
});

实际上,本文中提到的大多数库都提供有方法用于调试正在执行的调用。


我们研究了与数据库交互的三个不同的抽象层,即底层数据库驱动程序,中层查询构造器和上层ORM。我们还研究了使用每一层的利弊以及生成的SQL语句。包括使用数据库驱动程序生成动态查询会很困难,但ORM会使复杂性增加,最后得出结论:使用查询构造器是最佳选择。

感谢您的阅读,在构建下一个项目时一定要考虑到这一点。


完成之后,您可以运行以下命令以完全删除docker容器并从计算机中删除数据库文件:

docker stop pg-node-orms
docker rm pg-node-orms
sudo rm -rf ~/data/pg-node-orms
查看原文

赞 6 收藏 3 评论 4

Jason 收藏了文章 · 8月3日

js事件委托总结

补充一直没写的总结👀当简单回顾咯

DOM事件流

DOM事件流包括三个阶段:事件捕获阶段,处于目标阶段与事件冒泡阶段

clipboard.png

即比如点击td元素时,由外至内层会经历捕获阶段,目标阶段,冒泡阶段三个阶段,相应地会触发路径元素上的事件
此外,addEventListener(type, listener[, useCapture])函数默认设置在冒泡阶段,路径元素的事件才会触发,此参数为可选值,默认值为false,示例代码一并标出
示例代码:

    <div id="grandFather" style="width: 700px;height: 700px; background-color: red">
        <p>grandFather</p>
        <div id="father1" style="width: 300px; height: 300px; background-color: pink">
            <p>father1</p>
            <div id="son1" style="width: 100px; height: 100px; background-color: yellow">
                <p>son1</p>
            </div>
        </div>
        <div id="father2" style="width: 300px;height: 300px; background-color: green">
            <p>father2</p>
        </div>
    </div>
    <script type="text/javascript">
        let grandFather = document.getElementById('grandFather'),
            father1 = document.getElementById('father1'),
            son1 = document.getElementById('son1'),
            father2 = document.getElementById('father2');
        
        grandFather.addEventListener('click', function(event){console.log('I am grandFather')},false);
        father1.addEventListener('click', function(event){console.log('I am father1')},false);
        son1.addEventListener('click', function(event){console.log('I am son1')},false);
        father2.addEventListener('click', function(event){console.log('I am father2')},false);
    </script>

点击son1 div时,会冒泡触发father1与grandFather事件

clipboard.png

若将grandFather的use capture参数改为true,则会相应改变执行顺序

grandFather.addEventListener('click', function(event){console.log('I am grandFather')},true);

clipboard.png

以上是事件流的简单总结,下面事件委托是对事件冒泡的应用

事件委托

如果在需要有多个DOM事件需要监听的情况下(比如几百条微博点击事件注册),给每一个DOM都绑定监听函数,对性能会有极大的影响,因此,有一解决方案为事件委托。
事件委托利用了事件冒泡event.target

event.currentTarget与event.target

currentTarget:表示此事件绑定的元素
target:通俗理解为表示触发一系列事件的源头
我们再次修改grandFather的监听函数

grandFather.addEventListener('click', function(event){
    console.log('I am grandFather!!!')
    console.log('currentTarget:');
    console.log(event.currentTarget); 
    console.log('target:');
    console.log(event.target);},false); //这里已改回false

再次点击son1,进入grandFather的监听函数后显示如下

clipboard.png

事件委托具体步骤

了解冒泡与target后,不难可以想到若是在几百个dom监听之中,只需把监听函数绑定在父容器上即可,这里依旧拿这个简单的例子修改,我们要做的是点击某个div便显示打印'I am xxx'
首先,给div都绑定自定义的data-name属性

    <!-- 没有给p元素设置data-name,点击p元素时会显示data-name为null -->
    <div id="grandFather" data-name="grandFather" style="width: 700px;height: 700px; background-color: red">
        <p>grandFather</p>
        <div id="father1" data-name="father1" style="width: 300px; height: 300px; background-color: pink">
            <p>father1</p>
            <div id="son1" data-name="son1" style="width: 100px; height: 100px; background-color: yellow">
                <p>son1</p>
            </div>
        </div>
        <div id="father2" data-name="father2" style="width: 300px;height: 300px; background-color: green">
            <p>father2</p>
        </div>
    </div>

接着改写grandFather的监听函数

        grandFather.addEventListener('click', function(event){
            console.log('I am ' + event.target.getAttribute('data-name'));
        },false);
        //这些可以注释掉了!
        // father1.addEventListener('click', function(event){console.log('I am father1')},false);
        // son1.addEventListener('click', function(event){console.log('I am son1')},false);
        // father2.addEventListener('click', function(event){console.log('I am father2')},false);

这样子就是一个简单的委托,更多的应用可以结合switch等等

参考资料

《Javascript高级程序设计》
MDN:Event.target
MDN:event.currentTarget
w3:DOM Event Architecture

查看原文

Jason 赞了文章 · 8月3日

js事件委托总结

补充一直没写的总结👀当简单回顾咯

DOM事件流

DOM事件流包括三个阶段:事件捕获阶段,处于目标阶段与事件冒泡阶段

clipboard.png

即比如点击td元素时,由外至内层会经历捕获阶段,目标阶段,冒泡阶段三个阶段,相应地会触发路径元素上的事件
此外,addEventListener(type, listener[, useCapture])函数默认设置在冒泡阶段,路径元素的事件才会触发,此参数为可选值,默认值为false,示例代码一并标出
示例代码:

    <div id="grandFather" style="width: 700px;height: 700px; background-color: red">
        <p>grandFather</p>
        <div id="father1" style="width: 300px; height: 300px; background-color: pink">
            <p>father1</p>
            <div id="son1" style="width: 100px; height: 100px; background-color: yellow">
                <p>son1</p>
            </div>
        </div>
        <div id="father2" style="width: 300px;height: 300px; background-color: green">
            <p>father2</p>
        </div>
    </div>
    <script type="text/javascript">
        let grandFather = document.getElementById('grandFather'),
            father1 = document.getElementById('father1'),
            son1 = document.getElementById('son1'),
            father2 = document.getElementById('father2');
        
        grandFather.addEventListener('click', function(event){console.log('I am grandFather')},false);
        father1.addEventListener('click', function(event){console.log('I am father1')},false);
        son1.addEventListener('click', function(event){console.log('I am son1')},false);
        father2.addEventListener('click', function(event){console.log('I am father2')},false);
    </script>

点击son1 div时,会冒泡触发father1与grandFather事件

clipboard.png

若将grandFather的use capture参数改为true,则会相应改变执行顺序

grandFather.addEventListener('click', function(event){console.log('I am grandFather')},true);

clipboard.png

以上是事件流的简单总结,下面事件委托是对事件冒泡的应用

事件委托

如果在需要有多个DOM事件需要监听的情况下(比如几百条微博点击事件注册),给每一个DOM都绑定监听函数,对性能会有极大的影响,因此,有一解决方案为事件委托。
事件委托利用了事件冒泡event.target

event.currentTarget与event.target

currentTarget:表示此事件绑定的元素
target:通俗理解为表示触发一系列事件的源头
我们再次修改grandFather的监听函数

grandFather.addEventListener('click', function(event){
    console.log('I am grandFather!!!')
    console.log('currentTarget:');
    console.log(event.currentTarget); 
    console.log('target:');
    console.log(event.target);},false); //这里已改回false

再次点击son1,进入grandFather的监听函数后显示如下

clipboard.png

事件委托具体步骤

了解冒泡与target后,不难可以想到若是在几百个dom监听之中,只需把监听函数绑定在父容器上即可,这里依旧拿这个简单的例子修改,我们要做的是点击某个div便显示打印'I am xxx'
首先,给div都绑定自定义的data-name属性

    <!-- 没有给p元素设置data-name,点击p元素时会显示data-name为null -->
    <div id="grandFather" data-name="grandFather" style="width: 700px;height: 700px; background-color: red">
        <p>grandFather</p>
        <div id="father1" data-name="father1" style="width: 300px; height: 300px; background-color: pink">
            <p>father1</p>
            <div id="son1" data-name="son1" style="width: 100px; height: 100px; background-color: yellow">
                <p>son1</p>
            </div>
        </div>
        <div id="father2" data-name="father2" style="width: 300px;height: 300px; background-color: green">
            <p>father2</p>
        </div>
    </div>

接着改写grandFather的监听函数

        grandFather.addEventListener('click', function(event){
            console.log('I am ' + event.target.getAttribute('data-name'));
        },false);
        //这些可以注释掉了!
        // father1.addEventListener('click', function(event){console.log('I am father1')},false);
        // son1.addEventListener('click', function(event){console.log('I am son1')},false);
        // father2.addEventListener('click', function(event){console.log('I am father2')},false);

这样子就是一个简单的委托,更多的应用可以结合switch等等

参考资料

《Javascript高级程序设计》
MDN:Event.target
MDN:event.currentTarget
w3:DOM Event Architecture

查看原文

赞 4 收藏 3 评论 0

Jason 收藏了文章 · 7月23日

手写async await的最简实现(20行)

前言

如果让你手写async函数的实现,你是不是会觉得很复杂?这篇文章带你用20行搞定它的核心。

经常有人说async函数是generator函数的语法糖,那么到底是怎么样一个糖呢?让我们来一层层的剥开它的糖衣。

有的同学想说,既然用了generator函数何必还要实现async呢?

这篇文章的目的就是带大家理解清楚async和generator之间到底是如何相互协作,管理异步的。

示例

const getData = () => new Promise(resolve => setTimeout(() => resolve("data"), 1000))

async function test() {
  const data = await getData()
  console.log('data: ', data);
  const data2 = await getData()
  console.log('data2: ', data2);
  return 'success'
}

// 这样的一个函数 应该再1秒后打印data 再过一秒打印data2 最后打印success
test().then(res => console.log(res))

思路

对于这个简单的案例来说,如果我们把它用generator函数表达,会是怎么样的呢?

function* testG() {
  // await被编译成了yield
  const data = yield getData()
  console.log('data: ', data);
  const data2 = yield getData()
  console.log('data2: ', data2);
  return 'success'
}

我们知道,generator函数是不会自动执行的,每一次调用它的next方法,会停留在下一个yield的位置。

利用这个特性,我们只要编写一个自动执行的函数,就可以让这个generator函数完全实现async函数的功能。

const getData = () => new Promise(resolve => setTimeout(() => resolve("data"), 1000))
  
var test = asyncToGenerator(
    function* testG() {
      // await被编译成了yield
      const data = yield getData()
      console.log('data: ', data);
      const data2 = yield getData()
      console.log('data2: ', data2);
      return 'success'
    }
)

test().then(res => console.log(res))

那么大体上的思路已经确定了,

asyncToGenerator接受一个generator函数,返回一个promise

关键就在于,里面用yield来划分的异步流程,应该如何自动执行。

如果是手动执行

在编写这个函数之前,我们先模拟手动去调用这个generator函数去一步步的把流程走完,有助于后面的思考。

function* testG() {
  // await被编译成了yield
  const data = yield getData()
  console.log('data: ', data);
  const data2 = yield getData()
  console.log('data2: ', data2);
  return 'success'
}

我们先调用testG生成一个迭代器

// 返回了一个迭代器
var gen = testG()

然后开始执行第一次next

// 第一次调用next 停留在第一个yield的位置
// 返回的promise里 包含了data需要的数据
var dataPromise = gen.next()

这里返回了一个promise,就是第一次getData()所返回的promise,注意

const data = yield getData()

这段代码要切割成左右两部分来看,第一次调用next,其实只是停留在了yield getData()这里,

data的值并没有被确定。

那么什么时候data的值会被确定呢?

下一次调用next的时候,传的参数会被作为上一个yield前面接受的值

也就是说,我们再次调用gen.next('这个参数才会被赋给data变量')的时候

data的值才会被确定为'这个参数才会被赋给data变量'

gen.next('这个参数才会被赋给data变量')

// 然后这里的data才有值
const data = yield getData()

// 然后打印出data
console.log('data: ', data);

// 然后继续走到下一个yield
const data2 = yield getData()

然后往下执行,直到遇到下一个yield,继续这样的流程...

这是generator函数设计的一个比较难理解的点,但是为了实现我们的目标,还是得去学习它~

借助这个特性,如果我们这样去控制yield的流程,是不是就能实现异步串行了?

function* testG() {
  // await被编译成了yield
  const data = yield getData()
  console.log('data: ', data);
  const data2 = yield getData()
  console.log('data2: ', data2);
  return 'success'
}

var gen = testG()

var dataPromise = gen.next()

dataPromise.then((value1) => {
    // data1的value被拿到了 继续调用next并且传递给data
    var data2Promise = gen.next(value1)
    
    // console.log('data: ', data);
    // 此时就会打印出data
    
    data2Promise.value.then((value2) => {
        // data2的value拿到了 继续调用next并且传递value2
         gen.next(value2)
         
        // console.log('data2: ', data2);
        // 此时就会打印出data2
    })
})

这样的一个看着像callback hell的调用,就可以让我们的generator函数把异步安排的明明白白。

实现

有了这样的思路,实现这个高阶函数就变得很简单了。

先整体看一下结构,有个印象,然后我们逐行注释讲解。

function asyncToGenerator(generatorFunc) {
    return function() {
      const gen = generatorFunc.apply(this, arguments)
      return new Promise((resolve, reject) => {
        function step(key, arg) {
          let generatorResult
          try {
            generatorResult = gen[key](arg)
          } catch (error) {
            return reject(error)
          }
          const { value, done } = generatorResult
          if (done) {
            return resolve(value)
          } else {
            return Promise.resolve(value).then(val => step('next', val), err => step('throw', err))
          }
        }
        step("next")
      })
    }
}

不多不少,22行。

接下来逐行讲解。

function asyncToGenerator(generatorFunc) {
  // 返回的是一个新的函数
  return function() {
  
    // 先调用generator函数 生成迭代器
    // 对应 var gen = testG()
    const gen = generatorFunc.apply(this, arguments)

    // 返回一个promise 因为外部是用.then的方式 或者await的方式去使用这个函数的返回值的
    // var test = asyncToGenerator(testG)
    // test().then(res => console.log(res))
    return new Promise((resolve, reject) => {
    
      // 内部定义一个step函数 用来一步一步的跨过yield的阻碍
      // key有next和throw两种取值,分别对应了gen的next和throw方法
      // arg参数则是用来把promise resolve出来的值交给下一个yield
      function step(key, arg) {
        let generatorResult
        
        // 这个方法需要包裹在try catch中
        // 如果报错了 就把promise给reject掉 外部通过.catch可以获取到错误
        try {
          generatorResult = gen[key](arg)
        } catch (error) {
          return reject(error)
        }

        // gen.next() 得到的结果是一个 { value, done } 的结构
        const { value, done } = generatorResult

        if (done) {
          // 如果已经完成了 就直接resolve这个promise
          // 这个done是在最后一次调用next后才会为true
          // 以本文的例子来说 此时的结果是 { done: true, value: 'success' }
          // 这个value也就是generator函数最后的返回值
          return resolve(value)
        } else {
          // 除了最后结束的时候外,每次调用gen.next()
          // 其实是返回 { value: Promise, done: false } 的结构,
          // 这里要注意的是Promise.resolve可以接受一个promise为参数
          // 并且这个promise参数被resolve的时候,这个then才会被调用
          return Promise.resolve(
            // 这个value对应的是yield后面的promise
            value
          ).then(
            // value这个promise被resove的时候,就会执行next
            // 并且只要done不是true的时候 就会递归的往下解开promise
            // 对应gen.next().value.then(value => {
            //    gen.next(value).value.then(value2 => {
            //       gen.next() 
            //
            //      // 此时done为true了 整个promise被resolve了 
            //      // 最外部的test().then(res => console.log(res))的then就开始执行了
            //    })
            // })
            function onResolve(val) {
              step("next", val)
            },
            // 如果promise被reject了 就再次进入step函数
            // 不同的是,这次的try catch中调用的是gen.throw(err)
            // 那么自然就被catch到 然后把promise给reject掉啦
            function onReject(err) {
              step("throw", err)
            },
          )
        }
      }
      step("next")
    })
  }
}

源码地址

这个 js文件 的代码可以直接放进浏览器里运行,欢迎调戏。

总结

本文用最简单的方式实现了asyncToGenerator这个函数,这是babel编译async函数的核心,当然在babel中,generator函数也被编译成了一个很原始的形式,本文我们直接以generator替代。

这也是实现promise串行的一个很棒的模式,如果本篇文章对你有帮助,点个赞就好啦。

❤️感谢大家

1.如果本文对你有帮助,就点个赞支持下吧,你的「赞」是我创作的动力。

2.关注公众号「前端从进阶到入院」即可加我好友,我拉你进「前端进阶交流群」,大家一起共同交流和进步。

公众号

查看原文

Jason 赞了文章 · 7月23日

手写async await的最简实现(20行)

前言

如果让你手写async函数的实现,你是不是会觉得很复杂?这篇文章带你用20行搞定它的核心。

经常有人说async函数是generator函数的语法糖,那么到底是怎么样一个糖呢?让我们来一层层的剥开它的糖衣。

有的同学想说,既然用了generator函数何必还要实现async呢?

这篇文章的目的就是带大家理解清楚async和generator之间到底是如何相互协作,管理异步的。

示例

const getData = () => new Promise(resolve => setTimeout(() => resolve("data"), 1000))

async function test() {
  const data = await getData()
  console.log('data: ', data);
  const data2 = await getData()
  console.log('data2: ', data2);
  return 'success'
}

// 这样的一个函数 应该再1秒后打印data 再过一秒打印data2 最后打印success
test().then(res => console.log(res))

思路

对于这个简单的案例来说,如果我们把它用generator函数表达,会是怎么样的呢?

function* testG() {
  // await被编译成了yield
  const data = yield getData()
  console.log('data: ', data);
  const data2 = yield getData()
  console.log('data2: ', data2);
  return 'success'
}

我们知道,generator函数是不会自动执行的,每一次调用它的next方法,会停留在下一个yield的位置。

利用这个特性,我们只要编写一个自动执行的函数,就可以让这个generator函数完全实现async函数的功能。

const getData = () => new Promise(resolve => setTimeout(() => resolve("data"), 1000))
  
var test = asyncToGenerator(
    function* testG() {
      // await被编译成了yield
      const data = yield getData()
      console.log('data: ', data);
      const data2 = yield getData()
      console.log('data2: ', data2);
      return 'success'
    }
)

test().then(res => console.log(res))

那么大体上的思路已经确定了,

asyncToGenerator接受一个generator函数,返回一个promise

关键就在于,里面用yield来划分的异步流程,应该如何自动执行。

如果是手动执行

在编写这个函数之前,我们先模拟手动去调用这个generator函数去一步步的把流程走完,有助于后面的思考。

function* testG() {
  // await被编译成了yield
  const data = yield getData()
  console.log('data: ', data);
  const data2 = yield getData()
  console.log('data2: ', data2);
  return 'success'
}

我们先调用testG生成一个迭代器

// 返回了一个迭代器
var gen = testG()

然后开始执行第一次next

// 第一次调用next 停留在第一个yield的位置
// 返回的promise里 包含了data需要的数据
var dataPromise = gen.next()

这里返回了一个promise,就是第一次getData()所返回的promise,注意

const data = yield getData()

这段代码要切割成左右两部分来看,第一次调用next,其实只是停留在了yield getData()这里,

data的值并没有被确定。

那么什么时候data的值会被确定呢?

下一次调用next的时候,传的参数会被作为上一个yield前面接受的值

也就是说,我们再次调用gen.next('这个参数才会被赋给data变量')的时候

data的值才会被确定为'这个参数才会被赋给data变量'

gen.next('这个参数才会被赋给data变量')

// 然后这里的data才有值
const data = yield getData()

// 然后打印出data
console.log('data: ', data);

// 然后继续走到下一个yield
const data2 = yield getData()

然后往下执行,直到遇到下一个yield,继续这样的流程...

这是generator函数设计的一个比较难理解的点,但是为了实现我们的目标,还是得去学习它~

借助这个特性,如果我们这样去控制yield的流程,是不是就能实现异步串行了?

function* testG() {
  // await被编译成了yield
  const data = yield getData()
  console.log('data: ', data);
  const data2 = yield getData()
  console.log('data2: ', data2);
  return 'success'
}

var gen = testG()

var dataPromise = gen.next()

dataPromise.then((value1) => {
    // data1的value被拿到了 继续调用next并且传递给data
    var data2Promise = gen.next(value1)
    
    // console.log('data: ', data);
    // 此时就会打印出data
    
    data2Promise.value.then((value2) => {
        // data2的value拿到了 继续调用next并且传递value2
         gen.next(value2)
         
        // console.log('data2: ', data2);
        // 此时就会打印出data2
    })
})

这样的一个看着像callback hell的调用,就可以让我们的generator函数把异步安排的明明白白。

实现

有了这样的思路,实现这个高阶函数就变得很简单了。

先整体看一下结构,有个印象,然后我们逐行注释讲解。

function asyncToGenerator(generatorFunc) {
    return function() {
      const gen = generatorFunc.apply(this, arguments)
      return new Promise((resolve, reject) => {
        function step(key, arg) {
          let generatorResult
          try {
            generatorResult = gen[key](arg)
          } catch (error) {
            return reject(error)
          }
          const { value, done } = generatorResult
          if (done) {
            return resolve(value)
          } else {
            return Promise.resolve(value).then(val => step('next', val), err => step('throw', err))
          }
        }
        step("next")
      })
    }
}

不多不少,22行。

接下来逐行讲解。

function asyncToGenerator(generatorFunc) {
  // 返回的是一个新的函数
  return function() {
  
    // 先调用generator函数 生成迭代器
    // 对应 var gen = testG()
    const gen = generatorFunc.apply(this, arguments)

    // 返回一个promise 因为外部是用.then的方式 或者await的方式去使用这个函数的返回值的
    // var test = asyncToGenerator(testG)
    // test().then(res => console.log(res))
    return new Promise((resolve, reject) => {
    
      // 内部定义一个step函数 用来一步一步的跨过yield的阻碍
      // key有next和throw两种取值,分别对应了gen的next和throw方法
      // arg参数则是用来把promise resolve出来的值交给下一个yield
      function step(key, arg) {
        let generatorResult
        
        // 这个方法需要包裹在try catch中
        // 如果报错了 就把promise给reject掉 外部通过.catch可以获取到错误
        try {
          generatorResult = gen[key](arg)
        } catch (error) {
          return reject(error)
        }

        // gen.next() 得到的结果是一个 { value, done } 的结构
        const { value, done } = generatorResult

        if (done) {
          // 如果已经完成了 就直接resolve这个promise
          // 这个done是在最后一次调用next后才会为true
          // 以本文的例子来说 此时的结果是 { done: true, value: 'success' }
          // 这个value也就是generator函数最后的返回值
          return resolve(value)
        } else {
          // 除了最后结束的时候外,每次调用gen.next()
          // 其实是返回 { value: Promise, done: false } 的结构,
          // 这里要注意的是Promise.resolve可以接受一个promise为参数
          // 并且这个promise参数被resolve的时候,这个then才会被调用
          return Promise.resolve(
            // 这个value对应的是yield后面的promise
            value
          ).then(
            // value这个promise被resove的时候,就会执行next
            // 并且只要done不是true的时候 就会递归的往下解开promise
            // 对应gen.next().value.then(value => {
            //    gen.next(value).value.then(value2 => {
            //       gen.next() 
            //
            //      // 此时done为true了 整个promise被resolve了 
            //      // 最外部的test().then(res => console.log(res))的then就开始执行了
            //    })
            // })
            function onResolve(val) {
              step("next", val)
            },
            // 如果promise被reject了 就再次进入step函数
            // 不同的是,这次的try catch中调用的是gen.throw(err)
            // 那么自然就被catch到 然后把promise给reject掉啦
            function onReject(err) {
              step("throw", err)
            },
          )
        }
      }
      step("next")
    })
  }
}

源码地址

这个 js文件 的代码可以直接放进浏览器里运行,欢迎调戏。

总结

本文用最简单的方式实现了asyncToGenerator这个函数,这是babel编译async函数的核心,当然在babel中,generator函数也被编译成了一个很原始的形式,本文我们直接以generator替代。

这也是实现promise串行的一个很棒的模式,如果本篇文章对你有帮助,点个赞就好啦。

❤️感谢大家

1.如果本文对你有帮助,就点个赞支持下吧,你的「赞」是我创作的动力。

2.关注公众号「前端从进阶到入院」即可加我好友,我拉你进「前端进阶交流群」,大家一起共同交流和进步。

公众号

查看原文

赞 31 收藏 21 评论 2

Jason 收藏了文章 · 7月22日

antd组件使用进阶及踩过的坑

更多我对Antd的使用及思考,请参考:antd-doddle

扯点犊子

一晃眼,两个月过去了,自己从一家不大不小的屌丝公司跳到一家被具有纯正互联网血液的公司。从以前的围绕jQuery、Echarts为主技术栈开展工作,到现在以React、Antd为主技术栈开发业务;但不是所有的业务antd都能支持,所以有时得自己动手,在antd上做一层浅封装。
文章中提到的示例都可以在codeBox找到:codeBox

自定义表单组件

Antd的Form表单介绍一节中,提到过自定义表单控件。其实例是关于货币价值转换的,如下图所示:
image
当我们在我们的页面中需要频繁的用到某一个组合类型的组件,而Antd又不支持时,最好的做法就是对Antd组件做一层浅封装行成一个独立的组件,当然也可以使用html 自有的表单元素进行封装,只是这样做出来费事,且样式和整个页面没有那么容易统一。封装的注意事项在上面的截图中已经一一列出:核心就是value的处理,与onChange事件的支撑,接下来将以一个实例来操作说明。

一个带远程搜索的下拉选择组件

2018.12月更新:随着Select组件comobox模式在新的版本中被舍弃,和autoComplete组件的出现。这个组件也进行了重写。但整体逻辑没有改变,主要时改变了激活弹出框和关闭弹出框的逻辑。具体可参见我的github项目:React进阶
seri
这个组件的大致实现需求如上面动态图所示。产品需求就是需要一个编辑框,这个框在用户点击输入时,需要弹出一个搜索框,根据用户的输入远程搜索获取数据形成一个下拉列表,供用户选择。这在jquery时代,这是一个很常见的需求,也有很多的组件可选择,但在Antd的组件库中,没有完全匹配的,但有及其相似功能的,比如:

image

这个组件与产品的需求契合度已经达到了80%, 但是产品说了搜索输入框需要与编辑输入框分开,并且有明显的区别,ok,那就费点事,把Antd组件稍微做一下改变嘛。
image
所以简单分解一下,需要用到Input,Icon, Select这三种组件,具体实现可以查看SandBox上的源码及示例。说一下自己遇到的难点:

支持双向绑定

<FormItem type="inline" label="员工姓名">
  {getFieldDecorator('search',{
    initialValue: { name: 'Dom' }
  })(
    <OriginSearch {...modalProps} />
  )}
</FormItem>

在Antd的Form表单组件中,如果需要做数据双向绑定,就需要用到其提供的getFieldDecorator方法来装饰组件,而我们自己封装的组件要支持这个特性的话,如最开始提到的,我们需要使用onChange方法来触发装饰器值的同步。

在我们为一个组件添加了装饰器后,可以查看props明显发现多了id,onChange, value三个属性,value属性是用于获取我们在initialValue设定的值的,而onChange方法是用于同步值,实现双向绑定。所以在我们这个组件中,当用户从下拉框中选择一个选项后,我们需要调用onChange方法去同步值,代码如下所示:

handleSelect(value, option) {
  const { handleSelect } = this.props;
  const { seachRes } = this.state;
  // 初始化基础信息
  const selectValue = handleSelect(value, option, seachRes) || value;
  this.triggerChange(selectValue);
  this.setState({ value: selectValue });
  this.handleCloseSearch();
}
triggerChange(value) {
  const { onChange } = this.props;
  // 调用装饰器方法去同步选中后的值
  onChange && onChange(value);
}

点击组件以外的地方收起组件

这看似是个很容易实现的需求,但因为Antd所有的弹框组件都用了同一套方法,其弹框Dom树并不是挂载在Select输入框的父节点上,而是直接挂载在Body节点上,所以想用冒泡的机制来实现就不可能了。所以就和投机用了点击事件的节点名称来判断,看具体实现:

componentDidUpdate(prevProps, prevState) {
  const { isShowSearch } = this.state;
  const bodyNode = document.querySelector("body");
  if (isShowSearch !== prevState.isShowSearch) {
    // 状态切换的时候才改变状态
    if (isShowSearch) {
      document
        .querySelector(".js-origin-search .ant-select-search__field")
        .focus();
      bodyNode.addEventListener("click", this.handleChangeVisible);
    } else {
      bodyNode.removeEventListener("click", this.handleChangeVisible);
    }
  }
}
handleChangeVisible(event) {
  const { isShowSearch } = this.state;
  event = event || window.event;
  const elem = event.target;
  let inComponentClick = false;
  // 当搜索框框被打开时,点击空白处搜索框收起;由于antd的下拉列表是挂载在body下,而非搜索框节点下的某一子节点,所以
  // 无法采用阻止冒泡的方式来避免body下的click事件被响应,所以只有靠判断被点击的节点类,来判断body的click事件是否响应
  if (
    (this.searchInputElement && this.searchInputElement.contains(elem)) ||
    elem.className.indexOf("ant-select-dropdown") !== -1
  ) {
    inComponentClick = true;
  }
  // 当点击事件为非下拉列表选中事件,切搜索框为展开时,触发搜索框收起方法;
  !inComponentClick && isShowSearch && this.handleCloseSearch();
}

虽然这只是一次很简单的封装,但其包含的知识点还是非常多的。自己还封装过日期多选,日期选择增加至今,地址地区联合选择器这种,从实现上其实都是一个思路,在这一个SandBox项目中都能看到。

组件奇特使用方式(持续更新)

动态更新表单组件Required参数,但验证没有同步

加入现在有这样一个需求,用户需要选择自己的性别(男,女,其他),当用户选择其他时,下面说明项由非必填变为必填。这看似是一个很简单的需求,在用户选择其他时,将isRequired变量变为true就行了,看起来好像,大概,貌似成功了。但是自己在antd组件的应用上,发现,当你把isRequired置为true,label标签前面会加上一个*,但这只是一个假象,当你填上数据再删除时,antd组件这时并不会自动验证,并触发提示词显示。但是,这些都没有。所谓的isRequired置为true,并没有达到真正想要的效果,测试代码如下,我猜测antd的这个机制和他的initValue更新机制相似,只有在组件初始化的时候会设置一次初始值,后面都是组件内部state参数进行状态切换,原以为用resetFields可以解决,最后发现不能。但是巧的方法没有,不代表笨办法也没有。

<FormItem
  label="性别"
  labelCol={{ span: 5 }}
  wrapperCol={{ span: 12 }}
>
  {getFieldDecorator('gender', {
    rules: [{ required: true, message: 'Please select your gender!' }],
  })(
    <Select
      placeholder="Select a option and change input text above"
      onChange={this.handleSelectChange}
    >
      <Option value="male">male</Option>
      <Option value="female">female</Option>
    </Select>
  )}
</FormItem>
<FormItem
  label="说明"
  labelCol={{ span: 5 }}
  wrapperCol={{ span: 12 }}
>
  {getFieldDecorator('note', {
    rules: [{ required: isRequired, message: 'Please input your note!' }],
  })(
    <Input />
  )}
</FormItem>

最后想出的最好的解决办法就是动态销毁重新挂载这个组件,我们可以通过动态设定key值,来保证状态的同步。其实这是重新渲染了一个新的组件来替换。

<FormItem
  label="说明"
  key={isRequired}
  labelCol={{ span: 5 }}
  wrapperCol={{ span: 12 }}
> 

2019.01.06日更:
上面的问题,在自己使用3.9的版本上没有再出现了,但是好像又出现了一个更大的表单动态校验问题。看下图,需求和上面差不多:
2303313742-5c317658312cd_articlex
当选择启用时,原因必填。其他时,变为非必填。现在出现的情况时,必填时触发错误提醒,但将状态置为禁用时,label的必填属性虽然被重置了,但错误提醒仍然存在,有些奇怪。
基于上面的现象,我去基友社区搜了一下Issuse,果然时存在的:form动态校验问题,幸运的时,antd大佬也给出了解决方案:使用form.validateFields([key], { force: true })来解决

实践后的幡然醒悟

用了两个多月,其实Antd自己本身没啥坑,只是由于我们组现在使用的版本是2.9,但自己习惯于看3.x的版本文档,所以多次在一个地方徘徊好久,总以为是自己代码实现有问题,其实是2.9版本还没有实现。

总结一

在Select组件上,2.9与3.x就有较大的差异:

  1. Select 的option必须带有不同的Key值,且value值也不能有相同的,比如在远程搜索加载员工列表时,就会出现同名的情况,所以这时的value就不能只用名字,得用value加工号或则其他值来代替。
  2. Select 的 onchange事件在3.x版本以前回调函数只有value值,没有option回调参数。
  3. Select 的notFundContent属性可配置结合Spin实现加载动画,但在版本3以下,该配置对于comobox模式无效(其文档未对这个特性(Bug)做说明)。。。
  4. Select 的 onSelect事件在3.x以后也有较大改动,其option参数包含的内容作了很大调整,在2.9版本还可以通过option.props.index获取选择的索引,在3.x版本只能间接通过设置key为index,然后通过获取key值来获取index;
  5. Select 组件渲染出来的下拉列表是没有挂载在Select组件父节点上的,其是采用绝对定位,挂载在body节点上的。。。所有用父节点做筛选是无法获取的。

总结二

另外在表单组件自校验validator的使用上,有一个隐藏的少有人知的使用方法是:

<FormItem {...formItemLayout} label="确认密码">
    {
        getFieldDecorator('confirmPassword', {
            rules: [{
                    required: true,
                    message: '请再次输入以确认新密码',
                }, {
                    validator: this.handleConfirmPassword
                }],
        })(<Input type="password" />)
    }
</FormItem> 
handleConfirmPassword(rule, value, callback) {
    if (value && value.length < 5 ) {
        callback('长度不足');
        return;
    }
    // Note: 必须总是返回一个 callback,否则 validateFieldsAndScroll 无法响应
    callback()
}

总结三

当我们使用getFieldDecorator并用initialValue设定初始值时,当我们改变组件的值时,组件表现出的值也改变了,但这个值并不是initialValue设定的,其是组件内部的state值保持的,如果需要继续用initialValue来设定组件的值,我们需要调用resetFields方法使initialValue有效;

总结四:Table设置width无效

Antd组件个人觉得最好用的功能就是Table,其配合pagination可以直接实现前端分页,在有些使用场景可以大大提高使用体验。但是Table也有坑(其实也是css一个隐形知识点),就是有时你会发现你为一列设置了width,但是并没有鸟用。

{
  key: 'userId',
  name: '用户ID',
  value: 'asddsddsfsfsdfsdfsdfsfsfdsfsfsfsfsfddefgervwerbvw'
  width: 80
}

就像上面的这种数据,设置了80的宽度,但最后撑开差不多是300。最后的最后,记起了又一个css属性叫 word-break ,来历就是在浏览器中,纯数字或者纯字母的字符串,他的显示默认是不换行的,就算他已经超出了这一行的边际,就是这么叼的一个属性。所以Table也受这个影响,由于我要展示的内容中是纯字母,纵然我设置了width,依然没什么鸟用。需要设置与td相关联的table样式,加上word-break:break-all这样的解药。

总结四:Select(下拉弹框类)组件页面滚动时,下拉内容与弹出父组件分离

image
如上图所示,外层页面一滚动,下拉框与下拉内容就分离了,分离,离,离了。这个出现原就是因为ANTD所有的弹框都是默认挂载在body下面,然后绝对定位的。所以滚动body内容, 就会造成与弹出触发的那个组件错位。幸好在3.0以后ANTD对这个bug做出了一个解决方案,就是增加了getPopupContainer属性,可以让下拉内容挂载任何存在的dom节点上,并相对其定位。具体用例请查看官方示例

Hooks自定义组件报cannot be given refs

Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()

随着React hooks不断的应用,在antd中,用hooks自定义的组件,会报如上所示的refs无法获取,这是antd form双向绑定对ref有需要。因为ref和key一样,不是通过prop来传递的,react对其有特殊的处理。好在这个可以通过forwardRef来解决,官方文档,看一个示例:

export default function FundSelect(props) {
    return (
        <YourComponent {...props}>
    )
}

使用forwardRef转发Ref

function FundSelect(props, ref) {
    return (
        <YourComponent ref={ref} {...props}>
    )
}

export default forwardRef(FundSelect);

getFieldDecorator包裹下的Switch组件无法显示为true状态

<FormItem {...formItemLayout} label="是否显示">
     {getFieldDecorator('enable', {
      initialValue: true,})
(<Switch />)}
 </FormItem>

发现在getFieldDecorator包裹下的Switch组件无法显示为true的状态,3.23版本报以下提示信息:

warning.js:6 Warning: [antd: Switch] value is not validate prop, do you mean checked?

查找文档得知Switch组件是通过checked的属性来显示状态的,所以需要一个额外的属性valuePropName

<FormItem {...formItemLayout} label="是否显示文档">
            {getFieldDecorator('showDocument', {
              initialValue: true,
              valuePropName: 'checked'
            })(<Switch />)}
</FormItem>

文章首发于: http://closertb.site

(https://github.com/closertb/c...

查看原文

Jason 赞了文章 · 7月22日

antd组件使用进阶及踩过的坑

更多我对Antd的使用及思考,请参考:antd-doddle

扯点犊子

一晃眼,两个月过去了,自己从一家不大不小的屌丝公司跳到一家被具有纯正互联网血液的公司。从以前的围绕jQuery、Echarts为主技术栈开展工作,到现在以React、Antd为主技术栈开发业务;但不是所有的业务antd都能支持,所以有时得自己动手,在antd上做一层浅封装。
文章中提到的示例都可以在codeBox找到:codeBox

自定义表单组件

Antd的Form表单介绍一节中,提到过自定义表单控件。其实例是关于货币价值转换的,如下图所示:
image
当我们在我们的页面中需要频繁的用到某一个组合类型的组件,而Antd又不支持时,最好的做法就是对Antd组件做一层浅封装行成一个独立的组件,当然也可以使用html 自有的表单元素进行封装,只是这样做出来费事,且样式和整个页面没有那么容易统一。封装的注意事项在上面的截图中已经一一列出:核心就是value的处理,与onChange事件的支撑,接下来将以一个实例来操作说明。

一个带远程搜索的下拉选择组件

2018.12月更新:随着Select组件comobox模式在新的版本中被舍弃,和autoComplete组件的出现。这个组件也进行了重写。但整体逻辑没有改变,主要时改变了激活弹出框和关闭弹出框的逻辑。具体可参见我的github项目:React进阶
seri
这个组件的大致实现需求如上面动态图所示。产品需求就是需要一个编辑框,这个框在用户点击输入时,需要弹出一个搜索框,根据用户的输入远程搜索获取数据形成一个下拉列表,供用户选择。这在jquery时代,这是一个很常见的需求,也有很多的组件可选择,但在Antd的组件库中,没有完全匹配的,但有及其相似功能的,比如:

image

这个组件与产品的需求契合度已经达到了80%, 但是产品说了搜索输入框需要与编辑输入框分开,并且有明显的区别,ok,那就费点事,把Antd组件稍微做一下改变嘛。
image
所以简单分解一下,需要用到Input,Icon, Select这三种组件,具体实现可以查看SandBox上的源码及示例。说一下自己遇到的难点:

支持双向绑定

<FormItem type="inline" label="员工姓名">
  {getFieldDecorator('search',{
    initialValue: { name: 'Dom' }
  })(
    <OriginSearch {...modalProps} />
  )}
</FormItem>

在Antd的Form表单组件中,如果需要做数据双向绑定,就需要用到其提供的getFieldDecorator方法来装饰组件,而我们自己封装的组件要支持这个特性的话,如最开始提到的,我们需要使用onChange方法来触发装饰器值的同步。

在我们为一个组件添加了装饰器后,可以查看props明显发现多了id,onChange, value三个属性,value属性是用于获取我们在initialValue设定的值的,而onChange方法是用于同步值,实现双向绑定。所以在我们这个组件中,当用户从下拉框中选择一个选项后,我们需要调用onChange方法去同步值,代码如下所示:

handleSelect(value, option) {
  const { handleSelect } = this.props;
  const { seachRes } = this.state;
  // 初始化基础信息
  const selectValue = handleSelect(value, option, seachRes) || value;
  this.triggerChange(selectValue);
  this.setState({ value: selectValue });
  this.handleCloseSearch();
}
triggerChange(value) {
  const { onChange } = this.props;
  // 调用装饰器方法去同步选中后的值
  onChange && onChange(value);
}

点击组件以外的地方收起组件

这看似是个很容易实现的需求,但因为Antd所有的弹框组件都用了同一套方法,其弹框Dom树并不是挂载在Select输入框的父节点上,而是直接挂载在Body节点上,所以想用冒泡的机制来实现就不可能了。所以就和投机用了点击事件的节点名称来判断,看具体实现:

componentDidUpdate(prevProps, prevState) {
  const { isShowSearch } = this.state;
  const bodyNode = document.querySelector("body");
  if (isShowSearch !== prevState.isShowSearch) {
    // 状态切换的时候才改变状态
    if (isShowSearch) {
      document
        .querySelector(".js-origin-search .ant-select-search__field")
        .focus();
      bodyNode.addEventListener("click", this.handleChangeVisible);
    } else {
      bodyNode.removeEventListener("click", this.handleChangeVisible);
    }
  }
}
handleChangeVisible(event) {
  const { isShowSearch } = this.state;
  event = event || window.event;
  const elem = event.target;
  let inComponentClick = false;
  // 当搜索框框被打开时,点击空白处搜索框收起;由于antd的下拉列表是挂载在body下,而非搜索框节点下的某一子节点,所以
  // 无法采用阻止冒泡的方式来避免body下的click事件被响应,所以只有靠判断被点击的节点类,来判断body的click事件是否响应
  if (
    (this.searchInputElement && this.searchInputElement.contains(elem)) ||
    elem.className.indexOf("ant-select-dropdown") !== -1
  ) {
    inComponentClick = true;
  }
  // 当点击事件为非下拉列表选中事件,切搜索框为展开时,触发搜索框收起方法;
  !inComponentClick && isShowSearch && this.handleCloseSearch();
}

虽然这只是一次很简单的封装,但其包含的知识点还是非常多的。自己还封装过日期多选,日期选择增加至今,地址地区联合选择器这种,从实现上其实都是一个思路,在这一个SandBox项目中都能看到。

组件奇特使用方式(持续更新)

动态更新表单组件Required参数,但验证没有同步

加入现在有这样一个需求,用户需要选择自己的性别(男,女,其他),当用户选择其他时,下面说明项由非必填变为必填。这看似是一个很简单的需求,在用户选择其他时,将isRequired变量变为true就行了,看起来好像,大概,貌似成功了。但是自己在antd组件的应用上,发现,当你把isRequired置为true,label标签前面会加上一个*,但这只是一个假象,当你填上数据再删除时,antd组件这时并不会自动验证,并触发提示词显示。但是,这些都没有。所谓的isRequired置为true,并没有达到真正想要的效果,测试代码如下,我猜测antd的这个机制和他的initValue更新机制相似,只有在组件初始化的时候会设置一次初始值,后面都是组件内部state参数进行状态切换,原以为用resetFields可以解决,最后发现不能。但是巧的方法没有,不代表笨办法也没有。

<FormItem
  label="性别"
  labelCol={{ span: 5 }}
  wrapperCol={{ span: 12 }}
>
  {getFieldDecorator('gender', {
    rules: [{ required: true, message: 'Please select your gender!' }],
  })(
    <Select
      placeholder="Select a option and change input text above"
      onChange={this.handleSelectChange}
    >
      <Option value="male">male</Option>
      <Option value="female">female</Option>
    </Select>
  )}
</FormItem>
<FormItem
  label="说明"
  labelCol={{ span: 5 }}
  wrapperCol={{ span: 12 }}
>
  {getFieldDecorator('note', {
    rules: [{ required: isRequired, message: 'Please input your note!' }],
  })(
    <Input />
  )}
</FormItem>

最后想出的最好的解决办法就是动态销毁重新挂载这个组件,我们可以通过动态设定key值,来保证状态的同步。其实这是重新渲染了一个新的组件来替换。

<FormItem
  label="说明"
  key={isRequired}
  labelCol={{ span: 5 }}
  wrapperCol={{ span: 12 }}
> 

2019.01.06日更:
上面的问题,在自己使用3.9的版本上没有再出现了,但是好像又出现了一个更大的表单动态校验问题。看下图,需求和上面差不多:
2303313742-5c317658312cd_articlex
当选择启用时,原因必填。其他时,变为非必填。现在出现的情况时,必填时触发错误提醒,但将状态置为禁用时,label的必填属性虽然被重置了,但错误提醒仍然存在,有些奇怪。
基于上面的现象,我去基友社区搜了一下Issuse,果然时存在的:form动态校验问题,幸运的时,antd大佬也给出了解决方案:使用form.validateFields([key], { force: true })来解决

实践后的幡然醒悟

用了两个多月,其实Antd自己本身没啥坑,只是由于我们组现在使用的版本是2.9,但自己习惯于看3.x的版本文档,所以多次在一个地方徘徊好久,总以为是自己代码实现有问题,其实是2.9版本还没有实现。

总结一

在Select组件上,2.9与3.x就有较大的差异:

  1. Select 的option必须带有不同的Key值,且value值也不能有相同的,比如在远程搜索加载员工列表时,就会出现同名的情况,所以这时的value就不能只用名字,得用value加工号或则其他值来代替。
  2. Select 的 onchange事件在3.x版本以前回调函数只有value值,没有option回调参数。
  3. Select 的notFundContent属性可配置结合Spin实现加载动画,但在版本3以下,该配置对于comobox模式无效(其文档未对这个特性(Bug)做说明)。。。
  4. Select 的 onSelect事件在3.x以后也有较大改动,其option参数包含的内容作了很大调整,在2.9版本还可以通过option.props.index获取选择的索引,在3.x版本只能间接通过设置key为index,然后通过获取key值来获取index;
  5. Select 组件渲染出来的下拉列表是没有挂载在Select组件父节点上的,其是采用绝对定位,挂载在body节点上的。。。所有用父节点做筛选是无法获取的。

总结二

另外在表单组件自校验validator的使用上,有一个隐藏的少有人知的使用方法是:

<FormItem {...formItemLayout} label="确认密码">
    {
        getFieldDecorator('confirmPassword', {
            rules: [{
                    required: true,
                    message: '请再次输入以确认新密码',
                }, {
                    validator: this.handleConfirmPassword
                }],
        })(<Input type="password" />)
    }
</FormItem> 
handleConfirmPassword(rule, value, callback) {
    if (value && value.length < 5 ) {
        callback('长度不足');
        return;
    }
    // Note: 必须总是返回一个 callback,否则 validateFieldsAndScroll 无法响应
    callback()
}

总结三

当我们使用getFieldDecorator并用initialValue设定初始值时,当我们改变组件的值时,组件表现出的值也改变了,但这个值并不是initialValue设定的,其是组件内部的state值保持的,如果需要继续用initialValue来设定组件的值,我们需要调用resetFields方法使initialValue有效;

总结四:Table设置width无效

Antd组件个人觉得最好用的功能就是Table,其配合pagination可以直接实现前端分页,在有些使用场景可以大大提高使用体验。但是Table也有坑(其实也是css一个隐形知识点),就是有时你会发现你为一列设置了width,但是并没有鸟用。

{
  key: 'userId',
  name: '用户ID',
  value: 'asddsddsfsfsdfsdfsdfsfsfdsfsfsfsfsfddefgervwerbvw'
  width: 80
}

就像上面的这种数据,设置了80的宽度,但最后撑开差不多是300。最后的最后,记起了又一个css属性叫 word-break ,来历就是在浏览器中,纯数字或者纯字母的字符串,他的显示默认是不换行的,就算他已经超出了这一行的边际,就是这么叼的一个属性。所以Table也受这个影响,由于我要展示的内容中是纯字母,纵然我设置了width,依然没什么鸟用。需要设置与td相关联的table样式,加上word-break:break-all这样的解药。

总结四:Select(下拉弹框类)组件页面滚动时,下拉内容与弹出父组件分离

image
如上图所示,外层页面一滚动,下拉框与下拉内容就分离了,分离,离,离了。这个出现原就是因为ANTD所有的弹框都是默认挂载在body下面,然后绝对定位的。所以滚动body内容, 就会造成与弹出触发的那个组件错位。幸好在3.0以后ANTD对这个bug做出了一个解决方案,就是增加了getPopupContainer属性,可以让下拉内容挂载任何存在的dom节点上,并相对其定位。具体用例请查看官方示例

Hooks自定义组件报cannot be given refs

Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()

随着React hooks不断的应用,在antd中,用hooks自定义的组件,会报如上所示的refs无法获取,这是antd form双向绑定对ref有需要。因为ref和key一样,不是通过prop来传递的,react对其有特殊的处理。好在这个可以通过forwardRef来解决,官方文档,看一个示例:

export default function FundSelect(props) {
    return (
        <YourComponent {...props}>
    )
}

使用forwardRef转发Ref

function FundSelect(props, ref) {
    return (
        <YourComponent ref={ref} {...props}>
    )
}

export default forwardRef(FundSelect);

getFieldDecorator包裹下的Switch组件无法显示为true状态

<FormItem {...formItemLayout} label="是否显示">
     {getFieldDecorator('enable', {
      initialValue: true,})
(<Switch />)}
 </FormItem>

发现在getFieldDecorator包裹下的Switch组件无法显示为true的状态,3.23版本报以下提示信息:

warning.js:6 Warning: [antd: Switch] value is not validate prop, do you mean checked?

查找文档得知Switch组件是通过checked的属性来显示状态的,所以需要一个额外的属性valuePropName

<FormItem {...formItemLayout} label="是否显示文档">
            {getFieldDecorator('showDocument', {
              initialValue: true,
              valuePropName: 'checked'
            })(<Switch />)}
</FormItem>

文章首发于: http://closertb.site

(https://github.com/closertb/c...

查看原文

赞 40 收藏 29 评论 4

Jason 收藏了文章 · 7月7日

React Mixins入门指南

  对于很多初级的前端工程师对mixins的概念并不是很了解,也没有在React中尝试使用过Mixins,这边文章基本会按照Mixins的作用、用途、原理等多个方面介绍React中Mixins的使用。
  首先解释一下什么是Mixins,在一些大型项目中经常会存在多个组件需要使用相同的功能的情况,如果在每个组件中都重复性的加入相同的代码,那么代码的维护性将会变的非常差,Mixins的出现就是为了解决这个问题。可以将通用共享的方法包装成Mixins方法,然后注入各个组件实现,我们首先给出一个Mixins简单的例子:

const mixin = function(obj, mixins) {
  const newObj = obj;
  newObj.prototype = Object.create(obj.prototype);
  for (let prop in mixins) {
    if (mixins.hasOwnProperty(prop)) {
      newObj.prototype[prop] = mixins[prop];
    }
  }
  return newObj;
}
const manMixins = {
  speak: function (){
    console.log("I'm "+this.name);
  }
};
const Man = function() {
  this.name = 'wang';
};
const manCanSpeak = mixin(Man,manMixins);
const man = new manCanSpeak(); 
man.speak(); //'I'm wang'

  上述代码就实现了一个简单的mixin函数,其实质就是将mixins中的方法遍历赋值给newObj.prototype,从而实现mixin返回的函数创建的对象都有mixins中的方法。在我们大致明白了mixin作用后,让我们来看看如何在React使用mixin

React.createClass

  假设我们所有的React组件的props中都含有一个默认的displayName,在使用React.createClass时,我们必须给每个组件中都加入

getDefaultProps: function () {
    return {displayName: "component"};
}

  当然我们,我们通过实现一个mixin函数,就可以实现这个功能,并且在createClass方法使用mixin非常简单:

var mixinDefaultProps = {
    getDefaultProps: function(){
        return {displayName: 'component'}
    }
}

var ExampleComponent = React.createClass({
    mixins: [mixinDefaultProps],
    render: function(){
        return <div>{this.props.displayName}</div>
    }
});

  这样我们就实现了一个最简单的mixin函数,通过给每一个组件配置mixin,我们就实现了不同组件之间共享相同的方法。需要注意的是:

mixin中有相同的函数

  1. 组件中含有多个mixin,不同的mixin中含有相同名字的非生命周期函数,React会抛出异常(不是后面的函数覆盖前面的函数)。

  2. 组件中含有多个mixin,不同的mixin中含有相同名字的生命周期函数,不会抛出异常,mixin中的相同的生命周期函数(除render方法)会按照createClass中传入的mixins数组顺序依次调用,全部调用结束后再调用组件内部的相同的声明周期函数。

mixin中设置props或state

  1. 组件中含有多个mixin,不同的mixin中的默认props或初始state中不存在相同的key值时,则默认props和初始state都会被合并。

  2. 组件中含有多个mixin,不同的mixin中默认props或初始state中存在相同的key值时,React会抛出异常。

JSX

  目前几乎很少有人会使用React.createClass的方式使用React,JSX + ES6成了标配,但是JavaScript在ES6之前是原生不支持的mixin的,ES7引入了decorator,首先介绍一下decorator到底是什么?

Decorator

  ES7的Decorator语法类似于Python中的Decorator,在ES7中也仅仅只是一个语法糖,@decorator主要有两种,一种是面向于类(class)的@decorator,另一种是面向于方法(function)的@decorator。并且@decorator实质是利用了ES5中的Object.defineProperty

Object.defineProperty  

  关于Object.defineProperty不是很了解的同学其实非常推荐看一下《JavaScript高级程序设计》的第六章第一节,大概总结一下:在ES5中对象的属性其实分为两种: 数据属性访问器属性

数据属性

数据属性有四个特性:

  1. configurable: 属性是否可删除、重新定义

  2. enumerable: 属性是否可枚举

  3. writable: 属性值是否可修改

  4. value: 属性值

访问器属性
  1. configurable: 属性是否可删除、重新定义

  2. enumerable: 属性是否可枚举

  3. get: 读取属性调用

  4. set: 设置属性调用

  Object.defineProperty(obj, prop, descriptor)的三个参数是定义属性的对象、属性名和描述符,描述符本身也是Object,其中的属性就是数据属性或者访问器属性规定的参数,举个栗子:

var person = {};
Object.defineProperty(person,'name',{
    configurable: true,
    enumerable: true,
    writable: true,
    value: 'wang'
});
console.log(person.name);//wang

了解了Object.defineProperty,我们分别看下面向于类(class)的@decorator和面向于方法(function)的@decorator。

面向方法的@decorator

  class语法其实仅仅只是ES6的一个语法糖而已,class其实质是function。并且class中的内部方法会通过Object.defineProperty定义到function.prototype,例如:

class Person {
  speak () {
    console.log('I am Person!') 
  }
}

会被Babel转成:

function Person(){}

Object.defineProperty(Person.prototype,'speak',{
  value: function () { 'I am Person!' },
  enumerable: false,
  configurable: true,
  writable: true
})

  Decorator函数接受的参数与Object.defineProperty类似,与对类(class)的方法使用@decorator,接受到的方法分别是类的prototype,内部方法名和描述符,@decorator会在调用Object.defineProperty前劫持,先调用Decorator函数,将返回的descriptor定义到类的prototype上。
  例如:

function readonly(target, key, descriptor) {
  //可以通过修改descriptor参数实现各种功能
  descriptor.writable = false
  return descriptor
}

class Person {
  @readonly
  speak () {
    return 'I am Person!'
  }
}

const person = new Person();
person.speak = ()=>{
    console.log('I am human')
}

面向类的@decorator

  当我们对一个class使用@decorator时,接受到的参数target是类本身。例如:
  

function name (target) {
  target.name = 'wang'
}

@name
class Person {}

console.log(Dog.name)
//'wang'

  讲完了@decorator,现在让我们回到JSX中,react-mixincore-decorators两个库都提供了mixin函数可用。大致让我们看一下core-decorators库中mixin的大致代码:

function handleClass(target, mixins) {
  if (!mixins.length) {
    throw new SyntaxError(`@mixin() class ${target.name} requires at least one mixin as an argument`);
  }

  for (let i = 0, l = mixins.length; i < l; i++) {
    const descs = getOwnPropertyDescriptors(mixins[i]);
    const keys = getOwnKeys(descs);

    for (let j = 0, k = keys.length; j < k; j++) {
      const key = keys[j];

      if (!(hasProperty(key, target.prototype))) {
        defineProperty(target.prototype, key, descs[key]);
      }
    }
  }
}

export default function mixin(...mixins) {
  if (typeof mixins[0] === 'function') {
    return handleClass(mixins[0], []);
  } else {
    return target => {
      return handleClass(target, mixins);
    };
  }
}

@mixin使用如下:

import { mixin } from 'core-decorators';

const SingerMixin = {
  sing(sound) {
    alert(sound);
  }
};

const FlyMixin = {
  fly() {},
  land() {}
};

@mixin(SingerMixin, FlyMixin)
class Bird {
  singMatingCall() {
    this.sing('tweet tweet');
  }
}

var bird = new Bird();
bird.singMatingCall();

  我们可以看到mixin函数相当于采用Currying的方式接受mixins数组,返回

return target => {
      return handleClass(target, mixins);
};

handleClass函数的大致作用就是采用defineProperty将mixins数组中的函数定义在target.prototype上,这样就实现了mixin的要求。

Mixin在React中的作用

  讲了这么多Mixin的东西,那么Mixin在React中有什么作用呢?Mixin的作用无非就是在多个组件中共享相同的方法,实现复用,React中的Mixin也是相同的。比如你的组件中可能有共同的工具方法,为了避免在每个组件中都有相同的定义,你就可以采用Mixin。下面依旧举一个现实的例子。
  React的性能优化一个非常常见的方法就是减少组件不必要的render,一般我们可以在生命周期shouldComponentUpdate(nextProps, nextState)中进行判断,通过判断nextPropsnextStatethis.prosthis.state是否完全相同(浅比较),如果相同则返回false,表示不重新渲染,如果不相同,则返回true,使得组件重新渲染(当然你也可以不使用mixin,而使用React.PureComponent也可以达到相同的效果)。并且现在有非常多的现成的库提供如上的功能,例如react-addons-pure-render-mixin中提供了PureRenderMixin方法,首先我们可以在项目下运行:

npm install --save react-addons-pure-render-mixin;

然后在代码中可以如下使用

import PureRenderMixin from 'react-addons-pure-render-mixin';
import {decorate as mixin} from 'react-mixin'
@mixin(PureRenderMixin)
class FooComponent extends React.Component {
  constructor(props) {
    super(props);
  }

  render() {
    return <div className={this.props.className}>foo</div>;
  }
}

当然你也可以这样写:

var PureRenderMixin = require('react-addons-pure-render-mixin');
React.createClass({
  mixins: [PureRenderMixin],

  render: function() {
    return <div className={this.props.className}>foo</div>;
  }
});

甚至这样写:

import PureRenderMixin from 'react-addons-pure-render-mixin';
class FooComponent extends React.Component {
  constructor(props) {
    super(props);
    this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
  }

  render() {
    return <div className={this.props.className}>foo</div>;
  }
}

  因为@decorator是ES7的用法,所以必须使用Babel才能使用,所以我们需要在.babelrc文件中设置:

{
  "presets": ["es2015", "stage-1"],
  "plugins": [
    "babel-plugin-transform-decorators-legacy"
  ]
}

并安装插件:

npm i babel-cli babel-preset-es2015 babel-preset-stage-1 babel-plugin-transform-decorators

  之后我们就可以尽情体验ES7的decorator了!

查看原文

Jason 收藏了文章 · 6月3日

《领域驱动设计之PHP实现》全书翻译 - DDD入门

DDD 入门

  1. 《领域驱动设计之PHP实现》全书翻译 - DDD入门
  2. 《领域驱动设计之PHP实现》全书翻译 - 架构风格
  3. 《领域驱动设计之PHP实现》全书翻译 - 值对象
  4. 《领域驱动设计之PHP实现》全书翻译 - 实体
  5. 《领域驱动设计之PHP实现》全书翻译 - 服务
  6. 《领域驱动设计之PHP实现》全书翻译 - 领域事件
  7. 《领域驱动设计之PHP实现》全书翻译 - 模块
  8. 《领域驱动设计之PHP实现》全书翻译 - 聚合
  9. 《领域驱动设计之PHP实现》全书翻译 - 工厂
  10. 《领域驱动设计之PHP实现》全书翻译 - 仓储
  11. 《领域驱动设计之PHP实现》全书翻译 - 应用服务
  12. 《领域驱动设计之PHP实现》全书翻译 - 集成上下文
  13. 《用PHP实现六边形架构》

如果你已经读过 Vaughn Vernon 和 Eric Evans 著作里的这些主题,你可能会很熟悉我们讲的是什么,因为我们大量借鉴了他们的定义和解释。领域驱动设计(DDD)是一种帮助我们理解和构建软件模型设计的方法。它提供了战略和战术模型工具来帮助我们设计高质量的软件从而达到我们的业务目标。

本书的主要目标是向你展示领域驱动设计战略模式的 PHP 代码实例。如果你想了解更多战略模式和领域驱动设计的核心,你最好去读 Vaughn Vernon 的《领域驱动设计精简版》和 Eric Evans 的《领域驱动设计参考:定义和模式摘要》。

更重要的是,领域驱动设计不是关于技术的。相反的,它是关于围绕业务挖掘知识,用技术来提供商业价值的。只有当你有能力理解你公司的业务时,你才可能参与到软件模型的探索过程,从而得到一个通用语言(Ubiquitous Language)。

为什么领域驱动设计重要

软件不仅仅是代码。如果你好好思考过这一点,代码绝非我们所追求的最终目标。代码只不过是解决业务问题的手段。领域驱动设计强调要确保业务和软件使用同一语言。所以为什么非要用不同的语言来讲述呢?只要打破其中壁垒,就不再需要翻译或者繁琐的同步过程,信息也不会丢失。每个人都可以为探索业务领域做出贡献,而不仅仅是程序员。最终所产出的软件就是公共语言的真实体现。

领域驱动设计同时提供了战略和战术设计的框架 -- 战略设计精确定位业务中最重要的领域,根据业务价值来进行开发;战术设计则通过久经考验构建块和模式来建立可工作的领域模型。

领域驱动设计的三个核心

领域驱动设计是交付软件的一种方法,它主要聚集三个核心要点:

1. 通用语言

为了建立业务领域的共同语言,领域专家和软件开发人员需要一起工作。这里没有我们与他们之分,这里只有我们。开发软件是一种商业投资而不仅是花销。为构建通用语言而作出的努力将帮助团队所有成员对领域有更深的认识。

2. 战略设计

领域驱动设计指明业务方向背后的战略,而不仅仅是技术层面。这有助于定义内部关系和早期预警反馈系统。在技术面,战略设计通过提供如何实现面向服务架构的动机来保护每个业务服务。

3. 战术设计

领域驱动设计提供工具和构建块来持续交付软件。战术设计工具产出的软件,不仅是正确,而且同时是易测试的和出错少的。

通用语言

通用语言,以及第十二章整合限界上下文,是领域驱动设计最具威力的一部分。

就上下文而言
现在设想一个限界上下文就是一个系统内的概念边界,边界内的通用语言是有其特殊含义的,而边界外的上下文概念可能有不同的含义。

那么,如何寻找,发现和捕获这些非常特别的语言呢,下面将列出几个要点:

  • 识别业务流程的关键点,输入和输出
  • 创建一些词汇和定义
  • 用一些文档来捕获重要的软件概念
  • 与团队其他人分享和扩展以上知识(开发人员和领域专家)

自从领域驱动设计诞生以来,改进构建通用语言进程的新技术层出不穷。其中最重要的也是现今使用最普遍的,就是事件风暴

事件风暴

Alberto Brandolini 在一个博客帖子上介绍了事件风暴及其好处,他解释得比我们简洁多了。事件风暴,就是一种快速发现复杂业务领域的研讨会形式:

  • 它非常强大:它让自己和许多参与者在数小时内提出一个完整业务流的综合模型,而以前往往需要数周。
  • 它非常吸引人:它整个想法就是为了构建一个模型而把有疑问的人和知道答案的人聚在同一个屋子里。
  • 它非常高效:它所得到的模型完全切合领域驱动设计的实施方式(尤其适合一种事件源方法),并可以快速确定上下文和聚合边界。
  • 它很简单:它的表示符号非常简单,也没有复杂的 UML 以致参会者想从讨论核心中抽身离开。
  • 它很有乐趣:我总是能主持一个美妙的工作会议,大家总是很投入并能提出超出他们预期的东西。正确的问题浮现了,气氛也是融洽的。

如果你想对事件风暴了解更多,请查阅 Brandolini 的书 《Introducing EventStorming》

关于领域驱动设计

领域驱动设计并不是银弹,就像软件里的一切都依赖于上下文。作为一个经验法则,使用它可以让你的领域模型简单化,绝不会增加复杂性。

如果你的应用仅仅是以数据为中心,用例也主要是在数据库行列上做 CRUD 操作,即增删改查,那么你并不需要领域驱动设计。你所需要的仅仅是在数据库之上做一个漂亮的前端。

如果你的应用少于 30 个用例,那最好使用像 Symfony 或者 Laravel 这样的框架来简单处理你的业务逻辑。

可是,如果你的应用超 30 个用例,你的系统可能走向一个可怕的大泥球。如果你知道你的系统确定无疑会变得越来越复杂,那么你应该考虑用领域驱动设计来应对这些复杂度。

如果你并不理解你工作中那些领域,因为它们很陌生,之前也没有任何人给出解决方案,那么对你来说应用领域驱动设计将变得很复杂。在这种情况下,你最需要与领域专家紧密工作来得到正确的模型。

棘手的部分

应用领域驱动设计并不容易。它需要时间和努力来投入到业务领域,术语,文献中,而不是代码堆里。你需要领域专家承诺参与到整个进程当中。这需要一个开放的,友好的持续交流,来将他们的行话(spoken lauguage)转化为我们的软件模型。另外我们要努力避免使用技术思维,而应该首先认真思考对象的行为和通用语言。

战略总结

为了给出领域驱动设计战略这部分一个总体概括,我们将用 Jimmy Nilsson 的书《应用领域驱动设计与模式》中的一种方法,即考虑两个空间:问题空间和解空间。

在问题空间里,领域驱动设计用领域和子域来规划和规类公司想要解决的问题。在在线旅行社(OTA)这个例子当中,问题是关于处理像航班机票,酒店预订这样的事情。这样的领域就可以规划为不同的子域,如价格,库存,用户管理等等。

在解空间里,领域驱动设计提供两种模式:限界上下文和上下文映射图。其目标是定义如何为所有已识别的子域提供一个实现,通过定义他们的交互和这些交互的细节。继续用 OTA 这个例子,每个子域问题都将用一个限界上下文实现来解决。例如,一个由价格管理子域团队开发的自定义 web 应用,以及用户管理子域的一个现成解决方案。上下文映射图将展示每个限界上下文是怎样与其它部分发生关联的。在上下文映射图内,我们可以看到两个限界上下文间有什么关联形式(例如:客户-供应商,伙伴)。理想的方案是每个子域由一个限界上下文实现,但实现不可能总是如此。就实现来说,依照领域驱动设计你就最终以一个分布式架构结束。正如你已经知道的,分布式架构远比单体架构复杂,那么为什么这个方法有意思,尤其是对大而复杂的公司来说?这真的值得吗?是的,它值得!

分布式架构被证明能提高企业整体生产效率,因为它能为你的产品定义好边界,从而让专门的团队来开发。

如果你需要解决的领域问题不是很复杂,应用领域驱动设计的战略部分会增加不必要的开销同时会拖慢你的研发速度。

如果你想了解更多关于领域驱动设计的战略部分,你应该去看看Vaughn Vernon的书《实现领域驱动设计》的前三章,或者Eric Evans的《领域驱动设计:软件核心复杂性应对之道》,二者对于这方面都有专门的讲解。

相关趋势:微服务与自包含系统

现在还有一些其他遵循领域驱动设计的相关运动正蓬勃发展,微服务和自包含系统就是很好的例子。James Lewis 和 Martin Fowler 在 Microservices Resource Guide 里是这样定义微服务

微服务架构风格是把单个应用的开发分解为一个个小的服务的方法,每个服务都有自己独立的进程和轻量的通信机制,通常是用 HTTP 资源的 API。这些服务都是围绕其业务能力构建,可独立部署自动升级,去中心管理。服务可以用不同的程序语言编写,使用不同的数据存储技术。

如果你想了解更多关于微服务的内容,他们的指导就是一个良好的开端。微服务是怎样与领域驱动设计发生关系的?按照 Sam Newman 《微服务设计》 一书中的解释,微服务就是领域驱动设计的限界上下文的实现。

除了微服务,另外一个相关的趋势就是自包含系统(SCS),依据自包含系统官网的解释:

自包含系统是关注将功能分离至许多独立系统的一种架构,由这些独立系统相互协作来提供一个完整的逻辑系统。这可以避免单体系统不断成长导致最后变得不可维护的问题。纵观过去的几年里,我们看到许多中型和大型项目受益于此。这个思路是把一个大型系统分解为一些更小的自包含系统,依照下列确定的规则(官网上同样有阐明自包含系统 的七个特征):
  1. 每个自包含系统是一个自治的web应用。对于自包含系统里的所有处理数据的逻辑和所有渲染 web 界面的代码都包含在其中。一个自包含系统可以完全自主处理其所有的用例,不用依赖其它可用的系统。
  2. 每个自包含系统由一个团队管理。这并不是必然意味着只有一个团队才可以改变代码,不过所有者团队可以最终决定什么可以放进代码库,例如合并,拉取请求。
  3. 不同自包含系统或者第三方系统间的通信任何情况下都应该是异步的。尤其是其他自包含系统或扩展系统不应该同步访问自包含系统内的请求/回复。这一点能帮助解耦系统,减少失败结果,因此能支持自治性。其目的是关于时间上的解耦:一个自包含系统可以很好的工作即使其它自包含系统临时离线。即使通信技术层次是同步的也能实现,例如复制数据或缓存请求。
  4. 一个自包含系统有可选的服务 API。因为每个自包含系统都有自己的 web UI 同用户交互,所以不需要一个 UI 服务。不过即便如此,为移动客户端或者其他自包含系统而存在的 API 仍然是有用的。
  5. 每个自包含系统必须包含数据与逻辑。要真正实现任何有意义的功能,两者都是必需的。一个自包含系统应该通过自身实现所有功能因此两者缺一不可。
  6. 一个自包含系统通过自身的UI将功能交给最终用户使用。因此自包含系统不应该与其他自包含系统共享UI。自包含系统可能仍旧与其它系统有关联。不过,异步整合意味着即使在其他自包含系统不可用的情况下还应该能正常工作。为避免紧耦合,一个自包含系统不应该与其他自包含系统共享业务代码。也许可以通过创建一个自包含系统或者公共库,例如:数据驱动层或者认证客户端。

练习

与你的同事讨论例如分布式架构的利弊。考虑用不同语言,部署过程,责任心等等。

小结

在这一章里你将学到:

  • 领域驱动设计不是关于技术的;它的价值实际上是通过聚焦你工作领域中的模型体现的。每个人都参与到领域发现的过程,开发人员和领域专家用相同的语言来共同建立知识,即通用语言。
  • 领域驱动设计提供战术和战略模型工具来设计高质量软件。战略设计关注业务方向,帮助明确内部关系,用明确的强边界严格地保护好每个业务服务。战术则为迭代设计提供有用的构建块。
  • 领域驱动设计只有在明确的上下文中才有意义。它并不是软件中所有问题的银弹,所以是否用它应根据你手中工作的复杂度来决定。
  • 领域驱动设计是一项长期投资;它需要长期努力。开发人员需要与领域专家紧密合作,同时要根据业务思考。最后,业务中的客户因素也是需要你考虑的。

实现领域驱动设计需要努力。如果它很简单,那每个人都可以写出高质量代码了。准备好了,因为你马上就要开始学习怎么写这些代码了,在读的过程中,你将可以完美地描述清晰你公司现有的业务。享受这段旅程吧!

查看原文

认证与成就

  • 获得 119 次点赞
  • 获得 7 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 7 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2016-08-09
个人主页被 973 人浏览