深刻wepy小程序組件化框架

wepy是一個優秀的微信小程序組件化框架,突破了小程序的限制,支持了npm包加載以及組件化方案,而且在性能優化方面也下了功夫,無論以後項目是否會使用到,該框架組件化編譯方案都是值得學習和深刻的。
javascript

本文同步於我的博客 www.imhjm.com/article/597…
wepy文檔: wepyjs.github.io/wepy/#/
github地址:github.com/wepyjs/wepy前端

咱們先提出幾個問題,讓下面的分析更有針對性:java

  1. wepy如何實現單文件組件.wpy編譯?
  2. 如何隔離組件做用域?
  3. 如何實現組件通訊?
  4. 如何實現加載外部npm包?

先從源碼目錄入手

咱們能夠先clone下wepy的github目錄node

  • docs目錄下是開發文檔以及使用docsify生成的文檔網站
  • packages目錄下就是核心代碼了,wepy是小程序端的框架,wepy-cli是腳手架(負責組件化編譯等等),其餘就是一些預處理編譯插件、還有一些壓縮插件等等
  • scripts是一些shell腳本,負責test等等一些操做
  • gulpfile.js主要負責將package中的src開發目錄中js文件babel轉換至lib目錄供使用,並支持watch compile
  • 等等

咱們就重點看wepy以及wepy-cli,接下來的文章也是圍繞這兩個展開
git

wepy-cli分析

目錄結構


上面的bin/wepy.js是入口文件,其實就一句話引入lib中通過babel轉換的文件

require('../lib/wepy');複製代碼

編譯構建流程

下圖簡單畫出了總體wepy-cli的編譯構建流程,忽略了部分細節
github


接下來答大致說下build的流程(能夠跟着圖看)

  • wepy-cli使用commander做爲命令行工具,build命令執行調用compile build方法
  • 若是沒有指定文件build,則獲取src目錄下全部文件,尋找沒有引用的(指的是這種<script src="">),調用compile方法開始編譯,若是指定文件,則相應判斷尋找父組件或者尋找引用去編譯
  • compile方法根據文件後綴判斷,調用不一樣文件的方法(下面就說wpy單文件組件)
  • compile-wpy調用resolveWpy方法(核心)shell

    • 替換內容中的attr(好比@tap => bindtap等等)
    • 使用xmldom放入內容,操做節點
    • 獲取wpy中的包裹的config,放入rst.config
    • 將樣式放入rst.style
    • 提早編譯wxml,若是是jade/pug之類的
    • 獲取文件中的import和components放入rst.template.components
    • 獲取代碼中components的屬性,獲取props以及events放入rst.script.code中
      rst.script.code = rst.script.code.replace(/[\s\r\n]components\s*=[\s\r\n]*/, (match, item, index) => {
                    return `$props = ${JSON.stringify(props)};\r\n$events = ${JSON.stringify(events)};\r\n${match}`;
                });複製代碼
    • 最後拆解style、template、script構成一個rst文件
      let rst = {
            moduleId: moduleId,
            style: [],
            template: {
                code: '',
                src: '',
                type: '',
                components: {},
            },
            script: {
                code: '',
                src: '',
                type: '',
            },
           config: {},
        };複製代碼
    • rst構建完成,開始逐個操做express

      • rst.config寫入xxx.json
      • rst.template
        • 通過compiler後再次使用xmldom
        • updateSlot,替換內容
        • updateBind, 將{{}}以及attr中的加入組件前綴($prefix)
        • 將組件替換成相應xml
      • rst.style
        • 尋找require再進入compile-style
        • compiler預編譯處理,而且置入依賴(@import)
      • rst.scriptnpm

        • compiler處理
        • 假如不是npm包,則置入wepy框架初始化代碼json

          if (type !== 'npm') {
            if (type === 'page' || type === 'app') {
                code = code.replace(/exports\.default\s*=\s*(\w+);/ig, function (m, defaultExport) {
                    if (defaultExport === 'undefined') {
                        return '';
                    }
          
                    if (type === 'page') {
                        let pagePath = path.join(path.relative(appPath.dir, opath.dir), opath.name).replace(/\\/ig, '/');
                        return `\nPage(require('wepy').default.$createPage(${defaultExport} , '${pagePath}'));\n`;
                    } else {
                        appPath = opath;
                        let appConfig = JSON.stringify(config.appConfig);
                        return `\nApp(require('wepy').default.$createApp(${defaultExport}, ${appConfig}));\n`;
                    }
                });
            }
          }複製代碼
        • resolveDeps(核心),根據require的形式,模仿node require加載機制,將文件拷貝到相應目錄,修改require內容,這裏包括外部npm包的拷貝加載
        • 假如是npm包,特殊處理部分代碼
    • 均經過相應plugins順序經過一遍,最後輸出到dist目錄

