从0到1纯手工打造前端框架

lxf2023-05-04 15:09:01

前言

前端框架一路走来,从最原始的DOM操作,到jQuery-like之类的DOM操作工具库,再到后来的AMD、 CMD模块化开发,以及后续涌现出的一系列MVC、MVVM框架等,本质都是为了让前端开发更加职责分明,快速迭代 。

如果我们仔细思考,我们就会发现不管我们采用哪种框架,亦或者直接操作底层DOM来组织前端代码,我们潜意识都会将前端组织架构从凌乱、松散的结构慢慢向树形结构靠拢进,形成一颗隐形树从而进行管理。

为什么都会向树形结构靠拢呢?

那么我们就有必要了解一下数据结构中如何定义的树以及有哪些优势。

-- 摘自维基百科-树(数据结构)

它是由n(n>0)个有限节点组成一个具有层次关系的集合。把它叫做“树”是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。它具有以下的特点:

  • 每个节点都只有有限个子节点或无子节点;
  • 没有父节点的节点称为根节点;
  • 每一个非根节点有且只有一个父节点;
  • 除了根节点外,每个子节点可以分为多个不相交的子树;
  • 树里面没有环路(cycle)

如下所示:

从0到1纯手工打造前端框架

优势不言而喻:

  1. 有向图,组织架构清晰
  1. 树是连通的,给定一个节点,就可以知道该节点下的子孙;以及知道该节点上的父辈;

基于树形结构的优势,我们还会发现生活中也用到了很多,典型就是家族、公司人员管理。

目标

借助ES6现有能力,自己实现一套小型前端框架:

  1. 将松散、无序模块(组件)转换成一颗树
  1. 节点之间能良好通信
  1. 数据更新触发页面实时更新
  1. 组件销毁实时释放占用内存

思路

为了达到上述目标,我们逐个击破。

转换树

为了将模块亦或者组件,组织成树形结构,必然我们会想到写一个基类,以其为纽带,将松散、无序的代码结构转变成一颗有序树。

节点互通

转变成一颗有序树后,模块、组件之间还涉及到通信问题:

  • 父节点通知子孙节点

    • 在关联之际,我们可以将子节点存储在父节点下统一管理,需要的时候,通过遍历拿到指定子节点
    • 如果父节点,想要跨层获取孙子以及后代节点,我们可以通过父节点->子节点->...->指定后代节点,递归获取
  • 子孙节点通知父辈节点

    • 在关联之际,我们同时也可以将父节点注入子节点之中,这样后续子节点需要的时候,通过已关联的父节点引用,直接通知
    • 如果子节点,想要跨越多层,通知父辈节点,那么我们可以通过子节点->父节点->...->指定父辈节点,递归获取
  • 堂兄弟之间互相通信

    • 基于上述思路,父子节点已能完成通信,那么堂兄弟间,可以借助共同的父辈来进行通信
    • 共同父辈,隔了几代,为了不使代码结构变得强耦合,我们也可以通过发布/订阅模式来达到堂兄弟之间的通信

数据更新

利用ES6的模版字符串,动态注入更新数据,采用局部更新页面,如下

const template = function ({name}) {
    return `<div class="slot1"></div><div class="slot2"></div>`;
};
init() {
 this.$el.find(('.slot1'))
}
const data = {
    name: 'nian'
};
this.$el.html(template(data));

组件销毁

考虑组件下面挂载的子组件以及内存占用问题

实现

有了上述思路,下面我们就来一一实现吧。

转换树

写一个基类,所有模块、组件继承于它。

export default class BasicView {
    constructor() {
        this._components = {};
    }

    render () {}

    registerComponent (name, component) {
        // 已注册,先销毁
        if (this._components.hasOwnProperty(name)) {
            let comp = this._components[name];
            // 销毁remove待写
            comp.remove();
        }
        // 父子关联
        this._components[name] = component;
        component._parentView = this;
        component._componentName = name;
        
        return component;
    }
    
    remove() {
        // todo
    }
}

如上所示,调用registerComponent方法,通过this._components和component._parentView关联了父子,并返回component,这样在父节点就可以获取到该子节点了。

但,如果有多层级,想访问子节点的子孙,那么我们仍然通过registerComponent返回的component来获取代码会变得不可控,并且会重复处理异常。

