您好!
欢迎来到京东云开发者社区
登录
首页
博文
课程
大赛
工具
用户中心
开源
首页
博文
课程
大赛
工具
开源
更多
用户中心
开发者社区
>
博文
>
前端框架扩展性设计之插件机制
分享
打开微信扫码分享
点击前往QQ分享
点击前往微博分享
点击复制链接
前端框架扩展性设计之插件机制
自猿其说Tech
2022-04-12
IP归属:未知
7083浏览
前端
### 1 什么是框架的扩展性? 当我们在谈论框架的扩展性的时候,首先需要明确什么是框架,以及什么是扩展性: #### 1.1 框架 所谓框架,它提供的是一种架构,这种架构决定了运用这个框架的代码,是怎么被组织到一起的。 比如我们熟悉的Vue,它通过观察者模式构建出了Vue这个类,提供数据的存储、传递、监听和自动变更,并基于虚拟DOM的计算和更新、基于DOM的挂载和卸载、事件绑定,最后暴露出我们熟悉的API让我们在里面去组织自己的代码。 #### 1.2 扩展性 扩展性就是,在面对变化时,用最少的代价去实现。比如需求变更时,框架的代码不需要重写或者只需要修改少量的代码,就能很方便地去引入新功能和新模块。 如Babel: ![](//img1.jcloudcs.com/developer.jdcloud.com/2f27203d-268f-4fe8-b5e7-89f53698ee3520220412141825.png) Babel的核心功能是将一种语言的代码转换为另外一种语言的代码。那么它在设计的时候就会面临一个问题:就是它没法去穷举所有的语法类型。因为它没有办法去穷举所有的语法类型,也就意味着它没法穷举从一种语法到另一种语法的转换方式。 那怎么办呢?它把这部分工作分了出去,交给了社区有各种各样需求的开发者,让他们通过插件的形式自己去实现。那Babel做什么呢?它只负责这个图中parse和generate的部分。它将目标代码转换为抽象语法树,然后交给插件去做任意的转化,最后把转换好的树再次生成为代码。 这样我们无论是把es6的代码转换为es5,还是将jsx转换为js,都不需要去改动Babel的核心,而只需要提供相应的插件就可以了。 #### 1.3 框架扩展性的设计模式 以上是框架扩展性的其中一种设计模式,那么除了这种模式,还有没有其他的模式呢? 首先要让框架具备扩展性,需要考虑的核心原则就是拆分,把原来紧密耦合的功能拆分成更小的粒度,分而治之。一般会有以下三种拆分方式: - 面向流程拆分:将整个业务流程拆分为几个阶段,每个阶段作为一部分,如MVC分层架构; - 面向服务拆分:将系统提供的服务拆分,每个服务作为一部分,如微服务架构; - 面向功能拆分:将系统提供的功能拆分,每个功能作为一部分,如微内核架构(插件机制)。 其中插件机制被广泛地运用于前端领域:Chrome之所以被认为功能强大,一个很重要的原因就是它有着丰富的插件扩展;VS Code初始安装后还只是个简单的文本编辑器,但用户可以安装各种插件,从而让它摇身一变成为功能强大的IDE。除此之外,还有我们非常熟悉的Webpack、Egg、以及前面提到的Babel这些框架,都选择通过插件机制来实现它们的扩展性。 插件机制具备以下优势: - 提高了软件的复用度; - 强大的独立性提高软件开发的并行性,为大规模生产提供支持; - 可以满足用户不断变化的需求,提供了更多的开发可能性,任何对该软件感兴趣的程序员都可以加入开发大军中,不断丰富完善软件。 基于这些优势,以及它在前端领域的广泛运用,本文将着重对插件机制做详细介绍。 ### 2 如何通过插件机制设计一个可扩展的框架 前面我们通过Babel的例子引入框架的扩展性,那具体要设计一套插件模式的框架,我们应该从哪些方面去考虑呢? #### 2.1 插件的注入、配置和初始化 ##### 2.1.1 插件的注入 注入是如何让系统感知到插件的存在,方式一般可以分为声明式和编程式。 - 声明式就是通过配置信息,告诉系统应该去哪里去取什么插件,系统运行时会按照约定与配置去加载对应的插件。类似 Babel,可以通过在配置文件中填写插件名称,运行时就会去 modules 目录下去查找对应的插件并加载。 - 编程式的就是系统提供某种注册 API,开发者通过将插件传入 API 中来完成注册。如Vue.use(): ``` Vue.use = function (plugin: Function | Object) { // 1.检测是否已经注册了插件 const installedPlugins = (this._installedPlugins || (this._installedPlugins = [])) if (installedPlugins.indexOf(plugin) > -1) { return this } // 2.处理参数 const args = toArray(arguments, 1) args.unshift(this) // 3.调用install方法 if (typeof plugin.install === 'function') { plugin.install.apply(plugin, args) } else if (typeof plugin === 'function') { plugin.apply(null, args) } installedPlugins.push(plugin) return this } ``` Vue.use()把Vue构造器传递给插件暴露的的install方法,让插件把自己加到Vue构造器上,供用户全局调用。 ```javascript MyPlugin.install = function (Vue, options) { // 1. 添加全局方法或property Vue.myGlobalMethod = function () { // 逻辑... } // 2. 添加全局资源 Vue.directive('my-directive', { bind (el, binding, vnode, oldVnode) { // 逻辑... } ... }) // 3. 注入组件选项 Vue.mixin({ created: function () { // 逻辑... } ... }) // 4. 添加实例方法 Vue.prototype.$myMethod = function (methodOptions) { // 逻辑... } } ``` ##### 2.1.2 插件的配置 配置的主要目的是实现插件的可定制,因为一个插件在不同使用场景下,可能对于其行为需要做一些微调。 ##### 2.1.3 插件的初始化 工厂模式:一个插件暴露出来的是一个工厂函数,由调用者或者插件架构来将提供配置信息传入,生成插件实例,如Webpack: ```javascript plugins: [ new webpack.ProgressPlugin(), new HtmlWebpackPlugin({ template: './src/index.html' }), ] ``` 运行时模式:插件架构在调度插件时会通过约定的上下文把配置信息给到插件,如: ```javascript Vue.use(ElementUI, { size: 'medium }) ``` #### 2.2 系统如何调用插件 ##### 2.2.1 钩子机制(事件机制) 系统定义一系列事件,插件将自己的逻辑挂载在事件监听上,系统通过触发事件进行调度。在这个机制上, Webpack 是一个比较明显的例子,后面会重点介绍。 ##### 2.2.2 使用者调度机制 将插件提供的能力,统一作为系统的额外能力对外透出,最后由系统的开发使用者决定什么时候调用。例如 Egg 的插件可以向上下文中注册额外的接口能力。这种模式比较适合既需要定制更多对外能力,又需要对能力的出口做收口的场景。 #### 2.3 插件如何使用系统的能力 - 纯工具:可以使用的公共工具,不影响系统状态 - 获取当前系统状态 - 修改当前系统状态 对于需要提供哪些能力,一般的建议是根据插件需要完成的工作,提供最小够用范围内的能力,尽量减少插件破坏系统的可能性。 ### 3 Webpack的扩展性设计 上面讲的都是理论,下面我们通过具体的Webpack的例子,来看它的扩展性设计。 #### 3.1 一个简单的插件 我们先来看一个简单的插件开发需求:比如我们要开发一个文件清单的插件,希望每次Webpack打包后,自动产生一个打包文件清单,上面要记录文件名、文件数量等信息。 **思路:** 显然这个操作需要在文件生成到dist目录之前进行,所以我们要注册的是Compiler上的emit钩子。 emit 是一个异步串行钩子,我们用 tapAsync来注册。 在 emit 的回调函数里我们可以拿到 compilation 对象,所有待生成的文件都在它的 assets 属性上。 通过 compilation.assets 获取我们需要的文件信息,并将其整理为新的文件内容准备输出。 然后往 compilation.assets 添加这个新的文件。 插件完成后,最后将写好的插件放到 webpack 配置中,这个包含文件清单的文件就会在每次打包的时候自动生成了。 **实现:** ```javascript class FileListPlugin { constructor (options) { // 获取插件配置项 this.filename = options && options.filename ? options.filename : 'FILELIST.md'; } apply(compiler) { // 注册 compiler 上的 emit 钩子 compiler.hooks.emit.tapAsync('FileListPlugin', (compilation, cb) => { // 通过 compilation.assets 获取文件数量 let len = Object.keys(compilation.assets).length; // 添加统计信息 let content = `# ${len} file${len>1?'s':''} emitted by webpack\n\n`; // 通过 compilation.assets 获取文件名列表 for(let filename in compilation.assets) { content += `- ${filename}\n`; } // 往 compilation.assets 中添加清单文件 compilation.assets[this.filename] = { // 写入新文件的内容 source: function() { return content; }, // 新文件大小(给 webapck 输出展示用) size: function() { return content.length; } } // 执行回调,让 webpack 继续执行 cb(); }) } } ``` 以上就是一个简单的Webpack插件的编写。我们可以看到它其实就是一个发布订阅模式。也就是前面说到的,系统如何调用插件中的钩子机制:即系统定义一系列事件,插件将自己的逻辑挂载在事件监听上,系统通过触发事件进行调度。 Webpack将这一套事件调度的逻辑提炼成了一个库,叫Tapable,Webpack 基于 Tapable 构建了其复杂庞大的流程管理系统,不仅解耦了流程节点和流程的具体实现,还保证了 Webpack 强大的扩展能力。下面我们就先来看看它的基本使用: #### 3.2 Tapable的基本使用 ```javascript const { SyncHook } = require('tapable'); class MyHooks { constructor() { this.hook = new SyncHook(['param1', 'param2']); } tap() { this.hook.tap('job1', (param1, param2) => { console.log(`execute the first job with params: ${param1}, ${param2}`); }); this.hook.tap('job2', (param1, param2) => { console.log(`execute the second job with params: ${param1}, ${param2}`) }); return this; } call() { this.hook.call('第一个参数', '第二个参数'); return this; } } const mh = new MyHooks(); mh.tap().call(); // execute the first job with params: 第一个参数, 第二个参数 // execute the second job with params: 第一个参数, 第二个参数 ``` 我们先引入了Tapable中的SyncHook钩子,顾名思义,就是同步钩子。钩子本身提供2个方法,tap和call。 tap是在这个钩子上注册要执行的方法,call是调用钩子,进而调用钩子上注册的方法。 我们在MyHooks这个钩子上注册了2个任务,job1和job2,这2个job接收钩子在执行call方法时传入的2个参数,并打印出来。 接下来我们再来看一个稍微复杂一点的场景: 比如我们去参加公司的年会,从准备出发到结束大致可以梳理出这几个流程:我们在出发之前首先要换上礼服,然后坐车到达酒店,到酒店之后领取伴手礼,然后入座观看节目表演,之后是晚宴。然后是游戏比赛,先每一桌各自PK,获胜者参加下一轮的PK,决胜出一名王者。最后结束拍照发朋友圈。 这一系列的过程我们可以将它们粗略地分为年会前、年会中、年会后。年会前换礼服、坐车、领伴手礼;年会中观看节目、晚宴、游戏比赛。游戏比赛有2轮,在第一轮中胜出的人才可以参加下一轮;年会后拍照发朋友圈。 在年会中有一个环节是和年会前的环节有差别的:就是游戏比赛环节,第二轮的比赛不是所有人都能参与,是有条件的,条件就是在第一轮中胜出的人才能参加第二轮; 年会后拍照发朋友圈这两个环节也是跟之前的有差别,就是只有拍了照才能发朋友圈,发朋友圈这个动作是依赖于拍照的。 根据前面的分析,我们需要Tapable中三种不同类型的钩子:SyncHook、SyncBailHook、SyncWaterfallHook。SyncHook前面我们已经用过,就是一个同步执行的钩子;SyncBailHook顾名思义,就是满足某项条件的时候就bail退出出来;SyncWaterfall就是瀑布流,后一项执行的任务需要依赖前一项任务产出的内容。 ```javascript const { SyncHook, SyncBailHook, SyncWaterfallHook } = require('tapable'); class Party { constructor() { this.hooks = { beforeParty: new SyncHook(), duringParty: new SyncBailHook(['param']), afterParty: new SyncWaterfallHook(['param']), } } tap() { this.hooks.beforeParty.tap('dressUp', () => { console.log('穿上礼服'); }); this.hooks.beforeParty.tap('headToHotel', () => { console.log('乘坐大巴前往酒店'); }); this.hooks.beforeParty.tap('getGift', () => { console.log('领取伴手礼'); }); this.hooks.duringParty.tap('watchShow', () => { console.log('观看节目表演'); }); this.hooks.duringParty.tap('haveDinner', () => { console.log('晚宴'); }); this.hooks.duringParty.tap('gameInTable', (isWinner) => { console.log('同桌比赛'); if(!isWinner) return 'lost'; console.log('获胜!参加下一轮比赛'); }); this.hooks.duringParty.tap('FinalGame', () => { console.log('决赛'); }); this.hooks.afterParty.tap('takePhoto', () => { console.log('拍照'); return '照片'; }) this.hooks.afterParty.tap('post', (photo) => { console.log(`用${photo}发朋友圈`); }) return this; } call() { this.hooks.beforeParty.call(); this.hooks.duringParty.call(false); this.hooks.afterParty.call(); } } const party = new Party(); party.tap().call(); // 穿上礼服 // 乘坐大巴前往酒店 // 领取伴手礼 // 观看节目表演 // 晚宴 // 同桌比赛 // 拍照 // 用照片发朋友圈 ``` 我们分别实例化SyncHook、SyncBailHook和SyncWaterfallHook这三个钩子来模拟年会前、年会中和年会后这三个过程。SyncHook的用法我们刚刚已经介绍了。 我们来看SyncBailHook,在同桌比赛的这个环节,我们通过传入一个参数来模拟比赛结果,如果比赛输了,return一个字符串lost,当下一个tap接收到这个参数,就不会再继续往下执行。 然后再来看SyncWaterfallHook,“发朋友圈”这一步接收到的参数,是拍照这一步返回的。 通过这个例子我们可以看到,Tapable不仅实现了事件的发布和订阅,还将这些事件加以归类,除了上面提到的SyncBailHook和SyncWaterfallHook外,还有: - AsyncSeriesHook:异步串行机制,按照 plugin 的次序依次处理,所有的 plugin 接收的是同样的参数。 - AsyncSeriesLoopHook:异步钩子,可以触发 handler 循环调用。 - AsyncSeriesWaterfallHook:异步钩子,上一个 handler 可以根据内部的回调函数传值给下一个 handler。 - AsyncWaterfallHook:异步流水作业机制,上一个 plugin 的返回值是下一个 Plugin 的输入值,依次执行。 - AsyncParallelBailHook:异步钩子,handler 并行触发,但是跟 handler 内部调用回调函数的逻辑有关。 - AsyncParallelHook:异步钩子,handler 并行触发。 #### 3.3 小结 Webpack通过插件机制来实现其扩展性,它的插件机制是通过在编译打包的过程中暴露出来的生命周期钩子,让用户可以在这些生命周期里实现自己的逻辑。而这些生命周期钩子,主要是通过发布订阅机制来实现。Webpack通过这个机制来扩展和丰富自己的功能,让社区能够参与进来,一起共建。 ### 4 总结 最后我们再来复习一下前面介绍的框架扩展性的设计理念。首先框架具备扩展性是为了解决当需求变化时如何用最小的改动来实现的问题。扩展性的实现需要遵循一个核心原则,就是分而治之。一般有三种拆分方式,本文主要介绍按照功能来拆分的插件模式,即框架把自己的核心功能抽象提炼,变动的部分交给插件。 对于抽象提炼,Babel把自己提炼为“将目标代码解析为AST、再将AST生成为代码”两部分,转换的规则和动作交给插件;Webpack把自己的工作抽象为一个事件流,在事件流中暴露出钩子供插件使用。 框架的扩展性设计是一个比较大的话题,受限于篇幅,本文只详细举了Webpack的例子。除了Webpack,Babel、Egg、VSCode都十分值得研究,这里希望能够抛砖引玉。 ------------ ###### 自猿其说Tech-JDL京东物流技术与数据智能部 ###### 作者:彭岚岚
原创文章,需联系作者,授权转载
上一篇:荷兰MCA-之无人自提门店解码
下一篇:基于Spring-AOP的自定义分片工具
相关文章
前端十年回顾 | 漫画前端的前世今生
Taro小程序跨端开发入门实战
【技术干货】企业级扫描平台EOS关于JS扫描落地与实践!
自猿其说Tech
文章数
426
阅读量
2150010
作者其他文章
01
深入JDK中的Optional
本文将从Optional所解决的问题开始,逐层解剖,由浅入深,文中会出现Optioanl方法之间的对比,实践,误用情况分析,优缺点等。与大家一起,对这项Java8中的新特性,进行理解和深入。
01
Taro小程序跨端开发入门实战
为了让小程序开发更简单,更高效,我们采用 Taro 作为首选框架,我们将使用 Taro 的实践经验整理了出来,主要内容围绕着什么是 Taro,为什么用 Taro,以及 Taro 如何使用(正确使用的姿势),还有 Taro 背后的一些设计思想来进行展开,让大家能够对 Taro 有个完整的认识。
01
Flutter For Web实践
Flutter For Web 已经发布一年多时间,它的发布意味着我们可以真正地使用一套代码、一套资源部署整个大前端系统(包括:iOS、Android、Web)。渠道研发组经过一段时间的探索,使用Flutter For Web技术开发了移动端可视化编程平台—Flutter乐高,在这里希望和大家分享下使用Flutter For Web实践过程和踩坑实践
01
配运基础数据缓存瘦身实践
在基础数据的常规能力当中,数据的存取是最基础也是最重要的能力,为了整体提高数据的读取能力,缓存技术在基础数据的场景中得到了广泛的使用,下面会重点展示一下配运组近期针对数据缓存做的瘦身实践。
自猿其说Tech
文章数
426
阅读量
2150010
作者其他文章
01
深入JDK中的Optional
01
Taro小程序跨端开发入门实战
01
Flutter For Web实践
01
配运基础数据缓存瘦身实践
添加企业微信
获取1V1专业服务
扫码关注
京东云开发者公众号