joe_sky

joe_sky 查看完整档案

北京编辑北京工业大学  |  计算机科学与技术 编辑jd.com  |  前端工程师 编辑 github.com/joe-sky 编辑
编辑

FE Developer JD.com

个人动态

joe_sky 收藏了文章 · 3月2日

一分钟用上热更新 React Fast Refresh(react-refresh)

预计阅读本文只需要两分钟,一分钟看文章,一分钟跑代码 nice!

什么是React Fast Refresh?

React Fast Refresh 是 React 官方为 React Native 开发的模块热替换(HMR)方案,由于其核心实现与平台无关,所以也适用于 Web。

官方实现为 react-refresh

快速上手

不浪费时间,请跟着我在 1分钟 内构建一个支持 React Fast Refresh 的应用。

安装依赖

主要使用下面两个 npm 包(可以跳过,直接拷贝下文的 package.json

npm install @pmmmwh/react-refresh-webpack-plugin react-refresh

webpack.config.js

const ReactRefreshPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: './index.js',
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: 'babel-loader',
        options: {
          presets: ['@babel/env', '@babel/preset-react'],
          plugins: [require.resolve('react-refresh/babel')], // 为 react-refresh 添加
        },
      },
    ],
  },
  plugins: [
    new ReactRefreshPlugin(),    // 为 react-refresh 添加
    new HtmlWebpackPlugin({
      template: './index.html',
    }),
  ],
};

index.js

import * as React from 'react';
import { render } from 'react-dom';
import App from './app'

render(<App />, document.getElementById('app'));

app.js

import React from "react";

function App() {
  return <h1>hello world!</h1>
}

export default App;

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React App</title>
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>

package.json

{
  "name": "react-refresh-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "start": "webpack-dev-server --hot"
  },
  "dependencies": {
    "react": "^16.13.1",
    "react-dom": "^16.13.1"
  },
  "devDependencies": {
    "@babel/core": "^7.10.5",
    "@babel/preset-env": "^7.10.4",
    "@babel/preset-react": "^7.10.4",
    "@pmmmwh/react-refresh-webpack-plugin": "^0.4.1",
    "babel-loader": "^8.1.0",
    "html-webpack-plugin": "^4.3.0",
    "react-refresh": "^0.8.3",
    "webpack": "^4.44.0",
    "webpack-cli": "^3.3.12",
    "webpack-dev-server": "^3.11.0"
  }
}

最后

安装 npm install

启动 npm run start

你肯定已经顺利跑起来了

那尝试修改 App.js 看看吧!

react-hot-loader 相比

使用过 HMR 的同学,想必都有接触 react-hot-loader 可能会说,我现在用的好好的,为什么要换,这不是没事找事闲扯淡嘛?

原因以下几点

1. 官方支持

官方的支持,除了光环之外,还带来性能与稳定性保障,对 hook 更完善的支持...

How should we set up apps for HMR now that Fast Refresh replaces react-hot-loader?(官方给出的简要使用教程)

2. 更低的侵入性

不必在项目代码中用 hot(App) 的形式来标记组件,或是在 webpack.entry 中注入额外代码

3. react-hot-loader 官方退役声明

引用官方声明

React-Hot-Loader is expected to be replaced by React Fast Refresh. Please remove React-Hot-Loader if Fast Refresh is currently supported on your environment.

翻译:预计 React-Hot-Loader 将被 React快速刷新 取代。如果您的环境支持 Fast Refresh,那么请删除 React-Hot-Loader。

注意事项

  • react-refresh 最低支持版本 react-dom@16.9+,如果添加到老项目中后,热更新没有生效,那么首先请确认版本是否满足要求。
  • 入口文件不支持 HMR,如果在入口文件中直接修改组件,会降级为 LiveReload。
  • webpack 的 externals 配置项会导致 react-refresh 失效,在 dev环境下可以先关闭配置。

最后

希望在两分钟内看完的同学都学会了,实践出真知嘛,最好要在项目中实际使用一番。

当然,历史遗留项目要在具体评估后,再决定是否使用,以免翻车。

如果你发现了其他关于 react-refresh 的问题或想法,可以在评论里补充,共同填坑,让其他同学也能早点下班,少修福报。

参考资料

查看原文

joe_sky 赞了文章 · 3月2日

一分钟用上热更新 React Fast Refresh(react-refresh)

预计阅读本文只需要两分钟,一分钟看文章,一分钟跑代码 nice!

什么是React Fast Refresh?

React Fast Refresh 是 React 官方为 React Native 开发的模块热替换(HMR)方案,由于其核心实现与平台无关,所以也适用于 Web。

官方实现为 react-refresh

快速上手

不浪费时间,请跟着我在 1分钟 内构建一个支持 React Fast Refresh 的应用。

安装依赖

主要使用下面两个 npm 包(可以跳过,直接拷贝下文的 package.json

npm install @pmmmwh/react-refresh-webpack-plugin react-refresh

webpack.config.js

const ReactRefreshPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: './index.js',
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: 'babel-loader',
        options: {
          presets: ['@babel/env', '@babel/preset-react'],
          plugins: [require.resolve('react-refresh/babel')], // 为 react-refresh 添加
        },
      },
    ],
  },
  plugins: [
    new ReactRefreshPlugin(),    // 为 react-refresh 添加
    new HtmlWebpackPlugin({
      template: './index.html',
    }),
  ],
};

index.js

import * as React from 'react';
import { render } from 'react-dom';
import App from './app'

render(<App />, document.getElementById('app'));

app.js

import React from "react";

function App() {
  return <h1>hello world!</h1>
}

export default App;

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React App</title>
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>

package.json

{
  "name": "react-refresh-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "start": "webpack-dev-server --hot"
  },
  "dependencies": {
    "react": "^16.13.1",
    "react-dom": "^16.13.1"
  },
  "devDependencies": {
    "@babel/core": "^7.10.5",
    "@babel/preset-env": "^7.10.4",
    "@babel/preset-react": "^7.10.4",
    "@pmmmwh/react-refresh-webpack-plugin": "^0.4.1",
    "babel-loader": "^8.1.0",
    "html-webpack-plugin": "^4.3.0",
    "react-refresh": "^0.8.3",
    "webpack": "^4.44.0",
    "webpack-cli": "^3.3.12",
    "webpack-dev-server": "^3.11.0"
  }
}

最后

安装 npm install

启动 npm run start

你肯定已经顺利跑起来了

那尝试修改 App.js 看看吧!

react-hot-loader 相比

使用过 HMR 的同学,想必都有接触 react-hot-loader 可能会说,我现在用的好好的,为什么要换,这不是没事找事闲扯淡嘛?

原因以下几点

1. 官方支持

官方的支持,除了光环之外,还带来性能与稳定性保障,对 hook 更完善的支持...

How should we set up apps for HMR now that Fast Refresh replaces react-hot-loader?(官方给出的简要使用教程)

2. 更低的侵入性

不必在项目代码中用 hot(App) 的形式来标记组件,或是在 webpack.entry 中注入额外代码

3. react-hot-loader 官方退役声明

引用官方声明

React-Hot-Loader is expected to be replaced by React Fast Refresh. Please remove React-Hot-Loader if Fast Refresh is currently supported on your environment.

翻译:预计 React-Hot-Loader 将被 React快速刷新 取代。如果您的环境支持 Fast Refresh,那么请删除 React-Hot-Loader。

注意事项

  • react-refresh 最低支持版本 react-dom@16.9+,如果添加到老项目中后,热更新没有生效,那么首先请确认版本是否满足要求。
  • 入口文件不支持 HMR,如果在入口文件中直接修改组件,会降级为 LiveReload。
  • webpack 的 externals 配置项会导致 react-refresh 失效,在 dev环境下可以先关闭配置。

最后

希望在两分钟内看完的同学都学会了,实践出真知嘛,最好要在项目中实际使用一番。

当然,历史遗留项目要在具体评估后,再决定是否使用,以免翻车。

如果你发现了其他关于 react-refresh 的问题或想法,可以在评论里补充,共同填坑,让其他同学也能早点下班,少修福报。

参考资料

查看原文

赞 5 收藏 2 评论 1

joe_sky 收藏了文章 · 2020-12-17

深入了解babel(二)

接着上一篇文章《深入了解babel(一)》

Babel 的处理步骤

Babel 的三个主要处理步骤分别是: 解析(parse),转换(transform),生成(generate)。对应着babel-core源码中分别用到的babylon、babel-traverse、babel-generator。

(1)Babylon

Babylon 是 Babel 的解析器。最初是 从Acorn项目fork出来的。Acorn非常快,易于使用。

import * as babylon from "babylon";

const code = `function square(n) {
  return n * n;
}`;

babylon.parse(code);
// Node {
//   type: "File",
//   start: 0,
//   end: 38,
//   loc: SourceLocation {...},
//   program: Node {...},
//   comments: [],
//   tokens: [...]
// }

(2)babel-traverse

Babel Traverse(遍历)模块维护了整棵树的状态,并且负责替换、移除和添加节点。我们可以和 Babylon 一起使用来遍历和更新节点。

import * as babylon from "babylon";
import traverse from "babel-traverse";

const code = `function square(n) {
  return n * n;
}`;

const ast = babylon.parse(code);

traverse(ast, {
  enter(path) {
    if (
      path.node.type === "Identifier" &&
      path.node.name === "n"
    ) {
      path.node.name = "x";
    }
  }
});

(3)babel-generator

Babel Generator模块是 Babel 的代码生成器,它读取AST并将其转换为代码和源码映射

import * as babylon from "babylon";
import generate from "babel-generator";

const code = `function square(n) {
  return n * n;
}`;

const ast = babylon.parse(code);

generate(ast, {}, code);
// {
//   code: "...",
//   map: "..."
// }

抽象语法树(AST)

ast抽象语法树在以上三个神器中都出现过,所以ast对于编译器来说至关重要。以下列举了一些ast的应用:

  • 浏览器会把js源码通过解析器转为抽象语法树,再进一步转化为字节码或直接生成机器码
  • JSLint、JSHint对代码错误或风格的检查,发现一些潜在的错误
  • IDE的错误提示、格式化、高亮、自动补全等等
  • UglifyJS
  • 代码打包工具webpack、rollup
  • CoffeeScript、TypeScript、JSX等转化为原生Javascript

自己动手写插件

presets预设就是关于一系列插件的集合,presets的存在减少了babelrc配置文件的体积,不用看到一大堆的插件数组,并且保证了每个用户配置的插件清单一模一样,所以插件对于babel来说至关重要,前端开发者如何开发一个自定义插件决定了今后对代码编译的掌控程度,babel插件就像一把手术刀对js源码进行精准、可靠的改装。
本人在写练习写插件的过程中主要用到了以下两个方法:

引用babel-core模块进行编码方式如下:

const {transform,generate}=require('babel-core');
const myPlugin=require('./myPlugin');

const code = `d = a + b + c`;

