wepy是一個優秀的微信小程式元件化框架,突破了小程式的限制,支援了npm包載入以及元件化方案,並且在效能優化方面也下了功夫,不管之後專案是否會使用到,該框架元件化編譯方案都是值得學習和深入的。
本文同步於個人部落格 www.imhjm.com/article/597…
wepy文件: wepyjs.github.io/wepy/#/
github地址:github.com/wepyjs/wepy
我們先提出幾個問題,讓下面的分析更有針對性:
- wepy如何實現單檔案元件.wpy編譯?
- 如何隔離元件作用域?
- 如何實現元件通訊?
- 如何實現載入外部npm包?
先從原始碼目錄入手
我們可以先clone下wepy的github目錄
- docs目錄下是開發文件以及使用
docsify
生成的文件網站 - packages目錄下就是核心程式碼了,wepy是小程式端的框架,wepy-cli是腳手架(負責元件化編譯等等),其他就是一些預處理編譯外掛、還有一些壓縮外掛等等
- scripts是一些shell指令碼,負責test等等一些操作
- gulpfile.js主要負責將package中的src開發目錄中js檔案babel轉換至lib目錄供使用,並支援watch compile
- 等等
我們就重點看wepy以及wepy-cli,接下來的文章也是圍繞這兩個展開
wepy-cli分析
目錄結構
上面的bin/wepy.js是入口檔案,其實就一句話引入lib中經過babel轉換的檔案
require('../lib/wepy');複製程式碼
編譯構建流程
下圖簡單畫出了整體wepy-cli的編譯構建流程,忽略了部分細節
接下來答大體說下build的流程(可以跟著圖看)
- wepy-cli使用commander作為命令列工具,build命令執行呼叫compile build方法
- 如果沒有指定檔案build,則獲取src目錄下所有檔案,尋找沒有引用的(指的是這種
<script src="">
),呼叫compile方法開始編譯,如果指定檔案,則相應判斷尋找父元件或者尋找引用去編譯 - compile方法根據檔案字尾判斷,呼叫不同檔案的方法(下面就說wpy單檔案元件)
compile-wpy呼叫resolveWpy方法(核心)
- 替換內容中的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構建完成,開始逐個操作
- rst.config寫入xxx.json
- rst.template
- 經過compiler後再次使用
xmldom
- updateSlot,替換內容
- updateBind, 將{{}}以及attr中的加入元件字首($prefix)
- 將元件替換成相應xml
- 經過compiler後再次使用
- rst.style
- 尋找require再進入compile-style
- compiler預編譯處理,並且置入依賴(@import)
rst.script
- compiler處理
假如不是npm包,則置入wepy框架初始化程式碼
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
- $bindEvt方法:
$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
歡迎繼續觀光我的新部落格~(老部落格近期可能遷移)
歡迎關注