Garfish 源码解析 —— 一个微应用是如何被挂载的

背景

Garfish 是字节跳动 web infra 团队推出的一款微前端框架

包含构建微前端系统时所需要的基本能力,任意前端框架均可使用。接入简单,可轻松将多个前端应用组合成内聚的单个产品

微前端基本架构

因为当前对 Garfish 的解读极少,而微前端又是现代前端领域相当重要的一环,因此写下本文,同时也是对学习源码的一个总结

本文基于 garfish#0d4cc0c82269bce8422b0e9105b7fe88c2efe42a 进行解读

学习源码

1
2
3
4
5
git clone https://github.com/modern-js-dev/garfish.git
cd garfish
pnpm install
pnpm build
pnpm dev

然后打开https://localhost:8090/ 即可看到演示项目

基本使用

主应用

1
2
3
4
5
6
7
export const GarfishInit = async () => {
try {
Garfish.run(Config);
} catch (error) {
console.log('garfish init error', error);
}
};

其中关键点是 Config 参数, 其所有参数都是可选的,一般比较重要的几个参数为:

  • basename 子应用的基础路径,默认值为 /,整个微前端应用的 basename。设置后该值为所有子应用的默认值,若子应用 AppInfo 中也提供了该值会替换全局的 basename 值
  • domGetter 子应用挂载点。如'#submodule'
  • apps 需要主要参数如 name, entry, activeWhen(路由地址)

此函数运行之后,Garfish会自动进行路由劫持功能。根据路由变化

子应用

以react17为例:

1
2
3
4
5
6
7
8
9
10
import { reactBridge, AppInfo } from '@garfish/bridge-react';

export const provider = reactBridge({
el: '#root', // 此处的root是子应用自己声明的root
// a promise that resolves with the react component. Wait for it to resolve before mounting
loadRootComponent: (appInfo: AppInfo) => {
return Promise.resolve(() => <RootComponent {...appInfo} />);
},
errorBoundary: (e: any) => <ErrorBoundary />,
});

其中:

  • RootComponent 是子应用的主要逻辑
  • reactBridge 是garfish导出的一个封装函数。大概的逻辑就是把react的一些特有写法映射到garfish的通用生命周期,包含renderdestroy

源码解读

那么简单了解了一些garfish的基本使用方案,我们就来看看garfish在此过程中到底做了什么。

Garfish.run开始:

garfish/packages/core/src/garfish.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
run(options: interfaces.Options = {}) {
if (this.running) {
/**
* 重复运行检测
*/
if (__DEV__) {
warn('Garfish is already running now, Cannot run Garfish repeatedly.');
}
return this;
}

/**
* 全局化配置
*/
this.setOptions(options);

/**
* 载入插件
*/
// Register plugins
options.plugins?.forEach((plugin) => this.usePlugin(plugin));

// Put the lifecycle plugin at the end, so that you can get the changes of other plugins
this.usePlugin(GarfishOptionsLife(this.options, 'global-lifecycle'));

// Emit hooks and register apps
this.hooks.lifecycle.beforeBootstrap.emit(this.options); // 生命周期事件beforeBootstrap
this.registerApp(this.options.apps || []); // 注册子应用
this.running = true;
this.hooks.lifecycle.bootstrap.emit(this.options); // bootstrap
return this;
}

其中移除插件等内容,最重要的是registerApp调用,用于将配置注册到实例中

接下来的代码会移除无关紧要的代码,仅保留核心逻辑

1
2
3
4
5
6
7
8
9
10
11
registerApp(list: interfaces.AppInfo | Array<interfaces.AppInfo>) {
if (!Array.isArray(list)) list = [list];

for (const appInfo of list) {
if (!this.appInfos[appInfo.name]) {
this.appInfos[appInfo.name] = appInfo;
}
}

return this;
}

看上去仅仅是一些配置设定,那么所谓的路由绑定是从哪里发生的呢?这一切其实早就暗中进行了处理。

1
2
3
4
export type { interfaces } from '@garfish/core';
export { default as Garfish } from '@garfish/core';
export { GarfishInstance as default } from './instance';
export { defineCustomElements } from './customElement';

当调用 import Garfish from 'garfish';时, 使用的是默认创建好的全局Garfish实例。该逻辑简化版大概如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { GarfishRouter } from '@garfish/router';
import { GarfishBrowserVm } from '@garfish/browser-vm';
import { GarfishBrowserSnapshot } from '@garfish/browser-snapshot';

// Initialize the Garfish, currently existing environment to allow only one instance (export to is for test)
function createContext(): Garfish {
// Existing garfish instance, direct return
if (inBrowser() && window['__GARFISH__'] && window['Garfish']) {
return window['Garfish'];
}

const GarfishInstance = new Garfish({
plugins: [GarfishRouter(), GarfishBrowserVm(), GarfishBrowserSnapshot()],
});

type globalValue = boolean | Garfish | Record<string, unknown>;
const set = (namespace: string, val: globalValue = GarfishInstance) => {
// NOTE: 这里有一部分状态判定的逻辑,以及确保只读,这里是精简后的逻辑
window[namespace] = val;
};

if (inBrowser()) {
// Global flag
set('Garfish');
Object.defineProperty(window, '__GARFISH__', {
get: () => true,
configurable: __DEV__ ? true : false,
});
}

return GarfishInstance;
}

export const GarfishInstance = createContext();