var es5Code = transform(code, {
  plugins: [myPlugin]
})
console.log(es5Code.code);

ast explorer

本人比较青睐的babel插件在线编写方式,可以实时看到编译后的结果以及对应的AST部分,结合babel-types可以很快的写出手术刀式的插件,下面这张图是ast explorer解析出来的json:
clipboard.png

插件编写第一站 -- 认识path

export default function (babel) {
  const {types:t}=babel;
  return {
    name: "可有可无的插件名字",
    visitor: {
      VariableDeclaration(path,state){
          console.log(path);
      }
    },
  };
  
}

每一个插件都要返回带有visitor字段的对象,而visitor对象中存放你的遍历方法,本人总结为等价于上面ast explorer截图中的type属性(例如:VariableDeclaration),遍历方法是指插件根据遍历方法让ast中的节点走进你写的遍历方法函数中。遍历方法就像js中的addeventlistener,可以重复写多个监听函数,所以当多个插件叠合在一起就会出现一些不可预料的事情,这是考验你插件编写是否安全、可靠的事情,也是最难的部分。

举一个最简单的例子,如何删除代码中的所有console?

let a=33;
console.log(12121212);
var b;
console.warn(12121212);
aaaa,cccc
console.error(12121212);
dd=0;
let c;
export default function ({types:t}) {
  return {
    name: "删除所有的console",
    visitor: {
      CallExpression(path,state){
          if(path.get('callee').isMemberExpression()){
               if(path.get('callee').get('object').isIdentifier()){
                          if(path.get('callee').get('object').get('name').node=='console')path.remove()
               }
        }
      }
    },
  };
  
}

CallExpression遍历方法也就是console.log(...)对应的AST type属性,当走进CallExpression函数后,我们可以获取path和state两个参数,path包含了当前节点的相关信息,按照前端的思维可以理解为dom节点,可以往上或者往下查找节点,当前节点path包含了很多信息,方便我们编写插件,而state中包含了插件的options和数据,options就是babelrc中plugins引入插件时,添加的options,在state中可以接收到它。

刚开始写插件的时候,完全当成dom节点直接获取节点中的信息是非常危险的(我也是看了babel多个插件后知道的),每往下取一个信息时都要去判断这个类型是否跟我们的ast树一样,这样就可以去除掉其他的情况,例如其他的CallExpression也走到这个函数中了,但是它可能并没有callee或者object,代码执行到这边就会出错或者误伤,严谨的控制节点获取流程将会帮助我们省去很多不必要的麻烦。

代码中获取callee节点可以有两种方式,一种是path.node.callee,还有一种是path.get('callee'),个人比较喜欢后者,因为可以直接调用方法(例如isMemberExpression),否则你就要像这样去判断t.isMemberExpression(path.node.callee),不够优雅。

当我们条件判断到当前node是console,直接用remove方法就可以删除ast节点了,编译后的代码:

let a=33;
var b;
aaaa,cccc
dd=0;
let c;

babel官方已经发布了一个删除console的插件,可以对比下发现,思路和步骤基本一致,babel官方开发的更加全面,考虑了其他两个情况。

插件编写第二站 -- 作用域的影响

function a(n){
    n*n
}
let n=1

考虑下如何改写函数中n变成_n?

export default function ({ types: t }) {
  let paramsName='';
  
  return {
    name: "给function中的参数加上下划线",
    visitor: {
      FunctionDeclaration(path) {
        if(!path.get('params').length||!path.get('params')[0])return;
        paramsName=path.get('params')[0].get('name').node;
        path.traverse({
          Identifier(path){
            if(path.get('name').node==paramsName)path.replaceWith(t.Identifier('_'+paramsName));
          }
        });
        
      },
      
    }
  };
}

按照第一个例子的思路,我们很容易就可以把n给改成_n,但是这时候fucntion外面的let n=1,也会被改写,所以我们在FunctionDeclaration方法中调用了path.traverse,把需要遍历的方法Identifier包裹在其中,这样就保护了外面代码的安全,这种方式保证了插件编写的安全性

插件编写第三站 -- bindings

const aaaa=1;
const bb=4;
function b(){
    let aaaa=2;
      aaaa=3;
}
aaaa=34;

让我们来接着做另外一个例子,如何将const改成var,并且对const声明的值给予只读保护?

export default function (babel, options) {
  return {
    name: "const polyfill",
    visitor: {
      VariableDeclaration(path) {
        if(path.get('kind').node!='const')return;
        path.node.kind='var';
      },
      ExpressionStatement(path){
          if(!path.get('expression').isAssignmentExpression())return;
        let nodeleft=path.get('expression').get('left');
          if(!nodeleft.isIdentifier())return;
        if(path.scope.bindings[nodeleft.get('name').node].kind=='const')console.error('Assignment to constant variable');
      }
    },
  };
  
}

VariableDeclaration方法中将const改成了let,ExpressionStatement方法中用来观察const的变量是否被修改,由于function有自己的作用域,所以aaaa可以被重新声明和修改,这里用到了bindings属性,可以查看该节点的变量申明类型,当发现kind为const时才发出error警告,这个例子是对bindings的一次应用。

插件编写第四站 -- 创建节点

当我们替换一个节点或者插入一个节点到容器中,我们需要按照节点的构建规则来创建,下面的例子是将n*n修改成n+100

function square(n) {
   return n * n;
}

先给出答案,代码如下:

export default function ({types:t}) {
  return {
    name: "将n*n修改成n+100",
    visitor: {
      BinaryExpression(path){
          path.replaceWith(t.binaryExpression('+', path.node.left, t.Identifier('100')));
        path.stop();
      }
    },
  };
}

现在我们要把BinaryExpression这个type的节点给替换掉,就要按照BinaryExpression节点的规则来创建,可以参考babel-types网站的说明文档:

clipboard.png

我们需要分别构建operator、left、right这三种类型的节点,再查看ast中对这三个节点的描述

clipboard.png

OK,left和right都是Identifier类型,而operator是字符串,字符串直接写入“+”就可以替换掉了,而Identifier类型节点的创建还要查看babel-types给出的文档:

clipboard.png

我们只要给出string类型的name就可以了,所以我们可以成功创建自己的节点了。

总结

ast explorer真的是一个很好的网站,并且可以在插件中写console,可以在控制台中实时看到console的结果,对我们理解ast节点用很大的帮助,另外以上介绍插件的例子还是太少,插件编写要注意的远不止这些方面,但是本人没时间想出那么多的例子来很好的介绍,所以大家可以直接阅读这篇文档来深入了解。

查看原文

joe_sky 赞了文章 · 2020-07-24

发布npm的scope包

前言

最近新装了vue-cli3,发现node_modules里面的包变成了@vue/cli-xxx,@vue下面包含了好几个文件夹,这个@是什么意思?查了下npm官网上是这么描述的

All npm packages have a name. Some package names also have a scope. A scope follows the usual rules for package names (URL-safe characters, no leading dots or underscores). When used in package names, scopes are preceded by an @ symbol and followed by a slash, e.g.

就是说scope相当于npm包的命名空间,如果以@开头,那它就是一个scope package。
这样分类之后就会使结构更加清晰,比如@vue下面的包都是vue用的,相当于给包做了一个分类。

npm发布包

非scope的包

  1. 先把registry指向npmjs
npm config set registry https://registry.npmjs.org/
  1. 登录npm(如果没有账号需要先注册),输入用户名,密码和邮箱
npm login
  1. 然后在要发布的根目录下
npm publish

scope的包

1,2步骤和前面一样,第三步的时候,如果是npm publish,会出现403,如下

clipboard.png

看了官网的说明,说是要在publish的时候加--access public
运行

npm publish --access public

没想到报错了!!!

clipboard.png

为什么找不到呢,再次看下npm的官网,发现下面的描述
Each npm user/organization has their own scope, and only you can add packages in your scope. This means you don’t have to worry about someone taking your package name ahead of you. Thus it is also a good way to signal official packages for organizations.
意思就是每个npm包要有个organization作为他们的命名空间,这样才能往里面加package

organization??这是哪里创建的?登录npm的网站上,还真的看到了organization!!

clipboard.png
在这里新建一个kaisaui,看到了私有的是要收费的,公有的是免费的,当然毫不犹豫地就创建了公有的~~~

回到之前的命令行,重新执行npm

npm publish --access public

发布成功~~~
撒花~~~

因为我发布的包还会关联scope的包,把scope的包放在dependencies里就可以了,千万不要放在devDependencies,因为这个是开发用的,npm install的时候是不会装这里的包的!!

查看原文

赞 4 收藏 2 评论 0

joe_sky 收藏了文章 · 2020-07-10

用 Babel JSX 扩展来创造响应式 Ant Design 表单解决方案

大家好,两年前我曾经发布过一篇文章《使用新一代js模板引擎NornJ提升React.js开发体验》,第一次尝试推广我创作的可扩展模板引擎 NornJ 。

Babel JSX 插件的新思路

在发表那篇文章后不到一周的时间,我仔细参考了jsx-control-statements,不自觉萌生出一个新的想法:

使用 Babel 提取含特殊信息的 JSX 标签,把它们转换为需运行时的渲染函数,是否能突破 JSX 现有的语法扩展能力?

这个想法随后就被实施:babel-plugin-nornj-in-jsx,并继续应用于公司部门内的多个实际项目中。Babel转换原理描述,请看这里

NornJ 下一代版本

有了上面的转换思路,并在繁忙业务中经过两年断断续续迭代,我在今年发布了重新设计后使用 JSX API 的 NornJ 正式版,并重写了文档,源码也用 Typescript 几乎完全重写:

github:https://github.com/joe-sky/nornj

文档(gitee.io):https://joe-sky.gitee.io/nornj

文档(github.io):https://joe-sky.github.io/nornj

基于 Mobx 与 JSX 语法扩展的 Ant Design 表单解决方案

我们部门团队自2016年起一直主力使用 Mobx 作为 React 状态管理方案,几年来我们一直受益于它的响应式数据流开发体验十分高效,也很容易优化。

Mobx 适配 Antd 表单可能存在的痛点

虽然关于 Mobx 与 Redux 等谁更优不是本篇文章里要对比的,但是通过几年的使用经验,我总结出 Mobx 在配合国内最流行的 React 组件库 Ant Design 组件,特别是表单验证组件时可能存在的一些开发痛点:

  • Ant Design Form 组件推荐的数据存取方式,无法很顺畅地与 Mobx 的响应式数据流结合

Antd Form 组件原生方式使用 getFieldsValue 和 setFieldsValue (官方文档)来对数据进行存取,这在使用 Mobx 做数据流管理时会遇到一些比较尴尬的场景:

  1. 请求后端接口把返回的表单字段数据存储在了 Mobx observable 数据中,然后我们需要把这些数据用 setFieldsValue 方法放置到 Form 组件实例内,各表单组件数据会更新。但这个数据更新的过程没有用上 observable 响应式特性,感觉对使用 Mobx 来说有点浪费;
  2. 从 Form 组件中获取表单字段值时要用 getFieldsValue。这样取出来直接在 render (或 Mobx computed)中使用时,Mobx 的 observer 不会自动重渲染(重计算),可能与直觉不符:
