taowen

taowen 查看完整档案

北京编辑  |  填写毕业院校滴滴出行  |  problem solver 编辑填写个人主网站
编辑

Go开发者们请加入我们,滴滴出行平台技术部 taowen@didichuxing.com

个人动态

taowen 发布了文章 · 7月29日

为什么拖拽的 api 都很难用?

最终效果

先来看达到了什么样的效果 https://github.com/mulcloud/s...

undo 操作

undo 操作

拖放到区域外自动回滚

drop outside

Framer Motion 的缺点

拖拽和动画是由 Framer Motion 实现的。Framer Motion 在表达动画方面毫无疑问相当牛逼,一点点的代码,就可以实现非常丰富的效果。比 react-spring 还要强!

Framer Motion 在拖拽方面要差点意思。这个是官方给的例子 https://codesandbox.io/s/framer-motion-2-drag-to-reorder-fc4rt 。 主要有两大缺点。

第一个缺点是缺少 drag over 事件。HTML5 的 Drag/drop api 是有两部分的,一部分是被 drag 的元素,一部分是 dragging over 的元素。而 framer motion 仅仅提供了被 drag 元素的 dragStart 和 dragEnd 两个事件。这个就很不符合直觉了,那我被 drag over 了,怎么显示我这里是可以被 drop 的呢?

第二个缺点是实现写得比较绕。第1步先用 ref 拿到了 DOM 元素。第2步,在 useEffect 里采集每个 DOM 元素的位置,复制到另外一个名字叫 positions 的 useRef 上。第3步,假定一个 DOM 元素的数组就是界面上要排序的元素,进行元素位置互换。这相当于是把一部分的 DOM 状态做了一个完整的拷贝,复制到了 react state 里。

为什么 react 拖拽库的 api 都不好用呢?

react dnd 也很难用 https://react-dnd.github.io/react-dnd/about

为啥这些 api 都不做得好用一点呢?我分析有以下原因

  • react 的理念是 ui 由 state 渲染而来。但是拖拽的时候是找不到对应的 react state 的。
  • 直接操作 dom 的拖拽库,例如 https://github.com/SortableJS/Sortable 直接把 DOM 当 state 操作。但是这个和 react 渲染的界面又会冲突。需要做一些 hack 的工作才能让这样模式的拖拽库和 react 配合起来。
  • 跨列表拖拽等需求经常需要在多个组件之间移动元素。这个时候是需要全局有 store 的,而不是每个组件各管各的状态。react 社区没有统一的全局 store,所以各个拖拽库只好让你来自己做适配了。
  • 拖拽过程的 placeholder/drop zone/辅助线 等各种交互效果都是有很重的业务逻辑的。这些业务逻辑严重依赖于 xy 坐标进行计算。但是 react 的代码里比较难拿到 DOM 元素,以及 xy 坐标。需要 ref 和 useEffect 这样做一份复制。

直接访问 DOM 就好了嘛

不能理解为啥用了 react,就一定要啥都用 react。毕竟 Web DOM 的 api 还在那里啊,为啥不用呢?拖拽过程中计算元素位置,最方便最直观的,当然是直接用 DOM 的 api 取得鼠标下面的元素,然后访问 offsetLeft / offsetHeight 等元素属性进行计算嘛。Framer Motion 的例子里复制一份有什么必要呢。如果有多个列表要拖拽,难道每个列表都要平行维护一份 DOM 的副本状态在 react state 中么?这也太原教旨主义了。

完全托管状态

这个想法就是用一个“状态管理”工具把所有的组件状态都管理起来。这样就能很方便的实现 undo/redo 了。当然你会说,redux 就是这样的状态管理工具。

而且我们希望是多 store 的状态管理,对应到每个可被 draggable 和 droppable 的 DOM 都有一个 store。这个用 redux 就不那么直接了。为什么有多 store 的需求呢?因为前面直接访问 DOM 做了排版上的计算,然后第二步就是要更新 state 触发重渲染了。我们需要从 DOM 元素找到对应的 store,dispatch action 更新这个 store。一个直观的做法就是在 DOM 元素上加上 data-model-class 和 data-model-id 的属性,拿这两个属性去找到对应的 store。

最终实现的代码在这里 https://github.com/mulcloud/state-management-demo/tree/master/src/Scenario6

招聘前端

工作地点:北京 / 杭州

非常有创新性和成长的工作内容 https://www.zhipin.com/job_detail/?query=%E4%B9%98%E6%B3%95%E4%BA%91&city=101010100&industry=&position=

查看原文

赞 6 收藏 4 评论 0

taowen 赞了文章 · 6月24日

《编程时间简史系列》JavaScript 模块化的历史进程

引言

昨天在思否上闲逛,发现了一个有意思的问题(点此传送)。

因为这个问题,我产生了写一个系列文章的想法,试图从站在历史的角度上来看待编程世界中林林总总的问题和解决方案。

目前中文网络上充斥着大量互相“转载”的内容,基本是某一个技术问题的解决方案(what?how?),却不涉及为什么这么做和历史缘由(why?when?)。比如你要搜 “JavaScript 有哪些模块化方案?它们有什么区别?”,能得到一万个有用的结果;但要想知道 “为什么 JavaScript 有这么多模块化方案?它们是谁创建的?”,却几乎不可能。

因此,这一系列文章内会尽可能的不涉及具体代码,只谈历史故事。但会在文末提供包含部分代码的参考链接,以供感兴趣的朋友自行阅读。