其中核心逻辑为:

  • 如果本地已经有Garfish实例,则直接从本地拿。(浏览器环境用于子应用,也可以从这边看出garfish并不支持其他的js环境
  • 创建Garfish实例,并安装插件:
    • GarfishRouter 路由劫持能力
    • GarfishBrowserVm js运行时沙盒隔离
    • GarfishBrowserSnapshot 浏览器状态快照
  • 在window上设置全局Garfish对象并标记__GARFISH__, 注意该变量为只读

其中安全和样式隔离的逻辑我们暂且不看,先看其核心插件 GarfishRouter 的实现

插件系统

Garfish 自己实现了一套插件协议,其本质是pubsub模型的变种(部分生命周期的emit阶段增加了异步操作的等待逻辑)。

我们以Garfish最核心的插件 @garfish/router 为学习例子,该代码的位置在: garfish/packages/router/src/index.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
export function GarfishRouter(_args?: Options) {
return function (Garfish: interfaces.Garfish): interfaces.Plugin {
Garfish.apps = {};
Garfish.router = router;

return {
name: 'router',
version: __VERSION__,

bootstrap(options: interfaces.Options) {
let activeApp: null | string = null;
const unmounts: Record<string, Function> = {};
const { basename } = options;
const { autoRefreshApp = true, onNotMatchRouter = () => null } =
Garfish.options;

async function active(
appInfo: interfaces.AppInfo,
rootPath: string = '/',
) {
routerLog(`${appInfo.name} active`, {
appInfo,
rootPath,
listening: RouterConfig.listening,
});

// In the listening state, trigger the rendering of the application
if (!RouterConfig.listening) return;

const { name, cache = true, active } = appInfo;
if (active) return active(appInfo, rootPath);
appInfo.rootPath = rootPath;

const currentApp = (activeApp = createKey());
const app = await Garfish.loadApp(appInfo.name, {
basename: rootPath,
entry: appInfo.entry,
cache: true,
domGetter: appInfo.domGetter,
});

if (app) {
app.appInfo.basename = rootPath;

const call = async (app: interfaces.App, isRender: boolean) => {
if (!app) return;
const isDes = cache && app.mounted;
if (isRender) {
return await app[isDes ? 'show' : 'mount']();
} else {
return app[isDes ? 'hide' : 'unmount']();
}
};

Garfish.apps[name] = app;
unmounts[name] = () => {
// Destroy the application during rendering and discard the application instance
if (app.mounting) {
delete Garfish.cacheApps[name];
}
call(app, false);
};

if (currentApp === activeApp) {
await call(app, true);
}
}
}

async function deactive(appInfo: interfaces.AppInfo, rootPath: string) {
routerLog(`${appInfo.name} deactive`, {
appInfo,
rootPath,
});

activeApp = null;
const { name, deactive } = appInfo;
if (deactive) return deactive(appInfo, rootPath);

const unmount = unmounts[name];
unmount && unmount();
delete Garfish.apps[name];

// Nested scene to remove the current application of nested data
// To avoid the main application prior to application
const needToDeleteApps = router.routerConfig.apps.filter((app) => {
if (appInfo.rootPath === app.basename) return true;
});
if (needToDeleteApps.length > 0) {
needToDeleteApps.forEach((app) => {
delete Garfish.appInfos[app.name];
delete Garfish.cacheApps[app.name];
});
router.setRouterConfig({
apps: router.routerConfig.apps.filter((app) => {
return !needToDeleteApps.some(
(needDelete) => app.name === needDelete.name,
);
}),
});
}
}

const apps = Object.values(Garfish.appInfos);

const appList = apps.filter((app) => {
if (!app.basename) app.basename = basename;
return !!app.activeWhen;
}) as Array<Required<interfaces.AppInfo>>;

const listenOptions = {
basename,
active,
deactive,
autoRefreshApp,
notMatch: onNotMatchRouter,
apps: appList,
listening: true,
};
routerLog('listenRouterAndReDirect', listenOptions);
listenRouterAndReDirect(listenOptions);
},

registerApp(appInfos) {
const appList = Object.values(appInfos);
// @ts-ignore
router.registerRouter(appList.filter((app) => !!app.activeWhen));
// After completion of the registration application, trigger application mount
// Has been running after adding routing to trigger the redirection
if (!Garfish.running) return;
routerLog('registerApp initRedirect', appInfos);
initRedirect();
},
};
};
}

一个插件的结构形如 (context: Garfish) => Plugin

其中 Plugin 类型为一个对象,包含各个阶段的生命周期以及name/version等插件信息描述属性。

router 插件为例,其作用在bootstrapregisterApp两个生命周期阶段

生命周期定义可以在这里看到: garfish/packages/core/src/lifecycle.ts

Garfish.run 视角来看,执行顺序为: beforeBootstrap -> beforeRegisterApp -> registerApp -> bootstrap -> ... 因此我们先看registerApp的逻辑。

registerApp 阶段

1
this.hooks.lifecycle.registerApp.emit(currentAdds);

Garfish 执行 registerApp函数 完毕后触发 registerApp 生命周期hook, 将当前注册的子应用列表发送到事件回调

garfish/packages/router/src/index.ts

1
2
3
4
5
6
7
8
9
10
11
12
{
name: 'router',
registerApp(appInfos) {
const appList = Object.values(appInfos);
router.registerRouter(appList.filter((app) => !!app.activeWhen));
// After completion of the registration application, trigger application mount
// Has been running after adding routing to trigger the redirection
if (!Garfish.running) return;
routerLog('registerApp initRedirect', appInfos);
initRedirect();
},
}

插件接收到子应用列表, 将依次调用:

  • router.registerRouter 注册到路由列表,其中会把不存在activeWhen属性的子应用过滤
  • initRedirect 初始化重定向逻辑

garfish/packages/router/src/context.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
export const RouterConfig: Options = {
basename: '/',
current: {
fullPath: '/',
path: '/',
matched: [],
query: {},
state: {},
},
apps: [],
beforeEach: (to, from, next) => next(),
afterEach: (to, from, next) => next(),
active: () => Promise.resolve(),
deactive: () => Promise.resolve(),
routerChange: () => {},
autoRefreshApp: true,
listening: true,
};

export const registerRouter = (Apps: Array<interfaces.AppInfo>) => {
const unregisterApps = Apps.filter(
(app) => !RouterConfig.apps.some((item) => app.name === item.name),
);
RouterConfig[apps] = RouterConfig.apps.concat(unregisterApps);
};

const Router: RouterInterface = {
registerRouter,
};

export default Router;

registerRouter阶段仅仅是将子应用注册

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export const initRedirect = () => {
linkTo({
toRouterInfo: {
fullPath: location.pathname,
path: getPath(RouterConfig.basename!),
query: parseQuery(location.search),
state: history.state,
},
fromRouterInfo: {
fullPath: '/',
path: '/',
query: {},
state: {},
},
eventType: 'pushState',
});
};

initRedirect阶段则是调用linkTo函数去实现一个跳转,这里具体细节比较复杂。可以简单理解为子应用版页面跳转

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 重载指定路由
// 1. 当前的子应用需要销毁
// 2. 获取当前需要激活的应用
// 3. 获取新的需要激活应用
// 4. 触发函数beforeEach,在销毁所有应用之前触发
// 5. 触发需要销毁应用的deactive函数
// 6. 如果不需要激活应用,默认触发popstate应用组件view child更新
export const linkTo = async ({
toRouterInfo,
fromRouterInfo,
eventType,
}: {
toRouterInfo: RouterInfo;
fromRouterInfo: RouterInfo;
eventType: keyof History | 'popstate';
}) => Promise<void>

bootstrap 阶段

1
this.hooks.lifecycle.bootstrap.emit(this.options);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
{
name: 'router',
bootstrap(options: interfaces.Options) {
let activeApp: null | string = null;
const unmounts: Record<string, Function> = {};
const { basename } = options;
const { autoRefreshApp = true, onNotMatchRouter = () => null } =
Garfish.options;

async function active(
appInfo: interfaces.AppInfo,
rootPath: string = '/',
) {
routerLog(`${appInfo.name} active`, {
appInfo,
rootPath,
listening: RouterConfig.listening,
});

// In the listening state, trigger the rendering of the application
if (!RouterConfig.listening) return;

const { name, cache = true, active } = appInfo;
if (active) return active(appInfo, rootPath);
appInfo.rootPath = rootPath;

const currentApp = (activeApp = createKey());
const app = await Garfish.loadApp(appInfo.name, {
basename: rootPath,
entry: appInfo.entry,
cache: true,
domGetter: appInfo.domGetter,
});

if (app) {
app.appInfo.basename = rootPath;

const call = async (app: interfaces.App, isRender: boolean) => {
if (!app) return;
const isDes = cache && app.mounted;
if (isRender) {
return await app[isDes ? 'show' : 'mount']();
} else {
return app[isDes ? 'hide' : 'unmount']();
}
};

Garfish.apps[name] = app;
unmounts[name] = () => {
// Destroy the application during rendering and discard the application instance
if (app.mounting) {
delete Garfish.cacheApps[name];
}
call(app, false);
};

if (currentApp === activeApp) {
await call(app, true);
}
}
}

async function deactive(appInfo: interfaces.AppInfo, rootPath: string) {
routerLog(`${appInfo.name} deactive`, {
appInfo,
rootPath,
});

activeApp = null;
const { name, deactive } = appInfo;
if (deactive) return deactive(appInfo, rootPath);

const unmount = unmounts[name];
unmount && unmount();
delete Garfish.apps[name];

// Nested scene to remove the current application of nested data
// To avoid the main application prior to application
const needToDeleteApps = router.routerConfig.apps.filter((app) => {
if (appInfo.rootPath === app.basename) return true;
});
if (needToDeleteApps.length > 0) {
needToDeleteApps.forEach((app) => {
delete Garfish.appInfos[app.name];
delete Garfish.cacheApps[app.name];
});
router.setRouterConfig({
apps: router.routerConfig.apps.filter((app) => {
return !needToDeleteApps.some(
(needDelete) => app.name === needDelete.name,
);
}),
});
}
}

const apps = Object.values(Garfish.appInfos);

const appList = apps.filter((app) => {
if (!app.basename) app.basename = basename;
return !!app.activeWhen;
}) as Array<Required<interfaces.AppInfo>>;

const listenOptions = {
basename,
active,
deactive,
autoRefreshApp,
notMatch: onNotMatchRouter,
apps: appList,
listening: true,
};
routerLog('listenRouterAndReDirect', listenOptions);
listenRouterAndReDirect(listenOptions);
},
}

bootstrap阶段主要构造路由配置,并调用listenRouterAndReDirect(listenOptions)来进行路由的代理/拦截
其中主要需要关心的active操作(即子应用挂载逻辑)做了以下事情:

  • 调用 Garfish.loadApp 将子应用挂载到子应用挂载节点上(Promise 同步加载)
  • Garfish.apps 记录该app
  • 注册到 unmounts 记录销毁逻辑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* 1.注册子应用
* 2.对应子应用激活,触发激活回调
* @param Options
*/
export const listenRouterAndReDirect = ({
apps,
basename = '/',
autoRefreshApp,
active,
deactive,
notMatch,
listening = true,
}: Options) => {
// 注册子应用、注册激活、销毁钩子
registerRouter(apps);

// 初始化信息
setRouterConfig({
basename,
autoRefreshApp,
// supportProxy: !!window.Proxy,
active,
deactive,
notMatch,
listening,
});

// 开始监听路由变化触发、子应用更新。重载默认初始子应用
listen();
};
1
2
3
4
5
6
export const registerRouter = (Apps: Array<interfaces.AppInfo>) => {
const unregisterApps = Apps.filter(
(app) => !RouterConfig.apps.some((item) => app.name === item.name),
);
RouterSet('apps', RouterConfig.apps.concat(unregisterApps));
};

registerRouter没有什么特殊的,仅仅管理路由状态

接下来看一下listen()函数做的事情:

1
2
3
4
export const listen = () => {
normalAgent();
initRedirect();
};

initRedirect我们之前看过了,现在我们主要看normalAgent的实现

garfish/packages/router/src/agentRouter.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
export const normalAgent = () => {
// By identifying whether have finished listening, if finished listening, listening to the routing changes do not need to hijack the original event
// Support nested scene
const addRouterListener = function () {
window.addEventListener(__GARFISH_BEFORE_ROUTER_EVENT__, function (env) {
RouterConfig.routerChange && RouterConfig.routerChange(location.pathname);
linkTo((env as any).detail);
});
};

if (!window[__GARFISH_ROUTER_FLAG__]) {
// Listen for pushState and replaceState, call linkTo, processing, listen back
// Rewrite the history API method, triggering events in the call

const rewrite = function (type: keyof History) {
const hapi = history[type];
return function (this: History) {
const urlBefore = window.location.pathname + window.location.hash;
const stateBefore = history?.state;
const res = hapi.apply(this, arguments);
const urlAfter = window.location.pathname + window.location.hash;
const stateAfter = history?.state;

const e = createEvent(type);
(e as any).arguments = arguments;

if (
urlBefore !== urlAfter ||
JSON.stringify(stateBefore) !== JSON.stringify(stateAfter)
) {
window.dispatchEvent(
new CustomEvent(__GARFISH_BEFORE_ROUTER_EVENT__, {
detail: {
toRouterInfo: {
fullPath: urlAfter,
query: parseQuery(location.search),
path: getPath(RouterConfig.basename!, urlAfter),
state: stateAfter,
},
fromRouterInfo: {
fullPath: urlBefore,
query: parseQuery(location.search),
path: getPath(RouterConfig.basename!, urlBefore),
state: stateBefore,
},
eventType: type,
},
}),
);
}
// window.dispatchEvent(e);
return res;
};
};

history.pushState = rewrite('pushState');
history.replaceState = rewrite('replaceState');

// Before the collection application sub routing, forward backward routing updates between child application
window.addEventListener(
'popstate',
function (event) {
// Stop trigger collection function, fire again match rendering
if (event && typeof event === 'object' && (event as any).garfish)
return;
if (history.state && typeof history.state === 'object')
delete history.state[__GARFISH_ROUTER_UPDATE_FLAG__];
window.dispatchEvent(
new CustomEvent(__GARFISH_BEFORE_ROUTER_EVENT__, {
detail: {
toRouterInfo: {
fullPath: location.pathname,
query: parseQuery(location.search),
path: getPath(RouterConfig.basename!),
},
fromRouterInfo: {
fullPath: RouterConfig.current!.fullPath,
path: getPath(
RouterConfig.basename!,
RouterConfig.current!.path,
),
query: RouterConfig.current!.query,
},
eventType: 'popstate',
},
}),
);
},
false,
);

window[__GARFISH_ROUTER_FLAG__] = true;
}
addRouterListener();
};

normalAgent 做了以下事情:

  • 通过rewrite函数重写history.pushStatehistory.pushState
    • rewrite函数则是在调用以上方法的前后增加了一些当前情况的快照,如果url/state发生变化则触发__GARFISH_BEFORE_ROUTER_EVENT__事件
  • popstate事件增加监听
  • 调用 addRouterListener 增加路由监听回调。监听方法基于浏览器内置的事件系统,事件名: __GARFISH_BEFORE_ROUTER_EVENT__

综上, router 通过监听history的方法来执行副作用调用linkTo函数,而linkTo函数则通过一系列操作将匹配的路由调用active方法,将不匹配的路由调用deactive方法以实现类型切换

这时候我们再回过头来看一下active函数的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
async function active(
appInfo: interfaces.AppInfo,
rootPath: string = '/',
) {
routerLog(`${appInfo.name} active`, {
appInfo,
rootPath,
listening: RouterConfig.listening,
});

// In the listening state, trigger the rendering of the application
if (!RouterConfig.listening) return;

const { name, cache = true, active } = appInfo;
if (active) return active(appInfo, rootPath);
appInfo.rootPath = rootPath;

const currentApp = (activeApp = createKey());
const app = await Garfish.loadApp(appInfo.name, {
basename: rootPath,
entry: appInfo.entry,
cache: true,
domGetter: appInfo.domGetter,
});

if (app) {
app.appInfo.basename = rootPath;

const call = async (app: interfaces.App, isRender: boolean) => {
if (!app) return;
const isDes = cache && app.mounted;
if (isRender) {
return await app[isDes ? 'show' : 'mount']();
} else {
return app[isDes ? 'hide' : 'unmount']();
}
};

Garfish.apps[name] = app;
unmounts[name] = () => {
// Destroy the application during rendering and discard the application instance
if (app.mounting) {
delete Garfish.cacheApps[name];
}
call(app, false);
};

if (currentApp === activeApp) {
await call(app, true);
}
}
}

其核心代码则是调用了Garfish.loadApp方法来执行加载操作。

应用加载

接下来我们看一下loadApp函数

garfish/packages/core/src/garfish.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
loadApp(
appName: string,
options?: Partial<Omit<interfaces.AppInfo, 'name'>>,
): Promise<interfaces.App | null> {
assert(appName, 'Miss appName.');

let appInfo = generateAppOptions(appName, this, options);

const asyncLoadProcess = async () => {
// Return not undefined type data directly to end loading
const stop = await this.hooks.lifecycle.beforeLoad.emit(appInfo);

if (stop === false) {
warn(`Load ${appName} application is terminated by beforeLoad.`);
return null;
}

//merge configs again after beforeLoad for the reason of app may be re-registered during beforeLoad resulting in an incorrect information
appInfo = generateAppOptions(appName, this, options);

assert(
appInfo.entry,
`Can't load unexpected child app "${appName}", ` +
'Please provide the entry parameters or registered in advance of the app.',
);

// Existing cache caching logic
let appInstance: interfaces.App | null = null;
const cacheApp = this.cacheApps[appName];

if (appInfo.cache && cacheApp) {
appInstance = cacheApp;
} else {
try {
const [manager, resources, isHtmlMode] = await processAppResources(
this.loader,
appInfo,
);

appInstance = new App(
this,
appInfo,
manager,
resources,
isHtmlMode,
appInfo.customLoader,
);

// The registration hook will automatically remove the duplication
for (const key in this.plugins) {
appInstance.hooks.usePlugin(this.plugins[key]);
}
if (appInfo.cache) {
this.cacheApps[appName] = appInstance;
}
} catch (e) {
__DEV__ && warn(e);
this.hooks.lifecycle.errorLoadApp.emit(e, appInfo);
}
}

await this.hooks.lifecycle.afterLoad.emit(appInfo, appInstance);
return appInstance;
};

if (!this.loading[appName]) {
this.loading[appName] = asyncLoadProcess().finally(() => {
delete this.loading[appName];
});
}
return this.loading[appName];
}

该函数做了以下操作:

  • 首先执行asyncLoadProcess来异步加载app,如果app正在加载则返回该Promise
  • 使用generateAppOptions计算全局+本地的配置,并通过黑名单过滤掉一部分的无用参数(filterAppConfigKeys)
  • 如果当前app已加载则直接返回缓存后的内容
  • 如果是第一次加载,则执行 processAppResources 进行请求, 请求的地址为 entry 指定的地址。
  • 当请求完毕后创建new App对象,将其放到内存中
  • 应用插件/记录缓存/发布生命周期事件等

接下来我们看核心函数, processAppResources的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
export async function processAppResources(loader: Loader, appInfo: AppInfo) {
let isHtmlMode: Boolean = false,
fakeEntryManager;
const resources: any = { js: [], link: [], modules: [] }; // Default resources
assert(appInfo.entry, `[${appInfo.name}] Entry is not specified.`);
const { resourceManager: entryManager } = await loader.load({
scope: appInfo.name,
url: transformUrl(location.href, appInfo.entry),
});

// Html entry
if (entryManager instanceof TemplateManager) {
isHtmlMode = true;
const [js, link, modules] = await fetchStaticResources(
appInfo.name,
loader,
entryManager,
);
resources.js = js;
resources.link = link;
resources.modules = modules;
} else if (entryManager instanceof JavaScriptManager) {
// Js entry
isHtmlMode = false;
const mockTemplateCode = `<script src="${entryManager.url}"></script>`;
fakeEntryManager = new TemplateManager(mockTemplateCode, entryManager.url);
entryManager.setDep(fakeEntryManager.findAllJsNodes()[0]);
resources.js = [entryManager];
} else {
error(`Entrance wrong type of resource of "${appInfo.name}".`);
}

return [fakeEntryManager || entryManager, resources, isHtmlMode];
}

首先根据appInfo.entry调用loader.load函数,生成一个entryManager。如果entry指向的是html地址则获取静态数据后拿取js,link,modules,如果entry指向的是一个js地址则伪造一个仅包含这段js的js资源。最后的返回值是一个 [resourceManager, resources, isHtmlMode] 的元组。

其中resourceManager的大概结构如下:
resourceManager

loader.load的本质上就是发请求获取数据然后把请求到的纯文本转化成结构化,如果是html则对html声明的资源进行进一步的请求获取。这边就不再赘述。

我们回到loadApp函数的实现。

之后,代码根据processAppResources获取到的[resourceManager, resources, isHtmlMode]信息来创建一个new App;

1
2
3
4
5
6
7
8
appInstance = new App(
this,
appInfo,
manager,
resources,
isHtmlMode,
appInfo.customLoader,
);

appInstance

new App的过程中没有任何逻辑,仅仅是一些变量的定义。值得注意的是在此过程中会对插件系统做一些初始化设定

garfish/packages/core/src/module/app.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export class App {
constructor(
context: Garfish,
appInfo: AppInfo,
entryManager: TemplateManager,
resources: interfaces.ResourceModules,
isHtmlMode: boolean,
customLoader?: CustomerLoader,
) {
// ...

// Register hooks
this.hooks = appLifecycle();
this.hooks.usePlugin({
...appInfo,
name: `${appInfo.name}-lifecycle`,
});

// ...
}
}

到这一步为止,我们还在做一些准备工作:

  • 从远程获取资源
  • 将纯文本解析成结构化对象和AST
  • 进一步获取js/css的实际代码

接下来我们需要一个调用方能够帮助我们将获取到的资源执行并挂载到dom上。

这时候我们就需要回到我们的router插件。还记得我们的GarfishRouter.bootstrap.active里的代码么?

garfish/packages/router/src/index.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
export function GarfishRouter(_args?: Options) {
return function (Garfish: interfaces.Garfish): interfaces.Plugin {
return {
// ...

bootstrap(options: interfaces.Options) {
// ...

async function active(
appInfo: interfaces.AppInfo,
rootPath: string = '/',
) {
// ...
const app = await Garfish.loadApp(appInfo.name, {
basename: rootPath,
entry: appInfo.entry,
cache: true,
domGetter: appInfo.domGetter,
});

if (app) {
app.appInfo.basename = rootPath;

const call = async (app: interfaces.App, isRender: boolean) => {
if (!app) return;
const isDes = cache && app.mounted;
if (isRender) {
return await app[isDes ? 'show' : 'mount']();
} else {
return app[isDes ? 'hide' : 'unmount']();
}
};

Garfish.apps[name] = app;
unmounts[name] = () => {
// Destroy the application during rendering and discard the application instance
if (app.mounting) {
delete Garfish.cacheApps[name];
}
call(app, false);
};

if (currentApp === activeApp) {
await call(app, true);
}
}
}

// ...
};
};
}

当我们第一次执行到call函数时,会执行app.mount()函数来实现应用的挂载。

我们看下app.mount()的实现:

garfish/packages/core/src/module/app.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
export class App {
async mount() {
if (!this.canMount()) return false;
this.hooks.lifecycle.beforeMount.emit(this.appInfo, this, false);

this.active = true;
this.mounting = true;
try {
this.context.activeApps.push(this);
// add container and compile js with cjs
const { asyncScripts } = await this.compileAndRenderContainer();
if (!this.stopMountAndClearEffect()) return false;

// Good provider is set at compile time
const provider = await this.getProvider();
// Existing asynchronous functions need to decide whether the application has been unloaded
if (!this.stopMountAndClearEffect()) return false;

this.callRender(provider, true);
this.display = true;
this.mounted = true;
this.hooks.lifecycle.afterMount.emit(this.appInfo, this, false);

await asyncScripts;
if (!this.stopMountAndClearEffect()) return false;
} catch (e) {
this.entryManager.DOMApis.removeElement(this.appContainer);
this.hooks.lifecycle.errorMountApp.emit(e, this.appInfo);
return false;
} finally {
this.mounting = false;
}
return true;
}

// Performs js resources provided by the module, finally get the content of the export
async compileAndRenderContainer() {
// Render the application node
// If you don't want to use the CJS export, at the entrance is not can not pass the module, the require
await this.renderTemplate();

// Execute asynchronous script
return {
asyncScripts: new Promise<void>((resolve) => {
// Asynchronous script does not block the rendering process
setTimeout(() => {
if (this.stopMountAndClearEffect()) {
for (const jsManager of this.resources.js) {
if (jsManager.async) {
try {
this.execScript(
jsManager.scriptCode,
{},
jsManager.url || this.appInfo.entry,
{
async: false,
noEntry: true,
},
);
} catch (e) {
this.hooks.lifecycle.errorMountApp.emit(e, this.appInfo);
}
}
}
}
resolve();
});
}),
};
}
}

mount主要实现以下操作:

  • 生命周期的分发: beforeMount, afterMount
  • 状态变更: this.active, this.mounting, this.display
  • 调用 this.compileAndRenderContainer 执行编译
    • 调用this.renderTemplate渲染同步代码片段
    • 返回 asyncScripts 函数用于在下一个宏任务(task) 执行异步js代码片段
  • 在每一个异步片段过程中都尝试执行 stopMountAndClearEffect 来判断当前状态,以确保状态的准确性(用于处理在异步代码执行过程中被取消的问题)

我们看一下renderTemplate的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
export class App {
private async renderTemplate() {
const { appInfo, entryManager, resources } = this;
const { url: baseUrl, DOMApis } = entryManager;
const { htmlNode, appContainer } = createAppContainer(appInfo);

// Transformation relative path
this.htmlNode = htmlNode;
this.appContainer = appContainer;

// To append to the document flow, recursive again create the contents of the HTML or execute the script
await this.addContainer();

const customRenderer: Parameters<typeof entryManager.createElements>[0] = {
// ...
body: (node) => {
if (!this.strictIsolation) {
node = entryManager.cloneNode(node);
node.tagName = 'div';
node.attributes.push({
key: __MockBody__,
value: null,
});
}
return DOMApis.createElement(node);
},
script: (node) => {
const mimeType = entryManager.findAttributeValue(node, 'type');
const isModule = mimeType === 'module';

if (mimeType) {
// Other script template
if (!isModule && !isJsType({ type: mimeType })) {
return DOMApis.createElement(node);
}
}
const jsManager = resources.js.find((manager) => {
return !manager.async ? manager.isSameOrigin(node) : false;
});

if (jsManager) {
const { url, scriptCode } = jsManager;
this.execScript(scriptCode, {}, url || this.appInfo.entry, {
isModule,
async: false,
isInline: jsManager.isInlineScript(),
noEntry: toBoolean(
entryManager.findAttributeValue(node, 'no-entry'),
),
});
} else if (__DEV__) {
const async = entryManager.findAttributeValue(node, 'async');
if (typeof async === 'undefined' || async === 'false') {
const tipInfo = JSON.stringify(node, null, 2);
warn(
`Current js node cannot be found, the resource may not exist.\n\n ${tipInfo}`,
);
}
}
return DOMApis.createScriptCommentNode(node);
},

// ...
};

// Render dom tree and append to document.
entryManager.createElements(customRenderer, htmlNode);
}
}
  • 调用 createAppContainer 函数创建一些空白的容器dom, 注意此时还没有挂载到界面上:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    export function createAppContainer(appInfo: interfaces.AppInfo) {
    const name = appInfo.name;
    // Create a temporary node, which is destroyed by the module itself
    let htmlNode: HTMLDivElement | HTMLHtmlElement =
    document.createElement('div');
    const appContainer = document.createElement('div');

    if (appInfo.sandbox && appInfo.sandbox.strictIsolation) {
    htmlNode = document.createElement('html');
    const root = appContainer.attachShadow({ mode: 'open' });
    root.appendChild(htmlNode);
    // asyncNodeAttribute(htmlNode, document.body);
    dispatchEvents(root);
    } else {
    htmlNode.setAttribute(__MockHtml__, '');
    appContainer.appendChild(htmlNode);
    }
    appContainer.id = `${appContainerId}_${name}_${createKey()}`;

    return {
    htmlNode,
    appContainer,
    };
    }
    • 如果开启了 sandboxstrictIsolation 配置则进行严格的隔离(使用appContainer.attachShadow)来创建ShadowDOM
  • 调用addContainer来将代码挂载容器组件到文档中, 通过执行domGetter来获取父容器节点
    1
    2
    3
    4
    5
    6
    7
    private async addContainer() {
    // Initialize the mount point, support domGetter as promise, is advantageous for the compatibility
    const wrapperNode = await getRenderNode(this.appInfo.domGetter);
    if (typeof wrapperNode.appendChild === 'function') {
    wrapperNode.appendChild(this.appContainer);
    }
    }
  • 调用entryManager.createElements(customRenderer, htmlNode); 来实际创建节点。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    // Render dom tree
    createElements(renderer: Renderer, parent: Element) {
    const elements: Array<Element> = [];
    const traverse = (node: Node | Text, parentEl?: Element) => {
    let el: any;
    if (this.DOMApis.isCommentNode(node)) {
    // Filter comment node
    } else if (this.DOMApis.isText(node)) {
    el = this.DOMApis.createTextNode(node);
    parentEl && parentEl.appendChild(el);
    } else if (this.DOMApis.isNode(node)) {
    const { tagName, children } = node as Node;
    if (renderer[tagName]) {
    el = renderer[tagName](node as Node);
    } else {
    el = this.DOMApis.createElement(node as Node);
    }
    if (parentEl && el) parentEl.appendChild(el);

    if (el) {
    const { nodeType, _ignoreChildNodes } = el;
    // Filter "comment" and "document" node
    if (!_ignoreChildNodes && nodeType !== 8 && nodeType !== 10) {
    for (const child of children) {
    traverse(child, el);
    }
    }
    }
    }
    return el;
    };

    for (const node of this.astTree) {
    if (this.DOMApis.isNode(node) && node.tagName !== '!doctype') {
    const el = traverse(node, parent);
    el && elements.push(el);
    }
    }
    return elements;
    }
    使用traverse函数对自身进行树节点遍历,将ast树转换为dom树并挂载到parent
    • 注意有意思的一点是他是在遍历ast过程中的同时执行appendChild方法加载到dom树上而不是将节点生成完毕后一次性加载(也许是因为操作都是在一个task中所以浏览器会一次性执行?)

总结

综上,garfish完成了一次远程获取目标代码 => 解析成ast => 然后再从ast转换成dom树的过程。

将一段远程的页面/js加载到当前页面的固定位置

把代码仓库当做数据库,github action持久化存储新思路

背景

我想做一个rss订阅机器人,通过一个定时任务定期将我关注的内容推送到我的 Tailchat 群组。但是我又不想自己去单独搭建一个服务器来部署,因为功能很小、单独部署的成本会被放大,也不容易被其他人很简单的使用。而且长期维护的成本也是比较高的,希望能处于无人值守的运行模式

那么整理一下需求:

  • 定时任务
  • 简单部署
  • 不需要运维

可以说是非常理想了,那么有这样成熟的解决方案么?答案是有的。那就是github action

Github action 可以满足我的所有需求,只需要一个简单的定时任务即可实现我的三个需求。唯一的难点在于数据库,也就是持久化存储。

众所周知,rss机器人的原理就是定时请求rss订阅地址,将返回的内容结构化以后与之前存储的数据进行比较,将更新的信息提取出来发送到外部服务。那么为了能够比较差异,一个持久化的数据库是必不可少的。那么github action可以实现数据库么?答案是可以的,我只需要将数据存储在代码仓库中,每次执行action之前将数据取出,然后在action执行完毕之后将数据存回仓库,那么一个用于低频读写的文件数据库就实现了。

理论存在,实践开始!

开始造轮子

在github上搜索了一圈没有发现有现成的轮子,因此就开始自己造一个。

核心流程如下:

准备数据流程

  • 通过git worktree创建一个独立的工作区
  • 指定工作区的分支为一个独立分支用于存储数据
  • 如果该分支之前不存在,跳过准备过程
  • 如果分支已存在,拉取分支代码,将存储分支的指定目录文件复制到主工作空间的指定目录文件

修改数据

  • 在后续的action中执行脚本
  • 脚本读取文件数据库,在这里使用的是lowdb,当然也可以使用sqlite,看个人喜好
  • 更新数据库并写入

持久化存储数据

  • action执行完毕进入post阶段
  • 执行post action将主工作区的数据库文件覆盖到存储工作区中
  • 存储工作区通过github action的token或者传入参数的token 提交变更到github的存储分支中。
  • 结束流程,等待后续的执行

成果

那么通过上面一系列步骤,我们就成功把github当做我们自己的action应用的数据库了。

一个简单的示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
name: Tests

on:
workflow_dispatch:

jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Checkout date
uses: moonrailgun/[email protected]
with:
path: date
- name: Read and show
run: cat date
- name: update date
run: echo $(date) > date

这个action表示,每执行一次,我们的actions/filedb中的date文件就会更新成最新的.当然也可以加上一些定时任务触发器让他自动执行。当然建议不要滥用哦,可以使用低频一些

在Github Marketplace查看: https://github.com/marketplace/actions/branch-filestorage-action

开源地址: moonrailgun/branch-filestorage-action

RSSBot地址: msgbyte/tailchat-rss-bot

如何让团队项目白嫖 vercel 的免费服务

背景

Vercel 是一个对 Hobby 计划提供免费服务,并且在中国地区做了很好的CDN的serverless项目,用于代理静态页面或者做一些简单的api是非常方便的。

对于个人项目来说,vercel可以很好的在网页上直接操作导入,但是对于存储在Github组织的项目来说想要直接创建是不行的,这时候vercel会跳转到 pro plan 并且付费后才能使用

这时候我们就要取巧用一些方法绕过 github 组织的限制

创建不与github绑定的Vercel项目

使用npm全局安装vercel命令行终端

1
npm install -g vercel

在项目目录下直接执行以下命令

1
2
vercel login
vercel

这里会有一个交互式的终端操作。按照他的步骤顺序执行下去,就会在Vercel上创建一个没有连接任何一个Github项目的服务了。此时如果部署成功的话是可以通过网页界面直接点击到已经部署的服务的。

这时候我们就成功了一半了,剩下的是需要我们实现每次提交代码自动部署vercel的功能。

设置自动部署

github action上创建一个编译CI,并在build操作后面插入以下命令:

1
2
3
4
5
6
7
8
9
10
- name: Deploy to Vercel
uses: amondnet/vercel-action@master
env:
VERSION: ${{ env.GITHUB_SHA }}
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.ORG_ID}}
vercel-project-id: ${{ secrets.PROJECT_ID}}
working-directory: ./
vercel-args: '--prod' # 可不填