const Demo = () => {
  const [form] = Form.useForm();

  return useObserver(() => (
    <div>
      <Form form={form} name="control-hooks">
        <Form.Item name="note" label="Note" rules={[{ required: true }]}>
          <Input />
        </Form.Item>
        <Form.Item name="gender" label="Gender" rules={[{ required: true }]}>
          <Select placeholder="Select a option and change input text above" allowClear>
            <Option value="male">male</Option>
            <Option value="female">female</Option>
            <Option value="other">other</Option>
          </Select>
        </Form.Item>
      </Form>
      //表单值更新时,以下文字不会更新
      <i>Note:{form.getFieldValue('note')}</i>
      <i>Gender:{form.getFieldValue('gender')}</i>
    </div>
  ));
};

当然,上述场景是有办法解决的。但是无论怎样解决,我们都会感觉到有两份数据存在:Mobx 状态的数据、以及表单自己的数据。对适应了 Mobx 响应式数据流的开发人员来说,可能会觉得麻烦。

  • 部分 Mobx 的 observable 数据,在传入 Ant Design Form 表单组件时需要执行 toJS 转换

这可能是 Mobx observable 这种包装数据类型的硬伤,但像 CheckBox.Group 组件这种,每次传入组件的值都手工执行一次 toJS 转换值为普通数组,也确实有点麻烦。

寻找 Mobx 环境的表单方案 - mobx-react-form

我们可以找到现有的解决方案:mobx-react-form

它与 Antd Form 基于组件内管理数据的思路是不一样的。mobx-react-form 把表单数据、验证状态等都交给一个含 Mobx observable 成员的特殊结构实例来管理,再通过 JSX 延展操作符 API 通知到 Form 相关组件。一个简单的例子:

import React from 'react';
import { observer } from 'mobx-react';
import MobxReactForm from 'mobx-react-form';

const fields = [{
  name: 'email',
  label: 'Email',
  placeholder: 'Insert Email',
  rules: 'required|email|string|between:5,25',
}, {
  name: 'password',
  label: 'Password',
  placeholder: 'Insert Password',
  rules: 'required|string|between:5,25',
}];
const myForm = new MobxReactForm({ fields });

export default observer(({ myForm }) => (
  <form onSubmit={myForm.onSubmit}>
    <label htmlFor={myForm.$('email').id}>
      {myForm.$('email').label}
    </label>
    <input {...myForm.$('email').bind()} />
    <p>{myForm.$('email').error}</p>
    <button type="submit" onClick={myForm.onSubmit}>Submit</button>
    <button type="button" onClick={myForm.onClear}>Clear</button>
    <button type="button" onClick={myForm.onReset}>Reset</button>
    <p>{myForm.error}</p>
  </form>
));

mobx-react-form 的数据管理思路无疑是更符合 Mobx 响应式数据流的。虽然官方没给例子,但它在加一些扩展后应也可适配 Antd Form 组件。但我们从上面代码不难看出,mobx-react-form 和 Antd Form 原生方式比,可能还有以下几个让人顾虑的方面:

  • 用 json 方式定义各表单字段属性,不及 Antd 的 JSX 语法更符合 React 环境的特色;
  • 用 JSX 延展操作符通知各表单组件,语法可读性可能不是太好;
  • 它的底层验证组件,并没有提供 Antd 采用的 async-validator。

基于 JSX 扩展的表单方案 - mobxFormData

参考了 mobx-react-form 的数据管理思路,我利用 NornJ 现有的 JSX 扩展能力,开发出了基于 async-validator 的解决方案:mobxFormData ,同时支持Antd v3 & v4,性能也不错。详细文档在这里

Codesandbox 示例(如果一次无法运行,多刷新几次就好)

使用方式很简单,安装 preset:

npm install babel-preset-nornj-with-antd

再配一下 Babel:

{
  "presets": [
    ...,
    "nornj-with-antd"  //通常放在所有 preset 的最后面
  ]
}

然后就可以在 JSX/TSX 内直接使用了:

import React from 'react';
import { Form, Input, Button, Checkbox } from 'antd';
import { useLocalStore, useObserver } from 'mobx-react-lite';
import 'nornj-react';

export default props => {
  const { formData } = useLocalStore(() => (
    <mobxFormData>
      <mobxFieldData name="userName" required message="Please input your username!" />
      <mobxFieldData name="password" required message="Please input your password!" />
      <mobxFieldData name="remember" />
    </mobxFormData>
  ));
  
  return useObserver(() => (
    <Form>
      <Form.Item mobxField={formData.userName} label="Username">
        <Input />
      </Form.Item>
      <Form.Item mobxField={formData.password} label="Password">
        <Input.Password />
      </Form.Item>
      <Form.Item mobxField={formData.remember}>
        <Checkbox>Remember me</Checkbox>
      </Form.Item>
    </Form>
  ));
};

如上,此方案的表单字段数据放在 <mobxFormData> 标签返回的 formData 实例中。与 mobx-react-form 思路类似,formData 是一个扁平化的 Mobx observable 数据类型,上面包含了各表单数据字段、以及各种表单数据操作 API,使用起来非常方便,可以很好地与 Mobx 数据流对接:

export default props => {
  const { formData } = useLocalStore(() => (
    <mobxFormData>
      <mobxFieldData name="userName" required message="Please input your username!" />
      <mobxFieldData name="password" required message="Please input your password!" />
    </mobxFormData>
  ));
  
  useEffect(() => {
    axios.get('/user', { params: { ID: 12345 } })
    .then(function (response) {
      const user = response.data;
      formData.userName = user.userName;
      formData.password = user.password;
    });
  }, []);
  
  //表单数据操作 api 都在 formData 实例上,可以把实例传递给其他组件
  const onSubmit = () =>
    formData
      .validate()
      .then(values => {
        console.log(values);
      })
      .catch(errorInfo => {
        console.log(errorInfo);
      });
  
  return useObserver(() => (
    <div>
      <Form>
        <Form.Item mobxField={formData.userName} label="Username">
          <Input />
        </Form.Item>
        <Form.Item mobxField={formData.password} label="Password">
          <Input.Password />
        </Form.Item>
        <Form.Item>
          <Button type="primary" onClick={onSubmit}>
            Submit
          </Button>
        </Form.Item>
      </Form>
      //表单值更新时,以下文字会实时更新
      <i>Username:{formData.userName}</i>
      <i>Password:{formData.password}</i>
    </div>
  ));
};
  • 这里用到的 mobxFormData 是一种 JSX 扩展:标签,它被 Babel 转换后的实际值并不是 React.createElement 方法,而只是返回了特殊的对象结构,供 Mobx 转换为 observable 类型,转换原理请看这里
  • 而 mobxField 是另一种 JSX 扩展:指令,使用它将 formData 实例与 Form.Item 组件建立双向数据绑定。在 mobxField 指令的底层实现中,通过配置对不同的 Antd 表单元素组件选取了特定的值属性、事件属性等进行自动更新,并且已经在该转换时调用过 Mobx 的 toJS 方法了,无需再手工 toJS。

mobxFormData 方案的语法整体看起来,和 React JSX 环境感觉也比较契合,IDE 语法提示也是完整的。除了语法,它的各方面功能其实也挺全面,Antd 原生 Form 能实现的它也几乎都能实现。具体可以看它的文档和示例

mobxFormData 的各种表单示例文档

为了更好地服务于开发者,mobxFormData 方案按照 antd v4 版官方文档,重写了其中10多个可运行示例文档,并使用 Dumi 部署在 NornJ 的文档站点中:mobxFormData 表单示例文档

大家可以拿它和 antd 官方表单示例文档 做下对比,其实可以看出在同样功能的情况下,mobxFormData 的代码量通常会更少一些。

mobxFormData 能用于生产环境吗

mobxFormData 方案在我司大部门内已有多个线上实际项目在用,所以我觉得如果您认为它对您的开发体验有好处,或有兴趣尝试,则可以用于生产环境。作者也会一直坚持更新这个项目,如果发现问题非常欢迎您的反馈。

关于 JSX 扩展,一些作者的经验

最后,依作者的实践经验,总结出一些作者认为的目前 JSX 扩展方案可行经验,在此分享给大家:

经验一:JSX 扩展其实能支持 IDE 代码提示

在一些文章评论中,我记得不只一次看到过有人提过: Babel 做的 JSX 扩展是否会无法与现有的 Eslint 与 IDE 语法提示环境融合。这里可以给出一个结论:JSX 扩展其实绝大多数都可以支持 IDE 语法提示

而方法就是使用 Typescript,只要掌握一些 TS 重写类型的知识即可,定义在 global.d.ts 内。例如:

const Test = () => <customDiv id="test">test</customDiv>

为上面的 customDiv 标签补上 TS 类型,只要这样:

interface ICustomDiv {
  id: string;
}

declare namespace JSX {
  interface IntrinsicElements {
    /**
     * customDiv tag
     */
    customDiv: ICustomDiv;
  }
}

指令的话,例如:

const Test = () => <div customId="test">test</div>

TS 这样写就可以:

declare namespace JSX {
  interface IntrinsicAttributes {
    /**
     * customId directive
     */
    customId?: string;  //因为每个组件都可能用到,为不影响类型检查,所以定义为可选的
  }
}

NornJ 项目所有的预置 JSX 扩展都是这样来定义类型,代码可以看这里。Eslint 的话,如果 TS 类型定义好了它通常不会受影响,但可能用到未使用的变量等,这时也不难处理简单加个配置就好,配置方法可以看这里

经验二:React 用双向数据绑定的场景其实不等于用指令语法

还有些观点觉得 “双向绑定” 这个概念,似乎在 React 环境中出现会是一种不合时宜的场景。

双向绑定的含义理解起来是视图组件和数据模型之间建立的绑定关系,它们会双向同步更新。这种场景 React 中也可能会存在,像 Antd 的 Form 组件,从早期版本直到最新的 V4 版,在我看来它的数据管理方式其实一直都类似于双向数据绑定,但并没有用指令方式 API 实现。从它的官方文档中,也一直可以看到对双向绑定的描述

对于指令的实现,不同的 Babel JSX 扩展项目的实现也不同,大多数是语法糖转换;也有比较特殊的,比如 NornJ 的mobxBind 指令,它的实现其实是一个React 高阶组件。所以说 API 只是形式,并不一定代表底层实现。

经验三:目前有哪些现存的 JSX 语法扩展方案

这个领域确实比较偏,以下是作者这些年来见过的几个 Babel JSX 扩展项目,它们都提供了流程控制等常见 JSX 扩展:

目前作者已知的可扩展 Babel JSX 插件:

