小程序组件化编程

在开发微信小程序时,发现缺乏了组件化开发体验,在网上找了一波资源,发现都不是很好。其中,有用开发Vue的方式去开发小程序,好比,WePY,最后将源代码编译成小程序的官方文件模式。这种方式,开发感受爽,可是若是小程序版本升级变了以后,不在支持这种方式,那么就得从新开发一套小程序官方支持的代码了,成本代价很大。而且,此次项目时间很是紧,团队成员不熟悉vue的状况下,不敢用WePY。可是,小程序官方又对组件化支持不是很友好。因而,决定本身弄一套,既有组件化开发体验,又是最大限度的接近小程序官方的开发模式。javascript

目前项目已经成功上线,小程序:会过精选
源码地址以及实例地址vue

第一步,改写Page

因为小程序的页面定义是经过Page方法去定义的,那么,Page必定在小程序内能够认为是一个全局变量,我只须要改写Page这个方法,去能够引用组件,调用组件,触发组件的生命周期方法,维持组件内部的数据状态,那么,是否是就能够接近了组件化的编程体验了,而且能够抽离经常使用组件,达到复用的目的。java

//先保存原Page
const nativePage = Page;

/* 自定义Page */
Page = data => {
  //...改写Page逻辑,增长本身的功能
  //最后必定得调用原Page方法,否则,小程序页面没法生成
  nativePage(c);
};

确保后面页面调用的Page是咱们改写的,那么,必须在小程序启动时引入这个文件,达到改写Page的目的。git

//在app.js 头部引入,假如咱们的文件名叫registerPage.js
import "./registerPage";

App();

第二步,引入组件

page的参数是一个对象,这个对象里定义了页面的data ,生命周期方法,等等。若是要引入组件,我得定一个字段,用来代表,须要引入的组件。我决定用componnets 这个字段去引入当前页面须要引入的组件。components是一个数组,能够同时引入多个不一样的组件。es6

//在components中引入页面须要的组件,咱们这里引入了Toast和LifeCycle这2个组件
Page({
  components: ["Toast", "LifeCycle"],
  data: {
    motto: "Hello World",
    userInfo: {}
  }
});

经过components,代表了须要引入的组件。那么,咱们须要注入组件的相关数据和方法到当前页面,以保证当前页面内能调用组件的方法,或更改组件的数据状态,以达到页面的更新。为了实现这个,咱们须要定义规范组件的结构,这样才能正确拿到组件的相关信息。咱们定义的组件格式为github

//定义了一个初始化组件的方法initComponent,这个方法就是返回一个对象,跟page里的参数相似,描述了组件了相关信息。
function initComponent() {
  return {
    timer: null,
    data: {
      content: ""
    },
    show: function(msg, options) {
    }
  };
}

export { initComponent };

第三步,注入组件

有了组件的相关信息,咱们须要把这些信息自动注入到页面中,这样,在页面中才能与组件通讯,而且也须要把页面的信息引入到组件内,这样,在组件中也可与父级页面通讯。其中,组件内部最为重要的就是data 字段了,这个字段内的数据变化了,也要保证页面自动刷新,跟页面功能同样。为了隔离各个组件内部的数据,我对每一个组件默认定一个命名空间,这个命名空间就是组件的名字。把组件内部的数据挂在本身的命名空间下,再把这个命名空间挂到页面的data 下。同时,把组件的方法和其余熟悉以组件名.方法名或属性名的方法挂到页面下。这样,组件的相关信息就都注入到页面中了。编程

//挂载组件的data,以组件名为命名空间挂载
if (v.data) {
  o.data = { ...o.data, [v.name]: v.data };
}
//挂载组件的方法,以【组件名.方法名】挂载
let fns = Object.keys(v).filter(
  vv => v.hasOwnProperty(vv) && typeof v[vv] === "function"
);
for (let fn of fns) {
  o[`${v.name}.${fn}`] = function() {
    let newThis = createComponentThis(v, this);
    let args = Array.from(arguments);
    args.length < 5
      ? v[fn].call(newThis, ...args)
    : v[fn].apply(newThis, args);
  };
}

第四步,隔离组件

为了在组件内调用本身的方法,有本身的做用域,咱们必须为每一个组件建立一个独立的做用域,以隔离组件和父级页面做用域。保证了,在组件内部更改this,不会对父级页面有影响。同时,组件内部也必须有和父级页面相似的setData方法,达到一样的刷新页面的目的。咱们定义一组保护的属性名。小程序