其中需要在Github Secrets中提前准备好以下参数

  • VERCEL_TOKEN: 通过 https://vercel.com/account/tokens 创建
  • ORG_ID: 项目根目录 .vercel/project.json 可见
  • PROJECT_ID: 项目根目录 .vercel/project.json 可见

CentOS7 安装gcc手册

众所周知,gcc版本数都已经两位数了,yum源的直接安装gcc的最新版本还停留在4.8.5。而对于部分c++的应用来说,高版本的gcc是必不可少的。而现在中文网络上教你升级gcc的办法都是手动下载gcc源码然后去编译。

别急!在你选择去按照教程手动一步步编译前,静下心来。手动编译的坑数不胜数,而Redhat 官方早就提供了解决方案, 那就是devtoolset(在centos8中改名为gcc-toolset)

devtoolset类似于node中的nvm,允许你在同一环境下安装多个gcc环境而不冲突

使用方法很简单:

1
2
yum install centos-release-scl # 通过centos-release-scl源安装devtoolset包
yum install devtoolset-8

其中

1
2
3
4
5
6
7
devtoolset-3对应gcc4.x.x版本
devtoolset-4对应gcc5.x.x版本
devtoolset-6对应gcc6.x.x版本
devtoolset-7对应gcc7.x.x版本
devtoolset-8对应gcc8.x.x版本
devtoolset-9对应gcc9.x.x版本
devtoolset-10对应gcc10.x.x版本