查看原文

joe_sky 发布了文章 · 2020-07-09

用 Babel JSX 扩展来创造响应式 Ant Design 表单解决方案

大家好,两年前我曾经发布过一篇文章《使用新一代js模板引擎NornJ提升React.js开发体验》,第一次尝试推广我创作的可扩展模板引擎 NornJ 。

Babel JSX 插件的新思路

在发表那篇文章后不到一周的时间,我仔细参考了jsx-control-statements,不自觉萌生出一个新的想法:

使用 Babel 提取含特殊信息的 JSX 标签,把它们转换为需运行时的渲染函数,是否能突破 JSX 现有的语法扩展能力?

这个想法随后就被实施:babel-plugin-nornj-in-jsx,并继续应用于公司部门内的多个实际项目中。Babel转换原理描述,请看这里

NornJ 下一代版本

有了上面的转换思路,并在繁忙业务中经过两年断断续续迭代,我在今年发布了重新设计后使用 JSX API 的 NornJ 正式版,并重写了文档,源码也用 Typescript 几乎完全重写:

github:https://github.com/joe-sky/nornj

文档(gitee.io):https://joe-sky.gitee.io/nornj

文档(github.io):https://joe-sky.github.io/nornj

基于 Mobx 与 JSX 语法扩展的 Ant Design 表单解决方案

我们部门团队自2016年起一直主力使用 Mobx 作为 React 状态管理方案,几年来我们一直受益于它的响应式数据流开发体验十分高效,也很容易优化。

Mobx 适配 Antd 表单可能存在的痛点

虽然关于 Mobx 与 Redux 等谁更优不是本篇文章里要对比的,但是通过几年的使用经验,我总结出 Mobx 在配合国内最流行的 React 组件库 Ant Design 组件,特别是表单验证组件时可能存在的一些开发痛点:

  • Ant Design Form 组件推荐的数据存取方式,无法很顺畅地与 Mobx 的响应式数据流结合

Antd Form 组件原生方式使用 getFieldsValue 和 setFieldsValue (官方文档)来对数据进行存取,这在使用 Mobx 做数据流管理时会遇到一些比较尴尬的场景:

  1. 请求后端接口把返回的表单字段数据存储在了 Mobx observable 数据中,然后我们需要把这些数据用 setFieldsValue 方法放置到 Form 组件实例内,各表单组件数据会更新。但这个数据更新的过程没有用上 observable 响应式特性,感觉对使用 Mobx 来说有点浪费;
  2. 从 Form 组件中获取表单字段值时要用 getFieldsValue。这样取出来直接在 render (或 Mobx computed)中使用时,Mobx 的 observer 不会自动重渲染(重计算),可能与直觉不符:
const Demo = () => {
  const [form] = Form.useForm();

  return useObserver(() => (
    <div>
      <Form form={form} name="control-hooks">
        <Form.Item name="note" label="Note" rules={[{ required: true }]}>
          <Input />
        </Form.Item>
        <Form.Item name="gender" label="Gender" rules={[{ required: true }]}>
          <Select placeholder="Select a option and change input text above" allowClear>
            <Option value="male">male</Option>
            <Option value="female">female</Option>
            <Option value="other">other</Option>
          </Select>
        </Form.Item>
      </Form>
      //表单值更新时,以下文字不会更新
      <i>Note:{form.getFieldValue('note')}</i>
      <i>Gender:{form.getFieldValue('gender')}</i>
    </div>
  ));
};

当然,上述场景是有办法解决的。但是无论怎样解决,我们都会感觉到有两份数据存在:Mobx 状态的数据、以及表单自己的数据。对适应了 Mobx 响应式数据流的开发人员来说,可能会觉得麻烦。

  • 部分 Mobx 的 observable 数据,在传入 Ant Design Form 表单组件时需要执行 toJS 转换

这可能是 Mobx observable 这种包装数据类型的硬伤,但像 CheckBox.Group 组件这种,每次传入组件的值都手工执行一次 toJS 转换值为普通数组,也确实有点麻烦。

寻找 Mobx 环境的表单方案 - mobx-react-form

我们可以找到现有的解决方案:mobx-react-form

它与 Antd Form 基于组件内管理数据的思路是不一样的。mobx-react-form 把表单数据、验证状态等都交给一个含 Mobx observable 成员的特殊结构实例来管理,再通过 JSX 延展操作符 API 通知到 Form 相关组件。一个简单的例子:

import React from 'react';
import { observer } from 'mobx-react';
import MobxReactForm from 'mobx-react-form';

const fields = [{
  name: 'email',
  label: 'Email',
  placeholder: 'Insert Email',
  rules: 'required|email|string|between:5,25',
}, {
  name: 'password',
  label: 'Password',
  placeholder: 'Insert Password',
  rules: 'required|string|between:5,25',
}];
const myForm = new MobxReactForm({ fields });

export default observer(({ myForm }) => (
  <form onSubmit={myForm.onSubmit}>
    <label htmlFor={myForm.$('email').id}>
      {myForm.$('email').label}
    </label>
    <input {...myForm.$('email').bind()} />
    <p>{myForm.$('email').error}</p>
    <button type="submit" onClick={myForm.onSubmit}>Submit</button>
    <button type="button" onClick={myForm.onClear}>Clear</button>
    <button type="button" onClick={myForm.onReset}>Reset</button>
    <p>{myForm.error}</p>
  </form>
));

mobx-react-form 的数据管理思路无疑是更符合 Mobx 响应式数据流的。虽然官方没给例子,但它在加一些扩展后应也可适配 Antd Form 组件。但我们从上面代码不难看出,mobx-react-form 和 Antd Form 原生方式比,可能还有以下几个让人顾虑的方面:

  • 用 json 方式定义各表单字段属性,不及 Antd 的 JSX 语法更符合 React 环境的特色;
  • 用 JSX 延展操作符通知各表单组件,语法可读性可能不是太好;
  • 它的底层验证组件,并没有提供 Antd 采用的 async-validator。

基于 JSX 扩展的表单方案 - mobxFormData

参考了 mobx-react-form 的数据管理思路,我利用 NornJ 现有的 JSX 扩展能力,开发出了基于 async-validator 的解决方案:mobxFormData ,同时支持Antd v3 & v4,性能也不错。详细文档在这里

Codesandbox 示例(如果一次无法运行,多刷新几次就好)

使用方式很简单,安装 preset:

npm install babel-preset-nornj-with-antd

再配一下 Babel:

{
  "presets": [
    ...,
    "nornj-with-antd"  //通常放在所有 preset 的最后面
  ]
}

然后就可以在 JSX/TSX 内直接使用了:

import React from 'react';
import { Form, Input, Button, Checkbox } from 'antd';
import { useLocalStore, useObserver } from 'mobx-react-lite';
import 'nornj-react';

export default props => {
  const { formData } = useLocalStore(() => (
    <mobxFormData>
      <mobxFieldData name="userName" required message="Please input your username!" />
      <mobxFieldData name="password" required message="Please input your password!" />
      <mobxFieldData name="remember" />
    </mobxFormData>
  ));
  
  return useObserver(() => (
    <Form>
      <Form.Item mobxField={formData.userName} label="Username">
        <Input />
      </Form.Item>
      <Form.Item mobxField={formData.password} label="Password">
        <Input.Password />
      </Form.Item>
      <Form.Item mobxField={formData.remember}>
        <Checkbox>Remember me</Checkbox>
      </Form.Item>
    </Form>
  ));
};

如上,此方案的表单字段数据放在 <mobxFormData> 标签返回的 formData 实例中。与 mobx-react-form 思路类似,formData 是一个扁平化的 Mobx observable 数据类型,上面包含了各表单数据字段、以及各种表单数据操作 API,使用起来非常方便,可以很好地与 Mobx 数据流对接:

export default props => {
  const { formData } = useLocalStore(() => (
    <mobxFormData>
      <mobxFieldData name="userName" required message="Please input your username!" />
      <mobxFieldData name="password" required message="Please input your password!" />
    </mobxFormData>
  ));
  
  useEffect(() => {
    axios.get('/user', { params: { ID: 12345 } })
    .then(function (response) {
      const user = response.data;
      formData.userName = user.userName;
      formData.password = user.password;
    });
  }, []);
  
  //表单数据操作 api 都在 formData 实例上,可以把实例传递给其他组件
  const onSubmit = () =>
    formData
      .validate()
      .then(values => {
        console.log(values);
      })
      .catch(errorInfo => {
        console.log(errorInfo);
      });
  
  return useObserver(() => (
    <div>
      <Form>
        <Form.Item mobxField={formData.userName} label="Username">
          <Input />
        </Form.Item>
        <Form.Item mobxField={formData.password} label="Password">
          <Input.Password />
        </Form.Item>
        <Form.Item>
          <Button type="primary" onClick={onSubmit}>
            Submit
          </Button>
        </Form.Item>
      </Form>
      //表单值更新时,以下文字会实时更新
      <i>Username:{formData.userName}</i>
      <i>Password:{formData.password}</i>
    </div>
  ));
};
  • 这里用到的 mobxFormData 是一种 JSX 扩展:标签,它被 Babel 转换后的实际值并不是 React.createElement 方法,而只是返回了特殊的对象结构,供 Mobx 转换为 observable 类型,转换原理请看这里
  • 而 mobxField 是另一种 JSX 扩展:指令,使用它将 formData 实例与 Form.Item 组件建立双向数据绑定。在 mobxField 指令的底层实现中,通过配置对不同的 Antd 表单元素组件选取了特定的值属性、事件属性等进行自动更新,并且已经在该转换时调用过 Mobx 的 toJS 方法了,无需再手工 toJS。

mobxFormData 方案的语法整体看起来,和 React JSX 环境感觉也比较契合,IDE 语法提示也是完整的。除了语法,它的各方面功能其实也挺全面,Antd 原生 Form 能实现的它也几乎都能实现。具体可以看它的文档和示例

mobxFormData 的各种表单示例文档

为了更好地服务于开发者,mobxFormData 方案按照 antd v4 版官方文档,重写了其中10多个可运行示例文档,并使用 Dumi 部署在 NornJ 的文档站点中:mobxFormData 表单示例文档

大家可以拿它和 antd 官方表单示例文档 做下对比,其实可以看出在同样功能的情况下,mobxFormData 的代码量通常会更少一些。

mobxFormData 能用于生产环境吗

mobxFormData 方案在我司大部门内已有多个线上实际项目在用,所以我觉得如果您认为它对您的开发体验有好处,或有兴趣尝试,则可以用于生产环境。作者也会一直坚持更新这个项目,如果发现问题非常欢迎您的反馈。

关于 JSX 扩展,一些作者的经验

最后,依作者的实践经验,总结出一些作者认为的目前 JSX 扩展方案可行经验,在此分享给大家:

经验一:JSX 扩展其实能支持 IDE 代码提示