故,我们需要在基类BasicView,提供getComponent方法来达到获取子组件能力,如下

export default class BasicView {
    constructor() {}

    render () {}

    registerComponent (name, component) {/* 保持不变 */}
    
    getComponent (name) {
        if (this._components.hasOwnProperty(name)) {
            let comp = this._components[name];
            return comp;
        } else {
            throw new Error(`${name}组件不存在`);
        }
    }
    
    remove() {/* todo */}
}

通过继承BasicView基类,这样就将松散无序的模块以及组件,转换成了一棵抽象树。

节点互通

  • 父节点通知子节点,我们可以利用上述getComponet方法来做到,如下

    • class ChildComponent extends BasicView {
          method() {
              // todo
          }
      }
      
      class ParentComponent extends BasicView {
          init () {
              this.comp = this.registerComponent('childComponent', new ChildComponent());
          }
          operateChild () {
              // this.comp.method();
              this.getComponent('childComponent').method();
          }
      }
      
  • 子节点通知父辈节点,我们可以借助在registerComponent时,关联在子节点上的_parentView

    • 最简单的方法,我们可以在BasicView下写一个getParent方法得到父节点并操作

      • export default class BasicView {
            constructor() {
                this._components = {};
            }
        
            render () {}
        
            registerComponent (name, component) {/* 保持不变 */}
            
            getComponent() {/* 保持不变 */}
            
            getParent() {
                return this._parentView || {};
            }
            
            remove() {/* todo */}
        }
        // demo
        childComponent.getParent().xxx();
        
      •     但,假设这么做,父子之间耦合度太深,子组件还需知道父组件或者约定父组件有哪些公共方法,扩展性以及独立性极差,缺点多多。
      •     所以,我们需要解耦,子节点只需抛出通知事件,具体执行细节在父辈组件执行,这样就极大减少了耦合度,俗称控制反转,如下
      • class ChildComponent extends BasicView {
            notifyParent (data) {
                this.trigger('eventName', data);
            }
        }
        
        class ParentComponent extends BasicView {
            constructor () {
                this.appEvents = {
                    'eventName childComponent': 'notifyMethod'
                };
            }
            init () {
                this.comp = this.registerComponent('childComponent', new ChildComponent());
            }
            notifyMethod (data) {
                // 实际执行细节
            }
        }
        
      •     要达到这一效果,我们需要在基类BasicView中,实现这一细节trigger方法,如下
      • export default class BasicView {
            constructor() {
                this.appEvents = {};
                this._components = {};
            }
        
            render () {}
        
            registerComponent (name, component) {/* 保持不变 */}
            
            getComponent() {/* 保持不变 */}
            
            trigger(eventName, ...data) {
                let parent = this._parentView;
                if (parent) {
                    let componentName = this._componentName;
                    // emitComponentEvent实现细节todo
                    parent.emitComponentEvent(eventName, componentName, ...data);
                    // 往上继续传播
                    parent.trigger(eventName, ...data);
                }
            }
            
            remove() {/* todo */}
        }
        
        1.     emitComponentEvent方法实现,需要拿到父节点appEvents,逐个匹配,成功后执行相关方法,否则不做任何操作,实现细节如下
      • export default class BasicView {
            constructor() {
                this.appEvents = {};
                this._components = {};
            }
        
            render () {}
        
            registerComponent (name, component) {/* 保持不变 */}
            
            getComponent() {/* 保持不变 */}
            
            emitComponentEvent (event, componentName, ...data) {
                let delegateEventSplitter = /^(\S+)\s*(\S+)$/;
                Object.keys(this.appEvents).forEach( (key) => {
                    let funcName = this.appEvents[key];
                    let match = key.match(delegateEventSplitter);
                    let eventName = match[1],
                        selector = match[2];
                    if (selector === componentName && event === eventName) {
                        this[funcName] && this[funcName](...data);
                    }
                });
            }
            
            trigger(eventName, ...data) {
                let parent = this._parentView;
                if (parent) {
                    let componentName = this._componentName;
                    // emitComponentEvent实现细节todo
                    parent.emitComponentEvent(eventName, componentName, ...data);
                    // 往上继续传播
                    parent.trigger(eventName, ...data);
                }
            }
            
            remove() {/* todo */}
        }
        
  • 堂兄弟节点之间互相通信

    • 借助共同的父辈节点,以其为纽带进行转发,如下:
    • class ChildComp1 extends BasicView {
          notifyComp2 (data) {
              this.trigger('eventName', data);
          }
      }
      class ChildComp2 extends BasicView {
          method() {}
      }
      class ParentView extends BasicView {
          constructor () {
              this.appEvents = {
                  'eventName child1': 'comp1NotifyComp2'
              };
          }
          init () {
              this.comp1 = this.registerComponent('child1', new ChildComp1());
              this.comp2 = this.registerComponent('child2', new ChildComp2());
          }
          comp1NotifyComp2 (data) {
              this.comp2.method(data);
          }
      }
      
    • 使用发布/订阅者模式,减少层级太深过度耦合