为使其生效还需要手动执行切换一下版本

1
source /opt/rh/devtoolset-8/enable

当然可以把这行代码保存在 .bashrc / .zshrc 中以每次连接shell都自动执行

k3s安装 OpenFaaS小记

官方手册

https://docs.openfaas.com/deployment/kubernetes/

使用hosts

因为众所周知的原因,国内访问部分网站不是很顺畅,如以下步骤有网络问题。这里建议使用 https://www.ipaddress.com/ 这个网站来获取最佳的hosts

First of all

1
2
3
4
5
# 获取 faas-cli
curl -sL https://cli.openfaas.com | sudo sh

# 获取 arkade
curl -SLsf https://dl.get-arkade.dev/ | sudo sh

arkade 是一个helm 的封装工具,用于一键安装应用到k8s集群

一键安装 openfaas

1
arkade install openfaas

在此过程中可能会出现集群无法抵达的问题,可以参考这个issue: https://github.com/k3s-io/k3s/issues/1126

1
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml

安装完成

安装完成后会输出如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
=======================================================================
= OpenFaaS has been installed. =
=======================================================================

# Get the faas-cli
curl -SLsf https://cli.openfaas.com | sudo sh

# Forward the gateway to your machine
kubectl rollout status -n openfaas deploy/gateway
kubectl port-forward -n openfaas svc/gateway 8080:8080 &