在一些文章评论中,我记得不只一次看到过有人提过: Babel 做的 JSX 扩展是否会无法与现有的 Eslint 与 IDE 语法提示环境融合。这里可以给出一个结论:JSX 扩展其实绝大多数都可以支持 IDE 语法提示

而方法就是使用 Typescript,只要掌握一些 TS 重写类型的知识即可,定义在 global.d.ts 内。例如:

const Test = () => <customDiv id="test">test</customDiv>

为上面的 customDiv 标签补上 TS 类型,只要这样:

interface ICustomDiv {
  id: string;
}

declare namespace JSX {
  interface IntrinsicElements {
    /**
     * customDiv tag
     */
    customDiv: ICustomDiv;
  }
}

指令的话,例如:

const Test = () => <div customId="test">test</div>

TS 这样写就可以:

declare namespace JSX {
  interface IntrinsicAttributes {
    /**
     * customId directive
     */
    customId?: string;  //因为每个组件都可能用到,为不影响类型检查,所以定义为可选的
  }
}

NornJ 项目所有的预置 JSX 扩展都是这样来定义类型,代码可以看这里。Eslint 的话,如果 TS 类型定义好了它通常不会受影响,但可能用到未使用的变量等,这时也不难处理简单加个配置就好,配置方法可以看这里

经验二:React 用双向数据绑定的场景其实不等于用指令语法

还有些观点觉得 “双向绑定” 这个概念,似乎在 React 环境中出现会是一种不合时宜的场景。

双向绑定的含义理解起来是视图组件和数据模型之间建立的绑定关系,它们会双向同步更新。这种场景 React 中也可能会存在,像 Antd 的 Form 组件,从早期版本直到最新的 V4 版,在我看来它的数据管理方式其实一直都类似于双向数据绑定,但并没有用指令方式 API 实现。从它的官方文档中,也一直可以看到对双向绑定的描述

对于指令的实现,不同的 Babel JSX 扩展项目的实现也不同,大多数是语法糖转换;也有比较特殊的,比如 NornJ 的mobxBind 指令,它的实现其实是一个React 高阶组件。所以说 API 只是形式,并不一定代表底层实现。

经验三:目前有哪些现存的 JSX 语法扩展方案

这个领域确实比较偏,以下是作者这些年来见过的几个 Babel JSX 扩展项目,它们都提供了流程控制等常见 JSX 扩展:

目前作者已知的可扩展 Babel JSX 插件:

查看原文

赞 2 收藏 2 评论 0

joe_sky 赞了文章 · 2020-06-02

React源码解析之React.createRef()/forwardRef()

一、React.createRef()
GitHub:
https://github.com/AttackXiaoJinJin/reactExplain/blob/master/react16.8.6/packages/react/src/ReactCreateRef.js

作用:
获取目标elementDOM实例

使用:

import React from 'react'

export default class Father extends  React.Completed{
  constructor(props){
    super(props)
    this.father=React.createRef()
  }

  componentDidMount(){
    this.father.current.value='hahhaha'
  }

  render(){
    return <div ref={this.father}>
      this is div
    </div>
  }
  
}

源码:

import type {RefObject} from 'shared/ReactTypes';

// an immutable object with a single mutable value
//可修改value的 不可变的对象
//没见过这种写法 :RefObject
export function createRef(): RefObject {
  //初始化ref对象,属性current初始值为null
  const refObject = {
    current: null,
  };
  if (__DEV__) {
    Object.seal(refObject);
  }
  return refObject;
}

解析:
源码比较简单,就是返回了带有current属性的refObject


二、React.forwardRef()
GitHub:
https://github.com/AttackXiaoJinJin/reactExplain/blob/master/react16.8.6/packages/react/src/forwardRef.js

作用:
从父组件中获取子组件是FunctionComponentDOM实例

使用:

import React from 'react'
//funciton component是没有dom实例的,因为它是PureComponent,所以没有this,
// 所以不能通过createRef()来拿到实例

//将Father的father传给子组件,并绑定子组件的DOM实例,从而能在父组件拿到子组件的DOM实例
const Child=React.forwardRef((props,ref)=>{
  return <div ref={ref}>child div</div>
})

export default class Father extends  React.Completed{
  constructor(props){
    super(props)
    this.father=React.createRef()
  }

  componentDidMount(){
    this.father.current.value='hahhaha'
  }

  render(){
    return <Child ref={this.father} />
  }

}

源码:

import {REACT_FORWARD_REF_TYPE, REACT_MEMO_TYPE} from 'shared/ReactSymbols';

import warningWithoutStack from 'shared/warningWithoutStack';

export default function forwardRef<Props, ElementType: React$ElementType>(
  render: (props: Props, ref: React$Ref<ElementType>) => React$Node,
) {
  //__DEV__可不看
  if (__DEV__) {
    if (render != null && render.$$typeof === REACT_MEMO_TYPE) {
      warningWithoutStack(
        false,
        'forwardRef requires a render function but received a `memo` ' +
          'component. Instead of forwardRef(memo(...)), use ' +
          'memo(forwardRef(...)).',
      );
    } else if (typeof render !== 'function') {
      warningWithoutStack(
        false,
        'forwardRef requires a render function but was given %s.',
        render === null ? 'null' : typeof render,
      );
    } else {
      warningWithoutStack(
        // Do not warn for 0 arguments because it could be due to usage of the 'arguments' object
        render.length === 0 || render.length === 2,
        'forwardRef render functions accept exactly two parameters: props and ref. %s',
        render.length === 1
          ? 'Did you forget to use the ref parameter?'
          : 'Any additional parameter will be undefined.',
      );
    }

    if (render != null) {
      warningWithoutStack(
        render.defaultProps == null && render.propTypes == null,
        'forwardRef render functions do not support propTypes or defaultProps. ' +
          'Did you accidentally pass a React component?',
      );
    }
  }

  return {
    //被forwardRef包裹后,组件内部的$$typeof是REACT_FORWARD_REF_TYPE
    $$typeof: REACT_FORWARD_REF_TYPE,
    //render即包装的FunctionComponent,ClassComponent是不用forwardRef的
    render,
  };
}

解析:
(1)不看__DEV__的话,返回的也是一个Object,也就是说,ChildforwardRef包裹后,React.forwardRef(Child)$$typeofREACT_FORWARD_REF_TYPE

注意:
一旦在Father组件中,用JSX引用了Child组件,那么就是React.createElement(React.forwardRef(Child)),又包裹了一层,此时的$$typeof`是`REACT_ELEMENT_TYPE`,`type`是`React.forwardRef(Child)`,`type`里面的`$$typeofREACT_FORWARD_REF_TYPE

const ReactElement = function(type,...) {
  const element = {
    $$typeof: REACT_ELEMENT_TYPE,
    type: type,
  };
}

(2)关于forward在高阶组件的用法,请参考:https://reactjs.org/docs/react-api.html#reactforwardref

(3)如何在antdPro/FunctionComponent中使用:
子:

const Child = (props,ref) => {
  const inputRef = React.useRef();
  React.useImperativeHandle(ref, () => ({
    focus: () => {
      // inputRef.current.focus();
      inputRef.current.value='aaaa'
    }
  }));

  return (<input type="text" ref={inputRef}/>)
}

export default React.forwardRef(Child)

父:

import Child from './Child';
const Father=(props)=> {
   const rref= React.useRef(null)
   useEffect(() => {
    //console.log(rref.current,'rref33')
    rref.current.focus()
  }, []);
return (<Child ref={rref}/>)
}

注意:
antdPro中使用的话,我试了是不好用dvaconnect包裹的,issue上作者也没回答,就关闭了:https://github.com/ant-design/ant-design-pro/issues/3123

useImperativeMethods已经重命名为useImperativeHandle,传送门:https://github.com/facebook/react/pull/14565


(完)

查看原文

赞 1 收藏 0 评论 0

joe_sky 收藏了文章 · 2020-05-21

webpack 源码从零开始 - tapable模型

前文

最近在看webpack的源码,发现有个比较头疼的点是:代码看起来非常跳跃,往往看不到几行就插入一段新内容,为了理解又不得不先学习相关的前置知识。层层嵌套之后,发现最基础的还是tapable模型,因此先对这部分的内容做一个介绍。

引子-webpack的基本流程

Webpack的流程可以分为以下三大阶段:

初始化:启动构建,读取与合并配置参数,加载 Plugin,实例化 Compiler。这个compile对象会穿行在本次编译的整个周期。
编译:从 Entry 发出,针对每个 Module 串行调用对应的 Loader 去翻译文件内容,再找到该 Module 依赖的 Module,递归地进行编译处理。
输出:对编译后的 Module 组合成 Chunk,把 Chunk 转换成文件,输出到文件系统。

在这个过程中,最核心的就是插件化的设计: 在不同的阶段执行相应的一些插件,来执行某些功能。
而这里的阶段,指的就是hook。 理论太抽象,来看一段webpack的源码(4.x版本):

// webpack/lib/MultiCompiler.js
const { Tapable, SyncHook, MultiHook } = require("tapable");
class MultiCompiler extends Tapable {
    constructor(compilers) {
        super();
        this.hooks = {
            done: new SyncHook(["stats"]),
            invalid: new MultiHook(compilers.map(c => c.hooks.invalid)),
            run: new MultiHook(compilers.map(c => c.hooks.run)),
            watchClose: new SyncHook([]),
            watchRun: new MultiHook(compilers.map(c => c.hooks.watchRun)),
            infrastructureLog: new MultiHook(
                compilers.map(c => c.hooks.infrastructureLog)
            )
        };
    }
    /// 省略其他代码
}

这是compile的构造函数,有几个注意点:

  1. 显示继承了Tapable,也就是本文的话题对象
  2. 注意看this.hooks部分的内容: doneinvalid, run, watchClose 等等都是内置的生命周期,具体的代码暂时不去关心。

这部分代码主要是为了说明一个思路: webpack 的生命周期hook,实际上是一个个插件的集合,代表的含义是,在某个阶段需要挂载某些插件。
到这里,脑海里有这种大概雏形就好,接下来我们开始介绍Tapable

Tapable机制初探

Tapable的核心思路有点类似于nodejs中的events,最基本的发布/订阅模式。

const EventEmitter = require('events');
const myEmitter = new EventEmitter();

// 注册事件对应的监听函数
myEmitter.on('安歌发布新文章', (title, tag) => {
    console.log("前去围观并吐槽",title, tag)
});

// 触发事件 并传入参数
myEmitter.emit('安歌发布新文章',’标题tapable机制‘, '标签webpack');

这个结构很简单也很清晰:

  1. events.on 用于注册要监听的事件和对应的毁掉方法
  2. events.emit 用于触发对应的事件

tapable的核心用法与此相似,那为什么多次一举要使用它呢?

根据前面的demo,不妨假设一下,如果我们注册了很多事件,比如event.on(’起床‘),event.on(’吃饭‘),event.on(’上班‘)等等,那事件之间可能就存在一些依赖关系,比如要先起床然后才能上班这样的时序依赖,而tapable就可以帮助我们很方便的管理这些关系。

