開發egg-helper插件目的
- 所有的工具函數維護在 app/util 文件內,在使用時需要手動require,如果多個文件使用,需要多個require,致使業務代碼實現不優雅
- 在工具函數內部無法直接讀取配置文件,通常是使用傳參的方式
- Egg也提供Helper框架擴展,但是需將工具函數維護在 app/extend/helper.js 文件內,我更傾向於單獨維護
開發經歷
第一版
使用egg官方文檔提供的loadToContext方法,將 app/helper 內所有文件掛載到ctx.helper對象
loadToContext官方文檔,請戳這裏
代碼實現
// --------egg-helper/app.js -------
module.exports = app => {
const dir = app.loader.getLoadUnits().map(unit => {
return path.join(unit.path, 'app/helper');
});
app.loader.loadToContext(dir, 'helper', {
inject: app,
call: true,
});
};
...
缺點:
- 覆蓋掉原有的ctx.helper對象(這裏可以選擇修改掛載屬性名稱來避免覆蓋,但爲了和egg保持一致,所以未選擇此方案)
源碼學習
打開egg-core工程,根據package.json找到入口文件
...
// -------- index.js -------
module.exports = {
EggCore,
EggLoader,
BaseContextClass,
utils,
};
在index.js文件內,export出四個對象
EggCore:
egg-core核心類,繼承於koa,初始化Application(app即Application的實例)對象方法和屬性
// -------- egg.js -------
...
class EggCore extends KoaApplication {
constructor() {
...
const Loader = this[EGG_LOADER];
assert(Loader, "Symbol.for('egg#loader') is required");
this.loader = new Loader({ // 實例化loder,即EggLoader
baseDir: options.baseDir,
app: this,
plugins: options.plugins,
logger: this.console,
serverScope: options.serverScope,
});
const Controller = this.BaseContextClass;
this.Controller = Controller; // 定義Controller使用基類
const Service = this.BaseContextClass;
this.Service = Service; // 定義Service使用基類
...
}
}
...
EggLoader:
egg-core核心類
- 提供加載方法,例如loadToContext、loadToApp
- 提供獲取egg基礎信息方法,例如 getAppInfo
- 掛載 /lib/loader/mixin目錄下定義的load函數,具體加載順序在 egg/lib/loader/app_worker_loader.js中定義
// -------- egg_loader.js -------
class EggLoader {
...
// 將property掛載到ctx
loadToContext(directory, property, opt) {
opt = Object.assign({}, {
directory,
property,
inject: this.app,
}, opt);
const timingKey = `Load "${String(property)}" to Context`;
this.timing.start(timingKey);
new ContextLoader(opt).load(); // 實例化ContextLoader
this.timing.end(timingKey);
},
// 獲取當前 應用/框架/插件下所有文件路徑,返回路徑數組
getLoadUnits() {
if (this.dirs) {
return this.dirs;
}
const dirs = this.dirs = [];
// 插入插件路徑
if (this.orderPlugins) {
for (const plugin of this.orderPlugins) {
dirs.push({
path: plugin.path,
type: 'plugin',
});
}
}
// 插入框架路徑
for (const eggPath of this.eggPaths) {
dirs.push({
path: eggPath,
type: 'framework',
});
}
// 插入當前應用路徑
dirs.push({
path: this.options.baseDir,
type: 'app',
});
debug('Loaded dirs %j', dirs);
return dirs;
}
}
...
const loaders = [
require('./mixin/plugin'),
require('./mixin/config'),
require('./mixin/extend'),
require('./mixin/custom'),
require('./mixin/service'),
require('./mixin/middleware'),
require('./mixin/controller'),
require('./mixin/router'),
];
// 將mixin/*.js文件下的對象掛載EggLoader原型
for (const loader of loaders) {
Object.assign(EggLoader.prototype, loader);
}
BaseContextClass
基類,定義了類的屬性,Service和Controller都是繼承了基類
utils
工具函數
通過Demo來分析helper的掛載和調用
demo/app目錄結構如下所示
調用步驟
第一步 調用loadToContent
app.loader.loadToContext() // 調用EggLoader實例方法
第二步 實例化ContextLoader並調用load方法
ContextLoader繼承自FileLoader,FileLoader在下面會講到
// -------- context_loader.js -------
class ContextLoader extends FileLoader {
constructor(options) {
...
// target在未調用load方法前是空對象
// 在調用load方法後,target是一個以文件層級作爲層級的對象,包含文件路徑及export出的對象(下面有樣本)
const target = options.target = {};
if (options.fieldClass) {
options.inject[options.fieldClass] = target;
}
super(options);
const app = this.options.inject; // 當前注入對象
const property = options.property; // 掛載屬性名
// 實例化時,使用Object.defineProperty將屬性名掛載到app.context上
// 此時僅僅是掛載了屬性名,且定義了getter方法,值爲空對象
// 當調用了load方法後,纔是我們所期望的值
Object.defineProperty(app.context, property, {
// 當獲取ctx屬性時,執行該方法
get() {
...
},
});
}
}
第三步 調用ctx.helper
// -------- controller/home.js -------
class HomeController extends Controller {
async index() {
// 在controller中調用工具函數
this.ctx.body =this.ctx.helper.util.demo();
}
}
此步驟獲取ctx屬性,執行上一步已定義的getter方法
...
// -------- context_loader.js -------
get() {
if (!this[CLASSLOADER]) {
// 創建緩存,egg根據每一個請求生成一個Context實例,每個實例不相同
// 緩存根據Context實例生成的,不同實例緩存不同.這裏是在同一個實例內,即同一個請求,創建一個緩存
// 在重複獲取屬性時,避免多次執行getInstance方法
this[CLASSLOADER] = new Map();
}
const classLoader = this[CLASSLOADER];
let instance = classLoader.get(property);
// 區分當前屬性是否被緩存,有緩存就直接返回緩存值
if (!instance) {
// 調用getInstance方法
// this指向app.context即ctx
instance = getInstance(target, this);
// 緩存屬性
classLoader.set(property, instance);
}
return instance;
},
...
第四步 調用getInstance方法
// 調用getInstance的參數values樣本
// 這也是getter方法內部的target的樣本
{ util:{
show: [Function: show],
[Symbol(EGG_LOADER_ITEM_FULLPATH)]:'.../app/helper/util.js',
[Symbol(EGG_LOADER_ITEM_EXPORTS)]: true
},
helper:{
util:{
action: [Function: action],
[Symbol(EGG_LOADER_ITEM_FULLPATH)]:'.../app/helper/helper/util.js',
[Symbol(EGG_LOADER_ITEM_EXPORTS)]: true
}
}
}
// -------- context_loader.js -------
function getInstance(values, ctx) {
// 判斷當前掛載對象是否是目錄,如果是目錄,則不含[EXPORTS]屬性
// 這個屬性在FileLoader中定義,下面會介紹
const Class = values[EXPORTS] ? values : null;
let instance;
if (Class) {
if (is.class(Class)) {
// 如果是類,則實例化,例如Service
// 實例化時會傳入ctx對象,所以在Service實例內可以訪問ctx對象(這個在基類中有定義)
instance = new Class(ctx);
} else {
// 如果不是類,則直接返回,例如 helper/util.js文件export出的對象
instance = Class;
}
} else if (is.primitive(values)) {
instance = values;
} else {
// 如果是目錄,則實例化ClassLoader,在ClassLoader內部也會調用getInstance方法
// 在ctx上就可以使用 ctx.dirname.dirname.dirname...filename.fn 來調用
// 例如values的樣本,最終掛載成 ctx.helper.util.action
instance = new ClassLoader({ ctx, properties: values });
}
return instance;
}
class ClassLoader {
constructor(options) {
assert(options.ctx, 'options.ctx is required');
const properties = options.properties;
// 做緩存,這裏的緩存和ContextLoader內的緩存不同
// ContextLoader是對 ctx.property 做緩存
// ClassLoader是對 ctx.property.childProperty[.childProperty...]做緩存
this._cache = new Map();
this._ctx = options.ctx;
for (const property in properties) {
// 將屬性掛載到ClassLoader實例上
// 通過這個函數實現ctx.dirname.dirname.dirname...filename.fn
this.defineProperty(property, properties[property]);
}
defineProperty(property, values) {
Object.defineProperty(this, property, {
get() {
let instance = this._cache.get(property);
if (!instance) {
// 雖然在這也會調用getInstance方法,但不會立即執行,只會在執行getter時執行,避免資源浪費
instance = getInstance(values, this._ctx);
this._cache.set(property, instance);
}
return instance;
},
});
}
}
當前版
使用FileLoader實現
調用步驟
代碼實現
// --------egg-helper/app.js -------
module.exports = app => {
const FileLoader = app.loader.FileLoader;
const dir = app.loader.getLoadUnits().map(unit => {
return path.join(unit.path, 'app/helper');
});
app.loader.loadToContext()
new FileLoader({
directory: dir,
target: app.Helper.prototype,
inject: app,
}).load();
};
...
優點
- 不會覆蓋原有的ctx.helper對象
源碼學習
// --------file_loader.js -------
class FileLoader {
constructor(options) {
assert(options.directory, 'options.directory is required');
assert(options.target, 'options.target is required');
this.options = Object.assign({}, defaults, options);
// 首字母是否小寫
if (this.options.lowercaseFirst === true) {
deprecate('lowercaseFirst is deprecated, use caseStyle instead');
this.options.caseStyle = 'lower';
}
}
// FileLoader加載文件主方法
// 該方法主要是獲取指定目錄下文件
parse() {
// 文件路徑匹配,可以查看末尾的options配置
let files = this.options.match;
if (!files) {
// 是否加載ts
files =
process.env.EGG_TYPESCRIPT === 'true' && require.extensions['.ts']
? ['**/*.(js|ts)', '!**/*.d.ts']
: ['**/*.js'];
} else {
files = Array.isArray(files) ? files : [files];
}
// 忽略的文件路徑匹配,可以查看末尾的options配置
let ignore = this.options.ignore;
if (ignore) {
ignore = Array.isArray(ignore) ? ignore : [ignore];
// 路徑不爲空
ignore = ignore.filter(f => !!f).map(f => '!' + f);
files = files.concat(ignore);
}
// 指定文件的目錄,可以查看末尾的options配置
let directories = this.options.directory;
if (!Array.isArray(directories)) {
directories = [directories];
}
// 文件導出的過濾,可以查看末尾的options配置
const filter = is.function(this.options.filter)
? this.options.filter
: null;
const items = [];
debug('parsing %j', directories);
for (const directory of directories) {
// 獲取指定目錄所有文件路徑,並匹配上面創建的規則(files),返回匹配的路徑數組
const filepaths = globby.sync(files, { cwd: directory });
for (const filepath of filepaths) {
const fullpath = path.join(directory, filepath);
// 保證當前路徑是文件而非目錄
if (!fs.statSync(fullpath).isFile()) continue;
// 將文件路徑按照"/"切割,並將包含"-"和"_"的文件名轉換爲駝峯形式
const properties = getProperties(filepath, this.options);
// 在文件路徑前拼上指定的文件目錄
const pathName = directory.split(/[/\\]/).slice(-1) + '.' + properties.join('.');
// 加載文件的關鍵函數
// 獲取了export對象,具體信息在下面
const exports = getExports(fullpath, this.options, pathName);
// 過濾export
if (exports == null || (filter && filter(exports) === false)) continue;
if (is.class(exports)) {
exports.prototype.pathName = pathName;
exports.prototype.fullPath = fullpath;
}
items.push({ fullpath, properties, exports });
debug(
'parse %s, properties %j, export %j',
fullpath,
properties,
exports
);
}
}
return items;
}
// 是FileLoader主方法
// 該方法將指定目錄下的所有文件,按照文件層次組成對象(就是上面提到的target對象)
load() {
// 執行parse方法,獲取指定文件目錄下所有文件,返回爲數組
// 例 [{fullpath:"",exports:"",properties:"" }]
const items = this.parse();
// 引用賦值,target改變後,this.options.target也將改變
const target = this.options.target;
for (const item of items) {
// 通過reduce函數,將target屬性不斷傳遞下去,最後形成以文件層級爲鍵名的對象
// 例 {help1:{util1:{...}}}
item.properties.reduce((target, property, index) => {
let obj;
const properties = item.properties.slice(0, index + 1).join('.');
// 當前屬性是否爲最後一位,如果是最後一位,則代表當前是文件的路徑,而非目錄
if (index === item.properties.length - 1) {
// 防止屬性覆蓋,同一個文件夾下文件名不允許重複,所以這裏主要是防止覆蓋掉target對象原來的屬性
if (property in target) {
if (!this.options.override)
throw new Error(
`can't overwrite property '${properties}' from ${
target[property][FULLPATH]
} by ${item.fullpath}`
);
}
obj = item.exports;
// 如果當前是文件且exports對象不是簡單數據類型
if (obj && !is.primitive(obj)) {
obj[FULLPATH] = item.fullpath;
// 這裏的EXPORTS屬性,即是在FileLoader裏使用的EXPORTS
obj[EXPORTS] = true;
}
} else {
// 當前是目錄時,如果target不含該屬性,則創建空對象
obj = target[property] || {};
}
target[property] = obj;
debug('loaded %s', properties);
return obj;
}, target);
}
return target;
}
}
// 加載文件
function getExports(fullpath, { initializer, call, inject }, pathName) {
// 根據路徑加載文件,在方法內部存在文件是否爲模塊的判斷:
// 如果當前文件擴展名是node不支持的(默認支持*.js,*.node,*.json),則以fs.readFileSync加載,否則以require加載
let exports = utils.loadFile(fullpath);
// 自定義export出的對象,可以查看末尾的options配置
if (initializer) {
exports = initializer(exports, { path: fullpath, pathName });
}
// 判斷export出對象的類型,egg規定的export類型有多種,例如
// export default {} export default app=>{}
if (is.class(exports) || is.generatorFunction(exports) || is.asyncFunction(exports)) {
return exports;
}
// 判斷是否爲函數
if (call && is.function(exports)) {
// 這一步實現注入對象
// 例如在helper/*.js文件內可以使用app對象
exports = exports(inject);
if (exports != null) {
return exports;
}
}
return exports;
}
Helper
Helper聲明
// --------egg/lib/application.js -------
...
get Helper() {
if (!this[HELPER]) {
// Helper也是繼承了BaseContextClass和Service、Controller一樣
class Helper extends this.BaseContextClass {}
this[HELPER] = Helper;
}
return this[HELPER];
}
...
Helper實例化
在框架擴展中加載的
// --------egg/app/extend/context.js -------
...
get helper() {
if (!this[HELPER]) {
this[HELPER] = new this.app.Helper(this);
}
return this[HELPER];
},
...
LoaderOptions
Param | Type | Description |
---|---|---|
directory | String/Array |
directories to be loaded |
target | Object |
attach the target object from loaded files |
match | String/Array |
match the files when load, default to **/*.js (if process.env.EGG_TYPESCRIPT was true, default to [ '**/*.(js|ts)', '!**/*.d.ts' ] ) |
ignore | String/Array |
ignore the files when load |
initializer | Function |
custom file exports, receive two parameters, first is the inject object(if not js file, will be content buffer), second is an options object that contain path |
caseStyle | String/Function |
set property’s case when converting a filepath to property list. |
override | Boolean |
determine whether override the property when get the same name |
call | Boolean |
determine whether invoke when exports is function |
inject | Object |
an object that be the argument when invoke the function |
filter | Function |
a function that filter the exports which can be loaded |