# If basic auth is enabled, you can now log into your gateway:
PASSWORD=$(kubectl get secret -n openfaas basic-auth -o jsonpath="{.data.basic-auth-password}" | base64 --decode; echo)
echo -n $PASSWORD | faas-cli login --username admin --password-stdin

faas-cli store deploy figlet
faas-cli list

# For Raspberry Pi
faas-cli store list \
--platform armhf

faas-cli store deploy figlet \
--platform armhf

# Find out more at:
# https://github.com/openfaas/faas

Thanks for using arkade!

为什么我选择Tailwindcss

前端的CSS发展经历了许多方案, 从早期的无序到后面的BEM,从自写到bootstrap的火爆,从 single css filecss modulecss in js

我想说说为什么我选择tailwind, 以及为什么我觉得tailwind这种提供工具的方式是我理想的解决方案

体积

因为tailwind几乎不会有冗余代码,那么tailwind项目的样式大小是可控的。此外它也不会引入无用的样式, 因为它会根据代码进行摇树优化。

可维护性与可控性

如果做过在一个现有的UI框架上进行一些覆盖样式,那么就会知道为什么说可维护性是多么重要。

因为这些工作往往是一个全局的样式。而当项目越来越复杂以后,你很难维护一个全局样式,因为你光看代码完全不知道这些代码的适用范围。当一个样式代码被越来越多人接手过后,你会发现他的可维护性越来越低 —— 因为你不敢删除任意一行代码,因为你不知道他会影响那些地方,那么你所能做的就是往上不断增加权重,不断覆盖,就如你的前人一样。

无需结构

可能很多初学者会认为BEM很清晰,比如tree__item就表示这是一个树的子项。但仔细想一想,你真的理解么? item到底是什么? 他是处于一颗dom树的哪个节点? 我必须得在他的父级增加一个名为<div class="tree"></div>的节点他的行为才是正确的么?

是的,对于BEM来说,如果是按照他的文档上所写的示例来复制的话, 这种固有的结构性是清晰的。但是如果想要深度进行一些定制的化,就显得有些力不从心了。

比如你想要修改树子项的字体大小:

1
2
3
<div class="tree">
<div class="tree__item"></div>
</div>

如果你不想增加行内样式, 那么你可以这样实现

1
2
3
.tree .tree__item {
font-size: 14px;
}

那如果想要增加作用域, 那可能会这样实现

1
2
3
<div class="tree foo">
<div class="tree__item"></div>
</div>
1
2
3
.tree.foo .tree__item {
font-size: 14px;
}

是的,这是一种理想情况。但是想象一下,这种方式,真的可维护么? 你可能会写出这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
.tree.foo {
.tree__item {
.tree__item-title {
font-size: 14px;
}
}

.tree__footer {
> span {
color: #ccc
}
}
}

看上去好像没什么问题,但是你要了解,现实情况可能更加复杂, 类名可能会有多套规则。而当我们为了修改一些样式,我们需要构造一套复杂的树形结构。这套结构是完整的,不可被破坏的。一点点的改动可能会引起整个结构树的崩溃 —— 到那时候,你真的能理解之前的代码想要表达什么意思么?

Tailwind 就不需要考虑这些问题。因为他几乎不需要额外的样式文件。当BEM在迭代中膨胀HTML与CSS时, tailwindcss只膨胀HTML

清晰

见名知意。学习成本低,阅读成本低。和直接写inline style一样但是更加优雅与可读。

性能

越长的CSS选择器查询越慢, 没有比tailwind更快的了。因为它的选择结构最多2层, 对于一个大型的前端应用来说,无数细微的性能优势最终也有产生有价值的点。

工具类样式库

我自己也在刚学react-native的时候写了个类似的样式库react-native-style-block。不过虽然我当时可能没有这么深刻的体会,只是本能的选择了这条路。从现在看来,这个方向完全是正确的。我相信工具类样式更加能够满足前端多变的需求。

每日一题 —— 混杂整数序列按规则进行重新排序

背景:

假设我们取一个数字 x 并执行以下任一操作:

  • a:将 x 除以 3 (如果可以被 3 除)
  • b:将 x 乘以 2

每次操作后,记下结果。如果从 9 开始,可以得到一个序列

有一个混杂的整数序列,现在任务是对它们重新排序,以使其符合上述序列并输出结果

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
输入: "[4,8,6,3,12,9]"
输出: [9,3,6,12,4,8]

解释: [9,3,6,12,4,8] => 9/3=3 -> 3*2=6 -> 6*2=12 -> 12/3=4 -> 4*2=8

输入: "[3000,9000]"
输出: [9000,3000]

输入: "[4,2]"
输出: [2,4]

输入: "[4,6,2]"
输出: [6,2,4]

人话翻译: 对数组重新排序,使得数组每一项可以满足这样一个规则:arr[i] = arr[i + 1] * 3 或者 arr[i] = arr[i + 1] / 2

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function changeArr(arr) {
const map = new Map();
let find = false;
let ret;
arr.forEach((n) => {
map.set(n, (map.get(n) || 0) + 1); // 定义数组中一个数剩余的次数
});
arr.forEach((n) => {
if (find) return;
dfs(n, 2, [n]);
});

function dfs(prev, index, res) {
if (find) return;
if (index === arr.length + 1) {
// 找完了,退出搜索
find = true;
ret = res;
}
if (map.has(prev * 2) && map.get(prev * 2) > 0) {
// 数组中有上个值 *2 的数据存在
map.set(prev * 2, map.get(prev * 2) - 1);
dfs(prev * 2, index + 1, [...res, prev * 2]); // 将这个值加到结果中,并
map.set(prev * 2, map.get(prev * 2) + 1); // 没有找到,把次数加回来
}
if (!(prev % 3) && map.get(prev / 3) > 0) {
// 当前值能被3整数并且被3整数的值存在
map.set(prev / 3, map.get(prev / 3) - 1);
dfs(prev / 3, index + 1, [...res, prev / 3]);
map.set(prev / 3, map.get(prev / 3) + 1); // 没有找到,把次数加回来
}
}
return ret;
}

来自: 2年前端,如何跟抖音面试官battle

Webpack是个什么鬼——了解编译结果

简述

webpack 是一款现代化的前端打包工具,那么webpack是怎么将模块化代码能够在浏览器运行的?让我们来看一下

MVP

从一个最小webpack实例开始:

src/index.js

1
console.log("Hello Webpack");

我们直接使用命令行进行打包, 结果如下:

webpack –mode development

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/*
* ATTENTION: The "eval" devtool has been used (maybe by default in mode: "development").
* This devtool is neither made for production nor for readable output files.
* It uses "eval()" calls to create a separate source file in the browser devtools.
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
* or disable the default devtool with "devtool: false".
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
*/
/******/ (() => { // webpackBootstrap
/******/ var __webpack_modules__ = ({

/***/ "./src/index.js":
/*!**********************!*\
!*** ./src/index.js ***!
\**********************/
/***/ (() => {

eval("console.log('Hello Webpack');\n\n\n//# sourceURL=webpack://webpack-demo/./src/index.js?");

/***/ })

/******/ });
/************************************************************************/
/******/
/******/ // startup
/******/ // Load entry module and return exports
/******/ // This entry module can't be inlined because the eval devtool is used.
/******/ var __webpack_exports__ = {};
/******/ __webpack_modules__["./src/index.js"]();
/******/
/******/ })()
;

webpack –mode development –devtool hidden-source-map

1
2
3
4
5
6
7
8
9
/******/ (() => { // webpackBootstrap
var __webpack_exports__ = {};
/*!**********************!*\
!*** ./src/index.js ***!
\**********************/
console.log('Hello Webpack');

/******/ })()
;

webpack –mode production

1
console.log("Hello Webpack");

可以看到, 对于简单代码来说, 是否使用webpack打包区别不大。稍微注意一下,在默认的development环境中引入了两个变量__webpack_exports____webpack_modules__。顾名思义,是分别管理导出内容与模块列表的两个代码

__webpack_modules__ 是一个key为代码(模块)路径,值为模块执行结果的一个对象。

我们来试试稍微复杂一点的例子:

使用import

src/index.js

1
2
3
import {add} from './utils'

console.log(add(1, 2));

src/utils.js

1
2
3
export function add(a, b) {
return a + b;
}

我们直接使用命令行进行打包, 结果如下:

webpack –mode development

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
/*
* ATTENTION: The "eval" devtool has been used (maybe by default in mode: "development").
* This devtool is neither made for production nor for readable output files.
* It uses "eval()" calls to create a separate source file in the browser devtools.
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
* or disable the default devtool with "devtool: false".
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
*/
/******/ (() => { // webpackBootstrap
/******/ "use strict";
/******/ var __webpack_modules__ = ({

/***/ "./src/index.js":
/*!**********************!*\
!*** ./src/index.js ***!
\**********************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./utils */ \"./src/utils.js\");\n\n\nconsole.log((0,_utils__WEBPACK_IMPORTED_MODULE_0__.add)(1, 2));\n\n\n//# sourceURL=webpack://webpack-demo/./src/index.js?");

/***/ }),

/***/ "./src/utils.js":
/*!**********************!*\
!*** ./src/utils.js ***!
\**********************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"add\": () => (/* binding */ add)\n/* harmony export */ });\nfunction add(a, b) {\n return a + b;\n}\n\n\n//# sourceURL=webpack://webpack-demo/./src/utils.js?");

/***/ })