基本用法

接下来用一个前几天参加的公司中秋晚会的例子,来简单说明一下Tapable的用法:
我把自己的参加流程分成以下阶段:

  1. 晚宴前

    1. 检查着装
    2. 乘坐班车到酒店
  2. 晚宴中

    1. 用餐并欣赏表演
    2. 在当前桌进行博饼
    3. 如果成为当前桌状元,那么就留下来博王中王,
  3. 晚宴后

    1. 拍一些照片,打车回家
    2. 用拍好的照片发朋友圈纪念

那么先写个全局demo:

// 1. 引入 tapable ,先不管具体的钩子类型
const {
    SyncHook,
    SyncBailHook,
    SyncWaterfallHook,
 } = require("tapable");

// 2. 定义不同阶段对应的钩子,

// 钩子: 晚宴前
let beforeDinner = new SyncHook(["stageName"]);
// 钩子:晚宴中
let atTheDinner = new SyncBailHook(["stageName"]);
// 钩子 晚宴后
let afterDinner = new SyncWaterfallHook(["stageName"]);


// 3. 为不同阶段注册事件,这里先写出晚宴前的事件

beforeDinner.tap('检查着装', (stageName)=>{
    console.log(`${stageName}: 检查着装`)
})

beforeDinner.tap('乘坐班车到酒店', (stageName)=>{
    console.log(`${stageName}: 乘坐班车到酒店`)
})


// 每个阶段触发自身需要执行的事件
beforeDinner.call('晚宴前');
atTheDinner.call('晚宴中');
afterDinner.call('晚宴后');

// 输出结果:
// 晚宴前: 检查着装
// 晚宴前: 乘坐班车到酒店
// ... 省略后面的输出

这个demo简单的定义了三个阶段,先不去关具体的hook类型,了解下整体的结构:

  1. beforeDinner在实例化时,使用数组声明了参数stageName, 这个地方的参数类型仅仅作为接口定义的目的使用,为了方便触发的时候传入对应的参数;
  2. call方法其实就类似前文的emit,与之不同的是,event.emit表示事件触发,而hook.call表示当前钩子要开始执行钩子上注册的所有事件。(当然我们只注册了晚宴前的2个事件)
  3. hook.call(param)执行之后,该hook对应的事件就按照注册顺序以及特定规则(具体规则后面说明,暂时略过)依次执行,因此上面的beforeDinner.call('晚宴前');会输出对应的阶段名称和事件名称。

到这里,我们已经用上了最基本的tapable了。回顾下它和events最大的区别:

tapable不仅提供了事件的注册和执行,还用不同的Hook 将事件进行分类(这里例子用三个阶段将基础事件分类)

SyncBailHook

接下来就是晚宴中的事件,这里有个注意点:晚宴中的第三个事件”如果成为当前桌状元,那么就留下来博王中王“是一个带有前提条件的事件,所以我们用了SyncBailHook,并且这么注册事件:

atTheDinner.tap('用餐并欣赏表演', (stageName) => {
    console.log(`${stageName}: 用餐并欣赏表演`);
})

atTheDinner.tap('在当前桌进行博饼', (stageName) => {
    console.log(`${stageName}: 在当前桌进行博饼`); 
    // 关键伪代码
    let getChampion = false //如果获得状元
    if(!getChampion){
        console.log(`${stageName}: 没有获得当前桌状元,不需要参与博王中王`);  // 注意这里的return
        return '提前结束!';
    }
})

atTheDinner.tap('博王中王', (stageName) => {
    console.log(`${stageName}: 博王中王`);
})

SyncBailHook翻译过来意思是“熔断类型的钩子”,作用就像保险丝,一旦有危险,则启动保护(一旦该钩子的某个事件,执行返回除了undefined以外的值,后面注册的事件就不再执行)。正如前面的例子中,如果在“当前桌子博饼”中没有成功搏到“状元”,就不会进行后面的“搏王中王”事件。常用于处理某些需要条件判断才触发的事件。

SyncWaterfallHook

晚宴之后的事件,与前面不用的地方在于:事件2发朋友圈 用的是事件1中所拍的照片,换句话说后面的事件依赖于前面事件的执行结果。所以可以这么写:

afterDinner.tap('回家前拍照',  (stageName) => {
    console.log(`${stageName}: 拍一些照片,打车回家`);
    let pictures = ['image1','image2'];
    return pictures; 
})

afterDinner.tap('回家后发朋友圈', (pictures)=> {
   // 注意这里的内置参数 不再是stageName 而是pictures
   return console.log(`回家后,用${pictures}:发朋友圈`);
})

实例化afterDinner时使用了SyncWaterfallHook,顾名思义,这种瀑布式的钩子,作用就是在执行该钩子内注册的事件时,会把每个阶段的执行结果传递给后面的阶段。

小结

这部分我们介绍了tapable的基本用法和三种基本类型的hook,大概可以总结一下:

hook表示事件的集合,hook的类型决定了注册在这个hook的事件如何执行

异步类型的hook

总览

开胃菜结束,接下来要真正开始系统化的了解tapable了,(好消息是如果前面的例子都看懂了,后面的学起来会非常简单,坏消息是:又要涉及前端最棘手的问题之一--异步)

先来一览所有的hook类型:

image

总体上,hook类型分成同步和异步两大类,异步再分为异步串行和异步并行。

先前已经介绍了同步hook里面的前三种。第四种SynloopHook也简要介绍下:

假设写文章这个事情,分成校对发表两个步骤,校对必须3次以上,才可以执行发表事件:

// 当监听函数被触发的时候,如果该监听函数返回true时则这个监听函数会反复执行,如果返回 undefined 则表示退出循环
let writeArticle  = SyncLoopHook();
let count = 0;

writeArticle.tap('校对',()=>{
    console.log('执行校对', count++)
    if(count<3){
        return true; // 没有达到3次则继续校对
    }
    return
})

writeArticle.tap('发表',()=>{
    console.log('发表')
})

异步并行 AsyncParallelHook

异步的hook,注册和触发可以用tapAsync/callAsynctapPromise/promise两种语法,写法上略有不用。直接上demo:

// AsyncParallelHook 钩子:tapAsync/callAsync 的使用
const { AsyncParallelHook } = require("tapable");

// 创建实例
let asyncParallelHook = new AsyncParallelHook(["demoName"]);

// 注册事件
console.time("time");
asyncParallelHook.tapAsync("异步事件1", (demoName, done) => {
    setTimeout(() => {
        console.log("1", demoName,  new Date());
        done(); //需要注意的是这里的`done`方法 
    }, 1000);
});

asyncParallelHook.tapAsync("异步事件2", (demoName, done) => {
    setTimeout(() => {
        console.log("2", demoName,  new Date());
        done();
    }, 2000);
});

asyncParallelHook.tapAsync("异步事件3", (demoName, done) => {
    setTimeout(() => {
        console.log("3", demoName,  new Date());
        done();
        console.timeEnd("time");
    }, 3000);
});

// 触发事件,让监听函数执行
asyncParallelHook.callAsync("异步并行", () => {
    // 只有当前钩子的所有事件都执行done 才进入这个callback
    console.log("complete");
});

// 输出
// 异步事件1 异步并行
// Sun Sep 08 2019 21:24:12 GMT+0800 (GMT+08:00) {}
// 异步事件2 异步并行
// Sun Sep 08 2019 21:24:13 GMT+0800 (GMT+08:00) {}
// 异步事件3 异步并行
// Sun Sep 08 2019 21:24:14 GMT+0800 (GMT+08:00) {}
// complete
// time: 3007.266845703125ms
// time: 3007.640ms 

需要注意的是这里的done方法, 每个注册的的事件都可以调用到这个done方法,这个方法的作用是:向对应的hook实例告知,当前的异步事件完成,只有当所有的事件回调都执行了done方法,才会进入钩子本身的回调函数(demo中的console.log("complete");)

从例子中的计时情况来看,很明显所有的事件是并行的 -- 事件1 2 3分别需要1s 2s 3s, 最终执行完也只花了3s。

tapPromise/promise来写的话,如下:

asyncParallelHook.tapPromise("异步事件1", (demoName) => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log("1", demoName,  new Date());
            resolve("1");
        }, 1000);
    });
});

// ...省略重复代码
asyncParallelHook.promise("异步并行").then(() => {
    console.log("最终结果", new Date());
}).catch(err => {
    console.log("发现错误", new Date());
});

区别在于:

  1. 使用tapPromise注册时,回调函数必须返回一个promise
  2. 使用tabAsync注册使用done表示当前执行完成,使用tapPromise时则只要使用resolve()即可
  3. 如果其中一个事件没有resolve, 而是reject(error),那么会进入asyncParallelHookcatch而不是then

这种写法其实很类似ES6中的promise.all,比较好理解

异步串行AsyncSeriesHook

其实到这里,已经一只脚踏进成功的大门了。 异步串行和异步并行的写法,完全一样。只需要简单把前面例子中,实例化的语句改成:

let asyncSeriesHook = new AsyncSeriesHook()

然后看看3个异步事件执行完后的事件间隔(并行的时候是3s,串行时总时长变成6s)。

没错,就是这么简单~!

引申 -- 实例案例webpack-dev-middlewaretapable的应用

webpack-dev-middleware是一个webpack的插件,作用是监听webpack的编译变化并写入到内存中。 核心代码:

// webpack-dev-middleware/lib/context.js

const context = {
    state: false, 
    webpackStats: null, // 
    callbacks: [],
    options,
    compiler,
    watching: null,
    forceRebuild: false,
  };
  
   function invalid(callback) {
    if (context.state) {
      context.options.reporter(context.options, {
        log,
        state: false,
      });
    }

    // We are now in invalid state
    context.state = false;
    if (typeof callback === 'function') {
      callback();
    }
  }

  
  // 关键代码 利用compile的hook 观察编译变化 并插入操作
  context.compiler.hooks.invalid.tap('WebpackDevMiddleware', invalid);
  context.compiler.hooks.run.tap('WebpackDevMiddleware', invalid);
  context.compiler.hooks.done.tap('WebpackDevMiddleware', done);
  context.compiler.hooks.watchRun.tap(
    'WebpackDevMiddleware',
    (comp, callback) => {
      invalid(callback);
    }
  );

核心的代码就是使用webpack提供的内置hookwatchRun来插入自定义的操作(检查编译情况,生成临时结果到内存)

总结

呼~ tapable的内容大概写完了,本文介绍了同步的几种钩子,和异步的2种代表性的钩子,至于异步并行熔断等等,就是前面介绍的钩子的合成,比较简单。回顾一下主要的内容:

  1. 同步hook触发后,按照事件注册顺序依次调用,并根据钩子类型,有一些特殊行为(bail loop);
  2. 异步的hook有tapAsync/callAsynctapPromise/promise两种使用方式,并且都可以观察事件整体执行结果;
  3. 异步串行和异步并行的区别,在于注册的事件依次执行(前一个完成才开始执行后一个)还是并发执行;

