理解前端依赖

现代的前端开发, 每个前端工程师都需要自己的项目添加第三方依赖, 也会自己开发的第三方库进行对外发布, 而承担这些功能的我们叫它包管理器, 世面上常见的包管理器有bower npm yarn, 这三个里npm是使用最为广泛的.

因为前端依赖是允许再同一个项目中可能在不同层级的依赖上使用同一个第三方依赖的多个版本, 所以前端的依赖管理逻辑往往让人理不清. 这本质的原因在于当允许依赖树中出现多个相同的依赖, 就会出现’环’这种东西, 所以实际上依赖应该被解析成graph 而不是 tree, 但是在文件系统确只能表达tree, 因此在不同的npm版本, 对于依赖的解析也有不同的方式, 我们下面提到的均是以v6版本为基础的.

npm是如何解析项目依赖的

在前端项目里, 基本上都能看到一个名叫package.json的文件, 里面有一部分重要的字段, 比如dependencies devDependencies 等等,都是是用来记录项目依赖的, 相关字段的区别无非是设置依赖的安装条件而已, 具体的可以查看官方文档, 这里不再赘述. 项目里的所有依赖都是有版本标记的, 他们都使用同一种版本命名法Semantic, 这可以让npm很好的对相同第三方库的版本进行区分和排序, 这也是能够实现一个依赖树中出现多个相同第三方库的基础.

我们知道, 前端项目不仅仅有package.json这个文件, 还有一个目录叫做node_modules. ‘package.json’是用来描述我们的项目依赖, 而node_modules则是用来保存对应的第三方依赖包的, 那么npm是如何通过package.json来构建node_modules文件夹的呢?

我还是直接上一个例子吧!

下面是一个项目mage的一个依赖图:

1
2
3
4
mage
+-- sugar-design@1.0.0 // 直接依赖
+-- react@16.2.0
+-- classname@5.0.0

首先, npm会尝试吧所有的依赖扁平化的保存到最顶级的node_modules, 也就是生成下面这样的文件结构:

1
2
3
4
5
mage
+-- node_modules
+-- sugar-design@1.0.0
+-- react@16.2.0
+-- classname@5.0.0

当我们给mage添加一个新的依赖react@16.13.1时, mage的依赖图会变成这样:

1
2
3
4
5
mage
+-- react@16.13.1
+-- sugar-design@1.0.0
+-- react@16.2.0
+-- classname@5.0.0

那么接下来node_modules的文件结构会变成怎样呢?

  1. 根据项目的顶级依赖, 顶级node_modules安装react@16.13.1
  2. 由于顶级node_modules目录已经安装了react@16.13.1, 而sugar-design依赖的react版本是16.2.0, 所以只能安装在它内部的node_modules

所以文件结构会是这样:

1
2
3
4
5
6
7
mage
+-- node_modules
+-- react@16.13.1
+-- sugar-design@1.0.0
| +-- node_modules
| +-- react@16.2.0
+-- classname@5.0.0

ok, 我们继续, 现在我们又为mage增加一个新的依赖editor@1.0.0, 它依赖于react@16.2.0className@5.0.0

对应的依赖图:

1
2
3
4
5
6
7
8
mage
+-- react@16.13.1
+-- sugar-design@1.0.0
| +-- react@16.2.0
| +-- classname@5.0.0
+-- editor@1.0.0
+-- react@16.2.0
| +-- classname@5.0.0

接下来npm再安装依赖的时候会这样做:

  • 在顶级node_modules上安装editor@1.0.0
  • 由于它依赖的react版本和顶级node_modulesreact版本不匹配, 所以在它的内部node_modules里安装react@16.2.0
  • 由于它依赖的classname版本和顶级node_modulesclassname版本匹配本, 所以不需要再单独安装

文件结构会是这样:

1
2
3
4
5
6
7
8
9
10
mage
+-- node_modules
+-- react@16.13.1
+-- sugar-design@1.0.0
| +-- node_modules
| +-- react@16.2.0
+-- classname@5.0.0
+-- editor@1.0.0
+-- node_modules
+-- react@16.2.0

我们看到, 在安装依赖时, 都是优先安装在顶级node_modules上, 如果有已经用相同的第三方库已经安装, 则在子级node_modules上安装, 如果还有, 则以此类推

最后让我们把sugar-design的版本进行一次升级, 新的版本也同时升级了它的依赖, 把react升级到了16.13.1 classname 升级到了 6.0.0, 对应的依赖图是:

1
2
3
4
5
6
7
8
mage
+-- react@16.13.1
+-- sugar-design@2.0.0
| +-- react@16.13.1
| +-- classname@6.0.0
+-- editor@1.0.0
+-- react@16.2.0
| +-- classname@5.0.0

接下来会发生:

  • 由于祖先有相同版本的react, 所以移除了sugar-design内部的react
  • 由于安装顺序原因sugar-designclassname@6.0.0会安装在node_modules根目录上, 所以editor在内部node_modules安装classname@5.0.0

最后的文件结构会是这样:

1
2
3
4
5
6
7
8
9
mage
+-- node_modules
+-- react@16.13.1
+-- sugar-design@2.0.0
+-- classname@6.0.0
+-- editor@1.0.0
+-- node_modules
+-- react@16.2.0
+-- classname@5.0.0

最后的最后, 我们来测试一下变换声明顺序:

我们把sugar-designeditor的声明顺序对调一下:

1
2
3
4
5
6
7
8
mage
+-- react@16.13.1
+-- editor@1.0.0 // 对调了
| +-- react@16.2.0
| +-- classname@5.0.0
+-- sugar-design@2.0.0 // 对调了
+-- react@16.13.1
+-- classname@6.0.0

对调后,虽然最后加载的版本没错, 但是生成的文件结构变了:

1
2
3
4
5
6
7
8
9
10
mage
+-- node_modules
+-- react@16.13.1
+-- sugar-design@2.0.0
+-- node_modules
+-- classname@6.0.0 // sugar-design 的依赖到了自己的内部
+-- classname@5.0.0 // editor 的依赖classname到了顶级
+-- editor@1.0.0
+-- node_modules
+-- react@16.2.0