数据更新

前面我们已解决将松散的结构转换成一颗抽象树,且实现了节点之间的通信。

那么,怎么将这棵树反应到页面中呢?

首先,我们考虑单个节点怎么将数据与模版映射。

利用ES6的字符串模版即可,并且我们考虑到目前浏览器性能都不错,当数据更新时,我们完全可以批量将模版更新到页面中。

注:这里采用原生DOM形式,后续可根据情况,替换成虚拟DOM

如下

class ComponentView extends BasicView {
    constructor() {
        this.$el = $('<div class="componentView" />');
    }
    template(data) {
        const { name } = data;
        return `我是${name}`;
    }
    async render() {
        const data = await axios.fetch('xxxx');
        this.$el.html(this.template(data));
    }
}

单个节点解决,那么我们就可以利用el,借助基类registerComponent方法将零散的el,借助基类registerComponent方法将零散的el也组装成为一棵树,如下

export default class BasicView {
    constructor() {
        this.appEvents = {};
        this._components = {};
    }

    render () {}

    registerComponent (name, component, container, dontRender) {
        if (this._components.hasOwnProperty(name)) {
            let comp = this._components[name];
            comp.remove();
        }
        this._components[name] = component;
        component._parentView = this;
        component._componentName = name;

        // 父节点$el挂载子节点$el
        if (container) {
            if (typeof container === 'string') {
                this.$el.find(container).append(component.$el);
            } else {
                $(container).append(component.$el);
            }
            if (dontRender !== true) {
                component.render();
            }
        }
        return component;
    }
    
    getComponent() {/* 保持不变 */}
    
    emitComponentEvent (event, componentName, ...data) {/* 保持不变 */}
    
    trigger(eventName, ...data) {/* 保持不变 */}
    
    remove() {/* todo */}
}

demo如下

class ChildComp1 extends BasicView {
    constructor() {
        this.$el = $('<div class="childComp1" />');
    }
    notifyComp2 (data) {
        this.trigger('eventName', data);
    }
    remove () {
        // todo
        super.remove()
    }
}
class ChildComp2 extends BasicView {
    constructor() {
        this.$el = $('<div class="childComp2" />');
    }
    method() {}
}
class ParentView extends BasicView {
    constructor () {
        this.$el = $('<div class="parentView" />');
        this.appEvents = {
            'eventName child1': 'comp1NotifyComp2'
        };
    }
    template() {
        return `<div class='childContainer'></div>`;
    }
    init () {
        this.comp1 = this.registerComponent('child1', new ChildComp1(), '.childContainer');
        this.comp2 = this.registerComponent('child2', new ChildComp2(), '.childContainer');
    }
    comp1NotifyComp2 (data) {
        this.comp2.method(data);
    }
}

组件销毁

当我们需要移除页面中指定组件时,我们需要考虑该组件下的所有子孙节点,以及相关内存泄漏等因素,所以在基类BasicView下,补充remove方法,如下

export default class BasicView {
    constructor() {
        this.appEvents = {};
        this._components = {};
    }

    render () {}

    registerComponent (name, component) {/* 保持不变 */}
    
    getComponent() {/* 保持不变 */}
    
    emitComponentEvent (event, componentName, ...data) {/* 保持不变 */}
    
    trigger(eventName, ...data) {/* 保持不变 */}
    
    remove() {
        this._components && Object.keys(this._components).forEach( (key) => {
            this._components[key].remove();
        });
        this.$el.remove();
    }
}