/* 受保护的属性 */
const protectedProperty = ["name", "parent", "data", "setData"];

name是组件的名称,parent是对父级页面的引用,data 是组件内部数据状态,setData是跟父级页面相似的方法,用来更改组件内部本身的数据。微信小程序

建立组件做用域数组

/* 建立一个新的Component做用域 */
const createComponentThis = (component, page) => {
  let name = component.name;
  if (page[`__${name}.this__`]) {
    return page[`__${name}.this__`];
  }
  let keys = Object.keys(component);
  let newThis = Object.create(null);
  let protectedKeys = protectedProperty.concat(protectedEvent);
  let otherKeys = keys.filter(v => !~protectedKeys.indexOf(v));
  for (let key of otherKeys) {
    if (typeof component[key] === "function") {
      Object.defineProperty(newThis, key, {
        get() {
          return page[`${name}.${key}`];
        },
        set(val) {
          page[`${name}.${key}`] = val;
        }
      });
    } else {
      Object.defineProperty(newThis, key, {
        get() {
          return component[`${key}`];
        },
        set(val) {
          component[`${key}`] = val;
        }
      });
    }
  }
  Object.defineProperty(newThis, "name", {
    configurable: false,
    enumerable: false,
    get() {
      return name;
    }
  });
  Object.defineProperty(newThis, "data", {
    configurable: false,
    enumerable: false,
    get() {
      return page.data[name];
    }
  });
  Object.defineProperty(newThis, "parent", {
    configurable: false,
    enumerable: false,
    get() {
      return page;
    }
  });
  Object.defineProperty(newThis, "setData", {
    value: function(data) {
      page.setData(parseData(name, this.data, data));
    },
    enumerable: false,
    configurable: false
  });
  page[`__${name}.this__`] = newThis;
  return newThis;
};

第五步,触发组件的生命周期方法

每一个组件必须均可以定义本身的生命周期方法,这些生命周期方法与父级页面的同样。由于,组件的生命周期方法必须是在父级页面的生命周期方法内触发的。必须是小程序官方支持的。

/* 受保护的页面事件 */
const protectedEvent = [
  "onLoad",
  "onReady",
  "onShow",
  "onHide",
  "onUnload",
  "onPullDownRefreash",
  "onReachBottom",
  "onPageScroll"
];

咱们必须把组件的生命周期方法挂在父级页面的对应的生命周期方法内,这样,才能在触发父级页面的生命周期方法时,自动触发组件对应的生命周期方法。其中,先是触发完全部的组件的方法,再最后触发父级页面的方法

/* 绑定子组件生命周期钩子函数 */
const bindComponentLifeEvent = page => {
  let components = page.components;
  for (let key of protectedEvent) {
    let symbols = page[Symbol["for"](key)];
    let pageLifeFn = page[key];
    if (Array.isArray(symbols) && symbols.length > 0) {
      if (typeof pageLifeFn === "function") {
        symbols.push({
          fn: pageLifeFn,
          type: "page",
          context: page
        });
      }
      page[key] = function() {
        let pageThis = this;
        let args = Array.from(arguments);
        for (let ofn of symbols) {
          let currentThis;
          if (ofn.type === "component") {
            currentThis = createComponentThis(ofn.context, pageThis);
          } else {
            currentThis = pageThis;
          }
          args.length < 5
            ? ofn.fn.call(currentThis, ...args)
            : ofn.fn.apply(currentThis, args);
        }
      };
    }
  }
};

经过上述这些步骤改写Page以后,那么我就能够快速开始了个人小程序组件化编程体验了。

其实原理以下:

  • 在小程序启动时劫获小程序的Page函数,在自定义的Page函数中注入子组件的相关数据到父级页面中。
  • 将组件的data注入到父级页面的data下,可是组件的data会以组件name为命名空间,以隔离父级data或其余组件的data
  • 将组件的通常方法(非生命周期方法)注入到父级页面的方法中,方法名变成了{组件name.方法名}
  • 在组件内部的方法都会生成一个新的组件this,隔离父级this,组件this中都是定义了一系列的getter,setter方法,实际操做的是注入到父级页面中的方法。

注意点

  • 组件里的方法必须是es5的函数声明模式,不能是es6的箭头函数,由于使用es6的箭头函数会丢失组件this。
  • 组件的js达到了自动化注入,可是wxml和wxss仍是得手动引入。