ok, 例子结束, 我们整理一下上面的例子, 得到一下的结论:

  • npm安装依赖时, 会尝试吧所有的依赖扁平化的安装在node_modules根目录
  • 如果祖先对应的依赖版本不匹配的话, 将在自己的内部node_modules内进行安装, 以此类推
  • 安装顺序和依赖的声明顺序有关, 所以声明顺序变了, 最后生成的`node_modules’结构可能会不同

经过上面的饿总结, 相信大家对npm的依赖安装逻辑已经有足够的了解了.

那么我们想一想, 依赖加载时, 查找的逻辑是什么呢? 其实从上面的依赖安装逻辑已经可以看出来了, 实际上就是’最近原则’, 也就是说先查找自己的node_modules, 没有就依次往上查找上级node_modules, 直到找到为止. 值得注意的是, 在真正调用依赖的时候, 系统并不会去验证版本号, 只要找到就进行加载, 版本号的验证都在安装阶段进行校验的

虽然了解了安装依赖的逻辑, 但是这对我们日常工作能有什么帮助呢? 毕竟日常当中, 我们往往是引入第三方库, 而不是发布第三方库. 如果我们是一个第三方库的作者, 我们需要在这方面注意什么呢?

第三方库开发误区

现在的前端工程, 离不开打包工具, 不管是webpack 还是rollup, 他们的核心理念都是吧多个模块封装到一个模块里.所以在我们开发的时候往往会对package.json內声明的依赖不太重视, 以为只要代码打包, 所有的依赖都已经打入构建好的源码中了, package.json里的东西只会在第三方库内部开发时才会用到, 不会对引用第三方库的人产生什么影响. 实际上这完全是错误的. 因为只要你的第三方库中的package.json声明了dependencies, 那么引入这个第三方的项目就都需要安装它, 虽然项目里确实不会真的引用到这些依赖.

上面这种情况, 虽然会安装多余的依赖, 但是并不会对项目本身产生多少负面作用.但接下来一个情况, 可能会对项目有重大印象, 并且在做bug排查时, 也可能会遇到很大的困难, 比如(Invalid Hook Call Warning)[https://reactjs.org/warnings/invalid-hook-call-warning.html].

首先我们应该理解一个概念, 在开发第三方库时, 应该吧运行时的依赖写在dependencies里.

如果我们开发的第三方库并不希望再运行时使用自己的依赖, 而是使用宿主的依赖, 比如react classname react-dom, 通常我们会对打包工具的external字段进行配置, 这样打包工具就不会把我们的对应依赖打入构建后的代码里. 这时构建好的第三方库是没对应的依赖的. 但是在其他项目引入我们写第三方库时, 项目打包工具在编译到我们的第三方库时, 由于我们的第三方库没有打包对应的依赖, 所以会去尝试加载我们第三方库的依赖,这时, 打包工具会去查找我们自己写第三方库下面的node_modules是否这些依赖, 如果第三方库里的依赖是使用dependencies来声明的话, 很有可能因为声明的版本和宿主环境的不同导致在内部又多安装了一份依赖, 构建工具最终会把我们自己写的第三方库的依赖也打入最后的项目构建代码中, 这样就会导致, 打入了多个不同版本的依赖, 就像下图

1
2
3
4
5
6
mage
+-- node_modules
+-- react@16.13.1 // 计划使用宿主的react
+-- sugar-design@2.0.0 // <- 我们自己的第三方库
+-- node_modules
+-- react@16.2.0 // 实际加载的是自己的依赖react

所以最好的方法是使用peerDependencies, 为什么不是dependencies呢? 因为peerDependencies可以保证你的第三方库不会额外引入一个react react-dom. 而像react react-dom这样的库, 因为设计原因, 同时引入多个可能会造成未知的错误, 而在排查这类bug的时候确往往是很困难的.

如果真的发生了上面说的问题, 应该怎么办呢?

如何找到多次引入的第三方包呢?

如果真的想确定是否有多个第三方库被引入呢? 以rect为例, 我这几个一个办法:

第一个
你可以使用npm命令, 查看当前的dependency graph:

1
npm ls react

上面的命令可以查看, 到底项目里有多少个第三方库依赖react, 之后在逐一进行排查.

第二个
首先在开发环境运行项目, 打开浏览器控制台: 按住 command + p, 并在出现的输入框中输入react.dev

如果出现多个结果, 但是来源却不一致, 就像上图一样, 那么恭喜你, 你已经找到了!

结语

所以, 请大家在写第三方库package.json的依赖部分时, 保持谨慎, 防止以外发生, 最好现在就去检查一下你写得第三方库是不是就有这类问题. 我相信将来npm也会为我们作更多的工作, 减少这些维护的成本.
因为npm一直再持续的迭代版本, 目前v7也在紧张的开发中, 这次带来了功能更强大的依赖树管理工具arborist, 感兴趣的同学可以进行深入了解.

推荐阅读

Understanding npm dependency resolution

Understanding the npm dependency model

npm install guideline

理解DSL

一个简单问题

如何使用程序来描述下面一个问题?

再一组候选人当中, 找到满足下列条件的数据:

  • 所有候选人性别为女
  • 数据按年龄排序
  • 是输出姓名以及年龄信息

ok, 问题已经抛出来了, 那么让我们用js来实现这个需求.

假设数据集叫data

1
2
3
data.filter((item) => item.sex === 'woman')
.sort((a, b) => a.age - b.age)
.map((item) => { name: item.item, age: item.ange });

上面的代码看上去, 再对js语言十分熟悉的人看来, 可以很快的看明白代码的意思, 但是如果是外行, 那可能有点难猜了, 那么有没有什么办法, 用更简单更易懂的方式来描述这个问题呢?

1
select 'name', 'age' from 'data' where sex = 'woman' sortBy 'age'

上面是一条SQL语句, 相信不管是不是程序员, 都能够快速理解这段语句的含义. 显然SQL再数据查询这个领域, 比js更加简洁, 表达力更强. 我们管这种再针对特殊领域的语言叫DSL(特殊领域语言)

什么是DSL

DSL是(Domain Specific Language)特定领域语言, 是做处理某一个特定领域的问题设计出来的语言, 这类语言比较简单, 再专属的领域内, 有非常强的表达力.

与之相对的是GPL(General Purpose Language) 通用编程语言. 这类语言是能够表达可被计算的逻辑, 必须是图灵完备, 他们的侧重点是灵活,全面. Java/python/js/go/c 这类都属于GPL

典型的DSL有哪些?

  • HTML
  • CSS
  • JSX
  • PUG
  • REGEX
  • SQL
  • XML
  • YMAL

上面的这些语言, 大家可以发现, 几乎都不是图灵完毕的计算机语言(为实现if空值/循环), 他们的语法很简单, 主要以声明式的表达方法来进行编写的

因为是使用声明式的表达方式, 所以在在阅读上会更加流畅

为什么要用DSL

DSL往往语法简单, 并且非常易于阅读, 再其擅长的领域内编码是非常高效的.
我们再拿HTML 和 js对象做一个对比:

1
{name: 'div', class: 'container', children: 'hello world'}

使用js来描述一个html节点, 只能把所有的属性平铺到一个对象中, 让人再阅读中无法找到重点, 并有效的区分区别

1
<div class="container"> hello world </div>

使用了xml语法, 可以快速辨别标签类型, 和内容, 整体语义化很强, 缺点就是有多余的符号和为了表达嵌套关系的闭合标签

1
div(class='container') hello world

使用pug独特的语法, 再不损失xml的表达力的前提下, 省略了无用的符号, 并通过缩进来表达嵌套逻辑, 降低了很多的语法噪音

可以看出来, DSL的引入确实能够有效的提高特定场景的表达效率, 再熟悉了相关语法后, 能够是代码更易理解, 也更加简洁

要不要引入DSL

再日常开发当中, 为了解决某一个场景的需求而引入一个新的DSL, 在我来看是需要谨慎考虑的. 因为这实际是引入了一种新的语言. 这可能会让整个项目的维护难度提升,
所以是否引入DSL需要考察几个点

  • 产能的提升是否能够抹平新语言引入带来的学习成本
  • 是否可以用相关第三方工具替代DSL

内部? 外部?

既然引入DSL是有成本的, 那么有没有什么成本比较低的引入方法?

也许有, 上面我们谈到的DSL在广义上作区分的话, 叫做外部DSL, 那么既然有外部DSL, 那肯定有内部DSL, 下面我举一个例子:

1
select 'name' from 'application' where id = 12345

1
2
3
select('name')
.from('application')
.where(id, '=', 12345)

上面的sql和下面的js代码实际上是等价的, 上面的sql我们叫它外部DSL, 下面的js叫做内部DSL.

再来一个例子:

外部DSL:

1
2
3
4
5
6
.container {
.content {
color: #fff;
margin: 10px
}
}

内部DSL:

1
2
3
$('.container .content')
.css('color', '#fff')
.css('margin', '10px')

显然这个内部DSL看着很眼熟, 感觉就是我们普通的js方法调用, 就是普通第三方库的接口调用. 实际上没有错, 内部DSL还有一个其他名字-流畅几口(fluency)

类似这样的内部DSL我们见过很多:

jquery:
JQuery可以被称作是对dom操作的内部DSL

1
2
3
4
$('.mydiv')
.addClass('flash')
.draggable()
.css('color', 'blue');

Moment
Moment 就可以被称作是对日期操作的 内部DSL

1
2
3
moment()
.subtract(10, 'days')
.calendar();

Chai
Chai 就可以被称作是对断言操作的 内部DSL

1
tea.should.have.property('flavors').with.lengthOf(3);

如何区分内部或外部DSL

通常来讲, 如果实现的功能,无法被宿主语言直接支持, 需要自己额外实现代码的编译和解析, 都属于外部DSL

如果实现的功能, 可以呗宿主语言直接支持, 就想上面的例子一样, 都是简单的函数形式, 那么这就属于内部的DSL

那么这里我有一个问题, 就是我们写React常用的JSX算是什么? 是内部的DSL么? 下面放一点示例代码

1
2
3
4
<Container>
<Menu list={this.state.list}/>
<Footer/>
</Container>

会被转成:

1
2
3
4
5
6
7
8
React.createElement(
Container,
null,
React.createElement(Menu, {
list: this.state.list
}),
React.createElement(Footer, null)
);

最然转义后的代码, 是合法的js代码, 但是源码并不是 一个合法的js代码, 正常的浏览器是无法正常识别这些代码的.
因为这些代码再被浏览器执行前, 会被Babel进行转移, 转移成可被浏览器识别的js代码而Babel再转义这些JSX的时候,
实际上内部已经实现了对这种特殊语法的编译和解析, 所以, 虽然jsx通常也写在.js文件中, 但它还是一个外部的DSL

如何看待内部DSL

DSL,实际上就是为了解决特定问题而出现的.
使用内部DSL来对某个领域进行扩展,在我看来是一个很好的解决方案, 因为没有引入新语言带来的新问题,

社区对,内部DSL的划分界定实际上很模糊, 希望大家不要过于纠结, 无论是API 还是 DSL, 能够有效的抽象并解决问题的, 都是好的解决之道

推荐阅读

前端 DSL 实践指南(上)—— 内部 DSL

你真的知道react-redux是怎么运行的么

最近我在想状态管理的一些技术, 突然想到, react这种只要一有数据变化就无脑进行reRender的框架, 再引入了redux的全局state后, 到底react-redux做了什么事情? 于是打算看看源码, 了解一下它幕后的故事,
由于目前我接触的项目绝大部分还停留在react 15上, 并且为了避免新增的hooks相关的代码干扰, 这次我主要看的是react-redux的5.x版本,毕竟更贴紧业务嘛!

通常看源码最好带一些问题去看效果比较好, 而我再看代码前, 我有以下几个疑问:

  1. 它是如果进行性能优化的
    2, 全局state的传递,是通过什么来实现的
  2. 如果使用context来传递store信息, 是如何不被CSU阻断的.(react15还在使用老的context api)

开始

react-redux有两个主要的api Provider connect. 他们俩类似于广播, 一个是广播塔, 一个是收音机, 我们先从广播塔Provider开始吧!

注: 以下的所有源码都被简化, 方便理解

Provider

我们先简单看一下用法, 方便对照调用和实现:

1
2
3
4
5
6
7
8
const store = createStore();
ReactDOM.render(
// 传递 全局状态到provider
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
)

ok,调用很简单, 让我们直接进入代码, 显然react-redux确实是使用context api进行全局store的传递的, 通过Provider从根组件传递了两个属性:

1
2
3
4
5
6
7
8
9
10
11
class Provider extends Component {
getChildContext() {
// 添加了两个context属性, 一个store, 一个storeSubscription
return { store: this.store, storeSubscription: null }
}
constructor(props, context) {
super(props, context)
this.store = props.store;
}
...
}

可以看出来 Provider只做了很少的事情, 就是把redux的store往下传递, 而这里有一个storeSubscription只是做了一个null的赋值, 那么这个属性到底是干什么的呢? 我们先卖个关子, 接着往下看

connect

上面说的Provider像是广播塔, 那么收音机connect它的调用方式我们也来回顾一下, 方便后面对照

1
2
3
4
5
6
7
8
9
function mapStateToProps(state) {
return { todos: state.todos }
}
function mapDispatchToProps(dispatch) {
return { actions: bindActionCreators(actionCreators, dispatch) }
}
// 最简单的调用, connect是一个高级函数的科里化, 第一次调用传入对应的mapToProps
// 方法, 第二次调用传入需要包裹的组件
export default connect(mapStateToProps, mapDispatchToProps)(TodoApp)

通过阅读源码,我发现connect的整个功能被分成了两个部分:

  1. 提取需要的stateProps, 注入被包裹的组件, 并做有效的缓存和性能优化
  2. 通知全局state状态改变

提取需要的stateProps

connect是一个会大规模使用的公共组件, 其性能一定是倍受关注的, 对于react, 除了普通的代码执行层面的优化, 最有效的优化无疑是componentShouldUpdate了, 因为即使有virtue Dom的存在, 依然不可避免的需要走完render函数, 走完diff算法, 而componentShouldUpdate则异常的直接, 只要你返回的是false, 就直接不做后面的所有事情, 无需render, 直接复用之前的元素.

那么如何判断我们的connect是否需要渲染呢?

  1. 组件本身的props有变化
  2. 全局state有变化

如上, 组件本身的props,我们是可以很容易拿到的, 任何一个有变化, 都应该reRender, 但是全局State变化, 我们就不一定都需要reRender了, 这个时候, 我们需要依赖的实际上是传入给connect的 mapStateToProps mapDispatchToProps, 他们两个都类似于选择器. 我只需要确认这两个函数的返回值是否有变化, 就可以确认我是否需要reRender, 因为她们的返回值, 最后都会作用下面组件的props上

创建一个selector

所以最后, 最好结果是组合 mapStateToProps mapDispatchToProps 这些函数, 最后返回一个结果对象, 我们只要判断这个结果对象前后两次是否相同, 就可以判断是否需要reRender, 大概是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const stateA = { a: 1, b: 2, c: 3, d: { aa: 4, bb: 5}};
const stateB = { a: 1, b: 999, c: 999, d: { aa: 4, bb: 5}};

function mapStateToProps1(state) {
return {
props1: state.a,
props2: state.d.bb,
}
}

function mapStateToProps2(state) {
return {
props3: state.a,
props4: state.d.aa,
}
}

function selector(mapToProps1, mapToProps2, state) {
return { ...mapToProps1(state), ...mapToProps2(state)};
}

const mergedPropsA = selector(mapStateToProps1, mapStateToProps2, stateA);
const mergedPropsB = selector(mapStateToProps1, mapStateToProps2, stateB);

shadowCompile(mergePropsA, mergePropsB); // true

上面是简单的实例, 来说明我们的 mapStateToProps mapDispatchToProps是如何来发挥选择器作用的.

下面我们看看react-redux是怎么做的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// 这个函数会传入我们再connect传入的 mapStateToProps, mapDispatchToProps, mergeProps, 三个函数, 以及dispatch
function pureFinalPropsSelectorFactory(
mapStateToProps,
mapDispatchToProps,
mergeProps,
dispatch,
) {
// 记录是否是第一次执行
let hasRunAtLeastOnce = false
let state
let ownProps
let stateProps
let dispatchProps
// 本函数最后都会返回这个值, 是一个结果对象, 最后会之间传递到被包裹的组件上
let mergedProps

// 首次执行, 计算props, 并生成缓存
function handleFirstCall(firstState, firstOwnProps) {
state = firstState
ownProps = firstOwnProps
stateProps = mapStateToProps(state, ownProps)
dispatchProps = mapDispatchToProps(dispatch, ownProps)
mergedProps = mergeProps(stateProps, dispatchProps, ownProps)
// 切换标志位
hasRunAtLeastOnce = true
return mergedProps
}

function handleNewPropsAndNewState() {
stateProps = mapStateToProps(state, ownProps)

if (mapDispatchToProps.dependsOnOwnProps)
dispatchProps = mapDispatchToProps(dispatch, ownProps)

mergedProps = mergeProps(stateProps, dispatchProps, ownProps)
return mergedProps
}
function handleNewProps() {
if (mapStateToProps.dependsOnOwnProps)
stateProps = mapStateToProps(state, ownProps)
if (mapDispatchToProps.dependsOnOwnProps)
dispatchProps = mapDispatchToProps(dispatch, ownProps)
mergedProps = mergeProps(stateProps, dispatchProps, ownProps)
return mergedProps
}
function handleNewState() {
const nextStateProps = mapStateToProps(state, ownProps)
const statePropsChanged = !areStatePropsEqual(nextStateProps, stateProps)
stateProps = nextStateProps
if (statePropsChanged)
mergedProps = mergeProps(stateProps, dispatchProps, ownProps)
return mergedProps
}
// 首次执行后都走这个函数
function handleSubsequentCalls(nextState, nextOwnProps) {
// 看组件自己接受的props是否相同
const propsChanged = !areOwnPropsEqual(nextOwnProps, ownProps)
// 判断收到的全局state是否相等, 上面两种方法都这是做浅比较
const stateChanged = !areStatesEqual(nextState, state)
state = nextState
ownProps = nextOwnProps
// 根据不同情况, 做不同处理, 最后的结果都是会去处理 mergedProps这个值
// 如果都相等就原封不动的返回mergedProps
if (propsChanged && stateChanged) return handleNewPropsAndNewState()
if (propsChanged) return handleNewProps()
if (stateChanged) return handleNewState()
return mergedProps
}
// 入口, 传入的是redux的全局state,和自己本身接受的props
return function selector(nextState, nextOwnProps) {
// 首次执行 和 之后的执行分别走了不同的函数
return hasRunAtLeastOnce
? handleSubsequentCalls(nextState, nextOwnProps)
: handleFirstCall(nextState, nextOwnProps)
}
}

上面我们看到, connect 生成了一个selector函数, 通过保存 mapStateToProps mapDispatchToProps的返回值,对两次结果进行比较来做优化,如果没变化则直接返回旧值.这样我们在每次调用selector之后,只要两次的返回值相等, 则说明我们并不需要渲染, 那么connectHOC的 componentShouldUpdate就可以返回true了
所以只要我们生成了select函数, 剩下只需要关注状态变化, 并渲染就可以了

state状态改变通知

再看代码前, 我们先来想一想应该怎样做状态变化的通知.

一种比较简单的状态通知方式是, 每次store改变, 触发根组件的刷新, 所有的connect都会在reRender的时机通过context取获取最新的全局state, 并通过selector进行属性提取, 最后把对应的props注入组件中

但是这有一个问题, 首先就是这种方式会在每次store有变化时,进行一次全应用的reRender, 性能可以说是非常差了. 所以要做性能优化, 上一节我们说了, 设计出来的selector是具被这些能力的,只需要使用
componentShouldUpdate优化, 使用它来判断是否需要对组件进行渲染.

但是如果我们仔细想一下,有任何一个connectHOC再做过前后stateProps对比后, 发现没有变化, 那么它就会停止了渲染, 这样会发生什么? 无疑, 这会导致 下级的connect
没有触发reRender, 从而也就不会触发它获取最新store, 整个数据的传递,再这里就被截断了, 在新的context api出现之前, 这个问题一直是倍受开发者诟病

上面这种方式状态的更新路径可以见下图;

既然有这样的问题, 我们就需要考虑能不能不让所有的connect去直接依赖store的变化? 如果这样,我们应该怎么办?

我们是不是可以通过嵌套层级进行一级一级的传递, 当顶级的store改变了, 向下一级进行通知, 对应的connect会自动向下一级进行转发, 这样就不再依赖于组件的渲染传递了, 具体的示意图如下:

实际上react-redux就是这样解决问题的, 每一个connectHOC都是一个订阅者, 同时也是一个发布者, 他们都包含一个subscription对象, 用来订阅上级connect, 并且通知下级connect,接下来上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
// 对select进行一次包裹, 方便吧一些值保存在这个包裹对象里
// 除了run, 还有props用来记录当前最新的stateProps, 还有shouldComponentUpdate
// 用来记录下次reRender是否应该通过
function makeSelectorStateful(sourceSelector, store) {
const selector = {
run: function runComponentSelector(props) {
const nextProps = sourceSelector(store.getState(), props)
if (nextProps !== selector.props) {
selector.shouldComponentUpdate = true
selector.props = nextProps
}
},
}
return selector
}
// 真正的connect包裹组件
class Connect extends Component {
constructor(props, context) {
super(props, context)
this.store = context.store
this.initSelector() // 初始化selector
this.initSubscription() // 初始化订阅器
}
getChildContext() {
// 覆盖上级的context, 这样下级的ConnectHOC就会获取覆盖后的订阅器,
return { storeSubscription: this.subscription }
}
componentDidMount() {
this.subscription.trySubscribe()
this.selector.run(this.props) // 计算当前最新的props
if (this.selector.shouldComponentUpdate) this.forceUpdate() // 如果计算结果有变化就强制刷新
}
componentWillReceiveProps(nextProps) {
this.selector.run(nextProps) // 每次获取到新的props都从新进行计算
}
shouldComponentUpdate() {
return this.selector.shouldComponentUpdate
}
initSelector() {
// 初始化selector, 这里的selector, 就是我们上面提到的selector, 接受最新的全局State, 和当前组件的props
const sourceSelector = selectorFactory(this.store.dispatch, selectorFactoryOptions)
// 做一个包裹, 用来保存更多信息
this.selector = makeSelectorStateful(sourceSelector, this.store)
// 调用run, 等于直接调用selector, 获取最新的props
this.selector.run(this.props)
}
initSubscription() {
const parentSub = this.context[subscriptionKey] // 获取上一级的订阅器
// 再上一级订阅器里注册onStateChange回调, 并生成自己的订阅器
// 之前卖的关子就在这里, Provider传入的 context.storeSubscription 是null, 因为当 storeSubscription为空的话, connect的会直接到store上进行订阅
// 否则会在上级connect再context中传入storeSubscription上进行订阅
// 也就是说, 只有第一级connect会直接订阅store的变化, 其他级别都是直接订阅上级connect的storeSubscription
this.subscription = new Subscription(this.store, parentSub, this.onStateChange.bind(this))
this.notifyNestedSubs = this.subscription.notifyNestedSubs.bind(this.subscription)
}
// 这个方法会在每次store有变化时调用
onStateChange() {
// 首先是计算props
this.selector.run(this.props)
if (!this.selector.shouldComponentUpdate) {
// 无变动则只通知
this.notifyNestedSubs()
} else {
// 有变动则再下次didupdate的时候进行通知, 我猜测是为了防止一次出发所有变动导致性能卡顿???
this.componentDidUpdate = this.notifyNestedSubsOnComponentDidUpdate
this.setState({})
}
}
// didUpdate生命周期的实际回调函数, 这么做官方解释也是为了能够减少boolean判断, 提高性能
notifyNestedSubsOnComponentDidUpdate() {
this.componentDidUpdate = undefined
this.notifyNestedSubs()
}
isSubscribed() {
return Boolean(this.subscription) && this.subscription.isSubscribed()
}
render() {
const selector = this.selector
selector.shouldComponentUpdate = false
return createElement(WrappedComponent,selector.props)
}
}

结语

OK, 看完这些, 我们基本上就把react-redux的核心理念都看完了,

  1. 首先是生成一个selector函数, 截取需要数据进行前后对比, 确定是否rerender
  2. 为了保证不被componentShouldUpdate截断, 使用发布订阅模式来对子级connect进行通知

上述的代码都是为了方便阅读而做了大量的删减, 只保存了最关键的代码, 实际上还有一些报错处理, 以及兼容处理, 比如如何应对一些react16已经不在安全的生命周期兼容,
ssr场景兼容, 以及更多的可扩展性的设计, 对了, 还有很多高阶函数的骚气调用, 看得我眼花缭乱. 总体来说, 收获不少, 能更深刻的理解设计者, 了解到他们代码之下的思考.

如何设计一个简单的模块系统

前言

前端社区早在几年前就进到了模块化开发方式中了, 实际上es6之前, js是没有模块概念的. 既没有变量的导出, 也没有变量的引入.

随着前端需要承担的功能越来越繁重, 需要复用的逻辑也越来越多, 全局变量越来越容易碰撞, 前端急需一个模块化方案. 那么既然javascript语言本身并没有模块化功能, 但是大佬们必然是有办法的. 于是各种模块化方案分分出现CMD AMD CommonJS, 再es6规范出来前, 这三种方案几乎统治了所有javascript开发. 他们都在自己不同的领域他提出了实现方案, 并解决了诸多的问题.

没想到的是, 前端是在变化太快, 2015年es6发布, 从此javascript有了自己的模块化方案. 并且很快, 基于模块化的打包工具webpack发布, 把前端工程化拉上一个全新高度—-万物皆模块.
善于接受变化的前端社区统统拥抱webpack, 很快webpack天下大统, 之前的三大解决方案,除了commonJS因为NodeJS亲儿子的高贵血统, CMD AMD的两个方案require.js sea.js几乎已经告别江湖, 成为历史, 变成了老前端回忆自己峥嵘岁月的谈资了. 是不是很像jquery的经历?

既然webpack这么厉害, 那么他的打包方案是什么? 是esModule么?

webpack时代

实际上由于webpack是基于NodeJs的运行时, 所以webpack显然一开始是使用的commonJS的方案, 相比CMD AMD,commonJS的群体比较大, 因为是运行再NodeJS上, webpack的配置文件,plugin,loader,都必须使用commonJS的方式来引入, 所以其他代码如果都是用一套方案,这样也比较统一, 也符合直觉.

虽然webpack再js代码的模块加载是按照commonJS规范来的, 但是实际上由于打包后的代码是运行再前端的, 所以需要实现一个前端的模块加载运行时, 那么webpack到底是怎么让基于nodeJS的模块系统再打包后能成功运行再前端呢?

如何实现一个模块

理想的一个js module应该是有自己的环境, 模块之间互相无法影响, 相互见的全局变量都是不可见的. 并且模块需要可以导出数据, 还要能够加载其他模块导出的数据.那么这样的功能我们该如何实现呢?

示例

我们先定义两个三个文件, 互相加载一下:

/index.js

1
2
var moduleA = require('./dep.js')
console.log('hello world')

/dep.js

1
2
3
var moduleA = require('./common.js')
var funA = function() {};
module.exports = funA;

/common.js

1
2
var util = function() {};
module.exports = util;

他们的依赖关系如下: index.js -> dep.js -> common.js;

好了, 既然上面的示例已经定好了, 使用的是commonJS的规范, 既然我们要实现一个简单版的模块系统, 那么先来分析分析这里会用到的语法把

基本语法

1
2
3
4
5
6
7
8
9
10
11
// import 代码
var moduleA = require('./a.js')
var funA = function() {};
// export 导出代发方式1
module.exports = {
funA
};
// or 导出代码方式2
exports.funcA = funA;

// 上面的两种导出方式是等价的

从上面的代码,我们可以看到, 这里有三个全局变量require module exports, 这三个全局变量我们待会再说

开始

ok, 既然是模块, 首先需要考虑的就是隔离全局变量, 换而言之就是要让每一个模块的执行环境都在不同的作用于下,
那么如何形成新的作用于我就不多说了,只要使用一个函数把模块中的代码进行包裹就可以了,就像这样:

1
2
3
4
5
6
7
8
function(require, module, exports) {
var moduleA = require('./a.js')
var funA = function() {};
// export
module.exports = funA;
// or
exports.funA = funA
}

这样模块內的全局变量就都变成了这个函数的局部变量, 现在只需把全局变量通过模块的函数参数传入, 那么接下来我们需要解决两个问题:

  • 如何把所有的模块都改写成包裹函数的方式, 并且把这些函数找个地方统一管理起来
  • 如何实现requiremodule exports 这三个变量

解析依赖树

第一个问题, 可以参考我之前的文章, 使用babel来解析入口文件index.js

因为在解析过程中, 我们需要获取所有的模块, 并且生成这个模块的唯一id, 方便起见, 我们直接已模块的项目相对路径为key来记录

整个过程大概分下面几部:

  1. 从入口未见解析, 只要遇到require方法的调用, 就解析他的参数来读取对应的模块文件, 并修改require的参数为require文件的项目相对路径
  2. 记录下文件的路径, 并js文件字符串,对其进行包裹
  3. 递归的重复上面两步解析js直到获取所有的依赖文件.
  4. 遍历后生成一个路径到模块的mapping即可, 生成后的模块图大概如此
    1
    2
    3
    4
    5
    6
    7
    const loadedModulesMap = {
    'src/dep.js': function(require, module, exports) {
    var moduleA = require('src/common.js')
    /* code */
    },
    'src/common.js': function(require, module, exports) { /* code */ }
    };

实现require

解决第二个问题, 码字太麻烦, 直接上代码把

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 这里记录的是第一步解析出来的所有依赖文件
const loadedModulesMap = {
'src/dep.js': function(require, module, exports) {
var moduleA = require('src/common.js')
var funA = function() {};
module.exports = funA;
},
'src/common.js': function(require, module, exports) {
var util = function() {};
module.exports = util;
},
};
// 缓存, 防止重复加载相同的模块
const cacheModulesMap = {};

// 定义require方法
require(path) {
// 先走缓存
if (cacheModulesMap[path]) {
return cacheModulesMap[path].exports;
}
// 注册缓存, 拿到缓存上的引用指针
const module = cacheModulesMap[path] = { exports: {} };
// 去模块map里拿对应的模块
const modulesFn = loadedModulesMap.path;
// 执行模块, 这里要注意, 模块的导出值灰写入的module上, 由于module是
// cacheModulesMap的引用, 所以导出值都会同时被保存
modulesFn(require, module, module.exports );
// module已经拿到导出值了, 直接返回对应导出值
return module.export;
}

// 运行入口文件代码, 一切慢慢展开
var moduleA = require('src/dep.js')
console.log('hello world')

这样, 一个解析后的代码, 就可以被正常的运行了, 这里我们可以发现一个commonJS模块的一个特点, 就是模块的加载是在运行时完成的, 也就是说, 只有在代码运行的时候, 模块才会被加载, 并且加载顺序和代码的调用顺序是强关联的, 这个和esModule是不同的, 感兴趣的同学可以去了解一下两者之间的区别

异步require

异步模块数据结构

上面是我们普通的require模块的简单实现. 但是在脱离的node环境后, 前端资源实际上很大程度上是依赖于异步获取的,那么一个需要异步获取的模块应该设计成什么样呢? 看看下面的数据结构;

1
2
3
4
5
6
{
`src/async.js`: function(require, module, exports) {
var asyncFn = function() {};
module.exports = funA;
}
}

这个结构保留了我们上面loadedModulesMap的数据结构, 方便merge到里面去, 现在需要设计一种加载机制, 把这部分代码加载到loadedModulesMap

主代码如何加载异步代码

webpack实现了一种异步加载的方法require.ensure, 我们就沿用把, 只不过既然是简单版, 就一个参数吧! 我们让dep.js变成异步加载common.js

/dep.js

1
2
3
4
5
6
7
8
9
10
11
// 之前
var moduleA = require('./common.js')
var funA = function() {};
module.exports = funA;
// 之后

var moduleA = require.ensure('./common.js').then((util) => {
util.xxx();
})
var funA = function() {};
module.exports = funA;

异步模块设计

有一点可以肯定, 我们肯定不能使用Ajax的方式进行, 因为我们需要执行它, 所以我们选择使用script标签, 那么只有一条路了! JSONP!

异步模块

1
2
3
4
5
6
7
8
9
// 两边定义一个共有变量 JSON_LIST
// 异步模块在加载后直接执行, 把模块挂载的全局队列里, 等待主模块读取
window.JSONP_LIST.push(
{
`src/async.js`: function(require, module, exports) {
var asyncFn = function() {};
module.exports = funA;
}
})

主模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

// 定义异步加载模块的方法
require.ensure = function(path) {
// 说明异步模块没加载
return new Promise((resolve) => {
// 判断是否加载过了
if (!loadedModulesMap[path]) {
resolve(require(path));
}

// 定义script标签
var script = document.createElement('script');
// 全局公共路径 + 相对路径拼接请求, 简单处理啦!
var url = PUBLIC_PATH + path;
script.url = url;
// 定义加载完成后的成功回调
script.onload = function() {
// 模块请求成功后, 加载队列里的所有模块
window.JSONP_LIST.forEach((moduleObj) => {
Object.keys(moduleObj).forEach((_path) => {
if (!loadedModulesMap[_path]) {
loadedModulesMap[_path] = moduleObj[_path];
}
})
})
// 处理了JSON_LIST里面已经加载郭的模块就要清空
window.JSON_LIST = [];
// 这时 已经成功加载了异步模块, 这时候返回模块值
if (!loadedModulesMap[path]) {
resolve(require(path));
}
}
document.head.appendChild(script);
})
}

一些其他细节

在增加了require.ensure之后, 你需要在解析文件的时候考虑异步加载的情况:

  • 解析require.ensure的调用
  • 异步应依赖的子树单独生成异步模块文件

最后生成的代码应该是:

主文件main.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
const loadedModulesMap = {
'src/dep.js': function(require, module, exports) {
var moduleA = require.ensure('src/common.js')
var funA = function() {};
module.exports = funA;
},
};

const cacheModulesMap = {};

require(path) {
if (cacheModulesMap[path]) {
return cacheModulesMap[path].exports;
}
const module = cacheModulesMap[path] = { exports: {} };
const modulesFn = loadedModulesMap.path;
modulesFn(require, module, module.exports );
return module.export;
}

require.ensure = function(path) {
return new Promise((resolve) => {
if (!loadedModulesMap[path]) {
resolve(require(path));
}
var script = document.createElement('script');
var url = PUBLIC_PATH + path;
script.url = url;
script.onload = function() {
window.JSONP_LIST.forEach((moduleObj) => {
Object.keys(moduleObj).forEach((_path) => {
if (!loadedModulesMap[_path]) {
loadedModulesMap[_path] = moduleObj[_path];
}
})
})
window.JSON_LIST = [];
if (!loadedModulesMap[path]) {
resolve(require(path));
}
}
document.head.appendChild(script);
})
}

var moduleA = require('src/dep.js')
console.log('hello world')

异步文件src/common.js;

1
2
3
4
5
6
7
window.JSONP_LIST.push(
{
`src/common.js`: function(require, module, exports) {
var util = function() {};
module.exports = util;
}
})

结语

以上只是一个非常非常简单的模块加载系统, 玩具中的玩具, 但是这不妨碍我们理解js模块化的本质, 只要看明白本质, 实际上webpack那些
眼花缭乱的操作也就不再那么神奇了

如果之后有机会, 我也许会在这个篇文章里补全简易版的webpack module federation, 敬请期待……

也许是后会无期?(大笑😄,逃……)

如何设计一个插件系统

前言

前端的日常开发, 离不开众多由社区提供的第三方library, 从创建到项目打包再到代码的运行时统统都少不了它们的身影. 我们再使用他们的时候由于不同的业务需求,有时候需要对他们进行一些修改. 而设计优秀的的library通常都有一些方式对使用者提供扩展的能力, 这种提供扩展能力的方法通常叫做插件

说到插件大家应该都不陌生, 几乎再日常开发中能用到形形色色的插件, 比如webpack的plugin, babel的plugin, 或者是eslint的plugin, 又或者是vue的plugin,甚至是很多coder都在使用的vscode的extend等等, 数不胜数. 那么这些优秀的项目的扩展体系都是如何设计的? 他们又为什么这么设计? 我们能从这当中学习到什么? 接下来我将从一个简单的小功能作为例子,以一个library开发者的角度来考虑, 一个插件系统应该如何设计.

起步

一下是一个非常简单功能例子, 就叫他miniLibrary吧! 它的执行顺序十分简单: 输入数据 -> 数据处理 -> 渲染数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class DataProcessRender {
constructor() {
this.state = {};
}

process(a, b) {
return a + b;
}

apply(a, b, targetDom) {
this.state {
a,
b,
};
this.targetDom = targetDom;
this.render();
}

render() {
document.querySelector(this.state.targetDom).innerHtml = process(this.state.a, this.state.b);
}

}

调用的方法:

1
2
const render = new DateProcessRender()
render.apply('hello', ' world!!', '#app');

显然目前看来, 这个library不仅很简单, 而且根本没有任何扩展可言, 除了能够修改apply方法的参数以外, 我们什么也做不了, 那么如果我们想给他增加扩展能力,我们应该怎么办呢?

插件的引入

首先我们需要设计一个插件引用的方式, 插件的引入一般有两种方法, 一种是通过配置声明的方式引入, 比如webpack, babel都是这样的, 使用的是它们的config文件来引入:

1
2
3
{
plugin: ['upperCase-param', 'default-render'],
}

另一种是vue这种调用式的加载方式:

1
Vue.use(xxx)

实际上第一个和第二个者没有本质区别, 最后还是会已第二种的形式对插件进行引入,那么既然两者实际上都一样, 那么我们就需要自己去实现一个插件的加载器功能

插件加载器一般分成插件的加载和插件的调用两个部分, 插件的加载很简单, 无非就是一个方法把插件注入到Library的内部, 方便随时调用, 但是插件的调用确非诚的依赖插件系统的本身设计, 所以我们我们先看看插件系统通常都有哪些把!

常见插件系统分类

通常插件系统的分类有:

  • 事件钩子型
  • pipeline的流式调用型
  • 获取实例型

那么下面我们分别按三种方式来对我们的这个这个miniLibrary进行插件化吧!

事件钩子型

使用钩子插件系统的Library有很多, webpack babel都是

从代码上看, 程序执行有几个主要的运行阶段: apply process render 我们只需要在这几个地方增加生命周期函数, 就可以吧把扩展的能力给到外面了,而你需要定义的就是每个钩子函数能拿到什么参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class DataProcessRender {
constructor() {
this.state = {};
this.plugin = null
}

process(a, b) {
// 增加process钩子, 用于增加处理结果的扩展能力
return this.plugin?.process?.(a, b) || a + b;
}

apply(a, b, targetDom) {
// apply钩子, 可以扩展初始化数据
const [_a, _b, _targetDom] = this.plugin?.onApply?.(a, b, targetDom);
this.state {
a: _a,
b: _b,
};
this.targetDom = _targetDom;
this.render();
}

render() {
// runder钩子, 用于修改render的节点
const dom = this.plugin?.onRender?.(document.querySelector(this.state.targetDom), this.targetDom);
dom.innerHtml = process(this.state.a, this.state.b);
}

use(plugin) {
this.plugin = plugin;
}
}

调用的方法:

1
2
3
4
5
6
7
8
const plugin = {
onApply: (a ,b ,targetDom) => [a.toUpperCase(), b.toUpperCase(), targetDom];
onProcess: (a, b) => a + ' ' + b + ' some extend text';
onRender: (dom) => dom || document.querySelector('#defaultDom');
}
const render = new DateProcessRender()
render.use(plugin);
render.apply('hello', ' world!!', '#app'); // HELLO WORLD!! some extend text

以上这种是使通过不同的周期函数来把新的逻辑值入到对应的阶段, 再不同的阶段对数据做不同程度的修改, 这看上去是不是很像写react组件?实际上你也可以吧每一个react的component看成react的插件, 而React只是一个调用器

这种设计插件的方式属于典型的声明式的编程, 代码的可读性比较强, 很容易再阅读插件源码的时候搞清楚来龙去脉, 并且对插件的能力做出了有效的控制. 不过对插件能力的控制试一把双刃剑, 及减少了不良插件对library的影响, 也同时代表插件的扩展能力没有那么强大

pipeline的流式调用型

使用pipeline的方式来进行扩展, 典型的应用有webpack loader 和 express的中间件.

一次调用一个plugin, 以类似流水线的方式击鼓传花对数据进行操作, 最后对数据做出响应的扩展方案, 一个个关联看似不大的插件, 就这么组合出一个强大的功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class DataProcessRender {
constructor() {
this.state = {};
this.plugins = [];
}

apply(a, b, targetDom) {
this.state {
a,
b,
}
this.targetDom = targetDom;
run();
}
process() {
data.result = data.a + '' + data.b;
}

render() {
document.querySelector(this.state.targetDom).innerHtml(this.state.result)
}

run() {
this.process();
let index = 0;
const context = {
data: this.state;
targetDom: this.targetDom;
}
// 这里只支持了同步的调用
const next = () => plugins.length < index + 1 ? this.plugins[index](context, next);
// 依次调用插件, 进行数据处理
if (this.plugins.length) {
this.plugins[index](context, next);
}
this.render();
}

use(plugin) {
this.plugins.push(plugin);
}
}

调用的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const pluginA = (context, next) =>  {
const { data } = context;
data.a = data.a.toUpperCase();
data.b = data.a.toUpperCase();
next()
}

const pluginB = (context, next) => {
const { data } = context;
data.result = data.a + ' ' + dat.b + ' some extend text';
next()
}

const pluginC = (context, next) => {
if (!document.querySelector(contest.targetDom)) {
context = '#defaultDom';
}
}

const render = new DateProcessRender()
// 注册
render.use(pluginA);
render.use(pluginB);
render.use(pluginC);
render.apply('hello', ' world!!', '#app'); // HELLO WORLD!! some extend text

上面故意多些几个例子是为了让每个插件的职责单一, 实际上这也着这类pipeline插件的特点, 比较容易写出职责单一的插件, 方便在后续的使用中进行有效的组合已经复用. 同时这种方式也能比较好的控制住插件的能力,你只需要把希望插件控制的属性传给他即可

获取实例型

获取实例的插件系统也有很多Library在使用, jQuery Vue
获取实例的方式, 可以让插件直接拿到Library的实例, 也就以为这他能拿到几乎所有的数据, 和直接改源码效果差不多, 非常的强大粗暴

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class DataProcessRender {
constructor() {
this.state = {};
}

process(a, b) {
return a + b;
}

apply(a, b, targetDom) {
this.state {
a,
b,
};
this.targetDom = targetDom;
this.render();
}

render() {
document.querySelector(this.state.targetDom).innerHtml = process(this.state.a, this.state.b);
}

use(plugin) {
plugin(this);
}
}

调用的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
const plugin(instance) {
instance.process = function(a, b) {
return a.toUpperCase() + ' ' + b.toUpperCase() + ' some extend text';
}
instance.render() {
const dom = document.querySelector(this.state.targetDom) || document.querySelector('#defaultDom')
dom.innerHtml = process(this.state.a, this.state.b);
}
}

const render = new DateProcessRender()
render.use(pluginA);
render.apply('hello', ' world!!', '#app'); // HELLO WORLD!! some extend text

这种方法显然是这三种当中最直接粗暴的策略, 给插件提供了非常非常强大的能力, 用这种方式, 你可以用很多种实现我们需要的功能, 但是强大的能力带给我们的是更多的风险. 我们需要去小心插件产生的副作用以及更陡峭的插件开发学习曲线. 因为你可以获得整个实例的所有数据. 你不仅仅可以读取实例所有数据, 你还可以给实例增加新的方法和属性, 但是这都需要又一个前提, 你对library的源码要了解的足够多, 以防你不小心覆盖了library的原生方法和属性导致了未知的错误

同时虽然有很好的扩展能力, 但是多个插件之间非常不容易相互配合, 并且插件的编写没什么解构调理, 让阅读代码上让人感到吃力

小结

首先得说明一下, 上面的例子都非常的简陋, 只是为了说明概念而设计的, 实际上的正规项目, 这些方案会复杂很多, 这里仅仅作为抛砖引玉.

那么问题来了, 以上三种插件模式到底哪一种好? 在我看来他们各有各的特点, 没有办法说出具体哪个好, 只能是各有千秋, 以及能力上的取舍而已, 实际上社区里的各大Library也并非是同时只在使用一个模式, 他们往往是相互使用的, 可能同时存在及使用钩子, 又使用继承, 既使用继承, 又使用pipeline. 比如webpack,他就是把这三种方式进行了融合, 感兴趣的同学可以看我之前的博文.

回到开头的问题上来, 我们能从这些插件系统模式上学到什么? 我觉得是让一个个看似黑箱的Library更加透明, 让写一个插件感觉并没有那么云里雾里, 让我们在写代码的时候能够知其然也能知其所以然.

银弹?

插件系统的设计有没有银弹呢? 我觉得没有, 但是会有一些很好的实践.

目前很多Library的插件系统做的都十分底层, 并不是简简单单中来做功能扩展的, 而是一个完整的运行架构. 所以的library内置功能都是基于插件来开发的, 比如babel-core 只是做代码到ast再到代码的转换, 大概是这样: (a) => a, 所有的语法转换都是基于一个个的插件来进行特定语法的转移
还有就是webpack的构建运行时, 从构建启动, 到文件生成, 都是一系列的声明周期钩子的调用.几乎95%的打包实现都是由不同的插件来实现的.

可以吧babel-core webpack-runtime 想象成我们开发使用的react, 所谓的Library功能, 就是我们写的react项目, 可以说功能插件化这是未来大型Library设计趋势,

结语

插件系统应该还有很多其他的方法论, 如果我在之后又看到新鲜的方案, 会第一时间分享出来.

深入webpack-插件

webpack目前是前端打包工具使用最多的工具, 这不仅仅是因为它有非常好的打包性能, 还应为它具备非常强的扩展性,可以为不同的场景进行功能的扩展.

如果我们去浏览一下webpack的源码, 我们会发现webpack源码里有了非常非常多的插件,在看过一部分webpack源码后你会发现, 实际上webpack的所有功能都是用一个一个插件堆砌出来的.下面我们先讲一讲webpack插件的核心逻辑到底是什么.

打包的主要流程

我们把webpack的构建过程想象成是一个有大量生命周期的一个MVVM前端组件, 从组件初始化到成功的渲染的页面上会触发多个生命周期, 在不同的生命周期来会触发不同的回调函数,而回调函数可以访问到组件的内部状态.

webpack在每一次构建的时候会生成一个compiler对象, 这个对象就类似于一个组件实例, 所有的生命周期都是对这个compiler对象的状态描述.compiler对象会依次触发它上面的每一个生命周期, 只不过在webpack里我们不叫它生命周期, 叫做钩子

webpack里所有的对象几乎都是由类来生成的, 而钩子就是在类的constructor方法中进行初始化. 看过上一篇对tapable介绍的同学应该知道, 这里在大概说一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Compiler {
constructor(context) {
this.hook = {
/** @type {SyncBailHook<[string, Entry], boolean>} 处理webpack config option */
entryOption: new SyncBailHook(["context", "entry"])
/** @type {AsyncSeriesHook<[Compiler]>} 正式开始 */
run: new AsyncSeriesHook(["compiler"]),
/** @type {SyncHook<[CompilationParams]>} 编译 */
compile: new SyncHook(["params"]),
/** @type {SyncHook<[Compilation, CompilationParams]>} 生成 compilation */
compilation: new SyncHook(["compilation", "params"]),
/** @type {AsyncParallelHook<[Compilation], Module>} 生成依赖树 */
make: new AsyncParallelHook(["compilation"]),
/** @type {AsyncSeriesHook<[Compilation]>} 写入文件系统 */
emit: new AsyncSeriesHook(["compilation"]),
/** @type {AsyncSeriesHook<[Stats]>} 完成 */
done: new AsyncSeriesHook(["stats"]),
/** @type {SyncHook<[Error]>} 失败 */
failed: new SyncHook(["error"]),
}
}
}

上面列出的钩子并不是所有的,做了很大的精简, 书写顺序合调用顺序一致,compiler钩子完整列表

如何使用钩子

那么在实例化Compiler后生成了对象compiler,当我们拿到compiler对象后, 我们就可以为compiler对象上面的各种钩子添加对应的回调函数.

1
2
3
4
5
const compiler = new Compiler();
compiler.hooks.compilation.tap('pluginName', (compilation, params) => {
// 通过对给对应的钩子添加回调函数, 在回调函数执行时,就可以拿到对应的对象
// 泽辉这个钩子里, 我们可以拿到compilation对象
})

看上去载一个钩子上添加回调函数是是非容易的. 但是问题来了, 我们怎样才能获取到compiler对象呢? 这里webpack插件就需要登场了

如何写一个插件

官方的plugin示例是这样的:

1
2
3
4
5
6
7
8
class Plugin {
apply(compiler) {
// 获得compiler了!
compiler.hooks.compilation.tap((compilation, params) => {
...
})
}
}

在定义好plugin后, 在webpack的config文件中引入这个plugin, 并在对应位置调用:

1
2
3
4
5
6
const Plugin = require('./Plugin.js');
module.export = {
...
plugins: [new Plugin()],
...
}

那么我们自定义的plugin是如何调用,在什么时候注入到compiler里的呢?我们来看一下初始化compiler的代码, 我们发现在生成compiler的时候:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const createCompiler = options => {
options = new WebpackOptionsDefaulter().process(options);
// 生成一个compiler对象
const compiler = new Compiler(options.context);
// 依次调用插件
if (Array.isArray(options.plugins)) {
for (const plugin of options.plugins) {
if (typeof plugin === "function") {
plugin.call(compiler, compiler);
} else {
plugin.apply(compiler);
}
}
}
...
};

从上面的源码我们可以看出来, 插件仅仅可以是一个类, 也可以是一个函数, 使用方法大概是:

1
2
3
4
5
6
7
8
9
10
11
12
// 使用function的方式挂载插件
function plugin(compiler) {
// 获得compiler了!
compiler.hooks.compilation.tap((compilation, params) => {
...
})
}
module.export = {
...
plugins: [plugin],
...
}

还有一种挂载插件的方式是在使用node api来调用webpack()打包的时候, 在拿到返回的compiler对象后,手动调用插件:

1
2
3
4
5
6
const Plugin = require('./Plugin.js');
const compiler = webpack(() => {
...
});
// 手动调用插件
(new Plugin()).apply(compiler);

不过这种方法并不适用与所有的场景, 因为在调用webpack的过程中,会有一些钩子已经运行完毕, 并不推荐.

上面的几个插件声明的示例都使用的是compilation钩子, 回调函数种可以拿到一个compilation对象, 那么它到底是什么呢?

complication

每一个compiler对象会在构建的时候会生成一个compilation对象, 用来保存在整个构建过程中产生的不同数据, 比如解析模块的结果缓存, 依赖模块图, 依赖文件图, chunk图等.compilation对象会在compile之后的大部分钩子回调中获得.

webpack的整个构建过程产生的数据信息都保存在compiler对象合compilation对象上,那么既然插件能够拿到这两个对象说明插件能够对webpack的全部构建流程进行干预.事实上也是如此. webpack的整个核心功能几乎都是由内部上百个私有插件呢组织而成的.所以说webpack插件的扩展能力是非常强大的.

compiler和compilation之间的关系:

compilation对象里有大量的钩子, 具体列表

一个简单的例子

现在我们希望在webpack构建完毕后,获取构建时依赖的所有项目文件.

目前的思路是获取到compilation对象, 上面应该有所有的依赖文件列表.

为了保证compilation对象上的依赖文件列表是已经收集完整的了,所以我们再选择钩子的时候要确保构建过程已经结束,依赖收集已经完毕的钩子

1
2
3
4
5
6
7
8
9
10
11
module.export = class exportDepPlugin {
apply(compiler) {
// emit钩子 说明整个构建已经完成, 准备输出文件,这是依赖已经收集完毕
compiler.hooks.emit.tagAsync('exportDepPlugin', (compilation) => {
const _fileDependencies= Array.from(compilation.fileDependencies);// fileDependencies是一个Set
})
// 排除node_modules里的文件
const dep = _fileDependencies.filter((d) => !d.includes('node_modules'));
console.log(dep);
}
}

小结

webpack的插件功能非常强大, 因为它能够访问到webpack构建过程中的两个核心对象,并做出干预.而且你也可以自己为这几个对象添加新的钩子,好让其他第三方插件来对你的插件进行二次扩展.

webpack的这种插件机制因为使用了类似发布订阅的模式,在我看来实实在在是一个双刃剑, 在提供了强大的扩展性的同时,也把相互之间的代码逻辑隐藏在了深处. 在查看源码的时候阻碍重重, 无法让编辑器进行任何静态分析.

想要了解更多进阶的插件使用方法, 这边建议还是看一些webpack源码, 里面有很多骚气的写法

扩展阅读:

webpack API文档

webpack 项目

深入webpack-tapable篇

打算写一些webpack相关的文章, 今天就先打个样

引子

说到webpack, 都知道是一个打包工具, 从一个入口开始, 遍历所有依赖, 并把所有文件都打包成一个js文件, webpack在整个构建过程中需要做一系列的工作, 那么它对这些复杂的工作是怎么组织的呢?
当我们去看webpack源码的时候, 会发现webpack在初始化的时候调用了大量的没用过的plugin来给compiler提供各种能力, 让人眼花缭乱的plugin,就这么一个个的挂载到compiler里面, 完全看不懂到底干了点啥子, 所以让我们先看看plugin到底是个啥

plugin

先让我们看一个webpack plugin 简单到不能再简单的例子

1
2
3
4
5
6
7
8
9
const pluginName = 'ConsoleLogOnBuildWebpackPlugin';

class ConsoleLogOnBuildWebpackPlugin {
apply(compiler) {
compiler.hooks.run.tap(pluginName, compilation => {
console.log('webpack 构建过程开始!');
});
}
}

调用:

1
2
3
4
5
6
{ 
...
plugins: [
new ConsoleLogOnBuildWebpackPlugin(),
]
}

代码很简单, 所有插件都是一个class, 插件传入配置的时候会被实例化, 而webpack在注册plugin的时候会调用这个实例的apply方法, 并把这次构建的compiler作为参数传入,
这个compiler会在整个构建周期中存在, 那么这句到底是什么含义?

1
2
3
compiler.hooks.run.tap(pluginName, compilation => {
console.log('webpack 构建过程开始!');
});

其实从代码上可以看到, 这段代码在compiler的钩子run上挂了一个插件,当这个钩子被调用的时候, 我们传入的回调函数将会被执行console.log('webpack 构建过程开始!');
那么我们看到的个hooks到底是什么东西? 让我们看一下compiler的源码(注掉不需关注的代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const {
SyncHook,
SyncBailHook,
AsyncParallelHook,
AsyncSeriesHook
} = require("tapable");
...
class Compiler {
/**
* @param {string} context the compilation path
*/
constructor(context) {
this.hooks = Object.freeze({
shouldEmit: new SyncBailHook(["compilation"]),
done: new AsyncSeriesHook(["stats"]),
afterDone: new SyncHook(["stats"]),
additionalPass: new AsyncSeriesHook([]),
beforeRun: new AsyncSeriesHook(["compiler"]),
run: new AsyncSeriesHook(["compiler"]), // <------------- 上面调用的run在这里!!
emit: new AsyncSeriesHook(["compilation"]),
assetEmitted: new AsyncSeriesHook(["file", "info"]),
afterEmit: new AsyncSeriesHook(["compilation"]),
thisCompilation: new SyncHook(["compilation", "params"]),
compilation: new SyncHook(["compilation", "params"]),
normalModuleFactory: new SyncHook(["normalModuleFactory"]),
...
});
...
}

可以发现Compiler类的所有hooks都是一个个不同类型的钩子的实例, 而这些钩子都来自于同一个库tapable.

tapable是什么

tapable是什么?
早先的webpack complier 是tapable的实例, 现在仅仅是tapable的调用者, 我也觉得这样更好, 把很多调用放到外部, 远比继承一个第三方库要好, 代码的调用更明显,不再那么隐晦

实际上webpack编译打包类似于一个流水线, 在流水线上有很多节点, 不同的节点暴露不同的属性和值, 你可以再不同的节点对这些属性和值左调整处理, 最后直到打包完成. 那么webpack流水线中的不同的节点我们成为不同的钩子(hook), 而这些钩子就是由tapable负责生成的.

今天先说说webpack的事件机制 tapable

tapable很像我们了解的发布订阅模式,我们先看看tapable是怎么做的

tapable 怎么用

1
const hook = new SyncHook(["arg1", "arg2", "arg3"]);

这里是新声明了一个钩子, 大家留意, SyncHook的实参传入一个数组为参数, 每一个数组item代表将来挂载这个钩子上的回调函数时能够接受到的参数数量. 即时之后我们再触发钩子时传入在多的实参, 也无法超过三个. 但是这里有一个疑问, 但是为什么要这么写? 为什么不能吧触发钩子传入的所有实参都同步给钩子的回调? 这样不是应该更加灵活么?这仅仅是一种类似注释的约定么?方便开发更快的时候钩子回调内的参数是什么? 我们这里留一手, 最后再讲

我们还是先来看看tapable里面有什么把!它会导出多种钩子类型, 从大的类型里能分为SyncHook, AsyncSeriesHook, AsyncParalleHook这两种, 顾名思义, 一种钩子的所有回调都是同步的, 另两种则是异步的. 两个异步钩子一种是顺序执行,也就是一个接一个, 另一个则是同步执行, 就是一次吧所有钩子回调都执行

多个钩子在调用上也有不同的讲究, 通常是根据上一个钩子回调的返回值有不同的处理, 规则有如下三款, 还有一款正在设计当中

  • basic (基本款)
  • waterfall (大瀑布款)
  • bail (保险款)
  • loop (自留款, todo…)

基本款就不多说了, 所有的回调依次调用, 大家好对一个个报数就可以了
大瀑布款, 其实所有回调依次调用, 不同的的房是, 每一个回调都能够接受到上一个回调的返回值, 就类似于咱们的击鼓传花啦
保险款, 顾名思义, 跟保险丝差不多, 再依次执行回调的时候, 如果有任何一个回调有返回值, 则这个钩子的执行将提前结束

同步钩子和异步钩子再加上我们刚才说的三款不懂得调用处理方式, 就把整个钩子的类型翻了一倍, 下面这些也就是tapable提供的所有种类钩子:

  • SyncHook
  • SyncBailHook
  • SyncWaterfallHook
  • SyncLoopHook
  • AsyncParallelHook
  • AsyncParallelBailHook
  • AsyncSeriesHook
  • AsyncSeriesBailHook
  • AsyncSeriesWaterfallHook

上面知道了钩子都叫什么, 也知道了为啥叫这个名字, 下面我们就来看看钩子应该怎么用, 该怎么触发

1
2
3
4
5
6
7
8
// 定义一个钩子
const hook = new SyncHook(["arg1", "arg2", "arg3"]);

hook.tap('whatEver', (arg1, arg2, arg3) => {
console.log(arg1, arg2, arg3) // 1 2 3
})

hook.call(1, 2,3)

实际上第一个参数 可以是一个对象, 里面可以有个before, stage, 用来设置这个plugin插入的位置
是不是相继了一般的发布订阅模式? hook.tap是订阅, hook.call是发布

刚我们看的是最最最简单的SyncHook, 他只有一种挂在钩子回调的方法, 就是tap, 实际上不同的钩子类型, 它提供的挂载方式也不同, 分成三种

  • tap
  • tapPromise
  • tapAsync
    区别也挺简单的, tap就是同步挂载,挂载的回调函数自然是一个同步函数, tapPromise挂载的异步函数了, 要求他必须返回一个Promise, tabAsync挂载的也是一个异步函数, 函数中的形参callback代表着函数的结束, 类似expressnext.

tapable也有拦截器, 其实就是在每个钩子的不同状态触发前的一个控制函数, 让你对整个钩子有更多的控制能力

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
hook.intercept({
context: true, // 开启时, 可以再拦截函数的第一个参数拿到context对象, context对象也可以从所有的钩子回调中获得
// hook 触发时
call: (arg1, arg2, arg2) => {
...
},
// hook 注册时
register: (tapInfo) => {
return tapInfo;
}
// 有新的钩子回调挂进来时
tap: (tap) => {
...
}
})

前端N面-elm-lang

elm是什么?

他是一个著名的外卖平台

elm是一门开发语言, 是一门函数式语言, 但是不同于其他函数式语言, 因为这门语言只聚焦在前端开发方面, 也就是说,最后它能够编译成javascript.
elm是一门很年轻的语言, Evan Czaplicki 再2012年才把它设计出来, 而且是在他的毕业设计上(大雾)…
但是尽管elm非常年轻, 但显然它再前端圈里也是有这举足轻重的地位的,至于为什么, 我们稍后再说

为什么我们需要了解elm

最近两年, 因为react 和redux 的函数式编程思想, 导致前端的函数式编程大热. 我们不谈函数式编程真正能给前端带来什么,仅仅说函数式编程作为一种完全不同编程范式,
再学习这种编程思想的同时, 一定能过扩宽我们的编程思路, 一些偏函数式的库的出发点和基石, 毕竟再js里函数也是一等公民
如今前端框架吸收函数式编程以及强类型语言优点的形势下, 甚至可能会开启我们对前端的重新认识

elm有什么特点?

  • 强类型 (static type)
  • 函数式语言 (fpr)
  • 一次编译, (处处运行?) No Runtime Exceptions
  • 数据不可变 (immutable)
  • 虚拟dom (Great Performance)

语法特点

elm的语法是来源于haskell的,虽然不是haskell的方言, 但还是吸收了很多形式,一下不会完全展开,
直挑一些后面实例用的到的一些语法做介绍

list

list和js里的array类似, 但是每一个值都必须是相同的类型

1
2
a = [1, 2, 3, 4] -- correct 
a = [1, "2", 3, 4] -- wrong

Records

Record 和js里的Object很像, 他的定义方法是:

1
2
3
4
5
6
7
john = { first = "John", last = "Hobson", age = 25 }

.last john // "Hobson" .last 是一个records访问器

john.last // "Hobson" 直接访问last

{john | last = 'blabla' } // { first = "John", last = "blabla", age = 25 }

Function

1
2
3
add : Int -> Int -> Int // 类型定义
add a b = a + b // function add (a,b) {return a + b}
add 1 2 // 3

这里就可以发现elm的函数式特点, 再我们定义一个函数的时候, 如果有多个参数, 参数和参数之间的定义使用->来分割的,
其实这也表名了这个函数是被科里化的,函数的执行可以被分解成以下两部

1
2
temp = add 1 // init -> init
temp 2 // 3

再elm里所有的函数都是被科里化的, 科里化的好处? 打个比方

1
2
3
4
5
6
7
8
9
10
11
repeat(10, 'a') // 'aaaaaaaaaa'
// 科里化后
repeat(10)('a') // 'aaaaaaaaaa'

// 没有科里化
repeat10(str) {
return repeat(10, str);
}

// 科里化的
repeat10 = repeat(10)

pipeline

因为是函数式语言, 会出现多层的函数嵌套,比如再js中

1
2
3
4
5
6
7
8
9
10
function test(param) {
let a = fa(param);
let b = fb(a);
let c = fc(c);
return c
}
// or
function test(param) {
return fc(fb(fa(param)));
}

你也可以通过lodash 或者 ramda 这样的fp工具库来做链式调用, 这里就不一一展开, 感兴趣的可以看对应的文档

而再elm里, 就有专门的语法来处理这种情况

1
2
3
4
5
param = 
param
|>fa
|>fb
|>fc

一个字, 优雅

type

elm是一个强类型语言, 所以显然我们需要对类型做定义, 这里重点讲一下elm里面的union type, 因为这是elm的重要核心

####普通类型

1
2
3
User : String 
User tom = 'tom'
User num = 1 // error

####Union Type
Union type 用来表示一组可能tag集合,比如Hr, Admin 都是User的一个variants

1
type User = Hr String | Admin String

而Union type 也可以被解构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
getUserLabel: User -> String
getUserLabel user =
case user of
Hr name ->
name
Admin name ->
role ++ name

```elm
注意`case user of`下面的代码, 是不是和js里的`switch`很像? 所以union type很多情况都被当做是一种条件判断