理解清楚tapable之后,再开始学习webpack的源码,会相对顺畅一些。

-----惯例偷懒分割线-----
如果觉得写得不好/有错误/表述不明确,都欢迎指出
如果有帮助,欢迎点赞和收藏,转载请征得同意后著明出处。如果有问题也欢迎私信交流,主页有邮箱地址
如果觉得作者很辛苦,也欢迎打赏~

查看原文

joe_sky 赞了文章 · 2020-05-21

webpack 源码从零开始 - tapable模型

前文

最近在看webpack的源码,发现有个比较头疼的点是:代码看起来非常跳跃,往往看不到几行就插入一段新内容,为了理解又不得不先学习相关的前置知识。层层嵌套之后,发现最基础的还是tapable模型,因此先对这部分的内容做一个介绍。

引子-webpack的基本流程

Webpack的流程可以分为以下三大阶段:

初始化:启动构建,读取与合并配置参数,加载 Plugin,实例化 Compiler。这个compile对象会穿行在本次编译的整个周期。
编译:从 Entry 发出,针对每个 Module 串行调用对应的 Loader 去翻译文件内容,再找到该 Module 依赖的 Module,递归地进行编译处理。
输出:对编译后的 Module 组合成 Chunk,把 Chunk 转换成文件,输出到文件系统。

在这个过程中,最核心的就是插件化的设计: 在不同的阶段执行相应的一些插件,来执行某些功能。
而这里的阶段,指的就是hook。 理论太抽象,来看一段webpack的源码(4.x版本):

// webpack/lib/MultiCompiler.js
const { Tapable, SyncHook, MultiHook } = require("tapable");
class MultiCompiler extends Tapable {
    constructor(compilers) {
        super();
        this.hooks = {
            done: new SyncHook(["stats"]),
            invalid: new MultiHook(compilers.map(c => c.hooks.invalid)),
            run: new MultiHook(compilers.map(c => c.hooks.run)),
            watchClose: new SyncHook([]),
            watchRun: new MultiHook(compilers.map(c => c.hooks.watchRun)),
            infrastructureLog: new MultiHook(
                compilers.map(c => c.hooks.infrastructureLog)
            )
        };
    }
    /// 省略其他代码
}

这是compile的构造函数,有几个注意点:

  1. 显示继承了Tapable,也就是本文的话题对象
  2. 注意看this.hooks部分的内容: doneinvalid, run, watchClose 等等都是内置的生命周期,具体的代码暂时不去关心。

这部分代码主要是为了说明一个思路: webpack 的生命周期hook,实际上是一个个插件的集合,代表的含义是,在某个阶段需要挂载某些插件。
到这里,脑海里有这种大概雏形就好,接下来我们开始介绍Tapable

Tapable机制初探

Tapable的核心思路有点类似于nodejs中的events,最基本的发布/订阅模式。

const EventEmitter = require('events');
const myEmitter = new EventEmitter();

// 注册事件对应的监听函数
myEmitter.on('安歌发布新文章', (title, tag) => {
    console.log("前去围观并吐槽",title, tag)
});

// 触发事件 并传入参数
myEmitter.emit('安歌发布新文章',’标题tapable机制‘, '标签webpack');

这个结构很简单也很清晰:

  1. events.on 用于注册要监听的事件和对应的毁掉方法
  2. events.emit 用于触发对应的事件

tapable的核心用法与此相似,那为什么多次一举要使用它呢?

根据前面的demo,不妨假设一下,如果我们注册了很多事件,比如event.on(’起床‘),event.on(’吃饭‘),event.on(’上班‘)等等,那事件之间可能就存在一些依赖关系,比如要先起床然后才能上班这样的时序依赖,而tapable就可以帮助我们很方便的管理这些关系。

基本用法

接下来用一个前几天参加的公司中秋晚会的例子,来简单说明一下Tapable的用法:
我把自己的参加流程分成以下阶段:

  1. 晚宴前

    1. 检查着装
    2. 乘坐班车到酒店
  2. 晚宴中

    1. 用餐并欣赏表演
    2. 在当前桌进行博饼
    3. 如果成为当前桌状元,那么就留下来博王中王,
  3. 晚宴后

    1. 拍一些照片,打车回家
    2. 用拍好的照片发朋友圈纪念

那么先写个全局demo:

// 1. 引入 tapable ,先不管具体的钩子类型
const {
    SyncHook,
    SyncBailHook,
    SyncWaterfallHook,
 } = require("tapable");

// 2. 定义不同阶段对应的钩子,

// 钩子: 晚宴前
let beforeDinner = new SyncHook(["stageName"]);
// 钩子:晚宴中
let atTheDinner = new SyncBailHook(["stageName"]);
// 钩子 晚宴后
let afterDinner = new SyncWaterfallHook(["stageName"]);


// 3. 为不同阶段注册事件,这里先写出晚宴前的事件

beforeDinner.tap('检查着装', (stageName)=>{
    console.log(`${stageName}: 检查着装`)
})

beforeDinner.tap('乘坐班车到酒店', (stageName)=>{
    console.log(`${stageName}: 乘坐班车到酒店`)
})


// 每个阶段触发自身需要执行的事件
beforeDinner.call('晚宴前');
atTheDinner.call('晚宴中');
afterDinner.call('晚宴后');

// 输出结果:
// 晚宴前: 检查着装
// 晚宴前: 乘坐班车到酒店
// ... 省略后面的输出

这个demo简单的定义了三个阶段,先不去关具体的hook类型,了解下整体的结构:

  1. beforeDinner在实例化时,使用数组声明了参数stageName, 这个地方的参数类型仅仅作为接口定义的目的使用,为了方便触发的时候传入对应的参数;
  2. call方法其实就类似前文的emit,与之不同的是,event.emit表示事件触发,而hook.call表示当前钩子要开始执行钩子上注册的所有事件。(当然我们只注册了晚宴前的2个事件)
  3. hook.call(param)执行之后,该hook对应的事件就按照注册顺序以及特定规则(具体规则后面说明,暂时略过)依次执行,因此上面的beforeDinner.call('晚宴前');会输出对应的阶段名称和事件名称。

到这里,我们已经用上了最基本的tapable了。回顾下它和events最大的区别:

tapable不仅提供了事件的注册和执行,还用不同的Hook 将事件进行分类(这里例子用三个阶段将基础事件分类)

SyncBailHook

接下来就是晚宴中的事件,这里有个注意点:晚宴中的第三个事件”如果成为当前桌状元,那么就留下来博王中王“是一个带有前提条件的事件,所以我们用了SyncBailHook,并且这么注册事件:

atTheDinner.tap('用餐并欣赏表演', (stageName) => {
    console.log(`${stageName}: 用餐并欣赏表演`);
})

atTheDinner.tap('在当前桌进行博饼', (stageName) => {
    console.log(`${stageName}: 在当前桌进行博饼`); 
    // 关键伪代码
    let getChampion = false //如果获得状元
    if(!getChampion){
        console.log(`${stageName}: 没有获得当前桌状元,不需要参与博王中王`);  // 注意这里的return
        return '提前结束!';
    }
})

atTheDinner.tap('博王中王', (stageName) => {
    console.log(`${stageName}: 博王中王`);
})

SyncBailHook翻译过来意思是“熔断类型的钩子”,作用就像保险丝,一旦有危险,则启动保护(一旦该钩子的某个事件,执行返回除了undefined以外的值,后面注册的事件就不再执行)。正如前面的例子中,如果在“当前桌子博饼”中没有成功搏到“状元”,就不会进行后面的“搏王中王”事件。常用于处理某些需要条件判断才触发的事件。

SyncWaterfallHook

晚宴之后的事件,与前面不用的地方在于:事件2发朋友圈 用的是事件1中所拍的照片,换句话说后面的事件依赖于前面事件的执行结果。所以可以这么写:

afterDinner.tap('回家前拍照',  (stageName) => {
    console.log(`${stageName}: 拍一些照片,打车回家`);
    let pictures = ['image1','image2'];
    return pictures; 
})

afterDinner.tap('回家后发朋友圈', (pictures)=> {
   // 注意这里的内置参数 不再是stageName 而是pictures
   return console.log(`回家后,用${pictures}:发朋友圈`);
})

实例化afterDinner时使用了SyncWaterfallHook,顾名思义,这种瀑布式的钩子,作用就是在执行该钩子内注册的事件时,会把每个阶段的执行结果传递给后面的阶段。

小结

这部分我们介绍了tapable的基本用法和三种基本类型的hook,大概可以总结一下:

hook表示事件的集合,hook的类型决定了注册在这个hook的事件如何执行

异步类型的hook

总览

开胃菜结束,接下来要真正开始系统化的了解tapable了,(好消息是如果前面的例子都看懂了,后面的学起来会非常简单,坏消息是:又要涉及前端最棘手的问题之一--异步)

先来一览所有的hook类型:

image

总体上,hook类型分成同步和异步两大类,异步再分为异步串行和异步并行。

先前已经介绍了同步hook里面的前三种。第四种SynloopHook也简要介绍下:

假设写文章这个事情,分成校对发表两个步骤,校对必须3次以上,才可以执行发表事件:

// 当监听函数被触发的时候,如果该监听函数返回true时则这个监听函数会反复执行,如果返回 undefined 则表示退出循环
let writeArticle  = SyncLoopHook();
let count = 0;

writeArticle.tap('校对',()=>{
    console.log('执行校对', count++)
    if(count<3){
        return true; // 没有达到3次则继续校对
    }
    return
})

writeArticle.tap('发表',()=>{
    console.log('发表')
})

异步并行 AsyncParallelHook

异步的hook,注册和触发可以用tapAsync/callAsynctapPromise/promise两种语法,写法上略有不用。直接上demo:

// AsyncParallelHook 钩子:tapAsync/callAsync 的使用
const { AsyncParallelHook } = require("tapable");

// 创建实例
let asyncParallelHook = new AsyncParallelHook(["demoName"]);

// 注册事件
console.time("time");
asyncParallelHook.tapAsync("异步事件1", (demoName, done) => {
    setTimeout(() => {
        console.log("1", demoName,  new Date());
        done(); //需要注意的是这里的`done`方法 
    }, 1000);
});

asyncParallelHook.tapAsync("异步事件2", (demoName, done) => {
    setTimeout(() => {
        console.log("2", demoName,  new Date());
        done();
    }, 2000);
});

asyncParallelHook.tapAsync("异步事件3", (demoName, done) => {
    setTimeout(() => {
        console.log("3", demoName,  new Date());
        done();
        console.timeEnd("time");
    }, 3000);
});

// 触发事件,让监听函数执行
asyncParallelHook.callAsync("异步并行", () => {
    // 只有当前钩子的所有事件都执行done 才进入这个callback
    console.log("complete");
});