这个系列暂定为十篇文章,内容会涉及前端、后端、编程语言、开发工具、操作系统等等。也给自己立个 Flag,在今年年底之前把整个系列写完。如果没完成目标……就当我没说过这句话(逃

全系列索引:

  1. 《编程时间简史系列》JavaScript 模块化的历史进程
  2. 《编程时间简史系列》Web Server 编年史

正文

模块化,是前端绕不过去的话题。

随着 Node.js 和三大框架的流行,越来越多的前端开发者们脑海中都会时常浮现一个问题:

为什么 JavaScript 有这么多模块化方案?

自从 1995 年 5 月,Brendan Eich 写下了第一行 JavaScript 代码起,JavaScript 已经诞生了 25 年。

但这门语言早期仅仅作为轻量级的脚本语言,用于在 Web 上与用户进行少量的交互,并没有依赖管理的概念。

随着 AJAX 技术得以广泛使用,Web 2.0 时代迅猛发展,浏览器承载了愈来愈多的内容与逻辑,JavaScript 代码越来越复杂,全局变量冲突、依赖管理混乱等问题始终萦绕在前端开发者们的心头。此时,JavaScript 亟需一种在其他语言中早已得到良好应用的功能 —— 模块化。

其实,JavaScript 本身的标准化版本 ECMAScript 6.0 (ES6/ES2015) 中,已经提供了模块化方案,即 ES Module。但目前在 Node.js 体系下,最常见的方案其实是 CommonJS。再加上大家耳熟能详的 AMDCMDUMD,模块化的事实标准如此之多。

那么为什么有如此之多的模块化方案?它们又是在怎样的背景下诞生的?为什么没有一个方案 “千秋万代,一统江湖”?

接下来,我会按照时间顺序讲述模块化的发展历程,顺带也就回答了上述几个问题。

萌芽初现:从 YUI Library 和 jQuery 说起

时间回到 2006 年 1 月,当时还是国际互联网巨头的 Yahoo(雅虎),开源了其内部使用已久的组件库 YUI Library

YUI Library 采用了类似于 Java 命名空间的方式,来隔离各个模块之间的变量,避免全局变量造成的冲突。其写法类似于:

YUI.util.module.doSomthing();

这种写法无论是封装还是调用时都十分繁琐,而且当时的 IDE 对于 JavaScript 来说智能感知非常弱,开发者很难知道他需要的某个方法存在于哪个命名空间下,经常需要频繁地查阅开发手册,导致开发体验十分不友好。

在 YUI 发布之后不久,John Resig 发布了 jQuery。当时年仅 23 岁的他,不会知道自己这一时兴起在 BarCamp 会议上写下的代码,将占据未来十几年的 Web 领域。

jQuery 使用了一种新的组织方式,它利用了 JavaScript 的 IIFE(立即执行函数表达式)和闭包的特性,将所依赖的外部变量传给一个包装了自身代码的匿名函数,在函数内部就可以使用这些依赖,最后在函数的结尾把自身暴露给 window。这种写法被很多后来的框架所模仿,其写法类似于:

(function(root){
    // balabala
    root.jQuery = root.$ = jQuery;
})(window);

这种写法虽然灵活性大大提升,可以很方便地添加扩展,但它并未解决根本问题:所需依赖还是得外部提前提供,还是会增加全局变量。

从以上的尝试中,可以归纳出 JavaScript 模块化需要解决哪些问题:

  1. 如何给模块一个唯一标识?
  2. 如何在模块中使用依赖的外部模块?
  3. 如何安全地(不污染模块外代码)包装一个模块?
  4. 如何优雅地(不增加全局变量)把模块暴漏出去?

围绕着这些问题,JavaScript 模块化开始了一段曲折的探索之路。

探索之路:CommonJS 与 Node.js 的诞生

让我们来到 2009 年 1 月,此时距离 ES6 发布尚有 5 年的时间,但前端领域已经迫切地需要一套真正意义上的模块化方案,以解决全局变量污染和依赖管理混乱等问题。

Mozilla 旗下的工程师 Kevin Dangoor,在工作之余,与同事们一起制订了一套 JavaScript 模块化的标准规范,并取名为 ServerJS

ServerJS 最早用于服务端 JavaScript,旨在为配合自动化测试等工作而提供模块导入功能。

这里插一句题外话,其实早期 1995 年,Netsacpe(网景)公司就提供了有在服务端执行 JavaScript 能力的产品,名为 Netscape Enterprise Server。但此时服务端能做的 JavaScript 还是基于浏览器来实现的,本身没有脱离其自带的 API 范围。直到 2009 年 5 月,Node.js 诞生,赋予了其文件系统、I/O 流、网络通信等能力,才真正意义上的成为了一门服务端编程语言。

2009 年年初,Ryan Dahl 产生了创造一个跨平台编程框架的想法,想要基于 Google(谷歌)的 Chromium V8 引擎来实现。经过几个月紧张的开发工作,在 5 月中旬,Node.js 首个预览版本的开发工作已全部结束。同年 8 月,欧洲 JSConf 开发者大会上,Node.js 惊艳亮相。

但在此刻,Node.js 还没有一款包管理工具,外部依赖依然要手动下载到项目目录内再引用。欧洲 JSConf 大会结束后,Isaac Z. Schlueter 注意到了 Ryan DahlNode.js,两人一拍即合,决定开发一款包管理工具,也就是后来大名鼎鼎的 Node Package Manager(即 npm)。

在开发之初,摆在二人面前的第一个问题就是,采用何种模块化方案?。二人将目光锁定在了几个月前(2009 年 4 月)在华盛顿特区举办的美国 JSConf 大会上公布的 ServerJS。此时的 ServerJS 已经更名为 CommonJS,并重新制订了标准规范,即Modules/1.0,展现了更大的野心,企图一统所有编程语言的模块化方案。

具体来说,Modules/1.0标准规范包含以下内容:

  1. 模块的标识应遵循一定的书写规则。
  2. 定义全局函数 require(dependency),通过传入模块标识来引入其他依赖模块,执行的结果即为别的模块暴漏出来的 API。
  3. 如果被 require 函数引入的模块中也包含外部依赖,则依次加载这些依赖。
  4. 如果引入模块失败,那么 require 函数应该抛出一个异常。
  5. 模块通过变量 exports 来向外暴露 API,exports 只能是一个 object 对象,暴漏的 API 须作为该对象的属性。

由于这个规范简单而直接,Node.jsnpm 很快就决定采用这种模块化的方案。至此,第一个 JavaScript 模块化方案正式登上了历史舞台,成为前端开发中必不可少的一环。

需要注意的是,CommonJS 是一系列标准规范的统称,它包含了多个版本,从最早 ServerJS 时的 Modules/0.1,到更名为 CommonJS 后的 Modules/1.0,再到现在成为主流的 Modules/1.1。这些规范有很多具体的实现,且不只局限于 JavaScript 这一种语言,只要遵循了这一规范,都可以称之为 CommonJS。其中,Node.js 的实现叫做 Common Node ModulesCommonJS 的其他实现,感兴趣的朋友可以阅读本文最下方的参考链接。

值得一提的是,CommonJS 虽然没有进入 ECMAScript 标准范围内,但 CommonJS 项目组的很多成员,也都是 TC39(即制订 ECMAScript 标准的委员会组织)的成员。这也为日后 ES6 引入模块化特性打下了坚实的基础。

分道扬镳:CommonJS 历史路口上的抉择

在推出 Modules/1.0 规范后,CommonJSNode.js 等环境下取得了很不错的实践。

但此时的 CommonJS 有两个重要问题没能得到解决,所以迟迟不能推广到浏览器上:

  1. 由于外层没有 function 包裹,被导出的变量会暴露在全局中。
  2. 在服务端 require 一个模块,只会有磁盘 I/O,所以同步加载机制没什么问题;但如果是浏览器加载,一是会产生开销更大的网络 I/O,二是天然异步,就会产生时序上的错误。

因此,社区意识到,要想在浏览器环境中也能顺利使用 CommonJS,势必重新制订新的标准规范。但新的规范怎么制订,成为了激烈争论的焦点,分歧和冲突由此诞生,逐步形成了三大流派:

  • Modules/1.x 派:这派的观点是,既然 Modules/1.0 已经在服务器端有了很好的实践经验,那么只需要将它移植到浏览器端就好。在浏览器加载模块之前,先通过工具将模块转换成浏览器能运行的代码了。我们可以理解为他们是“保守派”。
  • Modules/Async 派:这派认为,既然浏览器环境于服务器环境差异过大,那么就不应该继续在 Modules/1.0 的基础上小修小补,应该遵循浏览器本身的特点,放弃 require 方式改为回调,将同步加载模块变为异步加载模块,这样就可以通过 ”下载 -> 回调“ 的方式,避免时序问题。我们可以理解为他们是“激进派”。
  • Modules/2.0 派:这派同样也认为不应该沿用 Modules/1.0,但也不向激进派一样过于激进,认为 require 等规范还是有可取之处,不应该随随便便放弃,而是要尽可能的保持一致;但激进派的优点也应该吸收,比如 exports 也可以导出其他类型、而不仅局限于 object 对象。我们可以理解为他们是“中间派”。

其中保守派的思路跟今天通过 babel 等工具,将 JavaScript 高版本代码转译为低版本代码如出一辙,主要目的就是为了兼容。有了这种想法,这派人马提出了 Modules/Transport 规范,用于规定模块如何转译。browserify 就是这一观点下的产物。

激进派也提出了自己的规范 Modules/AsynchronousDefinition,奈何这一派的观点并没有得到 CommonJS 社区的主流认可。

中间派同样也有自己的规范 Modules/Wrappings,但这派人马最后也不了了之,没能掀起什么风浪。

激进派、中间派与保守派的理念不和,最终为 CommonJS 社区分裂埋下伏笔。

百家争鸣:激进派 —— AMD 的崛起

激进派的 James Burke 在 2009 年 9 月开发出了 RequireJS 这一模块加载器,以实践证明自己的观点。

但激进派的想法始终得不到 CommonJS 社区主流认可。双方的分歧点主要在于执行时机问题,Modules/1.0 是延迟加载、且同一模块只执行一次,而 Modules/AsynchronousDefinition 却是提前加载,加之破坏了就近声明(就近依赖)原则,还引入了 define 等新的全局函数,双方的分歧越来越大。

最终,在 James BurkeKarl Westin 等人的带领下,激进派于同年年底宣布离开 CommonJS 社区,自立门户。

激进派在离开社区后,起初专注于 RequireJS 的开发工作,并没有过多的涉足社区工作,也没有此草新的标准规范。

2011 年 2 月,在 RequireJS 的拥趸们的共同努力下,由 Kris Zyp 起草的 Async Module Definition(简称 AMD)标准规范正式发布,并在 RequireJS 社区的基础上建立了 AMD 社区。

AMD 标准规范主要包含了以下几个内容:

  1. 模块的标识遵循 CommonJS Module Identifiers
  2. 定义全局函数 define(id, dependencies, factory),用于定义模块。dependencies 为依赖的模块数组,在 factory 中需传入形参与之一一对应。
  3. 如果 dependencies 的值中有 requireexportsmodule,则与 CommonJS 中的实现保持一致。
  4. 如果 dependencies 省略不写,则默认为 ['require', 'exports', 'module']factory 中也会默认传入三者。
  5. 如果 factory 为函数,模块可以通过以下三种方式对外暴漏 API:return 任意类型;exports.XModule = XModulemodule.exports = XModule
  6. 如果 factory 为对象,则该对象即为模块的导出值。

其中第三、四两点,即所谓的 Modules/Wrappings,是因为 AMD 社区对于要写一堆回调这种做法颇有微辞,最后 RequireJS 团队妥协,搞出这么个部分兼容支持。

因为 AMD 符合在浏览器端开发的习惯方式,也是第一个支持浏览器端的 JavaScript 模块化解决方案,RequireJS 迅速被广大开发者所接受。

但有 CommonJS 珠玉在前,很多开发者对于要写很多回调的方式颇有微词。在呼吁高涨声中,RequireJS 团队最终妥协,搞出个 Simplified CommonJS wrapping(简称 CJS)的兼容方式,即上文的第三、四两点。但由于背后实际还是 AMD,所以只是写法上做了兼容,实际上并没有真正做到 CommonJS 的延迟加载。

CommonJS 规范有众多实现不同的是,AMD 只专注于 JavaScript 语言,且实现并不多,目前只有 RequireJSDojo Toolkit,其中后者已经停止维护。

一波三折:中间派 —— CMD 的衰落

由于 AMD 的提前加载的问题,被很多开发者担心会有性能问题而吐槽。

例如,如果一个模块依赖了十个其他模块,那么在本模块的代码执行之前,要先把其他十个模块的代码都执行一遍,不管这些模块是不是马上会被用到。这个性能消耗是不容忽视的。

为了避免这个问题,上文提到,中间派试图保留 CommonJS 书写方式和延迟加载、就近声明(就近依赖)等特性,并引入异步加载机制,以适配浏览器特性。

其中一位中间派的大佬 Wes Garland,本身是 CommonJS 的主要贡献者之一,在社区中很受尊重。他在 CommonJS 的基础之上,起草了 Modules/2.0,并给出了一个名为 BravoJS 的实现。

另一位中间派大佬 @khs4473 提出了 Modules/Wrappings,并给出了一个名为 FlyScript 的实现。

Wes Garland 本人是学院派,理论功底十分扎实,但写出的作品却既不优雅也不实用。而实战派的 @khs4473 则在与 James Burke 发生了一些争论,最后删除了自己的 GitHub 仓库并停掉了 FlyScript 官网。

到此为止,中间一派基本已全军覆灭,空有理论,没有实践。

让我们前进到 2011 年 4 月,国内阿里巴巴集团的前端大佬玉伯(本名王保平),在给 RequireJS 不断提出建议却被拒绝之后,萌生了自己写一个模块加载器的想法。

在借鉴了 CommonJSAMD 等模块化方案后,玉伯写出了 SeaJS,不过这一实现并没有严格遵守 Modules/Wrappings 的规范,所以严格来说并不能称之为 Modules/2.0。在此基础上,玉伯提出了 Common Module Definition(简称 CMD)这一标准规范。

CMD 规范的主要内容与 AMD 大致相同,不过保留了 CommonJS 中最重要的延迟加载、就近声明(就近依赖)特性。

随着国内互联网公司之间的技术交流,SeaJS 在国内得到了广泛使用。不过在国外,也许是因为语言障碍等原因,并没有得到非常大范围的推广。

兼容并济:UMD 的统一

2014 年 9 月,美籍华裔 Homa Wong 提交了 UMD 第一个版本的代码。

UMDUniversal Module Definition 的缩写,它本质上并不是一个真正的模块化方案,而是将 CommonJSAMD 相结合。

UMD 作出了如下内容的规定:

  1. 优先判断是否存在 exports 方法,如果存在,则采用 CommonJS 方式加载模块;
  2. 其次判断是否存在 define 方法,如果存在,则采用 AMD 方式加载模块;
  3. 最后判断 global 对象上是否定义了所需依赖,如果存在,则直接使用;反之,则抛出异常。

这样一来,模块开发者就可以使自己的模块同时支持 CommonJSAMD 的导出方式,而模块使用者也无需关注自己依赖的模块使用的是哪种方案。

姗姗来迟:钦定的 ES6/ES2015

时间前进到 2016 年 5 月,经过了两年的讨论,ECMAScript 6.0 终于正式通过决议,成为了国际标准。

在这一标准中,首次引入了 importexport 两个 JavaScript 关键字,并提供了被称为 ES Module 的模块化方案。

在 JavaScript 出生的第 21 个年头里,JavaScript 终于迎来了属于自己的模块化方案。

但由于历史上的先行者已经占据了优势地位,所以 ES Module 迟迟没有完全替换上文提到的几种方案,甚至连浏览器本身都没有立即作出支持。

2017 年 9 月上旬,Chrome 61.0 版本发布,首次在浏览器端原生支持了 ES Module

2017 年 9 月中旬,Node.js 迅速跟随,发布了 8.5.0,以支持原生模块化,这一特性被称之为 ECMAScript Modules(简称 MJS)。不过到目前为止,这一特性还处于试验性阶段。

不过随着 babelWebpackTypeScript 等工具的兴起,前端开发者们已经不再关心以上几种方式的兼容问题,习惯写哪种就写哪种,最后由工具统一转译成浏览器所支持的方式。

因此,预计在今后很长的一段时间里,几种模块化方案都会在前端开发中共存。


尾声

本文以时间线为基准,从作者、社区、理念等几个维度谈到了 JavaScript 模块化的几大方案。

其实模块化方案远不止提到的这些,但其他的都没有这些流行,这里也就不费笔墨。

文中并没有提及各个模块化方案是如何实现的,也没有给出相关的代码示例,感兴趣的朋友可以自行阅读下方的参考阅读链接。

下面我们再总结梳理一下时间线:

时间事件
1995.05Brendan Eich 开发 JavaScript。
2006.01Yahoo 开源 YUI Library,采用命名空间方式管理模块。
2006.01John Resig 开发 jQuery,采用 IIFE + 闭包管理模块。
2009.01Kevin Dangoor 起草 ServerJS,并公布第一个版本 Modules/0.1
2009.04Kevin Dangoor 在美国 JSConf 公布 CommonJS
2009.05Ryan Dahl 开发 Node.js
2009.08Ryan Dahl 在欧洲 JSConf 公布 Node.js
2009.08Kevin DangoorServerJS 改名为 CommonJS,并起草第二个版本 Modules/1.0
2009.09James Burke 开发 RequireJS
2010.01Isaac Z. Schlueter 开发 npm,实现了基于 CommonJS 模块化方案的 Common Node Modules
2010.02Kris Zyp 起草 AMDAMD/RequireJS 社区成立。
2011.01玉伯开发 SeaJS,起草 CMDCMD/SeaJS 社区成立。
2014.08Homa Wong 开发 UMD
2015.05ES6 发布,新增特性 ES Module
2017.09ChromeNode.js 开始原生支持 ES Module

注:文章中的所有人物、事件、时间、地点,均来自于互联网公开内容,由本人进行搜集整理,其中如有谬误之处,还请多多指教。


参考阅读


首发于 Segmentfault.com,欢迎转载,转载请注明来源和作者。

RHQYZ, Write at 2020.06.24.

查看原文

赞 48 收藏 29 评论 5

taowen 赞了文章 · 6月24日

揭秘webpack插件的工作原理

webpack系列1:常见 loader 源码简析,以及动手实现一个 md2html-loader
webpack系列2:揭秘webpack 插件工作原理
webpack系列3:webpack 主流程源码阅读以及实现一个 webpack

前言

通过插件我们可以扩展webpack,在合适的时机通过Webpack提供的 API 改变输出结果,使webpack可以执行更广泛的任务,拥有更强的构建能力。
本文将尝试探索 webpack 插件的工作流程,进而去揭秘它的工作原理。同时需要你对webpack底层和构建流程的一些东西有一定的了解。

想要了解 webpack 的插件的机制,需要弄明白以下几个知识点:

  1. 一个简单的插件的构成
  2. webpack构建流程
  3. Tapable是如何把各个插件串联到一起的
  4. compiler以及compilation对象的使用以及它们对应的事件钩子。

插件基本结构

plugins是可以用自身原型方法apply来实例化的对象。apply只在安装插件被Webpack compiler执行一次。apply方法传入一个webpck compiler的引用,来访问编译器回调。

一个简单的插件结构:

class HelloPlugin {
  // 在构造函数中获取用户给该插件传入的配置
  constructor(options) {}
  // Webpack 会调用 HelloPlugin 实例的 apply 方法给插件实例传入 compiler 对象
  apply(compiler) {
    // 在emit阶段插入钩子函数,用于特定时机处理额外的逻辑;
    compiler.hooks.emit.tap('HelloPlugin', (compilation) => {
      // 在功能流程完成后可以调用 webpack 提供的回调函数;
    })
    // 如果事件是异步的,会带两个参数,第二个参数为回调函数,在插件处理完任务时需要调用回调函数通知webpack,才会进入下一个处理流程。
    compiler.plugin('emit', function (compilation, callback) {
      // 支持处理逻辑
      // 处理完毕后执行 callback 以通知 Webpack
      // 如果不执行 callback,运行流程将会一直卡在这不往下执行
      callback()
    })
  }
}

module.exports = HelloPlugin

安装插件时, 只需要将它的一个实例放到Webpack config plugins 数组里面:

const HelloPlugin = require('./hello-plugin.js')
var webpackConfig = {
  plugins: [new HelloPlugin({ options: true })],
}

先来分析一下 webpack Plugin 的工作原理

  1. 读取配置的过程中会先执行 new HelloPlugin(options) 初始化一个 HelloPlugin 获得其实例。
  2. 初始化 compiler 对象后调用 HelloPlugin.apply(compiler) 给插件实例传入 compiler 对象。
  3. 插件实例在获取到 compiler 对象后,就可以通过compiler.plugin(事件名称, 回调函数) 监听到 Webpack 广播出来的事件。
    并且可以通过 compiler 对象去操作 Webpack

webapck 构建流程

在编写插件之前,还需要了解一下Webpack的构建流程,以便在合适的时机插入合适的插件逻辑。

Webpack 的基本构建流程如下:

  1. 校验配置文件 :读取命令行传入或者webpack.config.js文件,初始化本次构建的配置参数
  2. 生成Compiler对象:执行配置文件中的插件实例化语句new MyWebpackPlugin(),为webpack事件流挂上自定义hooks
  3. 进入entryOption阶段:webpack开始读取配置的Entries,递归遍历所有的入口文件
  4. run/watch:如果运行在watch模式则执行watch方法,否则执行run方法
  5. compilation:创建Compilation对象回调compilation相关钩子,依次进入每一个入口文件(entry),使用 loader 对文件进行编译。通过compilation我可以可以读取到moduleresource(资源路径)、loaders(使用的 loader)等信息。再将编译好的文件内容使用acorn解析生成 AST 静态语法树。然后递归、重复的执行这个过程,
    所有模块和和依赖分析完成后,执行 compilationseal 方法对每个 chunk 进行整理、优化、封装__webpack_require__来模拟模块化操作.
  6. emit:所有文件的编译及转化都已经完成,包含了最终输出的资源,我们可以在传入事件回调的compilation.assets上拿到所需数据,其中包括即将输出的资源、代码块 Chunk 等等信息。
// 修改或添加资源
compilation.assets['new-file.js'] = {
  source() {
    return 'var a=1'
  },
  size() {
    return this.source().length
  },
}
  1. afterEmit:文件已经写入磁盘完成
  2. done:完成编译
    奉上一张滴滴云博客的 WebPack 编译流程图,不喜欢看文字讲解的可以看流程图理解记忆
    WebPack 编译流程图

原图出自:https://blog.didiyun.com/inde...

看完之后,如果还是看不懂或者对缕不清 webpack 构建流程的话,建议通读一下全文,再回来看这段话,相信一定会对 webpack 构建流程有很更加深刻的理解。

理解事件流机制 tapable

webpack本质上是一种事件流的机制,它的工作流程就是将各个插件串联起来,而实现这一切的核心就是 Tapable。

WebpackTapable 事件流机制保证了插件的有序性,将各个插件串联起来, Webpack 在运行过程中会广播事件,插件只需要监听它所关心的事件,就能加入到这条 webapck 机制中,去改变 webapck 的运作,使得整个系统扩展性良好。

Tapable也是一个小型的 library,是Webpack的一个核心工具。类似于node中的events库,核心原理就是一个订阅发布模式。作用是提供类似的插件接口。

webpack 中最核心的负责编译的Compiler和负责创建 bundles 的Compilation都是 Tapable 的实例,可以直接在 CompilerCompilation 对象上广播和监听事件,方法如下:

/**
 * 广播事件
 * event-name 为事件名称,注意不要和现有的事件重名
 */
compiler.apply('event-name', params)
compilation.apply('event-name', params)
/**
 * 监听事件
 */
compiler.plugin('event-name', function (params) {})
compilation.plugin('event-name', function (params) {})

Tapable类暴露了taptapAsynctapPromise方法,可以根据钩子的同步/异步方式来选择一个函数注入逻辑。

tap 同步钩子

compiler.hooks.compile.tap('MyPlugin', (params) => {
  console.log('以同步方式触及 compile 钩子。')
})

tapAsync 异步钩子,通过callback回调告诉Webpack异步执行完毕
tapPromise 异步钩子,返回一个Promise告诉Webpack异步执行完毕

compiler.hooks.run.tapAsync('MyPlugin', (compiler, callback) => {
  console.log('以异步方式触及 run 钩子。')
  callback()
})

compiler.hooks.run.tapPromise('MyPlugin', (compiler) => {
  return new Promise((resolve) => setTimeout(resolve, 1000)).then(() => {
    console.log('以具有延迟的异步方式触及 run 钩子')
  })
})

Tabable 用法

const {
  SyncHook,
  SyncBailHook,
  SyncWaterfallHook,
  SyncLoopHook,
  AsyncParallelHook,
  AsyncParallelBailHook,
  AsyncSeriesHook,
  AsyncSeriesBailHook,
  AsyncSeriesWaterfallHook,
} = require('tapable')

tapable

简单实现一个 SyncHook

class Hook {
  constructor(args) {
    this.taps = []
    this.interceptors = [] // 这个放在后面用
    this._args = args
  }
  tap(name, fn) {
    this.taps.push({ name, fn })
  }
}
class SyncHook extends Hook {
  call(name, fn) {
    try {
      this.taps.forEach((tap) => tap.fn(name))
      fn(null, name)
    } catch (error) {
      fn(error)
    }
  }
}

tapable是如何将webapck/webpack插件关联的?

Compiler.js

const { AsyncSeriesHook ,SyncHook } = require("tapable");
//创建类
class Compiler {
    constructor() {
        this.hooks = {
           run: new AsyncSeriesHook(["compiler"]), //异步钩子
           compile: new SyncHook(["params"]),//同步钩子
        };
    },
    run(){
      //执行异步钩子
      this.hooks.run.callAsync(this, err => {
         this.compile(onCompiled);
      });
    },
    compile(){
      //执行同步钩子 并传参
      this.hooks.compile.call(params);
    }
}
module.exports = Compiler

MyPlugin.js

const Compiler = require('./Compiler')

class MyPlugin {
  apply(compiler) {
    //接受 compiler参数
    compiler.hooks.run.tap('MyPlugin', () => console.log('开始编译...'))
    compiler.hooks.complier.tapAsync('MyPlugin', (name, age) => {
      setTimeout(() => {
        console.log('编译中...')
      }, 1000)
    })
  }
}

//这里类似于webpack.config.js的plugins配置
//向 plugins 属性传入 new 实例

const myPlugin = new MyPlugin()

const options = {
  plugins: [myPlugin],
}
let compiler = new Compiler(options)
compiler.run()

想要深入了解tapable的文章可以看看这篇文章:

webpack4核心模块tapable源码解析:
https://www.cnblogs.com/tugen...

理解 Compiler(负责编译)

开发插件首先要知道compilercompilation 对象是做什么的

Compiler 对象包含了当前运行Webpack的配置,包括entry、output、loaders等配置,这个对象在启动Webpack时被实例化,而且是全局唯一的。Plugin可以通过该对象获取到 Webpack 的配置信息进行处理。

如果看完这段话,你还是没理解compiler是做啥的,不要怕,接着看。
运行npm run build,把compiler的全部信息输出到控制台上console.log(Compiler)

compiler

// 为了能更直观的让大家看清楚compiler的结构,里面的大量代码使用省略号(...)代替。
Compiler {
  _pluginCompat: SyncBailHook {
    ...
  },
  hooks: {
    shouldEmit: SyncBailHook {
     ...
    },
    done: AsyncSeriesHook {
     ...
    },
    additionalPass: AsyncSeriesHook {
     ...
    },
    beforeRun: AsyncSeriesHook {
     ...
    },
    run: AsyncSeriesHook {
     ...
    },
    emit: AsyncSeriesHook {
     ...
    },
    assetEmitted: AsyncSeriesHook {
     ...
    },
    afterEmit: AsyncSeriesHook {
     ...
    },
    thisCompilation: SyncHook {
     ...
    },
    compilation: SyncHook {
     ...
    },
    normalModuleFactory: SyncHook {
     ...
    },
    contextModuleFactory: SyncHook {
     ...
    },
    beforeCompile: AsyncSeriesHook {
      ...
    },
    compile: SyncHook {
     ...
    },
    make: AsyncParallelHook {
     ...
    },
    afterCompile: AsyncSeriesHook {
     ...
    },
    watchRun: AsyncSeriesHook {
     ...
    },
    failed: SyncHook {
     ...
    },
    invalid: SyncHook {
     ...
    },
    watchClose: SyncHook {
     ...
    },
    infrastructureLog: SyncBailHook {
     ...
    },
    environment: SyncHook {
     ...
    },
    afterEnvironment: SyncHook {
     ...
    },
    afterPlugins: SyncHook {
     ...
    },
    afterResolvers: SyncHook {
     ...
    },
    entryOption: SyncBailHook {
     ...
    },
    infrastructurelog: SyncBailHook {
     ...
    }
  },
  ...
  outputPath: '',//输出目录
  outputFileSystem: NodeOutputFileSystem {
   ...
  },
  inputFileSystem: CachedInputFileSystem {
    ...
  },
  ...
  options: {
    //Compiler对象包含了webpack的所有配置信息,entry、module、output、resolve等信息
    entry: [
      'babel-polyfill',
      '/Users/frank/Desktop/fe/fe-blog/webpack-plugin/src/index.js'
    ],
    devServer: { port: 3000 },
    output: {
      ...
    },
    module: {
      ...
    },
    plugins: [ MyWebpackPlugin {} ],
    mode: 'production',
    context: '/Users/frank/Desktop/fe/fe-blog/webpack-plugin',
    devtool: false,
    ...
    performance: {
      maxAssetSize: 250000,
      maxEntrypointSize: 250000,
      hints: 'warning'
    },
    optimization: {
      ...
    },
    resolve: {
      ...
    },
    resolveLoader: {
      ...
    },
    infrastructureLogging: { level: 'info', debug: false }
  },
  context: '/Users/frank/Desktop/fe/fe-blog/webpack-plugin',//上下文,文件目录
  requestShortener: RequestShortener {
    ...
  },
  ...
  watchFileSystem: NodeWatchFileSystem {
    //监听文件变化列表信息
     ...
  }
}

Compiler 源码精简版代码解析

源码地址(948 行):https://github.com/webpack/we...

const { SyncHook, SyncBailHook, AsyncSeriesHook } = require('tapable')
class Compiler {
  constructor() {
    // 1. 定义生命周期钩子
    this.hooks = Object.freeze({
      // ...只列举几个常用的常见钩子,更多hook就不列举了,有兴趣看源码
      done: new AsyncSeriesHook(['stats']), //一次编译完成后执行,回调参数:stats
      beforeRun: new AsyncSeriesHook(['compiler']),
      run: new AsyncSeriesHook(['compiler']), //在编译器开始读取记录前执行
      emit: new AsyncSeriesHook(['compilation']), //在生成文件到output目录之前执行,回调参数: compilation
      afterEmit: new AsyncSeriesHook(['compilation']), //在生成文件到output目录之后执行
      compilation: new SyncHook(['compilation', 'params']), //在一次compilation创建后执行插件
      beforeCompile: new AsyncSeriesHook(['params']),
      compile: new SyncHook(['params']), //在一个新的compilation创建之前执行
      make: new AsyncParallelHook(['compilation']), //完成一次编译之前执行
      afterCompile: new AsyncSeriesHook(['compilation']),
      watchRun: new AsyncSeriesHook(['compiler']),
      failed: new SyncHook(['error']),
      watchClose: new SyncHook([]),
      afterPlugins: new SyncHook(['compiler']),
      entryOption: new SyncBailHook(['context', 'entry']),
    })
    // ...省略代码
  }
  newCompilation() {
    // 创建Compilation对象回调compilation相关钩子
    const compilation = new Compilation(this)
    //...一系列操作
    this.hooks.compilation.call(compilation, params) //compilation对象创建完成
    return compilation
  }
  watch() {
    //如果运行在watch模式则执行watch方法,否则执行run方法
    if (this.running) {
      return handler(new ConcurrentCompilationError())
    }
    this.running = true
    this.watchMode = true
    return new Watching(this, watchOptions, handler)
  }
  run(callback) {
    if (this.running) {
      return callback(new ConcurrentCompilationError())
    }
    this.running = true
    process.nextTick(() => {
      this.emitAssets(compilation, (err) => {
        if (err) {
          // 在编译和输出的流程中遇到异常时,会触发 failed 事件
          this.hooks.failed.call(err)
        }
        if (compilation.hooks.needAdditionalPass.call()) {
          // ...
          // done:完成编译
          this.hooks.done.callAsync(stats, (err) => {
            // 创建compilation对象之前
            this.compile(onCompiled)
          })
        }
        this.emitRecords((err) => {
          this.hooks.done.callAsync(stats, (err) => {})
        })
      })
    })

    this.hooks.beforeRun.callAsync(this, (err) => {
      this.hooks.run.callAsync(this, (err) => {
        this.readRecords((err) => {
          this.compile(onCompiled)
        })
      })
    })
  }
  compile(callback) {
    const params = this.newCompilationParams()
    this.hooks.beforeCompile.callAsync(params, (err) => {
      this.hooks.compile.call(params)
      const compilation = this.newCompilation(params)
      //触发make事件并调用addEntry,找到入口js,进行下一步
      this.hooks.make.callAsync(compilation, (err) => {
        process.nextTick(() => {
          compilation.finish((err) => {
            // 封装构建结果(seal),逐次对每个module和chunk进行整理,每个chunk对应一个入口文件
            compilation.seal((err) => {
              this.hooks.afterCompile.callAsync(compilation, (err) => {
                // 异步的事件需要在插件处理完任务时调用回调函数通知 Webpack 进入下一个流程,
                // 不然运行流程将会一直卡在这不往下执行
                return callback(null, compilation)
              })
            })
          })
        })
      })
    })
  }
  emitAssets(compilation, callback) {
    const emitFiles = (err) => {
      //...省略一系列代码
      // afterEmit:文件已经写入磁盘完成
      this.hooks.afterEmit.callAsync(compilation, (err) => {
        if (err) return callback(err)
        return callback()
      })
    }

    // emit 事件发生时,可以读取到最终输出的资源、代码块、模块及其依赖,并进行修改(这是最后一次修改最终文件的机会)
    this.hooks.emit.callAsync(compilation, (err) => {
      if (err) return callback(err)
      outputPath = compilation.getPath(this.outputPath, {})
      mkdirp(this.outputFileSystem, outputPath, emitFiles)
    })
  }
  // ...省略代码
}

apply方法中插入钩子的一般形式如下:

// compiler提供了compiler.hooks,可以根据这些不同的时刻去让插件做不同的事情。
compiler.hooks.阶段.tap函数('插件名称', (阶段回调参数) => {})
compiler.run(callback)

理解 Compilation(负责创建 bundles)

Compilation对象代表了一次资源版本构建。当运行 webpack 开发环境中间件时,每当检测到一个文件变化,就会创建一个新的 compilation,从而生成一组新的编译资源。一个 Compilation 对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息,简单来讲就是把本次打包编译的内容存到内存里。Compilation 对象也提供了插件需要自定义功能的回调,以供插件做自定义处理时选择使用拓展。

简单来说,Compilation的职责就是构建模块和 Chunk,并利用插件优化构建过程。

Compiler 用法相同,钩子类型不同,也可以在某些钩子上访问 tapAsynctapPromise。

控制台输出console.log(compilation)
compilation

通过 Compilation 也能读取到 Compiler 对象。

源码 2000 多行,看不动了- -,有兴趣的可以自己看看。
https://github.com/webpack/we...

介绍几个常用的 Compilation Hooks

钩子类型什么时候调用
buildModuleSyncHook在模块开始编译之前触发,可以用于修改模块
succeedModuleSyncHook当一个模块被成功编译,会执行这个钩子
finishModulesAsyncSeriesHook当所有模块都编译成功后被调用
sealSyncHook当一次compilation停止接收新模块时触发
optimizeDependenciesSyncBailHook在依赖优化的开始执行
optimizeSyncHook在优化阶段的开始执行
optimizeModulesSyncBailHook在模块优化阶段开始时执行,插件可以在这个钩子里执行对模块的优化,回调参数:modules
optimizeChunksSyncBailHook在代码块优化阶段开始时执行,插件可以在这个钩子里执行对代码块的优化,回调参数:chunks
optimizeChunkAssetsAsyncSeriesHook优化任何代码块资源,这些资源存放在compilation.assets 上。一个 chunk 有一个 files 属性,它指向由一个 chunk 创建的所有文件。任何额外的 chunk 资源都存放在 compilation.additionalChunkAssets 上。回调参数:chunks
optimizeAssetsAsyncSeriesHook优化所有存放在 compilation.assets 的所有资源。回调参数:assets

Compiler 和 Compilation 的区别

Compiler 代表了整个 Webpack 从启动到关闭的生命周期,而 Compilation 只是代表了一次新的编译,只要文件有改动,compilation就会被重新创建。

常用 API

插件可以用来修改输出文件、增加输出文件、甚至可以提升 Webpack 性能、等等,总之插件通过调用Webpack 提供的 API 能完成很多事情。 由于 Webpack提供的 API 非常多,有很多 API 很少用的上,又加上篇幅有限,下面来介绍一些常用的 API。

读取输出资源、代码块、模块及其依赖

有些插件可能需要读取 Webpack 的处理结果,例如输出资源、代码块、模块及其依赖,以便做下一步处理。
在 emit 事件发生时,代表源文件的转换和组装已经完成,在这里可以读取到最终将输出的资源、代码块、模块及其依赖,并且可以修改输出资源的内容。
插件代码如下:

class Plugin {
  apply(compiler) {
    compiler.plugin('emit', function (compilation, callback) {
      // compilation.chunks 存放所有代码块,是一个数组
      compilation.chunks.forEach(function (chunk) {
        // chunk 代表一个代码块
        // 代码块由多个模块组成,通过 chunk.forEachModule 能读取组成代码块的每个模块
        chunk.forEachModule(function (module) {
          // module 代表一个模块
          // module.fileDependencies 存放当前模块的所有依赖的文件路径,是一个数组
          module.fileDependencies.forEach(function (filepath) {})
        })

        // Webpack 会根据 Chunk 去生成输出的文件资源,每个 Chunk 都对应一个及其以上的输出文件
        // 例如在 Chunk 中包含了 CSS 模块并且使用了 ExtractTextPlugin 时,
        // 该 Chunk 就会生成 .js 和 .css 两个文件
        chunk.files.forEach(function (filename) {
          // compilation.assets 存放当前所有即将输出的资源
          // 调用一个输出资源的 source() 方法能获取到输出资源的内容
          let source = compilation.assets[filename].source()
        })
      })

      // 这是一个异步事件,要记得调用 callback 通知 Webpack 本次事件监听处理结束。
      // 如果忘记了调用 callback,Webpack 将一直卡在这里而不会往后执行。
      callback()
    })
  }
}

监听文件变化

Webpack 会从配置的入口模块出发,依次找出所有的依赖模块,当入口模块或者其依赖的模块发生变化时, 就会触发一次新的 Compilation

在开发插件时经常需要知道是哪个文件发生变化导致了新的 Compilation,为此可以使用如下代码:

// 当依赖的文件发生变化时会触发 watch-run 事件
compiler.hooks.watchRun.tap('MyPlugin', (watching, callback) => {
  // 获取发生变化的文件列表
  const changedFiles = watching.compiler.watchFileSystem.watcher.mtimes
  // changedFiles 格式为键值对,键为发生变化的文件路径。
  if (changedFiles[filePath] !== undefined) {
    // filePath 对应的文件发生了变化
  }
  callback()
})

默认情况下 Webpack 只会监视入口和其依赖的模块是否发生变化,在有些情况下项目可能需要引入新的文件,例如引入一个 HTML 文件。 由于 JavaScript 文件不会去导入 HTML 文件,Webpack 就不会监听 HTML 文件的变化,编辑 HTML 文件时就不会重新触发新的 Compilation。 为了监听 HTML 文件的变化,我们需要把 HTML 文件加入到依赖列表中,为此可以使用如下代码:

compiler.hooks.afterCompile.tap('MyPlugin', (compilation, callback) => {
  // 把 HTML 文件添加到文件依赖列表,好让 Webpack 去监听 HTML 模块文件,在 HTML 模版文件发生变化时重新启动一次编译
  compilation.fileDependencies.push(filePath)
  callback()
})

修改输出资源

有些场景下插件需要修改、增加、删除输出的资源,要做到这点需要监听 emit 事件,因为发生 emit 事件时所有模块的转换和代码块对应的文件已经生成好, 需要输出的资源即将输出,因此 emit 事件是修改 Webpack 输出资源的最后时机。

所有需要输出的资源会存放在 compilation.assets 中,compilation.assets 是一个键值对,键为需要输出的文件名称,值为文件对应的内容。

设置 compilation.assets 的代码如下:

// 设置名称为 fileName 的输出资源
compilation.assets[fileName] = {
  // 返回文件内容
  source: () => {
    // fileContent 既可以是代表文本文件的字符串,也可以是代表二进制文件的 Buffer
    return fileContent
  },
  // 返回文件大小
  size: () => {
    return Buffer.byteLength(fileContent, 'utf8')
  },
}
callback()

判断 webpack 使用了哪些插件

// 判断当前配置使用使用了 ExtractTextPlugin,
// compiler 参数即为 Webpack 在 apply(compiler) 中传入的参数
function hasExtractTextPlugin(compiler) {
  // 当前配置所有使用的插件列表
  const plugins = compiler.options.plugins
  // 去 plugins 中寻找有没有 ExtractTextPlugin 的实例
  return (
    plugins.find(
      (plugin) => plugin.__proto__.constructor === ExtractTextPlugin
    ) != null
  )
}

以上 4 种方法来源于文章:
[Webpack 学习-Plugin] :http://wushaobin.top/2019/03/...

管理 Warnings 和 Errors

做一个实验,如果你在 apply函数内插入 throw new Error("Message"),会发生什么,终端会打印出 Unhandled rejection Error: Message。然后 webpack 中断执行。
为了不影响 webpack 的执行,要在编译期间向用户发出警告或错误消息,则应使用 compilation.warnings 和 compilation.errors。

compilation.warnings.push('warning')
compilation.errors.push('error')

文章中的案例 demo 代码展示

https://github.com/6fedcom/fe-blog/tree/master/webpack/plugin

webpack 打包过程或者插件代码里该如何调试?

  1. 在当前 webpack 项目工程文件夹下面,执行命令行:
node --inspect-brk ./node_modules/webpack/bin/webpack.js --inline --progress

其中参数--inspect-brk 就是以调试模式启动 node:

终端会输出:

Debugger listening on ws://127.0.0.1:9229/1018c03f-7473-4d60-b62c-949a6404c81d
For help, see: https://nodejs.org/en/docs/inspector
  1. 谷歌浏览器输入 chrome://inspect/#devices

点击inspect

  1. 然后点一下 Chrome 调试器里的“继续执行”,断点就提留在我们设置在插件里的 debugger 断点了。

debugger

查看原文

赞 39 收藏 28 评论 1

taowen 发布了文章 · 2019-07-22

减少状态引起的代码复杂度

要解决的问题是什么?

A problem well-stated is Half-solved

"No Silver Bullet - Essence and Accident in Software Engineering"

以及另外一篇著名的 "Out of the Tar Pit" 都把 State 造成的复杂度放到了首要的位置。

其实要解决问题一直都是房间里的那头大象,Imperative Programming 的方式去管理 State 太复杂了。

Imperative Programming 的问题是什么?

我们并不是没有办法去更新这些 State,Imperative Programming 的方式非常直观,就是把一堆读写状态的指令给CPU,CPU就会去一五一十地执行。我们可以把软件地执行过程画成这样地一棵树:

img

软件的外在行为,就是按照时间顺序,产生一系列的状态更新。也也就是有逻辑地按顺序产生这些黄颜色的节点。但是问题是:

如果一五一十地,按时间顺序描述每一个状态更新的编程风格,产生出来的代码冗长而且琐碎。

也就是最直观的,最easy的做法,并不能是最优的解法。即使我们抽了很多很好的函数,也就是这些蓝色的圈圈。虽然可以让代码看起来规整,但是还是冗长还是琐碎。我去年写了两篇关于代码可读性的文章,其实就是在讲这些问题:https://zhuanlan.zhihu.com/p/46435063https://zhuanlan.zhihu.com/p/34982747 。现在看来有点太啰嗦了。而且 readable 是一个偏主观的概念。Rich Hickey 有一个演讲 "Simple Made Easy" 讲得很好,他说 simple 是一个客观的指标。我把 Simple 具体为以下四个可以客观度量的属性

  • Quantity small:数量上少
  • Sequential:串行的
  • Continuous:上一行和下一行有必然的因果关系的必要。而有因果关系的逻辑,不应该相距太远
  • Isolated:事情之间的相互影响小。能够 isolate,才意味着可以变成组件分解出来

与这四个属性相反的是

  • Quantity large:数量上多
  • Concurrent, parallel:并发是逻辑上的,并行是物理上的。无论是哪种,都比 sequential 更复杂。
  • Long range causality:长距离的因果关系
  • Entangled:剪不断理还乱

Imperative Programming 代表的是这个真实世界。真实世界就是 Quantity large,无时无刻不 parallel,到处都是 long range causality,而且 entangled 的。Simplicity 是代表了人们假想的伊甸园,是我们对肉脑薄弱的感知和计算能力的迁就。Simplicity is hard,when simplicity is not the reality。

所以,我们可以把要解决的问题,分解成这两个问题:

  • 给我们的肉脑创造一个虚拟的伊甸园,在这里, Quantity small,Sequential,Continuous,Isolated。
  • 和 Imperative Programming 不同,伊甸园的叙事方式和真实世界脱节了。所以当在残忍的真实世界里出了问题,没法在代码里找到直接对应。需要提供工具帮助人类理解实际发生的 Quantity large, concurrent / parallel,long range causality,entangled。

OOP/DDD 解决了上面的四个问题么?

DDD 可以认为是这么三步

  1. Application Service 加载 Domain Model
  2. 由 Aggregate Root 封装对状态的修改
  3. 副作用体现为 Domain Model 的更新,以及产生的 Domain Event

其核心就是可以聚合根对状态的黑盒封装。这种所谓的黑盒封装有两个问题

  1. 说到底,聚合根的method,和 imperative programming 的 function,没有本质区别
  2. 对象之间的交互,特别是业务流程对多个对象的更新,没有自然的聚合根的归属。或者说,真正的聚合根应该是业务流程本身。但是流程并不是 Entity。

为什么说没有本质区别:

  • Quantity small:在 OOP/DDD 里所有的状态仍然是按时间顺序去逐个更新的,一个没少
  • Sequential:为了性能,仍然是要把代码写成多协程或者多线程的模式
  • Continuous:一个完整的业务流程,还是被拆成了各个API 的 controller里。然而经常在一个 controller 里,处理着只是恰好同时发生,但是业务逻辑上没有彼此关联的代码。
  • Isolated:ORM给我们创造出了一个幻觉,然后1+N查询的问题把我们拉回了现实。这种要求Application Service一次性把整个Domain Model加载到内存的做法,就一点都不isolated。经常有一种,倒不如把代码都写在Application Service拉倒的感觉。

综上面向对象不是那颗银弹,DDD也不是。

TypeScript 是如何解决这四个问题的?

Talk is cheap, show me the code

View 绑定到数据

首先要解决的问题是尽可能减少 State。比如说我们可以让 View 是“无状态”的,把所有的 View 绑定到数据上。例如为了实现这样的功能:

img

对应的 View 是 Html 的 DOM,这本身是一份状态。但是我们可以把它绑定到数据上:

<Button @onClick="onMinusClick">-</Button>
<span margin="8px">{{ value }}</span>
<Button @onClick="onPlusClick">+</Button>

对应的数据

export class CounterDemo extends RootSectionModel {
    value = 0;
    onMinusClick() {
        this.value -= 1;
    }
    onPlusClick() {
        this.value += 1;
    }
}

为什么这样算消除状态?在this.value被写入的时候,DOM这份状态不是还是被更新了吗?比较这两种写法

设置绑定关系: <span margin="8px">{{ value }}</span>
// 然后在流程内更新状态
this.value -= 1;

以及

// 然后在流程内更新两处状态
this.value -= 1;
this.updateView({msg: this.value})

this.value -= 1 触发的状态更新不算状态更新么?this.value -= 1 然后接着 this.updateView(this.value) 就不好呢?核心问题在于绑定的实质在于,绑定描述两个状态之间的恒等关系。这个关系是在时间轴之外提前设置好的,而不是在时间轴内描述做为流程的一部分。这样当我们对时间进行叙事的时候,就可以忽略掉被绑定了的状态了。这个就是绑定可以减少状态带来的认知负担的核心原理。

前端状态绑定到数据库状态

我们可以来看一下,整个系统里都有哪些状态。

img

仅仅托管了界面状态是不够的。只是把问题转移了,不是还要管理前端状态么?各种redux?所以还要进一步化简,对每一份状态,都要回答,有没有简化的可能?

比如我们希望直接把前端状态和数据库里主存储的状态来个绑定。

img

这是一个很常见的列表展示页的需求。我们当然可以封装一个后端的domain object,然后再搞几个url,封装一下dto,然后再前端封装几个view model,然后再展示出来。我们也可以这样:

<CreateReservation />
<Card title="预定列表" margin="16px">
    <Form layout="inline">
        <InputNumber :value="&from" label="座位数 from" />
        <span margin="8px"> ~ </span>
        <InputNumber :value="&to" label="to" />
    </Form>
    <span>总数 {{ totalCount }}</span>
    <List :dataSource="filteredReservations" itemLayout="vertical" size="small">
        <json #pagination>
            { "pageSize": 10 }
        </json>
        <slot #element="::element">
            <ShowReservation :reservation="element.item">
        </slot>
    </List>
    <Row justifyContent="flex-end" marginTop="8px">
        <Button type="primary" icon="plus" @onClick="onNewReservationClick">预定</Button>
    </Row>
</Card>

然后对应绑定到的对象是这样写的:

export class ListDemo extends RootSectionModel {
    public from: number = 1;
    public to: number = 9;
    public get filteredReservations() {
        return this.scene.query(Reservation_SeatInRange, { from: this.from, to: this.to });
    }
    public get totalCount() {
        return this.filteredReservations.length;
    }
    public onNewReservationClick() {
        this.getSectionModel(CreateReservation).isOpen = true;
    }
    public viewCreateReservation() {
        return this.scene.add(CreateReservation);
    }
}

我们可以看到, from 的值变了之后,filteredReservations 变了,totalCount 也跟着变了。如果数据源是一个数组,这个 demo 其实没啥。但是注意这里的数据源是 Mysql 数据库。但是我们使用的时候就像操作本地数组一样方便。

这里我们通过类似 GraphQL 的通用后端接口,把前端后端,中间RPC的状态都给合并成一个了。但是和 GraphQL 前端定义查询的做法不同,所能够查询的东西仍然是提前注册的,这样可以避免前端滥用无索引的查询的问题。这里做这个注册工作的就是 Reservation_SeatInRange,其定义是这样的

@sources.Mysql()
export class Reservation extends Entity {
    public seatCount: number;
    public phoneNumber: string;
}

@where('seatCount >= :from AND seatCount <= :to')
export class Reservation_SeatInRange {
    public static SubsetOf = Reservation;
    public from: number;
    public to: number;
}

省掉前后端互相翻译添加的额外状态

前端和后端都是在处理同一个流程的同一个步骤,其上下文是高度一致的。我们可以认为实际上有两层 RPC

img

当这个 RPC 协议完全服务于对应的页面表单的前提下,这个RPC协议的 request 和 response 状态基本上等价于页面表单的状态。当然你可以说,RPC协议可以是通用的,是可以复用的,和前端无关的。正是因为有这样的态度,所以才会多出来 BFF 这么额外的一层,不是么。创造新的问题。

img

假设要实现上面这个简单的表单。其视图是这样的

<Card title="餐厅座位预定" width="320px">
    <Form>
        {{ message }}
        <Input :value="&phoneNumber" label="手机号" />
        <InputNumber :value="&seatCount" label="座位数" />
        <Button @onClick="onReserveClick">预定</Button>
    </Form>
</Card>

然后我们把这个视图绑定到一个表单对象上,它同时兼任了前后端RPC交互协议的职责:

@sources.Scene
export class FormDemo extends RootSectionModel {
    @constraint.min(1)
    public seatCount: number;
    @constraint.required
    public phoneNumber: string;
    public message: string = '';

    public onBegin() {
        this.reset();
    }

    public onReserveClick() {
        if (constraint.validate(this)) {
            return;
        }
        this.saveReservation();
        setTimeout(this.clearMessage.bind(this), 1000);
    }

    @command({ runAt: 'server' })
    private saveReservation() {
        if (constraint.validate(this)) {
            return;
        }
        const reservation = this.scene.add(Reservation, this);
        try {
            this.scene.commit();
        } catch (e) {
            const existingReservations = this.scene.query(Reservation, { phoneNumber: this.phoneNumber });
            if (existingReservations.length > 0) {
                this.scene.unload(reservation);
                constraint.reportViolation(this, 'phoneNumber', {
                    message: '同一个手机号只能有一个预定',
                });
                return;
            }
            throw e;
        }
        this.reset();
        this.message = '预定成功';
    }

    private reset() {
        this.seatCount = 1;
        this.phoneNumber = '';
    }

    private clearMessage() {
        this.message = '';
    }
}

实际存储在数据库里,不是这个表单,是另外一个:

@sources.Mysql()
export class Reservation extends Entity {
    public seatCount: number;
    public phoneNumber: string;
}

我们通过以下手段,把状态要么省掉,要么从一个需要手工管理的状态变成一个衍生状态:

  • 转化为衍生的状态:计算属性,状态同步,视图表,物化视图表,缓存
  • 让远端的状态就像在本地一样直接使用
  • 减少因为网络传输引入的临时状态

Sequential 表达,Concurrent 执行

在兑现了一个 Quantity small 的目标之后,我们来看第二个目标,让代码 sequential。代码 sequential 其实很简单,就是串行写就好了。难题是,如果执行的时候也是 sequential,就会导致加载速度很慢。我们有两个可以参考学习的对象:

假设有这样两张表:

CREATE TABLE `User` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL,
  `inviterId` int(11) NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=latin1;

CREATE TABLE `Post` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `title` varchar(255) NOT NULL,
  `authorId` int(11) NOT NULL,
  `editorId` int(11) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=latin1;

对应的类定义:

@sources.Mysql()
export class User extends Entity {
    public id: number;
    public name: string;
    public inviterId: number;
    public get inviter(): User {
        return this.scene.load(User, { id: this.inviterId });
    }
    public get posts() {
        return this.scene.query(Post, { authorId: this.id });
    }
}
@sources.Mysql()
export class Post extends Entity {
    public id: number;
    public title: string;
    public authorId: number;
    public get author(): User {
        return this.scene.load(User, { id: this.authorId });
    }
    public get editor(): User {
        return this.scene.load(User, { id: this.editorId });
    }
    public get authorName(): string {
        return this.author.name;
    }
    public get inviterName(): string {
        const inviter = this.author.inviter;
        return inviter ? inviter.name : 'N/A';
    }
}

那么去访问 author 和 editor 的时候,可以写成串行的:

const author = somePost.author
const editor = somePost.editor
return { author, editor }

但是因为中间没有实际访问过这两个对象,所以没有实际的数据依赖,这样的串行代码就会被并发执行。但是这样的访问

const author = somePost.author
const authorInviter = author.inviter
return { author, authorInviter }

因为 author.inviter 产生了数据依赖,这样就没法并发执行。所以这样就提供了一个用串行代码,利用数据的依赖关系来表达并发的方式。

Isolated,让组件只用管自己

然后我们来看第三个目标,Isolated。

img

假设要把 Post 渲染成上面这样的表格。我们知道“作者”和“邀请人”这两个字段都是外键关联的。所以如果没有任何优化,就是 Isolated 写,Isolated 执行,那么必然是会产生额外的 N + N 条子查询,这里 N 就是 4 行。

img

但是实际执行的时候只产生了 3 条查询,第一条是查询有多个 Post,第二条查询所有的作者,第三条查询所有的这些作者的邀请人。这里把多个 HTTP 请求合并成三条的 IO 合并是自动做的。

2019-07-19T11:25:04.136927Z       27 Query    START TRANSACTION
2019-07-19T11:25:04.137426Z       27 Query    SELECT id, title, authorId FROM Post
2019-07-19T11:25:04.138444Z       27 Query    COMMIT
2019-07-19T11:25:04.772221Z       27 Query    START TRANSACTION
2019-07-19T11:25:04.773019Z       27 Query    SELECT id, name, inviterId FROM User WHERE id IN (10, 9, 11)
2019-07-19T11:25:04.774173Z       27 Query    COMMIT
2019-07-19T11:25:04.928393Z       27 Query    START TRANSACTION
2019-07-19T11:25:04.936851Z       27 Query    SELECT id, name, inviterId FROM User WHERE id IN (8, 7, 9)
2019-07-19T11:25:04.937918Z       27 Query    COMMIT

查询 mysql 的 general log,可以看到原来的 id = xxx 的查询编程了 id IN (xxx) 的查询了。所以不仅仅是合并成了两次 HTTP 请求,而且进一步合并成了两次 Mysql 查询。

这样就可以避免要求 Application Service 一次性拿一个大的 JOIN 查询把所有的领域层需要的数据全部加载进来这样的要求。可以让代码该 Isolated 的,就保持 Isolated 的。每个组件管好自己的事情,绑好自己的数据,不用管其他人都在干什么。

Continous 的业务流程

我们来看最后一个属性,Continuous。前面提到了两个问题

  • 在DDD里,业务流程不知道归属给什么聚合根。
  • Imperative Programming 会把连续的业务流程,切碎成小段来执行。前后逻辑通过全局状态(也就是数据库)来传递因果性。

我们的解决方案就是提供一种 Entity 叫 Process。它和其他的 Entity 一样,绑定了数据库表,就是数据的载体。同时它又代表了业务流程。也就是我们把一个业务流程函数,持久化成 Entity 了。也可以说我们把业务单据变成可执行的函数了。

img

假设需要实现上面所示的 Account 的生命周期。一开始账户是处于锁定状态,除非设置了密码。然后登录允许失败,但是最多失败三次。如果超过三次,则回到锁定状态。这个业务逻辑,用 Process 来写是这样的:

const MAX_RETRY_COUNT = 3;

@sources.Mysql()
export class Account extends Process {
    public name: string;
    // plain text, just a demo
    public password: string;

    public retryCount: number;

    public reset: ProcessEndpoint<string, boolean>;

    public login: ProcessEndpoint<string, boolean>;

    public process() {
        let password: string;
        while (true) {
            locked: this.commit();
            const resetCall = this.recv('reset');
            password = resetCall.request;
            if (this.isPasswordComplex(password)) {
                this.respond(resetCall, true);
                break;
            }
            this.respond(resetCall, false);
        }
        let retryCount = MAX_RETRY_COUNT;
        for (; retryCount > 0; retryCount -= 1) {
            normal: this.commit();
            const loginAttempt = this.recv('login');
            const success = loginAttempt.request === password;
            this.respond(loginAttempt, success);
            if (success) {
                retryCount = MAX_RETRY_COUNT + 1;
                continue;
            }
        }
        __GOBACK__('locked');
    }

    private isPasswordComplex(password: string) {
        return password && password.length > 6;
    }
}

这个实体是持久化的,表结构是这样的:

CREATE TABLE `Account` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL UNIQUE,
  `password` varchar(255) NOT NULL,
  `status` varchar(255) NOT NULL,
  `retryCount` int(11) NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=latin1;

所以并不是什么把 javascript 协程持久化成不可读的二进制那样的技术,那个是上一代的持久化协程了。值得注意是有一个 status 字段,这个和代码中的 label statement 是对应的执行到了对应的行,status 就会被设置成对应的值。相比使用独立的 BPM 引擎,我们无须额外管理流程上下文,以及同步流程状态回业务的数据库。流程就是业务单据业务实体,业务单据就承载了流程。

这样我们就同时解决了 DDD 里流程逻辑不知道往哪里放的问题,就应该放到流程单据上。例如订单,报价单,这些代表了流程状态的单据表。同时我们也解决了 continous 的问题。但是这样的一个大 process() 函数怎么用呢?不能每次都从头执行吧。使用的代码长这个样子:

这是展示界面 AccountDemo.xml

<Form width="320px" margin="24px">
    <Input label="用户名" :value="&name" />
    <Input label="密码" :value="&password" />
    <switch :value="status">
        <slot #default><Button @onClick="onLoginClick">登录</Button></slot>
        <slot #locked><Button @onClick="onResetClick">重新设置密码</Button></slot>
    </switch>
    {{ notice }}
</Form>

界面是 reactive 的,流程驱动到了什么状态,就对应展示什么状态的交互。

这是界面对应的 AccountDemo.ts

@sources.Scene
export class AccountDemo extends RootSectionModel {
    @constraint.required
    public name: string;

    @constraint.required
    public password: string;

    private justFailed: boolean;

    private get account() {
        const accounts = this.scene.query(Account, { name: this.name });
        return accounts.length === 0 ? undefined : accounts[0];
    }

    public get notice() {
        if (this.justFailed === undefined) {
            return '';
        }
        if (this.justFailed === false) {
            return '登录成功';
        }
        if (!this.account) {
            return '';
        }
        if (this.account.status === 'locked') {
            return '账户已被锁定';
        }
        return `还剩 ${this.account.retryCount} 次重试`;
    }

    public get status() {
        if (!this.justFailed || !this.account) {
            return 'default';
        }
        return this.account.status;
    }

    public onLoginClick() {
        if (constraint.validate(this)) {
            return;
        }
        if (!this.account) {
            constraint.reportViolation(this, 'password', {
                message: '用户名或者密码错误',
            });
            return;
        }
        try {
            const success = this.scene.call(this.account.login, this.password);
            if (!success) {
                throw new Error('failed');
            }
            this.justFailed = false;
        } catch (e) {
            this.justFailed = true;
            constraint.reportViolation(this, 'password', {
                message: '用户名或者密码错误',
            });
            return;
        }
    }

    public onResetClick() {
        if (this.account) {
            this.scene.call(this.account.reset, 'p@55word');
        }
    }
}

通过 Process 暴露出来的 ProcessEndpoint,我们可以驱动这个流程。如果不需要返回值,用 ProcessEvent 单向通信也可以。

通过 Process,我们可以把一个流程的状态修改都封装到这个 Process 里,实现真正的封装。同时对于,流程内的分叉合并这些可以表达起来更自然。以及一个用户操作,需要同时驱动多个Process的情况,比如同时要处理营销流程,售卖流程,仓储库存流程之类的,可以很好的实现各自的独立闭环。而不用在一个大的 controller 里,把所有人的业务都做一点点。

所以,OOP/DDD 不够看的,得上 TypeScript。但是,你这里的 TypeScript 是 TypeScript 吗?

你们是谁?

我们的名字叫乘法云。我们在挑战的问题是

从业务想法到软件上线,速度如何提高10x?

这里演示的 TypeScript 语法,可以完全通过 eslint/tslint 的检查,是纯正的 TypeScript。但是我们有自己的 aPaaS 平台,实现了以上所有的功能的运行时支持。官网和 IDE 正在紧张招人开发中。以下是广告时间,谢谢阅读。

求前端!求前端!求前端!

我们为顶尖工程师提供了与之相配的技术发挥空间、无后顾之忧的宽松工作环境以及有竞争力的薪酬福利。同时也为高潜质的行业新人提供充分的学习和成长机会。

这里,没有996,崇尚高效。
这里,话语权不靠职级和任命,靠的是代码的说服力。
这里,不打鸡血,我们用理性和内驱力去征服各种挑战。
这里,也会有项目排期,但不怕delay,我们有充足的时间,做到让自己更满意。

工作地点在北京西二旗,薪酬待遇见招聘链接:https://www.zhipin.com/job_de...

查看原文

赞 1 收藏 1 评论 0

taowen 发布了文章 · 2019-07-20

这里有一个前端工程师改变软件开发行业的机会 - 3

我们的名字叫乘法云。我们在挑战的问题是

从业务想法到软件上线,速度如何提高10x?

上一篇文章中,我们写了一个很简单的从前端直接提交改动到后端的例子。在复杂业务下,显然是不允许前端直接改数据库的数据,毕竟浏览器不是受信的执行环境。业务规则还是必须要在后端验证的。这篇里我们来看一下对于表单提交这个场景,乘法云是如何简化开发的。

Form Demo

图片描述

这个表单例子的运行时效果如上图所示。

代表数据库表的 Reservation.ts

@sources.Mysql()
export class Reservation extends Entity {
    public seatCount: number;
    public phoneNumber: string;
}

建表 SQL,注意 phoneNumber 上有唯一性约束:

CREATE TABLE `Reservation` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `phoneNumber` varchar(255) NOT NULL UNIQUE,
  `seatCount` int(11) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=latin1;

然后就是代表这个表单的 FormDemo.xml

<Card title="餐厅座位预定" width="320px">
    <Form>
        {{ message }}
        <Input :value="&phoneNumber" label="手机号" />
        <InputNumber :value="&seatCount" label="座位数" />
        <Button @onClick="onReserveClick">预定</Button>
    </Form>
</Card>

验证逻辑,以及前后端交互的代码都在同一个文件里 FormDemo.ts

@sources.Scene
export class FormDemo extends RootSectionModel {
    @constraint.min(1)
    public seatCount: number;
    @constraint.required
    public phoneNumber: string;
    public message: string = '';

    public onBegin() {
        this.reset();
    }

    public onReserveClick() {
        if (constraint.validate(this)) {
            return;
        }
        this.saveReservation();
        setTimeout(this.clearMessage.bind(this), 1000);
    }

    @command({ runAt: 'server' })
    private saveReservation() {
        if (constraint.validate(this)) {
            return;
        }
        const reservation = this.scene.add(Reservation, this);
        try {
            this.scene.commit();
        } catch (e) {
            const existingReservations = this.scene.query(Reservation, { phoneNumber: this.phoneNumber });
            if (existingReservations.length > 0) {
                this.scene.unload(reservation);
                constraint.reportViolation(this, 'phoneNumber', {
                    message: '同一个手机号只能有一个预定',
                });
                return;
            }
            throw e;
        }
        this.reset();
        this.message = '预定成功';
    }

    private reset() {
        this.seatCount = 1;
        this.phoneNumber = '';
    }

    private clearMessage() {
        this.message = '';
    }
}

乘法云相比传统技术有以下创新点

  • 前后端共享了 validate() 方法,以及 reset() 方法
  • 后端可以通过 this 拿到前端的表单,并且通过 this 直接修改前端的表单,就好像 saveReservation 这个方法是在浏览器里执行的一样。

通过省去了前后端 RPC 交互的 Request/Response 结构体的定义,可以减少数据拷贝来拷贝去的代码,减少系统中的概念。你可以把表单本身想像为人和机器"RPC"交互的 Request/Response 结构体,所以只是在前后端交互的时候复用了同一个结构体定义而已。

当然如果 this 本身并没有绑定到界面,那么这个结构体就是纯粹服务于前后端RPC的接口定义的用途。也就是我们完全可以定义一个 NewReservationProtocol 的对象,然后由这个对象来完成前后端的RPC。这样就和传统的定义后端API的做法没有区别了。只是这里给了额外一个选择,就是不要这个额外创造出来的中间状态,选择和前端表单复用同一份状态。如何选择,当然是取决于context啦。

下一篇我们会展示更复杂更能体现差异的一些例子,敬请关注。

求前端人才

我们为顶尖工程师提供了与之相配的技术发挥空间、无后顾之忧的宽松工作环境以及有竞争力的薪酬福利。同时也为高潜质的行业新人提供充分的学习和成长机会。

这里,没有996,崇尚高效。
这里,话语权不靠职级和任命,靠的是代码的说服力。
这里,不打鸡血,我们用理性和内驱力去征服各种挑战。
这里,也会有项目排期,但不怕delay,我们有充足的时间,做到让自己更满意。

工作地点在北京西二旗,薪酬待遇见招聘链接:https://www.zhipin.com/job_de...

查看原文

赞 0 收藏 0 评论 0

taowen 发布了文章 · 2019-07-19

这里有一个前端工程师改变软件开发行业的机会 - 2

我们的名字叫乘法云。我们在挑战的问题是

从业务想法到软件上线,速度如何提高10x?

上一篇文章中,列了一张非常抽象,不明觉历的脑图。但其实我们的愿望很简单,大白话就是把代码写得漂亮好懂。这一篇里我们就具体来看一些 TypeScript 代码。

Counter Demo

这个demo要实现的效果是上面这个截图所示。

入口点的代码 index.tsx。MarkupScene 是启动界面用的,指定一个 section 的类定义,把界面渲染出来。

ReactDOM.render(<MarkupScene section={CounterDemo} />, document.getElementById('app'));

对应的 CounterDemo.ts

export class CounterDemo extends RootSectionModel {
    value = 0;
    onMinusClick() {
        this.value -= 1;
    }
    onPlusClick() {
        this.value += 1;
    }
}

对应的 CounterDemo.xml。因为和 CounterDemo.ts 文件名前面部分相同,所以 MarkupScene 可以把这两个文件给关联起来。

<Button @onClick="onMinusClick">-</Button>
<span margin="8px">{{ value }}</span>
<Button @onClick="onPlusClick">+</Button>

基本上就是一个自己实现的 mobx,加上 React 实现了个 Vue。

持久化的 Counter Demo

这个例子和上面一个例子的界面效果是一样的。区别仅仅是第二次进入这个页面的时候,会保持上一次离开时候的 counter 计数。

新建了一个 Counter 类,代表这个持久化的计数器。

@sources.Mysql("default")
export class Counter extends Entity {
  name: string;
  value: number;
}

需要在配置里指定一下,这个 default 的数据库的连接信息。这一步在乘法云上是通过图形界面配置的。这里我们先用 JSON 的形式表达一下:

{
    "Mysql": {
        "default": {
            "host": "localhost",
            "user": "root",
            "password": "",
            "database": "ddarch"
        }
    }
}

以及初始化一下数据库,插入了一条名为 default 的 Counter。这一步在乘法云上也是通过图形界面来配置的。这里我们先用 SQL 的形式表达一下:

CREATE TABLE `Counter` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL,
  `value` int(11) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=latin1;

INSERT INTO Counter (name, value) VALUES ("default", 0);

然后修改一下界面的代码 PersistedCounterDemo.ts:

export class PersistedCounterDemo extends RootSectionModel {
  get value() {
    return this.theDefaultCounter.value;
  }
  get theDefaultCounter() {
    return this.scene.load(Counter, { name: "default" });
  }
  onMinusClick() {
    this.theDefaultCounter.value -= 1;
    this.scene.commit();
  }
  onPlusClick() {
    this.theDefaultCounter.value += 1;
    this.scene.commit();
  }
}

对应的视图代码 PersistedCounterDemo.xml 完全没有区别:

<Button @onClick="onMinusClick">-</Button>
<span margin="8px">{{ value }}</span>
<Button @onClick="onPlusClick">+</Button>

然后我们要改一下前端 WEB 的入口点的启动代码:

ReactDOM.render(
    <MarkupScene
        options={{
            host: 'localhost',
            port: 8080,
        }}
        section={PersistedCounterDemo}
    />,
    document.getElementById('app'),
);

这里指定了后端服务的 ip 和端口。然后再启动一个后端服务,把 Counter 这些代码注册进去。后端服务是托管的,乘法云界面上可以做控制。这里就不解释了。

这个例子想要说明的是,我们不是再重新发明 Vue。而是把前端状态管理的范畴从前端拓展到整个应用。你可以说,就是数据库直接绑定到界面了。

下一篇我们会展示更复杂更能体现差异的一些例子,敬请关注。

求前端人才

我们为顶尖工程师提供了与之相配的技术发挥空间、无后顾之忧的宽松工作环境以及有竞争力的薪酬福利。同时也为高潜质的行业新人提供充分的学习和成长机会。

这里,没有996,崇尚高效。
这里,话语权不靠职级和任命,靠的是代码的说服力。
这里,不打鸡血,我们用理性和内驱力去征服各种挑战。
这里,也会有项目排期,但不怕delay,我们有充足的时间,做到让自己更满意。

工作地点在北京西二旗,薪酬待遇见招聘链接:https://www.zhipin.com/job_de...

查看原文

赞 0 收藏 0 评论 0

taowen 发布了文章 · 2019-07-18

这里有一个前端工程师改变软件开发行业的机会 - 1

我们的名字叫乘法云。我们在挑战的问题是

从业务想法到软件上线,速度如何提高10x?

这个问题确实很难,也有很多先烈前辈尝试过了,但是失败了。但是现在的软件开发模式显然谈不上理想,而且也并没有感觉到一直在显著地改善。以前可能一个人能搞定的需求,现在前后端分离之后,上了react这些技术之后,甚至可能需要一个成建制的团队来做。某些时候,感觉技术越来越专业化之后,整体的开发成本是在上升的。这对于投入不起,利润微薄的传统企业显然不是好消息。

对资本家不是好消息,对于我们这些软件开发的无产者,也不是好消息。我们做的搬砖的工作,重复性的工作并没有变少。越来越多的时间再学习类似的事情,在不同的框架上是怎么实现的。任何一年前的代码,都还是迅速地变成焦油坑。更为重要的是,当年吸引我们加入这个行业的“开发乐趣“变得越来越少。

那凭什么是你们?凭什么之前不行,今天就有机会?因为以下三点

  1. 我们已经有一群志同道合的技术理想主义者
  2. 我们从事这个行业很多年,在多个互联网企业做过开发平台,有成功的经验和失败的教训
  3. typescript 和 node.js 这些开源组件已经成熟,可以直接拿来复用

下面这张脑图将是你加盟进来需要一起解决的问题清单。如果要说哪个技术突破了,速度就能10x,那绝无可能。但是在乘法的作用下,如果每一项下面的改进哪怕只能达到 1.5x,也可能达到最终 10x 的目标。

图片描述

这是一个前端工程师扛大梁的机会。我们的主要业务逻辑框架是用 TypeScript 写的。TypeScript 代码不仅仅定义前端逻辑,而且后端的业务逻辑也是跑在 node.js 里的。大量的开发者界面也需要非常重的前端交互。本篇是开篇第一章。后面我们会放出更多的内容,用更具体的代码示例来说明我们的与众不同。

我们为顶尖工程师提供了与之相配的技术发挥空间、无后顾之忧的宽松工作环境以及有竞争力的薪酬福利。同时也为高潜质的行业新人提供充分的学习和成长机会。

这里,没有996,崇尚高效。
这里,话语权不靠职级和任命,靠的是代码的说服力。
这里,不打鸡血,我们用理性和内驱力去征服各种挑战。
这里,也会有项目排期,但不怕delay,我们有充足的时间,做到让自己更满意。

工作地点在北京西二旗,薪酬待遇见招聘链接:https://www.zhipin.com/job_de...

查看原文

赞 1 收藏 0 评论 1

taowen 分享了头条 · 2018-10-24

Go程序员的goroutine曾经是很有优越感的。当年嘲笑FE的回调满天飞,那是多么的荣光。

赞 4 收藏 3 评论 4

taowen 分享了头条 · 2018-10-23

用啥表达“未来”?这看起来是显而易见的问题。那些所谓的业务逻辑由那么那么多的方式来表达。我们以“表达未来的方式“的视角重新审视很多初级的概念。然后我们可以看到这个问题其实没有我们想得那么简单。

赞 2 收藏 3 评论 0

taowen 分享了头条 · 2018-10-11

代码是否好读,关键是要看我们的大脑是如何“解释执行”这些代码的,有哪些因素会影响我们大脑的“执行效率”。大体上有4个主要的原因使得代码不可读

赞 0 收藏 4 评论 0

认证与成就

  • 获得 387 次点赞
  • 获得 3 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 3 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2014-09-06
个人主页被 7.2k 人浏览