简单谈谈我开发的两个库

简单谈谈我开发的两个库

Tags
前端
架构
设计模式
description
设计模式的应用
更新时间
Last updated January 26, 2022

在我工作一年多以来,其实也就开发过两个库,mock和marketing,这两个库的开发过程大量借鉴了一些开源库的思想,包括后端开源web框架,比如python的flask库,前端vue3的思路,使用了某些设计模式,如装饰器模式,代理模式,所以我就利用这个机会,复习一下我之前开发的库,并向大家展示一些设计模式的实战用法。当然下面只是我的个人经验体悟,大家作为参考就好了。
 

如何开发一个模块化的前端数据模拟库

 

目标是什么

  1. 我们需要一个能拦截前端请求,并且在前端处理这个请求的功能
  1. 通过处理我们需要返回一些模拟数据,已渲染和测试前端的功能正常与否。

mockjs

 
notion image
 
mockjs的原理就是将前端的XMLHttpRequest类给替换掉,用自己的类代替,但是保留原来的的类,在伪造的类中监听请求的路径,如果是需要mock的接口则用自己的方法去处理。
// 备份原生 XMLHttpRequest window._XMLHttpRequest = window.XMLHttpRequest window._ActiveXObject = window.ActiveXObject
所以他的使用方法大概是这样
Mock.mock( 'https://test.com', (option)=>{ // 处理数据 })
这样一看好像挺不错了,可以轻松的的对对应的url进行数据模拟
 

有什么问题

  1. 接口没有模块化的监听
  1. 和前端的Angular的相性不强
我们主要将第一点,后端应该都知道接口都是模块化组织的,比如一个接口
中,TAMU-VIRTUALSTORE-API是一个服务,/api/virtualstore/login是一个模块,doLoginNormal是一个具体的处理方法
 
虽然我对java写的后端不熟悉,但是我用python的flask框架开发过后端,其中的蓝图(blueprints) 的概念来在一个应用中或跨应用制作应用组件和支持通用的模式。
官方的描述为
蓝图很好地简化了大型应用工作的方式,并提供给 Flask 扩展在应用上注册操作的核心方法。一个 Blueprint 对象与 Flask 应用对象的工作方式很像,但它确实不是一个应用,而是一个描述如何构建或扩展应用的 蓝图 。
 
从使用来看,他是这样的
""" author: AC手环 """ user_bp = Blueprint('user', __name__) @user_bp.route('/login', methods=['POST']) def login(): """ 登录请求 :return: """ @user_bp.route('/logout', methods=['POST']) def login(): """ 登出请求 :return: """
最后相当于组装除了一个接口url为,user/login的模块化,相似功能的接口都放在了一起,挺好,
我们也想办法把这种方式抄过来,在前端模拟一个后端服务器,不是挺好吗。于是我就抄成了这样
@BluePrint('TAMU-VIRTUALSTORE-API/api/virtualstore/login', true) export class LoginBackend { private loginStatus = { name: 'acring' } @Route('/doLoginNormal', [GET'], 0, true) getLoginStatus(req: MockRequest<any>) { // 处理登录请求 return { result: this.loginStatus, status: successStatus, }; } @Route('/getLoginStatus', ['GET'], 0, true) getLoginStatus() { return { result: this.loginStatus, status: successStatus, }; } }
当我们使用时我们只知道是这么用的,但是为啥是这么用呢,为啥加一个@就可以把这个功能整合进一个模块里面,这就是关键点了。接下来就进入我们今天的第一个主角,装饰器

装饰器模式

装饰器是一个函数,用处是装饰一个类或者函数,生成一个新的函数或者类。
  • 类的装饰
function log(target){ target.isLog = true; } @log class C1{ } C1.isLog // true
function log(isLog){ // 当需要传入参数时 return (target){ target.isLog = isLog; } } @log(false) class C1{ } C1.isLog // false
  • 类方法装饰
function readonly(target, name, descriptor){ // descriptor对象原来的值如下 // { // value: specifiedFunction, // enumerable: false, // configurable: true, // writable: true // }; descriptor.value.isWritable = false; // 改变方法下的值 descriptor.writable = false; return descriptor; } class Person { @readonly name() { return `${this.first} ${this.last}` } } Person.name.isWritable // false
 
于是思路就有了,我们可以用装饰器装饰一个类,这个类便是模拟后端的一个模块,通过装饰器我们向这个类添加一些参数,如模块路由url,是否开启模块拦截,然后同样用装饰器给这个类的方法,传入这个方法的路径,请求方法类型,请求时延,是否开启拦截。
核心代码如下:
/** * 定义路由模块 * @param purl 前缀路由 * @param intercept 是否拦截/ 当为false时,所有子路由全部变为false */ export function BluePrint(purl: string, intercept: boolean = true, root: boolean = false): any { return (backend: any) => { backend.prototype.isMockBluePrint = true; backend.prototype.upstream = purl; backend.prototype.intercept = intercept; backend.prototype.root = root; }; } /** * * @param url 地址 * @param methods 允许的方法 * @param delay 时延 毫秒计时 * @param intercpt 是否拦截, false时不会拦截路由 */ export function Route(url: string, methods: string[] = ['GET'], delay: number = 0, intercept: boolean = true): any { return (target: any, name: string, descriptor: any) => { descriptor.value.isMockRoute = true; descriptor.value.route = url; descriptor.value.methods = methods; descriptor.value.delay = delay; descriptor.value.intercept = intercept; return descriptor; }; }
可以看到,装饰器并没有太多逻辑,他只是把原有的类,方法的内部参数做了修改,但这种简单的逻辑却大大简化了我们的代码,让我们的模块组织的非常漂亮和优雅。并且把细节隐藏了起来。
而之后我们只需要把这些类进行"注册"一下
TamuMockModule.forRoot(environment.URLAPI, [ ProductMock, UserMock, MarketingBackend, ])
这里面有关前端框架的细节我就不说了,直接到处理这些类的函数。
export class TamuMock { static backend: Map<string, any> = new Map(); static prefix: string; /** * 添加模拟地址和后台模拟函数 * @param url 地址 * @param mockHandle 模拟函数 */ constructor(@Inject(BACKENDS) backendConfig: {prefix: string, backendClasses: any[]}) { const { prefix, backendClasses } = backendConfig; TamuMock.prefix = prefix; TamuMock.prefix = backendConfig?.prefix; // 根据类,获取所有的模拟路由 function resolveBackendClass(backendClass: Type<any>) { const target = new backendClass(); const re = /(\/){2}/; // 去除重复的/ const upstream = target['upstream']; const classMember = Reflect.ownKeys(Reflect.getPrototypeOf(target)); for (const key of classMember) { if (typeof target[key] === 'function' && target[key].isMockRoute) { // 是否是拦截函数 const func = target[key]; const route = func['route']; const path = target['root'] ? (upstream + route) : TamuMock.prefix + (upstream + route).replace(re, '/'); if (!target.intercept) { func.intercept = false; } TamuMock.backend.set(path, Object.assign(func.bind(target), func)); // 获取原函数的属性 } } } backendClasses.forEach((backendClass) => { resolveBackendClass(backendClass); }); console.log(TamuMock.backend); } }
最终得到TamuMock.backend就是我们需要监听的url以及其处理函数,但是我们却通过装饰器和类的配合,让整个架构变得模块化了,组织更加清晰,代码也更加简单,通过都是隐藏在暗处的代码。这是非常需要思考的一点。这就是设计模式能给到我们的,虽然代码其实多了,但是代码也变少了。这是一种哲学。

如何制作一个数据驱动的绘图库

我开发的另一个库是一个基于pixijs的绘图库。
pixijs的用法非常简单,是一些oop的api设计
const app = new PIXI.Application({ backgroundColor: 0x1099bb }); const basicText = new PIXI.Text('Basic text in pixi'); app.stage.addChild(basicText); // 把视图放到html中渲染 document.body.appendChild(app.view);
notion image
面向对象自然对于写代码来说非常方便,但是对于模板配置来说却是难以满足条件的,因为对象没法被储存到服务器,也没法解析加载,我们通常就用json去存储配置,所以自然我们就想到我们需要利用JSON去存储模板配置,然后将配置解析成代码。
例如上面的代码我们可以写成这样的配置:
{ label: 'Basic text in pixi', type: 'Text', position: {x: 0, y: 0} }
然后呢,通过代码解析JSON去绘制
function drawText(option){ if(option.type !== 'Text'){ return; } const text = new PIXI.Text(option.label); text.position.set(option.position.x, option.position.y); }
类似这样,的写法,让数据和渲染分开,这也是我接手之前的写法,但是配置是直接写在前端的,也就是只有数据层到视图层的单向流动。如果我要改模板就需要修改配置然后重新上线。
当时我就跟半个运维一样,整天花小半时间修改模板代码,但是作为有想法的程序员自然是不能天天做这种体力劳动,所以我就准备改造这部分的内容。
首先我们的问题有:
  1. JSON配置没有规范,各种字段往上面加,渲染代码很多辣鸡代码。
  1. 没有一个编辑器,更新模板需要程序员做。
  1. 渲染架构的升级
前两者都很好解决,在配置上我参考了Echarts库,在编辑器设计上我参考了xiaopiu,Unity编辑器等,最后我们得出了这样的配置结构:
{ "width": 948, "height": 476, "direction": "horizontal", "elements": [ { "title": "图片", "isElement": true, "highlight": false, "id": "", "clickable": false, "dragable": false, "resizeable": false, "visible": true, "Node": { "position": { "x": 45.37, "y": 79.57 }, "scale": 0.13, "angle": 0, "anchor": { "x": 0, "y": 0.5 }, "opacity": 1, "zIndex": 1 }, "Sprite": { "src": "https://resources.wecareroom.com/immersive/imageResource/87_2fe0203bd2a6496baee32750e195fe91.png", "value": "" } }, { "title": "文字", "isElement": true, "highlight": false, "id": "", "clickable": false, "dragable": false, "resizeable": false, "visible": true, "Node": { "position": { "x": 784.92, "y": 172.27 }, "scale": 1, "angle": 0, "anchor": { "x": 0, "y": 0.5 }, "opacity": 1, "zIndex": 1 }, "Label": { "string": "元/㎡", "value": "", "textStyle": { "fontFamily": "Arial", "fontSize": 34, "fontStyle": "normal", "fontWeight": "lighter", "fill": "#ffffff", "stroke": "#000000", "strokeThickness": 0, "letterSpacing": 0, "wordWrap": false, "breakWords": false, "align": "left", "whiteSpace": "normal", "wordWrapWidth": 100, "lineHeight": 0, "padding": 0 } } } ]
 
但是最关键的渲染架构,该设计成什么样呢。

代理模式

所谓代理模式就是给目标对象提供一个代理对象,每次对目标对象的操作都会先经过代理对象的引用。
类似于一个中介。
在es6中正好已经有了proxy方法。简单的demo如下:
let target = { x: 10, y: 20 }; let hanler = { // 无论访问对象的任何变量都返回42 get: (obj, prop) => 42 }; target = new Proxy(target, hanler); target.x; //42 target.y; //42 target.x; // 42
这个简单的例子并没有是没用,我们还是得结合我们自己的需求,思考如何去使用他。
我们的需求其实很简单,当我们修改配置中的数据时绘图自动发生变化。
 
关键部分的源码:
/** * 绑定数据 * @param option 需要绑定的数据 */ private bindOption(option: ContentTemplate) { const self = this; this._option = option; const optionHandler = { // 当画布大小改变时重新设置画布大小 set(target, key, value) { Reflect.set(target, key, value); self.width = option.width; self.height = option.height; return true; } }; const elementHandler = { // 当element发生改变时,调用更新 element: null, get(target, key, description) { if (target.isElement) { this.element = description; } if (typeof target[key] === 'object' && target[key] !== null) { return new Proxy(target[key], elementHandler); } return Reflect.get(target, key); }, set(target, key, value, description) { console.debug('更新数据', description) if (target.isElement) { this.element = description; } self.updatingElements.push(this.element); Reflect.set(target, key, value); self.update(); return true; } }; const elementsHandler = { // 当elements对象改变时,直接重新绘制画板 set(target: any[], key, value) { if (!self.elementToObj.has(value) && !isNaN(+key)) { // 修改数组中的某一个 value = new Proxy(value, elementHandler); } if (key === 'length' || Number.isInteger(+key)) { // 修改数组长度 self.initDraw(); } Reflect.set(target, key, value); return true; } }; // 数据代理 this._rxOption = new Proxy(option, optionHandler); this._rxOption.elements = new Proxy(option.elements.map(d => { return new Proxy(d, elementHandler); }), elementsHandler); this.initDraw(); }
 
原理虽然简单,但是在实现过程中还是有很多要考虑的东西,比如多层数据是需要递归代理,各种情况的判断。但是当这个代理方法写完后,我们只需要专注的做好渲染函数的编写,而根本不用担心数据层和视图层的交互了,所有的一切都由代理层负责了。
 
 

总结

设计模式是一门很大的学问,不仅仅是难在如何理解,而难在如何去实现,如何将设计模式应用与自己的轮子中,而我的建议就是多学习别人的库,看别人是在何种场景中使用了何种设计模式,然后再去理解设计模式该如何应用与架构,需求中,然后自己动手写,创造自己的轮子,在开发轮子的过程中再更深入的去理解这些设计模式。