/******/ });
/************************************************************************/
/******/ // The module cache
/******/ var __webpack_module_cache__ = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/ // Check if module is in cache
/******/ var cachedModule = __webpack_module_cache__[moduleId];
/******/ if (cachedModule !== undefined) {
/******/ return cachedModule.exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = __webpack_module_cache__[moduleId] = {
/******/ // no module.id needed
/******/ // no module.loaded needed
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/************************************************************************/
/******/ /* webpack/runtime/define property getters */
/******/ (() => {
/******/ // define getter functions for harmony exports
/******/ __webpack_require__.d = (exports, definition) => {
/******/ for(var key in definition) {
/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
/******/ }
/******/ }
/******/ };
/******/ })();
/******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */
/******/ (() => {
/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
/******/ })();
/******/
/******/ /* webpack/runtime/make namespace object */
/******/ (() => {
/******/ // define __esModule on exports
/******/ __webpack_require__.r = (exports) => {
/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/ }
/******/ Object.defineProperty(exports, '__esModule', { value: true });
/******/ };
/******/ })();
/******/
/************************************************************************/
/******/
/******/ // startup
/******/ // Load entry module and return exports
/******/ // This entry module can't be inlined because the eval devtool is used.
/******/ var __webpack_exports__ = __webpack_require__("./src/index.js");
/******/
/******/ })()
;

webpack –mode development –devtool hidden-source-map

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
/******/ (() => { // webpackBootstrap
/******/ "use strict";
/******/ var __webpack_modules__ = ({

/***/ "./src/utils.js":
/*!**********************!*\
!*** ./src/utils.js ***!
\**********************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ "add": () => (/* binding */ add)
/* harmony export */ });
function add(a, b) {
return a + b;
}


/***/ })

/******/ });
/************************************************************************/
/******/ // The module cache
/******/ var __webpack_module_cache__ = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/ // Check if module is in cache
/******/ var cachedModule = __webpack_module_cache__[moduleId];
/******/ if (cachedModule !== undefined) {
/******/ return cachedModule.exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = __webpack_module_cache__[moduleId] = {
/******/ // no module.id needed
/******/ // no module.loaded needed
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/************************************************************************/
/******/ /* webpack/runtime/define property getters */
/******/ (() => {
/******/ // define getter functions for harmony exports
/******/ __webpack_require__.d = (exports, definition) => {
/******/ for(var key in definition) {
/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
/******/ }
/******/ }
/******/ };
/******/ })();
/******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */
/******/ (() => {
/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
/******/ })();
/******/
/******/ /* webpack/runtime/make namespace object */
/******/ (() => {
/******/ // define __esModule on exports
/******/ __webpack_require__.r = (exports) => {
/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/ }
/******/ Object.defineProperty(exports, '__esModule', { value: true });
/******/ };
/******/ })();
/******/
/************************************************************************/
var __webpack_exports__ = {};
// This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk.
(() => {
/*!**********************!*\
!*** ./src/index.js ***!
\**********************/
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./utils */ "./src/utils.js");


console.log((0,_utils__WEBPACK_IMPORTED_MODULE_0__.add)(1, 2));

})();

/******/ })()
;

(可以看到webpack --mode development --devtool hidden-source-map这个命令执行的结果和直接development是一样的,但是代码可读性更加高。之后的文章将以这个命令的输出为准)

webpack –mode production

1
(()=>{"use strict";console.log(3)})();

可以看到,webpack一旦发现了模块系统,那么就会增加很多中间代码(从注释 The module cache 到 变量 __webpack_exports__)

首先webpack每块代码都是以(() => {})() 这种形式的闭包来处理的,防止污染外部空间。

然后每一段都有一段注释来告知下面这块代码的逻辑是要做什么

我们来一一看一下:

module cache and require function

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// The module cache
var __webpack_module_cache__ = {};

// The require function
function __webpack_require__(moduleId) {
// Check if module is in cache
var cachedModule = __webpack_module_cache__[moduleId];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
// Create a new module (and put it into the cache)
var module = __webpack_module_cache__[moduleId] = {
// no module.id needed
// no module.loaded needed
exports: {}
};

// Execute the module function
__webpack_modules__[moduleId](module, module.exports, __webpack_require__);

// Return the exports of the module
return module.exports;
}

定义了一个__webpack_module_cache__用于缓存模块

定义了一个__webpack_require__方法, 接受一个moduleId, 从下面可以看到moduleId是这个模块的路径(包括拓展名, 也即是__webpack_modules__管理的key值)

先判断缓存中是否存在这个模块,即是否加载,如果加载直接返回导出的数据,如果没有则在缓存中创建一个空对象{exports: {}}, 然后把module, module.exports, __webpack_require__作为参数去执行__webpack_modules__对应的方法

__webpack_modules__的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
var __webpack_modules__ = ({
"./src/utils.js":
((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
__webpack_require__.r(__webpack_exports__);
__webpack_require__.d(__webpack_exports__, {
"add": () => (add)
});
function add(a, b) {
return a + b;
}
})
});

可以看到,这里调用了一个__webpack_require__.r和一个__webpack_require__.d方法。目前我们不知道这两个方法是做什么用的。继续看下去。

webpack/runtime/define property getters

1
2
3
4
5
6
7
8
9
10
11
/* webpack/runtime/define property getters */
(() => {
// define getter functions for harmony exports
__webpack_require__.d = (exports, definition) => {
for(var key in definition) {
if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
}
}
};
})();

ddefine的缩写。可以看到这个方法的作用就是定义导出的值。

其目的就是遍历definition对象将其一一填入exports。需要注意的是使用__webpack_require__.d的目的在于确保:

  • 只能有一个key存在,如果exports中已经存在过了这个导出值,则不会重复导入
  • 确保exports中的属性只有getter, 不能被外部设置

make namespace object

1
2
3
4
5
6
7
8
9
10
/* webpack/runtime/make namespace object */
(() => {
// define __esModule on exports
__webpack_require__.r = (exports) => {
if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
}
Object.defineProperty(exports, '__esModule', { value: true });
};
})();

这个方法完成了两个目的。

  • exports定义了Symbol.toStringTag的值为Module
  • exports定义了__esModule的值为true

目的在于完成导出模块的兼容性

我们试试换一种导出方式:
src/utils.js

1
2
3
4
exports.add = function(a, b) {
return a + b;
}

结果:

1
2
3
4
5
6
7
8
var __webpack_modules__ = ({
"./src/utils.js":
((__unused_webpack_module, exports) => {
exports.add = function(a, b) {
return a + b;
}
})
});

可以看到输出简洁了很多。但是结果是一样的。都是在exports中插入导出的方法, 只不过esmodule的方式更加谨慎一点

那么前面的__unused_webpack_module又是干嘛的呢?我们修改一下代码
src/utils.js

1
2
3
module.exports = function add(a, b) {
return a + b;
}

结果:

1
2
3
4
5
6
7
8
var __webpack_modules__ = ({
"./src/utils.js":
((module) => {
module.exports = function add(a, b) {
return a + b;
}
})
});

一个主要细节在于esmodule使用了__webpack_require__.d来确保其代码的只读性,而commonjs没有:

esmodule和commonjs的模块导出可访问性区别

CommonJS 模块输出的是一个值的拷贝, ES6 模块输出的是值的引用

举个例子

commonjs

1
2
3
4
5
6
var a = 1;
setTimeout(() => {
a = 2;
}, 0)

exports.a = a;

生成代码:

1
2
3
4
5
6
7
8
((module) => {
var a = 1;
setTimeout(() => {
a = 2;
}, 0)

module.exports.a = a;
})

esmodule:

1
2
3
4
5
6
var a = 1;
setTimeout(() => {
a = 2;
}, 0)
export { a }

输出代码:

1
2
3
4
5
6
7
8
9
10
11
((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ "a": () => (/* binding */ a)
/* harmony export */ });
var a = 1;
setTimeout(() => {
a = 2;
}, 0)
})

可以看到区别:

  • commonjs 输出的a: 1 -> 1
  • esmodule 输出的a: 1 -> 2

因为commonjs内部实现是赋值,程序导出以后原来的a和导出的a的关系就没有了

esmodule输出的一个对象,内部的getter会每次去拿最新的a的值


那么到此我们的中间代码就看完了,顺便还介绍了一下webpack的导出结果。完整的中间代码列表可以看这个文件

执行代码

在上面的示例中,我们得到以下代码:

1
2
3
4
5
6
7
var __webpack_exports__ = {};
(() => {
__webpack_require__.r(__webpack_exports__);
var _utils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/utils.js");

console.log((0,_utils__WEBPACK_IMPORTED_MODULE_0__.add)(1, 2));
})();

该代码作为项目的入口代码, 完成了以下逻辑

  • 通过 __webpack_require__.r 标记这个文件导出类型为esmodule
  • 执行 __webpack_require__ 并将导入的结果存放到临时变量 _utils__WEBPACK_IMPORTED_MODULE_0__
  • 执行 (0,_utils__WEBPACK_IMPORTED_MODULE_0__.add)(1, 2) 并导出结果。这里的(0, ...)是为了重置方法的this指向
    • Comma operator
    • 这个方法等价于
      1
      2
      const add = _utils__WEBPACK_IMPORTED_MODULE_0__.add
      add(1, 2)

让我们来微调一下代码:

1
2
3
const add = require('./utils')

console.log(add(1, 2));

输出:

1
2
3
4
5
var __webpack_exports__ = {};
(() => {
const add = __webpack_require__("./src/utils.js")
console.log(add(1, 2));
})();

可以看到, 其主要的区别就是__webpack_require__.r, 其他的区别不是很大。

动态代码

修改部分代码:

src/index.js

1
2
3
import('./utils').then(({add}) => {
console.log(add(1,2))
})

生成代码:

webpack –mode development –devtool hidden-source-map