大致就像開發文檔的圖同樣,如今看就很清晰了

核心方法

resolveWpy

這個方法用於生成rst,拆分wpy單文件組件,上面流程講了大部分,這裏就詳細講下props和event的提取
其實也不是很複雜,就是遍歷元素,取出相應attributes,放入events[comid][attr.name]以及props[comid][attr.name]放入代碼中

elems.forEach((elem) => {
                // ignore the components calculated in repeat.
                if (calculatedComs.indexOf(elem) === -1) {
                    let comid = util.getComId(elem);
                    [].slice.call(elem.attributes || []).forEach((attr) => {
                        if (attr.name !== 'id' && attr.name !== 'path') {
                            if (/v-on:/.test(attr.name)) { // v-on:fn user custom event
                                if (!events[comid])
                                    events[comid] = {};
                                events[comid][attr.name] = attr.value;
                            } else {
                                if (!props[comid])
                                    props[comid] = {};
                                if (['hidden', 'wx:if', 'wx:elif', 'wx:else'].indexOf(attr.name) === -1) {
                                    props[comid][attr.name] = attr.value;
                                }
                            }
                        }
                    });
                }
            });
            if (Object.keys(props).length) {
                rst.script.code =rst.script.code.replace(/[\s\r\n]components\s*=[\s\r\n]*/, (match, item, index) => {
                    return `$props = ${JSON.stringify(props)};\r\n$events = ${JSON.stringify(events)};\r\n${match}`;
                });
            }複製代碼
//... util.geComId
getComId(elem) {
        let tagName = elem.nodeName;
        let path = elem.getAttribute('path');
        let id = elem.getAttribute('id');
        if (tagName !== 'component')
            return tagName;
        if (id)
            return id;
        if (path && !id)
            return path;
    },複製代碼

updateBind && parseExp

  • updateBind遍歷調用parseExp,而且遇到子元素調用自身傳入前面的prefix,最後能夠生成$parent$child$xxx這種同樣的數據
  • parseExp就是用於替換添加prefix

下面精簡了下代碼,易於理解

updateBind(node, prefix, ignores = {}, mapping = {}) {
    let comid = prefix;
    if (node.nodeName === '#text' && prefix) {
      if (node.data && node.data.indexOf('{{') > -1) {
        node.replaceData(0, node.data.length, this.parseExp(node.data, prefix, ignores, mapping));
      }
    } else {
      [].slice.call(node.attributes || []).forEach((attr) => {
        if (prefix) {
          if (attr.value.indexOf('{{') > -1) {
            attr.value = this.parseExp(attr.value, prefix, ignores, mapping);
          }
        }
        if (attr.name.indexOf('bind') === 0 || attr.name.indexOf('catch') === 0) {
          if (prefix) {
            attr.value = `$${comid}$${attr.value}`;
          }
        }
      });
      [].slice.call(node.childNodes || []).forEach((child) => {
        this.updateBind(child, prefix, ignores, mapping);
      });
    }
  },複製代碼
parseExp(content, prefix, ignores, mapping) {
    let comid = prefix;
    // replace {{ param ? 'abc' : 'efg' }} => {{ $prefix_param ? 'abc' : 'efg' }}
    return content.replace(/\{\{([^}]+)\}\}/ig, (matchs, words) => {
      return matchs.replace(/[^\.\w'"](\.{0}|\.{3})([a-z_\$][\w\d\._\$]*)/ig, (match, expand, word, n) => {
        // console.log(matchs + '------' + match + '--' + word + '--' + n);
        let char = match[0];
        let tmp = word.match(/^([\w\$]+)(.*)/);
        let w = tmp[1];
        let rest = tmp[2];
        if (ignores[w] || this.isInQuote(matchs, n)) {
          return match;
        } else {
          if (mapping.items && mapping.items[w]) {
            // prefix 減小一層
            let upper = comid.split(PREFIX);
            upper.pop();
            upper = upper.join(PREFIX);
            upper = upper ? `${PREFIX}${upper}${JOIN}` : '';
            return `${char}${expand}${upper}${mapping.items[w].mapping}${rest}`;
          }
          return `${char}${expand}${PREFIX}${comid}${JOIN}${word}`;
        }
      });
    });
  },複製代碼

resolveDeps

這個方法用於wpy框架的加載機制
將require部分替換成正確的編譯後的路徑
npm包經過讀取相應package.json中的main部分去尋找文件,尋找npm文件會再繼續resolveDeps獲取依賴,最後寫入npm中