// 输出
// 异步事件1 异步并行
// Sun Sep 08 2019 21:24:12 GMT+0800 (GMT+08:00) {}
// 异步事件2 异步并行
// Sun Sep 08 2019 21:24:13 GMT+0800 (GMT+08:00) {}
// 异步事件3 异步并行
// Sun Sep 08 2019 21:24:14 GMT+0800 (GMT+08:00) {}
// complete
// time: 3007.266845703125ms
// time: 3007.640ms 

需要注意的是这里的done方法, 每个注册的的事件都可以调用到这个done方法,这个方法的作用是:向对应的hook实例告知,当前的异步事件完成,只有当所有的事件回调都执行了done方法,才会进入钩子本身的回调函数(demo中的console.log("complete");)

从例子中的计时情况来看,很明显所有的事件是并行的 -- 事件1 2 3分别需要1s 2s 3s, 最终执行完也只花了3s。

tapPromise/promise来写的话,如下:

asyncParallelHook.tapPromise("异步事件1", (demoName) => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log("1", demoName,  new Date());
            resolve("1");
        }, 1000);
    });
});

// ...省略重复代码
asyncParallelHook.promise("异步并行").then(() => {
    console.log("最终结果", new Date());
}).catch(err => {
    console.log("发现错误", new Date());
});

区别在于:

  1. 使用tapPromise注册时,回调函数必须返回一个promise
  2. 使用tabAsync注册使用done表示当前执行完成,使用tapPromise时则只要使用resolve()即可
  3. 如果其中一个事件没有resolve, 而是reject(error),那么会进入asyncParallelHookcatch而不是then

这种写法其实很类似ES6中的promise.all,比较好理解

异步串行AsyncSeriesHook

其实到这里,已经一只脚踏进成功的大门了。 异步串行和异步并行的写法,完全一样。只需要简单把前面例子中,实例化的语句改成:

let asyncSeriesHook = new AsyncSeriesHook()

然后看看3个异步事件执行完后的事件间隔(并行的时候是3s,串行时总时长变成6s)。

没错,就是这么简单~!

引申 -- 实例案例webpack-dev-middlewaretapable的应用

webpack-dev-middleware是一个webpack的插件,作用是监听webpack的编译变化并写入到内存中。 核心代码:

// webpack-dev-middleware/lib/context.js

const context = {
    state: false, 
    webpackStats: null, // 
    callbacks: [],
    options,
    compiler,
    watching: null,
    forceRebuild: false,
  };
  
   function invalid(callback) {
    if (context.state) {
      context.options.reporter(context.options, {
        log,
        state: false,
      });
    }

    // We are now in invalid state
    context.state = false;
    if (typeof callback === 'function') {
      callback();
    }
  }

  
  // 关键代码 利用compile的hook 观察编译变化 并插入操作
  context.compiler.hooks.invalid.tap('WebpackDevMiddleware', invalid);
  context.compiler.hooks.run.tap('WebpackDevMiddleware', invalid);
  context.compiler.hooks.done.tap('WebpackDevMiddleware', done);
  context.compiler.hooks.watchRun.tap(
    'WebpackDevMiddleware',
    (comp, callback) => {
      invalid(callback);
    }
  );

核心的代码就是使用webpack提供的内置hookwatchRun来插入自定义的操作(检查编译情况,生成临时结果到内存)

总结

呼~ tapable的内容大概写完了,本文介绍了同步的几种钩子,和异步的2种代表性的钩子,至于异步并行熔断等等,就是前面介绍的钩子的合成,比较简单。回顾一下主要的内容:

  1. 同步hook触发后,按照事件注册顺序依次调用,并根据钩子类型,有一些特殊行为(bail loop);
  2. 异步的hook有tapAsync/callAsynctapPromise/promise两种使用方式,并且都可以观察事件整体执行结果;
  3. 异步串行和异步并行的区别,在于注册的事件依次执行(前一个完成才开始执行后一个)还是并发执行;

理解清楚tapable之后,再开始学习webpack的源码,会相对顺畅一些。

-----惯例偷懒分割线-----
如果觉得写得不好/有错误/表述不明确,都欢迎指出
如果有帮助,欢迎点赞和收藏,转载请征得同意后著明出处。如果有问题也欢迎私信交流,主页有邮箱地址
如果觉得作者很辛苦,也欢迎打赏~

查看原文

赞 10 收藏 7 评论 3

joe_sky 赞了文章 · 2019-12-31

巧用 TypeScript(五)---- infer

介绍

infer 最早出现在此 PR 中,表示在 extends 条件语句中待推断的类型变量。

简单示例如下:

type ParamType<T> = T extends (param: infer P) => any ? P : T;

在这个条件语句 T extends (param: infer P) => any ? P : T 中,infer P 表示待推断的函数参数。

整句表示为:如果 T 能赋值给 (param: infer P) => any,则结果是 (param: infer P) => any 类型中的参数 P,否则返回为 T

interface User {
  name: string;
  age: number;
}

type Func = (user: User) => void

type Param = ParamType<Func>;   // Param = User
type AA = ParamType<string>;    // string

内置类型

在 2.8 版本中,TypeScript 内置了一些与 infer 有关的映射类型:

  • 用于提取函数类型的返回值类型:

    type ReturnType<T> = T extends (...args: any[]) => infer P ? P : any;

    相比于文章开始给出的示例,ReturnType<T> 只是将 infer P 从参数位置移动到返回值位置,因此此时 P 即是表示待推断的返回值类型。

    type Func = () => User;
    type Test = ReturnType<Func>;   // Test = User
  • 用于提取构造函数中参数(实例)类型:

    一个构造函数可以使用 new 来实例化,因此它的类型通常表示如下:

    type Constructor = new (...args: any[]) => any;

    infer 用于构造函数类型中,可用于参数位置 new (...args: infer P) => any; 和返回值位置 new (...args: any[]) => infer P;

    因此就内置如下两个映射类型:

    // 获取参数类型
    type ConstructorParameters<T extends new (...args: any[]) => any> = T extends new (...args: infer P) => any ? P : never;
    
    // 获取实例类型
    type InstanceType<T extends new (...args: any[]) => any> = T extends new (...args: any[]) => infer R ? R : any;
    
    class TestClass {
    
      constructor(
        public name: string,
        public string: number
      ) {}
    }
    
    type Params = ConstructorParameters<typeof TestClass>;  // [string, numbder]
    
    type Instance = InstanceType<typeof TestClass>;         // TestClass

一些用例

至此,相信你已经对 infer 已有基本了解,我们来看看一些使用它的「骚操作」:

  • tupleunion ,如:[string, number] -> string | number

    解答之前,我们需要了解 tuple 类型在一定条件下,是可以赋值给数组类型:

    type TTuple = [string, number];
    type TArray = Array<string | number>;
    
    type Res = TTuple extends TArray ? true : false;    // true
    type ResO = TArray extends TTuple ? true : false;   // false

    因此,在配合 infer 时,这很容做到:

    type ElementOf<T> = T extends Array<infer E> ? E : never
    
    type TTuple = [string, number];
    
    type ToUnion = ElementOf<ATuple>; // string | number

    stackoverflow 上看到另一种解法,比较简(牛)单(逼):

    type TTuple = [string, number];
    type Res = TTuple[number];  // string | number
  • unionintersection,如:string | number -> string & number

    这个可能要稍微麻烦一点,需要 infer 配合「 Distributive conditional types 」使用。

    相关链接中,我们可以了解到「Distributive conditional types」是由「naked type parameter」构成的条件类型。而「naked type parameter」表示没有被 Wrapped 的类型(如:Array<T>[T]Promise<T> 等都是不是「naked type parameter」)。「Distributive conditional types」主要用于拆分 extends 左边部分的联合类型,举个例子:在条件类型 T extends U ? X : Y 中,当 TA | B 时,会拆分成 A extends U ? X : Y | B extends U ? X : Y

    有了这个前提,再利用在逆变位置上,同一类型变量的多个候选类型将会被推断为交叉类型的特性,即

    type Bar<T> = T extends { a: (x: infer U) => void, b: (x: infer U) => void } ? U : never;
    type T20 = Bar<{ a: (x: string) => void, b: (x: string) => void }>;  // string
    type T21 = Bar<{ a: (x: string) => void, b: (x: number) => void }>;  // string & number

    因此,综合以上几点,我们可以得到在 stackoverflow 上的一个答案:

    type UnionToIntersection<U> =
      (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
    
    type Result = UnionToIntersection<string | number>; // string & number

    当传入 string | number 时:

    • 第一步:(U extends any ? (k: U) => void : never) 会把 union 拆分成 (string extends any ? (k: string) => void : never) | (number extends any ? (k: number)=> void : never),即是得到 (k: string) => void | (k: number) => void
    • 第二步:(k: string) => void | (k: number) => void extends ((k: infer I)) => void ? I : never,根据上文,可以推断出 Istring & number

当然,你可以玩出更多花样,比如 uniontuple

LeetCode 的一道 TypeScript 面试题

前段时间,在 GitHub 上,发现一道来自 LeetCode TypeScript 的面试题,比较有意思,题目的大致意思是:

假设有一个这样的类型(原题中给出的是类,这里简化为 interface):

interface Module {
  count: number;
  message: string;
  asyncMethod<T, U>(input: Promise<T>): Promise<Action<U>>;
  syncMethod<T, U>(action: Action<T>): Action<U>;
}

在经过 Connect 函数之后,返回值类型为

type Result {
  asyncMethod<T, U>(input: T): Action<U>;
  syncMethod<T, U>(action: T): Action<U>;
}

其中 Action<T> 的定义为:

interface Action<T> {
  payload?: T
  type: string
}

这里主要考察两点

  • 挑选出函数
  • 此篇文章所提及的 infer

挑选函数的方法,已经在 handbook 中已经给出,只需判断 value 能赋值给 Function 就行了:

type FuncName<T>  = {
  [P in keyof T]: T[P] extends Function ? P : never;
}[keyof T];

type Connect = (module: Module) => { [T in FuncName<Module>]: Module[T] }
/*
 * type Connect = (module: Module) => {
 *   asyncMethod: <T, U>(input: Promise<T>) => Promise<Action<U>>;
 *   syncMethod: <T, U>(action: Action<T>) => Action<U>;
 * }
*/

接下来就比较简单了,主要是利用条件类型 + infer,如果函数可以赋值给 asyncMethod<T, U>(input: Promise<T>): Promise<Action<U>>,则取值为 asyncMethod<T, U>(input: T): Action<U>。具体答案就不给出了,感兴趣的小伙伴可以尝试一下。

更多

参考

更多文章,请关注我们的公众号:

微信服务号

查看原文

赞 40 收藏 24 评论 4

认证与成就

  • 获得 29 次点赞
  • 获得 1 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 1 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2016-03-17
个人主页被 971 人浏览