dist/main.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
/******/ (() => { // webpackBootstrap
/******/ var __webpack_modules__ = ({});
/************************************************************************/
/******/ // The module cache
/******/ var __webpack_module_cache__ = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/ // Check if module is in cache
/******/ var cachedModule = __webpack_module_cache__[moduleId];
/******/ if (cachedModule !== undefined) {
/******/ return cachedModule.exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = __webpack_module_cache__[moduleId] = {
/******/ // no module.id needed
/******/ // no module.loaded needed
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = __webpack_modules__;
/******/
/************************************************************************/
/******/ /* webpack/runtime/define property getters */
/******/ (() => {
/******/ // define getter functions for harmony exports
/******/ __webpack_require__.d = (exports, definition) => {
/******/ for(var key in definition) {
/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
/******/ }
/******/ }
/******/ };
/******/ })();
/******/
/******/ /* webpack/runtime/ensure chunk */
/******/ (() => {
/******/ __webpack_require__.f = {};
/******/ // This file contains only the entry chunk.
/******/ // The chunk loading function for additional chunks
/******/ __webpack_require__.e = (chunkId) => {
/******/ return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {
/******/ __webpack_require__.f[key](chunkId, promises);
/******/ return promises;
/******/ }, []));
/******/ };
/******/ })();
/******/
/******/ /* webpack/runtime/get javascript chunk filename */
/******/ (() => {
/******/ // This function allow to reference async chunks
/******/ __webpack_require__.u = (chunkId) => {
/******/ // return url for filenames based on template
/******/ return "" + chunkId + ".js";
/******/ };
/******/ })();
/******/
/******/ /* webpack/runtime/global */
/******/ (() => {
/******/ __webpack_require__.g = (function() {
/******/ if (typeof globalThis === 'object') return globalThis;
/******/ try {
/******/ return this || new Function('return this')();
/******/ } catch (e) {
/******/ if (typeof window === 'object') return window;
/******/ }
/******/ })();
/******/ })();
/******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */
/******/ (() => {
/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
/******/ })();
/******/
/******/ /* webpack/runtime/load script */
/******/ (() => {
/******/ var inProgress = {};
/******/ var dataWebpackPrefix = "webpack-demo:";
/******/ // loadScript function to load a script via script tag
/******/ __webpack_require__.l = (url, done, key, chunkId) => {
/******/ if(inProgress[url]) { inProgress[url].push(done); return; }
/******/ var script, needAttach;
/******/ if(key !== undefined) {
/******/ var scripts = document.getElementsByTagName("script");
/******/ for(var i = 0; i < scripts.length; i++) {
/******/ var s = scripts[i];
/******/ if(s.getAttribute("src") == url || s.getAttribute("data-webpack") == dataWebpackPrefix + key) { script = s; break; }
/******/ }
/******/ }
/******/ if(!script) {
/******/ needAttach = true;
/******/ script = document.createElement('script');
/******/
/******/ script.charset = 'utf-8';
/******/ script.timeout = 120;
/******/ if (__webpack_require__.nc) {
/******/ script.setAttribute("nonce", __webpack_require__.nc);
/******/ }
/******/ script.setAttribute("data-webpack", dataWebpackPrefix + key);
/******/ script.src = url;
/******/ }
/******/ inProgress[url] = [done];
/******/ var onScriptComplete = (prev, event) => {
/******/ // avoid mem leaks in IE.
/******/ script.onerror = script.onload = null;
/******/ clearTimeout(timeout);
/******/ var doneFns = inProgress[url];
/******/ delete inProgress[url];
/******/ script.parentNode && script.parentNode.removeChild(script);
/******/ doneFns && doneFns.forEach((fn) => (fn(event)));
/******/ if(prev) return prev(event);
/******/ }
/******/ ;
/******/ var timeout = setTimeout(onScriptComplete.bind(null, undefined, { type: 'timeout', target: script }), 120000);
/******/ script.onerror = onScriptComplete.bind(null, script.onerror);
/******/ script.onload = onScriptComplete.bind(null, script.onload);
/******/ needAttach && document.head.appendChild(script);
/******/ };
/******/ })();
/******/
/******/ /* webpack/runtime/make namespace object */
/******/ (() => {
/******/ // define __esModule on exports
/******/ __webpack_require__.r = (exports) => {
/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/ }
/******/ Object.defineProperty(exports, '__esModule', { value: true });
/******/ };
/******/ })();
/******/
/******/ /* webpack/runtime/publicPath */
/******/ (() => {
/******/ var scriptUrl;
/******/ if (__webpack_require__.g.importScripts) scriptUrl = __webpack_require__.g.location + "";
/******/ var document = __webpack_require__.g.document;
/******/ if (!scriptUrl && document) {
/******/ if (document.currentScript)
/******/ scriptUrl = document.currentScript.src
/******/ if (!scriptUrl) {
/******/ var scripts = document.getElementsByTagName("script");
/******/ if(scripts.length) scriptUrl = scripts[scripts.length - 1].src
/******/ }
/******/ }
/******/ // When supporting browsers where an automatic publicPath is not supported you must specify an output.publicPath manually via configuration
/******/ // or pass an empty string ("") and set the __webpack_public_path__ variable from your code to use your own logic.
/******/ if (!scriptUrl) throw new Error("Automatic publicPath is not supported in this browser");
/******/ scriptUrl = scriptUrl.replace(/#.*$/, "").replace(/\?.*$/, "").replace(/\/[^\/]+$/, "/");
/******/ __webpack_require__.p = scriptUrl;
/******/ })();
/******/
/******/ /* webpack/runtime/jsonp chunk loading */
/******/ (() => {
/******/ // no baseURI
/******/
/******/ // object to store loaded and loading chunks
/******/ // undefined = chunk not loaded, null = chunk preloaded/prefetched
/******/ // [resolve, reject, Promise] = chunk loading, 0 = chunk loaded
/******/ var installedChunks = {
/******/ "main": 0
/******/ };
/******/
/******/ __webpack_require__.f.j = (chunkId, promises) => {
/******/ // JSONP chunk loading for javascript
/******/ var installedChunkData = __webpack_require__.o(installedChunks, chunkId) ? installedChunks[chunkId] : undefined;
/******/ if(installedChunkData !== 0) { // 0 means "already installed".
/******/
/******/ // a Promise means "currently loading".
/******/ if(installedChunkData) {
/******/ promises.push(installedChunkData[2]);
/******/ } else {
/******/ if(true) { // all chunks have JS
/******/ // setup Promise in chunk cache
/******/ var promise = new Promise((resolve, reject) => (installedChunkData = installedChunks[chunkId] = [resolve, reject]));
/******/ promises.push(installedChunkData[2] = promise);
/******/
/******/ // start chunk loading
/******/ var url = __webpack_require__.p + __webpack_require__.u(chunkId);
/******/ // create error before stack unwound to get useful stacktrace later
/******/ var error = new Error();
/******/ var loadingEnded = (event) => {
/******/ if(__webpack_require__.o(installedChunks, chunkId)) {
/******/ installedChunkData = installedChunks[chunkId];
/******/ if(installedChunkData !== 0) installedChunks[chunkId] = undefined;
/******/ if(installedChunkData) {
/******/ var errorType = event && (event.type === 'load' ? 'missing' : event.type);
/******/ var realSrc = event && event.target && event.target.src;
/******/ error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')';
/******/ error.name = 'ChunkLoadError';
/******/ error.type = errorType;
/******/ error.request = realSrc;
/******/ installedChunkData[1](error);
/******/ }
/******/ }
/******/ };
/******/ __webpack_require__.l(url, loadingEnded, "chunk-" + chunkId, chunkId);
/******/ } else installedChunks[chunkId] = 0;
/******/ }
/******/ }
/******/ };
/******/
/******/ // no prefetching
/******/
/******/ // no preloaded
/******/
/******/ // no HMR
/******/
/******/ // no HMR manifest
/******/
/******/ // no on chunks loaded
/******/
/******/ // install a JSONP callback for chunk loading
/******/ var webpackJsonpCallback = (parentChunkLoadingFunction, data) => {
/******/ var [chunkIds, moreModules, runtime] = data;
/******/ // add "moreModules" to the modules object,
/******/ // then flag all "chunkIds" as loaded and fire callback
/******/ var moduleId, chunkId, i = 0;
/******/ for(moduleId in moreModules) {
/******/ if(__webpack_require__.o(moreModules, moduleId)) {
/******/ __webpack_require__.m[moduleId] = moreModules[moduleId];
/******/ }
/******/ }
/******/ if(runtime) var result = runtime(__webpack_require__);
/******/ if(parentChunkLoadingFunction) parentChunkLoadingFunction(data);
/******/ for(;i < chunkIds.length; i++) {
/******/ chunkId = chunkIds[i];
/******/ if(__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) {
/******/ installedChunks[chunkId][0]();
/******/ }
/******/ installedChunks[chunkIds[i]] = 0;
/******/ }
/******/
/******/ }
/******/
/******/ var chunkLoadingGlobal = self["webpackChunkwebpack_demo"] = self["webpackChunkwebpack_demo"] || [];
/******/ chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0));
/******/ chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal));
/******/ })();
/******/
/************************************************************************/
var __webpack_exports__ = {};
/*!**********************!*\
!*** ./src/index.js ***!
\**********************/
__webpack_require__.e(/*! import() */ "src_utils_js").then(__webpack_require__.bind(__webpack_require__, /*! ./utils */ "./src/utils.js")).then(({add}) => {
console.log(add(1,2))
})

/******/ })()
;

dist/src/utils_js.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
(self["webpackChunkwebpack_demo"] = self["webpackChunkwebpack_demo"] || []).push([["src_utils_js"],{

/***/ "./src/utils.js":
/*!**********************!*\
!*** ./src/utils.js ***!
\**********************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ "add": () => (/* binding */ add)
/* harmony export */ });
function add(a, b) {
return a + b;
}


/***/ })

}]);

相同的代码我们跳过,我们首先来看一下入口文件的执行代码:

1
2
3
4
5
6
var __webpack_exports__ = {};
__webpack_require__.e("src_utils_js")
.then(__webpack_require__.bind(__webpack_require__, "./src/utils.js"))
.then(({add}) => {
console.log(add(1,2))
})

这个代码主要分成三部分:

  • 第一部分执行__webpack_require__.e
  • 第二部分生成一个__webpack_require__方法并绑定参数
  • 第三部分去执行实际逻辑。

我们来看下主要核心的中间代码__webpack_require__.e:

1
2
3
4
5
6
7
8
9
10
11
12
/* webpack/runtime/ensure chunk */
(() => {
__webpack_require__.f = {};
__webpack_require__.e = (chunkId) => {
return Promise.all(
Object.keys(__webpack_require__.f).reduce((promises, key) => {
__webpack_require__.f[key](chunkId, promises);
return promises;
}, [])
);
};
})();

简单了解一下reduce

这段代码很奇怪,看上去来说实际可以视为作为一个forEach在使用。目的是试图去执行__webpack_require__.f这个对象中的所有方法,最后返回一个总的Promise

至于执行的方法,目前只有一个__webpack_require__.f.j,里面是一堆代码总之暂且放置不看,我们可以将其视为加载js文件即可(通过生成script的方式)。

我们可以将其视为加载好dist/src/utils_js.js并将该文件里声明的对象的map添加到__webpack_modules__即可。

此时使用__webpack_require__去走之前的逻辑就可以正常调用模块了。

这样就实现了代码分割。

一些动态加载的小细节

1
2
3
4
5
// main
var chunkLoadingGlobal = self["webpackChunkwebpack_demo"] = self["webpackChunkwebpack_demo"] || [];