resolveDeps (code, type, opath) {

        let params = cache.getParams();
        let wpyExt = params.wpyExt;


        return code.replace(/require\(['"]([\w\d_\-\.\/@]+)['"]\)/ig, (match, lib) => {

            let resolved = lib;

            let target = '', source = '', ext = '', needCopy = false;

            if (lib[0] === '.') { // require('./something'');
                source = path.join(opath.dir, lib);  // e:/src/util
                if (type === 'npm') {
                    target = path.join(npmPath, path.relative(modulesPath, source));
                    needCopy = true;
                } else {
                    // e:/dist/util
                    target = util.getDistPath(source);
                    needCopy = false;
                }
            } else if (lib.indexOf('/') === -1 || // require('asset');
                lib.indexOf('/') === lib.length - 1 || // reqiore('a/b/something/')
                (lib[0] === '@' && lib.indexOf('/') !== -1 && lib.lastIndexOf('/') === lib.indexOf('/')) // require('@abc/something')
            ) {  
                let pkg = this.getPkgConfig(lib);
                if (!pkg) {
                    throw Error('找不到模塊: ' + lib);
                }
                let main = pkg.main || 'index.js';
                if (pkg.browser && typeof pkg.browser === 'string') {
                    main = pkg.browser;
                }
                source = path.join(modulesPath, lib, main);
                target = path.join(npmPath, lib, main);
                lib += path.sep + main;
                ext = '';
                needCopy = true;
            } else { // require('babel-runtime/regenerator')
                //console.log('3: ' + lib);
                source = path.join(modulesPath, lib);
                target = path.join(npmPath, lib);
                ext = '';
                needCopy = true;
            }

            if (util.isFile(source + wpyExt)) {
                ext = '.js';
            } else if (util.isFile(source + '.js')) {
                ext = '.js';
            } else if (util.isDir(source) && util.isFile(source + path.sep + 'index.js')) {
                ext = path.sep + 'index.js';
            }else if (util.isFile(source)) {
                ext = '';
            } else {
                throw ('找不到文件: ' + source);
            }
            source += ext;
            target += ext;
            lib += ext;
            resolved = lib;

            // 第三方組件
            if (/\.wpy$/.test(resolved)) {
                target = target.replace(/\.wpy$/, '') + '.js';
                resolved = resolved.replace(/\.wpy$/, '') + '.js';
                lib = resolved;
            }

            if (needCopy) {
                if (!cache.checkBuildCache(source)) {
                    cache.setBuildCache(source);
                    util.log('依賴: ' + path.relative(process.cwd(), target), '拷貝');
                    // 這裏是寫入npm包,而且繼續尋找依賴的地方
                    this.compile('js', null, 'npm', path.parse(source));
                }
            }
            if (type === 'npm') {
                if (lib[0] !== '.') {
                    resolved = path.join('..' + path.sep, path.relative(opath.dir, modulesPath), lib);
                } else {
                    if (lib[0] === '.' && lib[1] === '.')
                        resolved = './' + resolved;
                }

            } else {
                resolved = path.relative(util.getDistPath(opath, opath.ext, src, dist), target);
            }
            resolved = resolved.replace(/\\/g, '/').replace(/^\.\.\//, './');
            return `require('${resolved}')`;
        });
    },複製代碼

new loader.PluginHelper

在代碼中會常看到如下PluginHelper再進行寫入,咱們能夠看看如何實現plugin一個一個運用到content中的

let plg = new loader.PluginHelper(config.plugins, {
    type: 'wxml',
    code: util.decode(node.toString()),
    file: target,
    output (p) {
            util.output(p.action, p.file);
    },
    done (rst) {
            util.output('寫入', rst.file);
            rst.code = self.replaceBooleanAttr(rst.code);
            util.writeFile(target, rst.code);
    }
});複製代碼

核心代碼以下,其實跟koa/express中間的compose相似,經過next方法,調用完一個調用下一個next(),next()不斷,最後done(),next方法在框架內部實現,done方法有咱們配置便可,固然在插件中(就像中間件)須要在最後調用next

class PluginHelper {
    constructor (plugins, op) {
        this.applyPlugin(0, op);
        return true;
    }
    applyPlugin (index, op) {
        let plg = loadedPlugins[index];

        if (!plg) {
            op.done && op.done(op);
        } else {
            op.next = () => {
                this.applyPlugin(index + 1, op);
            };
            op.catch = () => {
                op.error && op.error(op);
            };
            if (plg)
                plg.apply(op);
        }
    }
}複製代碼

wepy分析

這裏的wepy是wepy框架的前端部分,須要在小程序中import的

主要職責就是讓框架中props和events能成功使用,就是須要setData一些加prefix的內容,而且實現組件之間的通訊,以及部分性能調優

目錄結構

  • wepy.js: 暴露$createApp、$createPage等接口
  • base.js: $createApp、$createPage邏輯,bindExt爲組件以及method添加prefix
  • app.js: promisifyAPI以及intercept攔截接口邏輯
  • page.js: 繼承component,route、page一些性能調優邏輯
  • component.js: 組件邏輯,props構建,computed計算,髒值檢查,組件通訊($invoke、$broadcast、$emit)
  • native.js: 空,代碼裏面用於app.js中從新定義wx自帶接口
  • event.js: 用於傳入method第一參數e,能夠獲取組件通訊的來源等
  • mixin.js: 將混合的數據,事件以及方法注入到組件之中
  • util.js:工具包

框架分析

針對前端wepy部分,也畫了個流程圖方便理解,也略去大量細節部分,後面分析能夠跟着圖來

  • 上一節wepy-cli編譯時往代碼中注入瞭如下代碼
    // page
    Page(require('wepy').default.$createPage(${defaultExport} , '${pagePath}'));
    // app
    App(require('wepy').default.$createApp(${defaultExport}, ${appConfig}));複製代碼
    這也是入口所在,從這裏開始入手分析
  • $createApp在App包裹中,正常小程序應該是App({}),因此這裏$createApp返回config,這裏new class extends wepy.app, 經過調用app.js中$initAPI實現接口promise化以及實現攔截器
    定義接口使用

    Object.defineProperty(native, key, {
      get () { return (...args) => wx[key].apply(wx, args) }
    });
    wepy[key] = native[key];複製代碼

    success時候reoslve,fail時候reject實現promise化,在其中查詢攔截器調用

    if (self.$addons.promisify) {
          return new Promise((resolve, reject) => {
                  let bak = {};
                  ['fail', 'success', 'complete'].forEach((k) => {
                          bak[k] = obj[k];
                          obj[k] = (res) => {
                                  if (self.$interceptors[key] && self.$interceptors[key][k]) {
                                          res = self.$interceptors[key][k].call(self, res);
                                  }
                                  if (k === 'success')
                                          resolve(res)
                                  else if (k === 'fail')
                                          reject(res);
                          };
                  });
                  if (self.$addons.requestfix && key === 'request') {
                          RequestMQ.request(obj);
                  } else
                          wx[key](obj);
          });
      }複製代碼
  • $createPage在Page包裹中,一樣返回config{},構造page實例,來自new class extends wepy.page,page class又繼承於component

    • $bindEvt方法:
      • 遍歷com.components,若是com還有子組件則遞歸調用,new class extend componeng後放入com.$coms
      • 遞歸設置com.$prefix,第一層沒有,接下去就是$one$,再有子級,就是$one$two$,以此類推...
      • 這個方法名叫$bindEvt,主要也是跟方法有關的,將全部methods中的方法放入config中並添加當前組件的$prefix,便是$prefix+method,而且方法最後都會調用com.$mixin,調用com.$apply
    • onload方法:
      • 調用$init而且調用super.$init,即class component的$init
      • 根據$props(這個是編譯註入的)生成$mappingProps,mapping雙向綁定的部分
      • Props.build(this.props);(注意這個props是前端編寫)構建props,並尋找父級的$props(編譯註入),獲取值之後放在this.data[key]裏,若是有props設定爲twoWay,一樣放入$mappingProps中
      • 初始化數據(注意把prefix加上去了),並將defaultData setData到頁面
        defaultData[`${this.$prefix}${k}`] = this.data[k];
          this[k] = this.data[k];複製代碼
      • 計算computed的值放入this[k]中
      • 獲取this.$com(在base.js中的bindEvt根據components綁定的),讓組件一個一個繼續$init、onLoad、$mixins、$apply
      • $init結束,調用page的onload方法,調用$mixins, 最後再page.$apply

$apply方法須要特別提一下,它於component中的$digest配合,是wepy框架裏比較核心的髒值檢查setData機制

下圖是官網的圖


咱們能夠看下$apply方法
帶fn去調用,則調用結束再調用自身,而後假如當前階段$$phase爲無,則設爲$apply階段,假如調用時候以前已經標記過apply,則調用 this.$digest();進入髒檢查階段

$apply (fn) {
        if (typeof(fn) === 'function') {
            fn.call(this);
            this.$apply();
        } else {
            if (this.$$phase) {
                this.$$phase = '$apply';
            } else {
                this.$digest();
            }
        }
    }複製代碼

$digest方法就是髒值檢查了,順便再講以前的$createPage咱們能夠看到data放在好幾個地方,this[k],this.data[k],this.$data,這裏來區分如下它們

  • this[k]是做爲當前的數據,沒有set上去的新數據,這也是wepy框架的一個特色,它將setData簡化,this.xxx = yyy 替代以前的setData({xxx: yyy})
  • this.data就是剛開始用到的初始化數據,放入defaultData中set,可是做爲this.data裏的,小程序更新數據setData也會更新到這裏的數據
  • this.$data是做爲set上去的數據,wepy框架重寫setData方法,會操做這個this.$data

分析完上面,髒值檢查就很明瞭了,拿this.$data跟this中比較,不等的話放入readyToSet中,而後再setData,更新this.$data便可,還需注意上面官網圖下兩個tips,注意只會有一個髒數據檢查流程

至於組件通訊方面,有了一棵組件樹,理好層級父級的關係就不復雜了,舉一個$emit觸發父級事件例子分析一下

  • 假如是用event傳入的,尋找父級組件$events(編譯時注入的,在attr裏遍歷收集的),而後apply相應的方法便可
  • 假如不是,則一層一層找上級方法emit便可

    $emit (evtName, ...args) {
          let com = this;
          let source = this;
          let $evt = new event(evtName, source, 'emit');
    
          // User custom event;
          if (this.$parent.$events && this.$parent.$events[this.$name]) {
              let method = this.$parent.$events[this.$name]['v-on:' + evtName];
              if (method && this.$parent.methods) {
                  let fn = this.$parent.methods[method];
                  if (typeof(fn) === 'function') {
                      this.$parent.$apply(() => {
                          fn.apply(this.$parent, args.concat($evt));
                      });
                      return;
                  } else {
                      throw new Error(`Invalid method from emit, component is ${this.$parent.$name}, method is ${method}. Make sure you defined it already.\n`);
                  }
              }
          }
          while(com && com.$isComponent !== undefined && $evt.active) {
              // 保存 com 塊級做用域組件實例
              let comContext = com;
              let fn = getEventsFn(comContext, evtName);
              fn && comContext.$apply(() => {
                  fn.apply(comContext, args.concat($evt));
              });
              com = comContext.$parent;
          }
      }複製代碼

其餘的invoke和broadcast不具體講了,只要構建出組件樹,問題就很好解決(構建就是在每次new的時候記住它的$parent就好了)

總結

到這裏,總體流程大體講完了,沒有涵蓋全部細節,做者的這個小程序框架很強大,而且做者還在積極地解決issue和更新,值得咱們點贊~
接下來來回答下文首提出的問題,應該就迎刃而解了
1. wepy如何實現單文件組件.wpy編譯?
答:wepy框架經過wepy-cli對.wpy編譯,拆解爲style,script(+config),template幾部分,再分別處理,生成到dist文件對應xxx.wxss,xxx.script,xxx.json,xxx.wxml

2. 如何隔離組件做用域?
答:經過組件在不一樣page的命名做爲前綴,而且以父級爲起點,依次爲$child,再子級就是$child$chind,依次類推。。。不一樣組件在不一樣的component實例下,data set到page就是帶上前綴,一樣的method也是加入前綴放在Page({})中

3. 如何實現組件通訊?
答:經過編譯獲取component的路徑注入代碼,在小程序代碼運行時,根據逐層require獲取,new component,並記下父級$parent,構建組件樹。
若是向子組件傳props和events?
編譯時就會收集在template中傳入的props和events注入到代碼中$props和$events,而後子組件init的時候獲取父級$parent的$props並加入前綴$prefix去setData(子組件的在page中的元素表現已經在編譯的時候被替換成了$prefix$data的樣子),這樣就實現了傳值。調用$emit觸發父組件event,直接尋找父級$parent apply調用相應方法便可。
廣播事件broadcast就是直接廣度優先去遍歷組件樹就好了。

4. 如何實現加載外部npm包?
答:wepy-cli在處理script部分,根據require的內容判斷是不是npm內容或者帶有npm標識,若是是require('xxx') require('xxx/yyy')的形式獲取package.json中的main部分找到引用文件,就去compile該文件(帶上npm標識繼續去resolveDeps),若是判斷不是npm內容修正require便可,帶有npm標識最後會打包到npm文件夾。

其餘能夠參考閱讀的相關介紹文章

最後

謝謝閱讀~
歡迎follow我哈哈github.com/BUPT-HJM
歡迎繼續觀光個人新博客~(老博客近期可能遷移)

歡迎關注

相關文章
相關標籤/搜索