```elm
type User = Guest String | Admin String Int
getWelcomeMessage: User -> String
getWelcomeMessage user =
case user of
Guest _->
"guest login!"
Admin _ _ ->
"admin login! welcome back!"

示例

ok, 学完上面的几个知识点, 我们开始看一个简单的示例,来了解一个elm程序是如何构成的吧!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
module Main exposing (..)

import Browser
import Html exposing (Html, button, div, text, span, input)
import Html.Events exposing (onClick, onInput)

-- MAIN
main =
Browser.sandbox { init = init, update = update, view = view }

-- MODEL
type alias Model = { count: Int }

init : Model
init = { count = 1 }

-- UPDATE
type Msg = Increment | Decrement

update : Msg -> Model -> Model
update msg model =
case msg of
Increment ->
{ model | count = model.count + 1 }

Decrement ->
{ model | count = model.count + 1 }

-- VIEW
view : Model -> Html Msg
view model =
div []
[ button [ onClick Decrement ] [ text "-" ]
, div [] [ text (String.fromInt model.count) ]
, button [ onClick Increment ] [ text "+" ]
]

这里view是一个函数,它使用当前的modell来渲染dom, 使用的是类似jsx的函数调用, 这是一个纯的标准的函数式组件

update函数接受 Msg, 根据不同variants来对model做更新, 这和redux的reducer是非常类似的, 不同的variants就类似于不同的actionType
状态被一个纯函数来更新,并返回一个新的状态

init定义了当前的初识状态

最后形成一个数据流 init -> view -> update -> init 这样的一个单向数据流

上面就是一个简单的例子, 能够很好的提现elm开发的理念, 和它的代码解构, 先看一下上面的几行注释,
能明显看出来, 代码把程序分成几个部分:

  • 数据 (model the state of your application)
  • 视图 (view a way to turn your state into HTML)
  • 更新 (update a way to update your state based on messages)

elm管这种结构叫做 elm architecture,
elm architecture

是不是很像redux的逻辑?实际上并不是elm借鉴了redux, 而是redux借鉴了elm! 同时也顺便激发了dva

I read about Elm before Redux but didn’t get it fully, later realized it was important influence. – Dan Abrawov

Lightweight front-end framework based on redux, redux-saga and react-router. (Inspired by elm and choo) – Dva

Side Effects

刚才一直在说elm都是纯函数, 但是在实际开发中, 是不可能这么完美的, 我们有很多场景都是不纯粹的, 比如io, network, 和其他js代码交互 等等, elm使用port, 和他本身的rentime封装了这层逻辑,
这样隔离了这些有副作用的逻辑
img

结语

实际上, 目前很少有人在再生产环境使用elm, 一方面是因为它的社区不够强大, 还有一方面是文档不够完善, 再有就是比较陡峭的学习成本, 往往让人望而生畏

不过如果真的要入手学习的话, 作者写的TODOMVC,看明白就很不错啦
如果你敢再生产环境使用, 那你是真正的勇士(其实还真有)

推荐阅读

elm官网

functional-frontend-architecture, 如何用纯js撸一个elm architecture

更详细的教程

初识Svelte

什么是Svelte?

首先, svelte是一个前端框架, 在国内使用人数非常少, 但是如果你到github上去看它的repo,你会发现居然有25.3k的star.
什么? 居然这么多? 为啥我没听过呢? 不是只有三大框架么? (问号脸?)
那今天就来给大家介绍一下它和我们熟悉的另外几个常见框架react vue 有什么区别?

Svelte有什么特点

让我们先看一下用svelte来书写组件是什么样子的:

try.svelte

1
2
3
4
5
6
7
8
9
10
11
<script>
let count = 0;

function handleClick() {
count += 1;
}
</script>

<button on:click={handleClick}>
Clicked {count} {count === 1 ? 'time' : 'times'}
</button>

我们有一说一, 是不是有点像vue的写法? 看想去很像是.vue的文件嘛!? 但又好少点什么?

没有错! 就是少了很多东西, 首先旧少了框架的引入, 也少了很多声明试的代码,如果让你用vue react 来写这个代码逻辑的话,
恐怕都没法写的这么精简把? 其实这就是Svelte框架对外宣称的第一个特点: ‘Write less code’, 整个代码几乎没有任何样板代码,
看上去十分清爽, 也刚容易理解

实际上通常一个功能的代码量, svelte要比 react少不少:
对比

让我们看看代码为什么这么少?我们发现代码在对变量更新的时候并没有使用类似reactsetState方法, 而是直接对变量进行了再赋值.
仅仅是对变量进行了赋值就可以引发视图的变化, 很显然是数据响应的, 这就是svelte的第二个特点: ‘truly reactive’

但是它又很奇怪,因为它像vue一样吧变量声明到一个对象中来进行数据劫持, 那到底是怎么做到的呢?

让我们看看他编译以后的代码, 编译以后变成了这样…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/* App.svelte generated by Svelte v3.12.1 */
import { SvelteComponent, append, detach, element, init,
insert, listen, noop, safe_not_equal, set_data, space, text
} from "svelte/internal";

function create_fragment(ctx) {
var button, t0, t1, t2, t3_value = ctx.count === 1 ? 'time' : 'times' + "", t3, dispose;
return {
c() {
button = element("button"); // 创建一个button
t0 = text("Clicked ");
t1 = text(ctx.count); // 变量count 的textnode
t2 = space(); // 空格?
t3 = text(t3_value); // 之前的三元表达式的结果 的 textnode
dispose = listen(button, "click", ctx.handleClick); // 给button元素添加点击时间
},
m(target, anchor) {
insert(target, button, anchor); // 插入到目标元素
append(button, t0); // 把文本添加到button上
append(button, t1);
append(button, t2);
append(button, t3);
},
p(changed, ctx) { // 数据到dom的操作函数, 这里直接改document上的对应元素了!
if (changed.count) { set_data(t1, ctx.count); } // 更新 count textnode
if ((changed.count) && t3_value !== (t3_value = ctx.count === 1 ? 'time' : 'times' + "")) {
set_data(t3, t3_value); // 更新三元表达式的 textnode
}
},
i: noop, o: noop,
d(detaching) { // 销毁组件
if (detaching) { detach(button); }
dispose();
}
};
}
function instance($$self, $$props, $$invalidate) {
let count = 0; // 真正的变量声明初始化
function handleClick() { // click回调 初始化
$$invalidate('count', count += 1);
}
return { count, handleClick };
}
// 组件的构造函数
class App extends SvelteComponent {
constructor(options) {
super();
init(this, options, instance, create_fragment, safe_not_equal, []);
}
}
export default App;

what? 没法看了,对不对? 让我们回过头仔细看看代码…

看完注释, 是不是大概搞明白了. 这不就是jQuery么!

没错, 大概意思差不多, 其实把svelte称作一个前端框架是不恰当的, 他更应该被称作编译器, 把开头的.svelet文件编译成上面的可执行代码.
而且编译后的代码中调用的大多数方法都是一些helper function, 所以svelte的runtime代码量是非常少的,它主要的工作都是在编译期完成的.

并且我们发现,数据和视图的绑定,使用的是vanillaJs, 数据的变动,直接修改对应的dom节点,也就是说,他并没有用virtual dom!
这就是svelte的第三个卖点: ‘no virtual dom’. 显然我们不需要猜测也知道, 他的性能会很好.

作者在官网上说:

虚拟DOM不是一种功能。而是一种声明性的,状态驱动的UI开发,的最终手段.因为它使你无需考虑状态转换即可构建应用程序,并且性能通常足够好。这意味着更少的错误代码,而将更多的时间花费构建功能上,而不是乏味的工作上. 但是事实证明,我们无需使用虚拟DOM就可以实现类似的编程模型-这就是Svelte的用武之地。

实际上最后, svelte编译好的代码是不再需要任何依赖的,是一个天然的组件, 只要把导出的类直接实例化, 组件就被正确的添加了

1
2
3
4
const el = new App({
target: document.body,
props: {},
})

所以svelte很容易被嵌入到其他框架中去使用

svelte潜在的问题

  • 虽然它只有很少的运行时代码,但他构建后的代码,有可能比用virtual dom构建的代码多,并且有很多运行时代码的重复调用. 随着项目的扩大,运行时代码的体量优势也会越来越小, 最终实际生产项目中能有多少尺寸的优势很难说
  • 虽然编译后的代码性能几乎和 vanillaJs持平, 现在主流框架的virtual dom 也可以做到只比vanillaJs慢1.5倍左右, 这也证明了为了web页面的性能的瓶颈很可能不在dom本身,而在框架
  • svelte的这种实现逻辑导致它无法被像virtual dom这样, 容易静态分析,继而移植为服务端渲染, 也很难基于vNode进行测试

结语

不论如何,不得不说, 作者在整个框架思路上,非常的清奇,与众不同. 看完让我眼前一亮. 果然virtual dom 并不一定是前端的的最佳实践 前端还会有很多路要走, 我们也有很多代码值得去看.
最后提一句, 作者操作这么秀, 一定不是普通人,对吧? 他就是前端轮子哥, 也是rollup的作者 Rich-Harris. 这是他的github

推荐阅读

svelte 官网
Truly reactive programming with Svelte 3.0

10分钟上手ast

什么是AST

AST(Abstract Syntax Tree)简称是抽象语法树,是源代码语法结构的一种抽象表示.它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构.
比如一下代码:

1
const 1 = 5;

这行代码大致会被解析为以下ast

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "a"
},
"init": {
"type": "NumericLiteral",
"value": 5
}
}
],
"kind": "const"
}

这里推荐一个工具 ast explorer, 它可以帮你快速把代码转换成ast, 提高你的开发效率

可以看出来, 代码变成了一个树结构的数组对象, 描述了一个对一个变量的赋值. 那么我们能用ast来做点什么呢?

重新认识babel

社区里, 能够解析js代码到ast的工具有很多, 这里我们就使用大家都比较熟悉的babel来举例

babel是一个js编译器,能够把es6等语法编译成es5, 并且他还提供了丰富的工具,可以让我们做很多我们想干的事情.比如:

1
import { Button } from 'element'

转化成:

1
import Button from 'element/lib/Button'

如果想完成这样的事情,我们需要大概了解一下babel的工具包

  1. @babel/parser用来把js代码转换成ast
  2. @babel/traverse用来帮助你遍历ast
  3. @babel/types 用来帮助你判断ast节点,或生成相应的节点
  4. @babel/generator 用来把ast从新转换成js代码

开始

让我们想一下, 如果要把代码a转变成代码b, 应该是一个什么思路?我的思路是:

  1. 先吧js转换成ast
  2. 遍历ast收集信息
  3. 更具收集的信息生成ast
  4. 把新生城的ast转换为js代码并输出

第一步,转换ast

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { parse } from '@babel/parser';
import traverse from "@babel/traverse";

const code = "import { Button } from 'element'";
// 生成传入code的ast
const ast = parse(code);

const names = [];
traverse(ast, {
ImportDeclaration: function(path) { // 只处理import语句
const sourceValue = path.node.source.value;
if (sourceValue === 'element') {
if (types.isImportSpecifier(specifier)) { // 只处理解构方式声明的变量明
// 拿到所有结构的变量名
names.push(specifier.local.name);
}
}
}
});

//根据收集到的解构变量名list,生成新的ast
const body = names.map((name) => {
const source = types.stringLiteral(`element/lib/${name}`); // 生成source节点
const specifier = types.importDefaultSpecifier(types.identifier(name)); // 生成 specifier节点
return types.ImportDeclaration([specifier], source); // 生成Import声明节点
})

// 在外面包了一层program节点, 生成完整的树
const convertedAst = types.program(body, [], 'module');

// 把ast转换成js代码并输出
const convertCode = generator(convertedAst).code;
return convertCode; // import Button from 'element/lib/Button'

以上就是这个简单小功能的用法, 可以发现其实就是按部就班的来,类似于把大象放进冰箱的思路

其实在生成js代码的时候也可以用@babel/template来做, 也可能会更方便, 相关的工具api可以再babel的主页上查找

也许这个babel的小栗子可以为你打开一扇门, 让你能够知道, 其实你可以做的更多