// src_utils_js.js
(self["webpackChunkwebpack_demo"] = self["webpackChunkwebpack_demo"] || []).push([["src_utils_js"],{...})

通过这种命名空间方式解决了单页面多项目可能错误添加动态加载代码的问题。

1
chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal));

重写数组的push方法,在push时做一些额外的操作
即执行chunkLoadingGlobal.push(arg1, arg2)时。执行webpackJsonpCallback(chunkLoadingGlobal.push, arg1, arg2)这种方式。老实说我没有想到这种写法的好处,但也算一种小技巧

总结

统一module方式

webpack 将两种形式导出方式进行了一定程度上的统一,即不论写法如何,都通过__webpack_require__对模块进行引入,而对于导出的模块来说,都统一成module的样式。

区别在于esmoduledefault导出和commonjs的module.exports导出略有区别

esmoduledefault导出在生成的代码中地位和一般的export导出是一样的

TRPG Engine 的项目工程化实践

First of all

一个人维护一个项目很久了, 差不多是时候总结一些经验做一些些输出了。因此来汇总一下这些年我对我的开源项目TRPG Engine做的工程化实践。

首先,我们要明确一点,即为什么要做工程化:

  • 提升开发效率
  • 降低开发成本
  • 提升产品质量
  • 降低企业成本

所有的代码, 所有的技术都依托于业务, 所有的手段都是为了最终目的而服务的。因此我们工程化最终目的就是提高产出。

Git workflow

参考文章:

Commitlint

使用 Commitlint 来保证项目成员或者外部贡献者的提交确保同样的格式。

TRPG Engine是使用 commitlint 来实现的提交内容校验

一般常用的一种提交方式是 angular 格式。

例:

1
2
3
fix: some message

fix(scope): some message

参考文档: https://github.com/angular/angular/blob/master/CONTRIBUTING.md#-commit-message-format

提交的类型说明:

1
2
3
4
5
6
7
8
feat:新功能(feature
fix:修补bug
docs:文档(documentation)
style: 格式(不影响代码运行的变动)
refactor:重构(即不是新增功能,也不是修改bug的代码变动)
perf:性能优化
test:增加测试
chore:构建过程或辅助工具的变动

https://github.com/angular/angular/blob/master/CONTRIBUTING.md#type

通过标准的 commit message 可以生成Change log

基于git commit message来自动收集并生成CHANGELOG

在升版本时可以通过standard-version来实现package.json version和changelog一起生成

Prettier and Eslint

确保项目有一个统一的格式是非常重要, 可以有效的防止贡献者因为不统一的格式带来的提交变更过大。

试想一下,一个人用的是4空格缩进,另一个人用的是2空格缩进。那么会来带的协作上的问题并导致code review无法正常进行

目前前端流行使用prettier + eslint的组合来确保项目的格式统一。

prettier是目前前端流行的Formatter工具, 用于保证项目能具有统一的格式,各大编辑器平台都具有插件支持,同时也支持许多语言

eslint 是一款确保你的代码质量的工具,特别是融合了tslint后更加成为了前端代码质量保证的唯一选择。其功能与prettier有部分交集,因此在同时使用两者时需要使用eslintprettier预设来确保不会出现冲突

另外推荐一个跨语言的格式工具, EditorConfig, 虽然功能没有prettier这么强大,但是他的优势是跨语言,更加具有通用性。

使用 lint-staged 来在工程化运行时中确保格式正确

Testing and benchmark

单元测试与基准测试是程序员要面对的两大关键测试方式。

单元测试应当用于几乎所有的代码, 来确保代码不出错(主要是可以防止其他协作者无意间改坏你的代码)

而基准测试用于对性能要求较高的场合,比如后端的高并发场景,以及前端的高CPU计算(By the way, 对于前端的高CPU场景我建议是使用web worker来处理,这样可以使用多线程而不会影响用户正常操作)

如何写单元测试

总所周知,一个纯函数是最好测的。那么单元测试的存在就相当于监督我们时刻注意将一些可以抽象出来的纯函数抽象出来,来方便写单元测试。能有效提高代码质量。

而对于一些副作用比较大的场景,我们需要想办法构建其上下文。比如TRPG Engine的后端代码,单元测试就是真实起了一个空白的数据库, redis, 和后端实例,通过数据库操作以及及时清理测试过的数据库代码来保证环境干净

对于比较难以测试的前端组件, TRPG Engine的做法是打快照,通过快照的变更告知开发者是否有一个 预期/非预期 的变更出现

单元测试的存在也应当集成到CI中,以确保每次执行都可用。

Bundler

在现代前端编程中, 打包编译是前端不得不重视的一环。

从less scss等css拓展语言, 到ts, coffee的js拓展。

从babel的es6,7,8,9支持, 到各种动态加载, 各种优化压缩。

面对日益复杂的现状,前端已经离不开打包工具的存在。

一般来说,我们常用的打包工具是webpackwebpack大而全,并提供足够的自定义化能力。是目前来说前端业务开发打包的不二之选。但成也萧何败萧何,webpack虽然十分强大, 但是配置非常非常复杂,甚至有webpack工程师这一说法,因此在一些特殊场景下, 我也十分推荐一些其他的打包工具。

CI/CD

Continuous Integration and Continuous Delivery
持续集成与持续交付

市面上有很多免费的CI系统, 比如 travis, appveyor, circleci, github action等等, 再比如gitlab自带的ci系统。

总的来说都大同小异, 我们使用CI系统无非是关注单元测试有没有跑通,如何可以的话顺便输出一份coverage覆盖率报告。如果再可以的话可以把代码编译了以后输出编译报告。来方便衡量每一次提交的代码质量。

一般来说CI/CD都放在一起来讲,因为只是最终的输出不一样罢了。

CD可以做一些每次提交都编译文件, 或者往特殊分支提交就部署页面的功能。(比如每次向docs提交代码都编译文档并部署到远程服务器上)

Analytics and Monitor

一些现成的分析服务:

  • Google Analytics
  • Datadog
  • Posthog
  • Sentry Tracking
  • Grafana
  • uptimerobot

这些工具是帮助你的项目在上线后能分析或监控的方式。通过这些工具可以收集用户的行为,检测服务可用性等。

监控可以帮助你的服务稳定可靠,发生宕机的情况能够第一时间发现,减少用户损失。没有监控的服务就是没有地图和罗盘的轮船 —— 什么时候沉默?天知道!

而用户行为收集是下一步迭代的重要依据,如果是用户比较少用的功能则可以考虑减慢开发进度。

对于监控,我推荐posthog,这是一款新兴的分析系统。推荐的理由很简单,因为他是开源的,我们可以自己部署,然后把他的数据进行二次加工与处理。

Performance

性能是提升用户体验的重要一环,即常规感知中的“卡不卡”。

我们有很多方式去提升性能,比如采集用户的首屏渲染时间,比如手动打开devtool去对具体某个操作进行堆栈分析,再比如用Lighthouse跑个分 —— google的工具都非常棒。

参考文档:

Logging

日志应当是我们分析问题最关键的一步,重视日志是一个有一定规模的项目最基本的一步。

大部分项目都会记录本地日志,但本地日志过于原始,很难产生一定价值。目前业内流行的方案是为日志单独准备一个elasticsearch服务, 所有日志中心化到一个数据库,再通过配套的 kibana 进行数据检索。

另外使用外部日志中心的好处在于项目的微服务化与集群化。因为项目离开单实例部署后日志分散在各地,更加难以查询问题。

对于 TRPG Engine 来说,目前使用的第三方日志服务是 Loggly, 因为ELK部署较耗资源,而其他大多数的日志服务都是收费的。Loggly具有一定的免费额度, 但是对中文编码不是很友好。

相关资源:

  • local file
  • Loggly
  • ELK
  • 阿里云日志腾讯云日志等服务商…

Error Report

除了日志, 我们可能需要一个单独的错误汇报中心。因为日志是一种被动式的、托底的问题查找方式。一个主动的错误汇报会让我们更早介入问题以防止更大的错误扩散。

TRPG Engine使用了Sentry作为错误汇报中心。开源,云服务具有一定免费额度,错误汇报可以带上堆栈信息和相关上下文,并且新的错误会发送邮件到相关人员。

开源对于企业的意义在于能够自己部署,企业也可以部署自己的sentry,就像是gitlab一样

  • Sentry

Develop for Distributed

有一点比较重要的就是在开始一个项目的时候就要考虑到之后的场景。在开发时就需要考虑分布式部署的场景。至少对于可能有分布式的场景进行一层抽象,就算现在不做,以后也要做。这点TRPG Engine走过很多弯路。

  • 比如日志,需要考虑使用外部日志的情况
  • 比如文件管理,需要考虑使用云文件
  • 比如配置,需要考虑使用外部的配置中心
  • 比如缓存,少用内存缓存而用外部缓存
  • 比如数据库 —— 当然这个大多数情况不用操心,除非用的是sqlite

因为现代的程序架构早就不是以前一台服务器打天下的时候了。有效组合各个服务可以帮助程序快速增长。

Coding with config

基于配置的代码会使你的程序更加灵活。特别是线上情况往往不适合发布,或者长期故障。通过配置我们可以将一部分代码关闭以保证整体代码可用性。

很多公司的功能开发分成两种管理方案,一种是做功能时切出一个功能分支,等到开发完毕后再合并到主分支。

还有一种方案是持续合并到主干分支,但是由配置来将新功能关闭。

说不清那种方案好,但是基于配置进行开发给与工程化代码更加灵活。

Read with Document and Comment

文档也是工程化代码的实践

一个静态文档网站可以帮助使用者快速理解整个项目

一行注释可以帮助代码阅读者理解你的用意,更重要的是可以防止其他的协作者在不了解你的用意的情况下改坏代码。

好的开源项目一定有足够文档,而一个好的企业项目代码中一定有很多注释。

对于企业业务项目来说,文档可能没有办法强制要求,但是需要明确一点的是注释是为自己写的,试想一下,一个复杂一点的方法,等一个月后,还能保证自己能理解当时自己的用意么?

Flexible Architecture

可变、灵活架构。

一个项目想要换底层架构是非常困难且痛苦的,想要解决这个问题,只有架构预先进行足够的设计,提前预想未来5年10年的业务变更。

比如插件化的架构就能保证业务代码的可拓展性。

MiniStar: 一个用于实现微内核(插件化)架构的前端框架

Dockerize

docker是现在开发的趋势,统一且单一的环境。

做过以前代码部署的工程师一定了解在不同环境下部署程序的痛苦,就算打包成一个war包也可能会有各种环境导致的奇怪问题。而docker就是解决这个问题的工具。

在实际中有很多使用场景:

  • 统一开发环境(统一开发环境)
  • 快速部署(无需搭建环境)
  • 集群部署(k8s, docker swarm)