Qiankun
JS沙箱
- SanpshotSandbox
- 微应用mount时先把上一次记录的变更
modifyPropsMap应用到微应用的全局window,没有则跳过;浅复制主应用的windowkey-value 快照,用于下次恢复全局环境。 - 微应用 unmount 时将当前微应用
window的key-value和快照的key-value进行 Diff,Diff 出来的结果modifyPropsMap用于下次恢复微应用环境的依据。将上次快照的key-value拷贝到主应用的window上,以此恢复环境 - 有一个问题:每次微应用 unmount 时都要对每个属性值做一次 Diff,大量属性更新时耗费性能
- 微应用mount时先把上一次记录的变更
- LegacySandbox
- 用
Proxy监听对window的修改来直接记录 Diff 内容。如果是新增属性,那么存到addedMap里,如果是更新属性,那么把原来的键值存到prevMap,把新的键值存到newMap,通过这三个变量就能反应出微应用和原环境的变化,qiankun也能以此恢复环境。
- 用
- 前面两种沙箱都是 单例模式 下使用的沙箱。也即一个页面中只能同时展示一个微应用,而且无论是
set还是get依然是直接操作window对象。 因此,如果在同一个路由页面下展示多个微应用,会有全局变量污染的问题。 - ProxySandbox
- 把当前
window的一些原生属性(如document,location,history,setTimeout等)拷贝出来,单独放在一个对象上,这个对象也称为fakeWindow, 之后对每个微应用分配一个fakeWindow - 用
Proxy监听fakeWindow的改变。当微应用set``get全局变量时,如果是原生属性则会操作全局window,如果不是原生属性,则优先操作fakeWindow。 - 每个微应用都有自己一个环境,当在
active时就给这个微应用分配一个fakeWindow,当inactive时就把这个fakeWindow存起来,以便之后再利用。
- 把当前
- 隔离原理
- 把要执行 JS 代码放在一个立即执行函数中,且函数入参有
window,self,globalThis - 给这个函数 绑定this上下文
window.proxy - eval执行这个函数,并把上面提到的沙箱对象
window.proxy作为入参分别传入 - 假如代码里有隐式声明的属性和方法如
a = 1; add = () => {}或调用全局对象的代码。上下文this则为刚刚绑定的window.proxy。由于隐式声明add不会自动挂载到window.proxy上,所以当执行add,eval就会报add is undefined。 如网关层自动注入的JS,老旧的第三方JSSDK,Webpack 插件引入的JS。解决的方法,把代码a = 1通过文本替换脚本window.a,添加全局声明window a。
- 把要执行 JS 代码放在一个立即执行函数中,且函数入参有
const executeScript = `
;(function(window, self, globalThis){
;${scriptText}${sourceUrl}
}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);
`
eval.call(window, executeScript)
- 实现一个快照沙箱SanpshotSandbox
class SnapshotSandbox {
windowSnapshot = {}
modifiedMap = {}
proxy = window;
constructor() {
}
active() {
// 记录 window 旧的 key-value
Object.entries(window).forEach(([key, value]) => {
this.windowSnapshot[key] = value;
})
// 恢复上一次的 key-value
Object.keys(this.modifiedMap).forEach(key => {
window[key] = this.modifiedMap[key];
})
}
inactive() {
this.modifiedMap = {};
Object.keys(window).forEach(key => {
// 如果有改动,则说明要恢复回来
if (window[key] !== this.windowSnapshot[key]) {
// 记录变更
this.modifiedMap[key] = window[key];
window[key] = this.windowSnapshot[key];
}
})
}
}
module.exports = SnapshotSandbox;
css沙箱
- Shadow DOM 沙箱,Qiankun样式隔离的严格模式
- 把微应用的内容用 Shadow DOM 封装起来,比如把
<style>挂截到 Shadow Tree 上,那么就可以实现样式的硬隔离 - 把当前微应用的根元素的内容取出并缓存,清空根元素内容
- 用根元素
attachShadow生成shadowDOM - 把缓存的内容放入这个shadow DOM,并把shadow DOM追加到渲染容器。
- 把微应用的内容用 Shadow DOM 封装起来,比如把
function shadowDOMsolation(contentHtmlString) {
contentHtmlString = contentHtmlString.trim();
const containerElement = document.createElement('div');
containerElement.innerHTML = contentHtmlString;
const appElement = containerElement.firstChild;
const { innerHTML } = appElement;
appElement.innerHTML = '';
let shadow;
if (appElement.attachShadow) {
// 兼容性更广的写法
shadow = appElement.attachShadow({ mode: 'open' });
} else {
// 旧写法
shadow = appElement.createShadowRoot();
}
// 生成 shadow DOM 的内容
shadow.innerHTML = innerHTML;
return appElement;
}
- Scoped CSS 沙箱,Qiankun样式隔离的实验性模式
- Scoped CSS 沙箱的原理更简单:将微应用里的
<style>的文本提取出来,将所有的选择器进行正则匹配转换。在原有选择器上添加下个父类选择器,如span -> div[data-app-name=microapp] span。
- Scoped CSS 沙箱的原理更简单:将微应用里的
function processCSS(appElement, stylesheetElement, appName) {
// 生成 CSS 选择器:div[data-app-name=microapp]
const prefix = `${appElement.tagName.toLowerCase()}[data-app-name="${appName}"]`;
// 生成临时 <style> 节点
const tempNode = document.createElement('style');
document.body.appendChild(tempNode);
tempNode.sheet.disabled = true
if (stylesheetElement.textContent !== '') {
// 将原来的 CSS 文本复制一份到临时 <style> 上
const textNode = document.createTextNode(stylesheetElement.textContent || '');
tempNode.appendChild(textNode);
// 获取 CSS 规则
const sheet = tempNode.sheet;
const rules = [...sheet?.cssRules ?? []];
// 生成新的 CSS 文本
stylesheetElement.textContent = this.rewrite(rules, prefix);
// 清理
tempNode.removeChild(textNode);
}
}
function scopedCSSsolation(appName, contentHtmlString) {
// 清理 HTML
contentHtmlString = contentHtmlString.trim();
// 创建一个容器 div
const containerElement = document.createElement('div');
// 生成内容 HTML 结构
containerElement.innerHTML = contentHtmlString; // content 最高层级必需只有一个 div 元素
// 获取根 div 元素
const appElement = containerElement.firstChild;
// 打上 data-app-name=appName 的标记
appElement.setAttribute('data-app-name', appName);
// 获取所有 <style></style> 元素内容,并将它们做替换
const styleNodes = appElement.querySelectorAll('style') || [];
[...styleNodes].forEach((stylesheetElement) => {
processCSS(appElement, stylesheetElement, appName);
})
return appElement;
}
const RuleType = {
STYLE: 1,
MEDIA: 4,
SUPPORTS: 12,
}
function ruleStyle(rule, prefix) {
//匹配 p {..., a { ..., span {... 这类字符串
return rule.cssText.replace(/^[\s\S]+{/, (selectors) => {
// 匹配 div,body,span {... 这类字符串
return selectors.replace(/(^|,\n?)([^,]+)/g, (selector, _, matchedString) => {
// 将 p { => div[data-app-name=微应用名] p {
return `${prefix} ${matchedString.replace(/^ */, '')}`;
})
});
}
function rewrite(rules, prefix) {
let css = '';
rules.forEach((rule) => {
switch (rule.type) {
case RuleType.STYLE:
css += ruleStyle(rule, prefix);
break;
// case RuleType.MEDIA:
// css += this.ruleMedia(rule, prefix);
// break;
// case RuleType.SUPPORTS:
// css += this.ruleSupport(rule, prefix);
// break;
default:
css += `${rule.cssText}`;
break;
}
});
return css;
}
- Web Componet实现,在html文件引入如下js。
class Solation extends HTMLElement {
constructor() {
super();
const name = this.getAttribute('data-app-name');
const mode = this.getAttribute('data-isolation-mode');
const html = `<div class="wrapper">${this.innerHTML.trim()}</div>`;
// 根据隔离模式来生成对应的 appElement
const appElement = mode === 'shadowDOM'
? shadowDOMIsolation(html) : scopedCSSIsolation(name, html);
// 清除内容
this.innerHTML = '';
// 再追加包裹的内容
this.appendChild(appElement);
}
}
// 原理先创建shadowDom,再appendChild;
customElements.define('isolation-content', Isolation)
加载微应用
qiankun import-html-entry是qiankun 框架中用于加载子应用的 HTML 入口文件的工具函数。加载微应用其实就是发请求获取 HTML 文本,以及处理 HTML 文本的操作。其中我们要针对<link>标签,将其转化为<style>标签,因为只有转化了才能进一步实现 CSS 的隔离。在做 CSS 隔离的时候,我们也发现了目前 CSS 隔离方案的一些问题。而对于 JS 代码,则是直接放在沙箱中执行。不过我们在实践过程中也发现了需要提供 JS 入口的这个问题。
// JS 沙箱
window.sandbox = new SnapshotSandbox();
window.proxy = window.sandbox.proxy;
// 加载微应用逻辑
const loadMicroApp = async (containerSelector, name, url) => {
// 发 Http 请求,获取 HTML 内容
const html = await (await fetch(url)).text();
// `processTpl` 主要是通过正则来匹配html中对应的 `<link>` 以及 `<script>`,
// 将 <link> 转换为注释 <!-- href ->占位符,为后续`<style>`内联样式插入到原来的地方
// 收集外部 <link> 的 href 地址,收集内联 <script> 代码以及外部 <script> 的 src 地址
const { template, scripts, styles } = processTpl(html);
// 远程加载所有外部样式,将之前设置的 link 注释标签替换即 <style>code</style>
const embedHtml = await getEmbedHtml(template, styles);
// CSS隔离,CSS 沙箱则是拿到了整串处理过的 HTML,
// 把里面的 `<style>` 的 CSS 代码重新再处理一次,从而实现样式隔离。
const wrapped = `<div class="wrapper">${embedHtml}</div>`;
const appElement = scopedCSSIsolation(name, wrapped);
// 再追加包裹的内容
const containerElement = document.querySelector(containerSelector);
containerElement.appendChild(appElement);
// 执行 JS
execScripts(scripts);
}
const execScripts = (scripts) => {
// 激活沙箱
window.sandbox.active();
// 同时处理外部 JS 以及内联 JS,因为 `sciprts` 数组既存着内联代码又存放着 `src` 地址
// `getExternalScripts` 返回值则全部为 JS 代码了
getExternalScripts(scripts)
.then(scriptText => {
const code = `
;function fn (window) {
${scriptText}
}
fn(window.proxy);
`
eval(code);
})
}
- 这里的难点在于对于一些外部资源的处理:把外部的
<link>转为内联的<style>,以及获取外部的<script src="xxx">JS 代码,最终放在一起执行。 - 执行入口,上面只是简单地遍历
scripts数组,然后一个个地执行。因此无论如何,Qiankun 都需要你提供一个入口标识来指定最先执行的文件。除了这个原因,由于 Qiankun 是基于 single-spa 开发的,Qiankun 也会从这个入口里拿到对应的生命周期,把它们传给 single-spa去调度执行。- qiankun 自己实现了一套通过 HTML 地址加载微应用的机制,但对于 “要在什么时候执行 JS” 依然用了 single-spa 的生命周期调度能力。这就是为什么微应用的入口文件
main.js依然需要提供 single-spa 的生命周期回调。在mount中执行渲染ReactDOM.render(<App />, container); - qiankun 提供了 2 种定位入口的方式:找 带有
entry属性的<script entry src="main.js"></script>;如果找不到,那么把 最后一个<script>作为入口 - 推荐第一种方法,可以使用 html-webpack-inject-attributes-plugin 这个 Webpack 插件,在打包的时候就给入口
main.js添加entry属性;后一种方法不可靠,因为微应用 HTML 有可能在一些公司代理、网关层中被拦截,自动注入一些脚本。
- qiankun 自己实现了一套通过 HTML 地址加载微应用的机制,但对于 “要在什么时候执行 JS” 依然用了 single-spa 的生命周期调度能力。这就是为什么微应用的入口文件
/**
* bootstrap 只会在微应用初始化的时候调用一次,
* 下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
* 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
*/
export async function bootstrap() {
console.log('react app bootstraped');
}
/**
* 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
* 通过 ReactDOM.render 挂载子应用时,需要保证每次子应用加载都应使用一个新的路由实例。
*/
export async function mount(props) {
ReactDOM.render(<App />, props.container
? props.container.querySelector('#root')
: document.getElementById('root'));
props.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log(state, prev);
});
props.setGlobalState(state);
}
/**
* 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
*/
export async function unmount(props) {
ReactDOM.unmountComponentAtNode(
props.container ?
props.container.querySelector('#root') : document.getElementById('root'),
);
}
/**
* 可选生命周期钩子,仅使用 loadMicroApp 方式加载微应用时生效
*/
export async function update(props) {
console.log('update props', props);
}
- 配置微应用的打包工具
- 安装插件
@rescripts/cli,当然也可以选择其他的插件,例如react-app-rewired。 - 根目录新增
.rescriptsrc.js:
- 安装插件
// 为了让主应用能正确识别微应用暴露出来的一些信息,微应用的打包工具需要增加如下配置
const { name } = require('./package');
module.exports = {
webpack: (config) => {
config.output.library = `${name}-[name]`;
config.output.libraryTarget = 'umd';
// webpack 5 需要把 jsonpFunction 替换成 chunkLoadingGlobal
config.output.jsonpFunction = `webpackJsonp_${name}`;
config.output.globalObject = 'window';
return config;
},
devServer: (_) => {
const config = _;
config.headers = {
'Access-Control-Allow-Origin': '*',
};
config.historyApiFallback = true;
config.hot = false;
config.watchContentBase = false;
config.liveReload = false;
return config;
},
};
-
样式隔离不彻底
- 发现 Scoped CSS 的隔离方式下,微应用依然是暴露在全局当中的,如果主应用有
div { color: red }这样全局 CSS 代码,那么微应用一样会受到影响,这也是 Scoped CSS 的一个缺点。(同样有挂在 body 的弹窗样式设置不上的问题) - Shadow DOM 隔离呢?这也并不完美,由于 Shadow DOM 的天然隔离特点,导致微应用里调用
document.querySelect会找不到微应用的元素。即使attchShadow里的 mode 设置为open,也只能通过element.shadowRoot.querySelector的方式来寻找 DOM。 - 这也可以解释为什么 Qiankun 对于一些全局 UI 组件不是那么友好,例如 Modal、Drawer、Popover,因为这些组件一般都会挂载到全局的
document.body上,此时全局组件使用的是主应用的样式,微应用内控制这些全局组件的样式不生效被ShadowDOM隔离了
- 发现 Scoped CSS 的隔离方式下,微应用依然是暴露在全局当中的,如果主应用有
-
执行时机
- single-spa 实现逻辑,它监听了路由以此来管理多个应用,而 Qiankun 则这个基础上做的更友好一些,我们只需要提供路由以及微应用地址即可。
- 主应用做路由切换的时候,子应用无法捕获主应用的路由切换事件。造成子应用没有正常切换页面。 当主应用点击
<Link>的时候,实际上调用了history.pushState,并不会派发hashchange事件。子应用没有捕获到对应的hashchange事件,因此无法做路由切换。- 将
<Link>转换为<a>标签即可 - 使用
window.dispatchEvent(new Event('hashchange')来手动产生一个事件,让子应用捕获到它,由此做路由切换
- 将
-
微应用通信
// 主应用
import { initGlobalState, MicroAppStateActions } from 'qiankun';
// 初始化 state
const actions: MicroAppStateActions = initGlobalState(state);
// 在当前应用监听全局状态,有变更触发callback,fireImmediately=true 立即触发callback
actions.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log(state, prev);
});
// 按一级属性设置全局状态,微应用中只能修改已存在的一级属性
actions.setGlobalState(state);
// 移除当前应用的状态监听,微应用 umount 时会默认调用
actions.offGlobalStateChange();
// 微应用
// 从生命周期 mount 中获取通信方法,使用方式和 master 一致
export function mount(props) {
props.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log(state, prev);
});
props.setGlobalState(state);
}
微前端架构
技术栈无关,独立开发、独立部署、独立运行时微应用之间状态隔离、增量技术升级渐进式重构
qiankun方案
- 优点
- 微应用间直接通信
initGlobalState - 比iframe快,非第一次加载更快。还支持预加载应用列表
prefetchApps - 没有DOM隔离限制,交互体验好
- 没有前进后退URL需要同步的问题
- 微应用间直接通信
- 缺点
- 旧应用改造时间成本高。所有微应用都要入侵代码进行改造。适配成本较高,包括工程化、生命周期、静态资源路径、路由等方面的适配;
- 新增
public-path.js文件,用于修改运行时的publicPath。你的项目是index.html和其他的所有文件分开部署的,说明你们已经将构建时的publicPath设置为了完整路径,则不用修改运行时的publicPath - 微应用建议使用
history模式的路由,需要设置路由base,值和它的activeRule是一样的。 - 在入口文件最顶部引入
public-path.js,修改并导出三个生命周期函数。 - 修改
webpack打包,允许开发环境跨域和umd打包。
- 新增
- 所有接口都需要支持跨域CORS和改成绝对路径publicPath。
- js,css隔离存在一些兼容性问题。随着页面复杂度增加而增加
- 测试回归成本高,需要全量回归测试。
- 旧应用改造时间成本高。所有微应用都要入侵代码进行改造。适配成本较高,包括工程化、生命周期、静态资源路径、路由等方面的适配;
iframe方案
- 优点
- iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。
- 无需对微应用入侵代码改造,测试,时间成本低
- 缺点
- 但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。iframe适用场景,iframe占据了全部的内容区域
- 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
- url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
- UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中
- 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。
iframe
url同步
- iframe页面内部的跳转虽然不会让浏览器地址栏发生变化,但是却会产生一个看不见的“history记录”,也就是点击前进或后退按钮(
history.forward()或history.back())可以让iframe页面也前进后退,但是地址栏无任何变化。前进后退无需我们做任何处理,我们要做的就是让浏览器地址栏同步更新即可。 - 在导航栏加载iframe跳转前发送一个通知给父页面,父页面通过
history.replaceState去更新URL。 - 全局跳转拦截,A标签跳转,location.href,window.open,history.pushState, history.replaceState拦截,执行
beforeRedirect后在跳转。 - beforeRedirect使用
postMessage通知主应用基座,主应用使用window.history.replaceState同步URL地址栏。
// 子应用
function beforeRedirect(href) {
postMessage('beforeHistoryChange', { url: href });
}
// 主应用
window.addEventListener('message', e => {
const { data, type } = e.data || {};
if ((type === 'beforeHistoryChange' || type === 'afterHistoryChange') && data?.url) {
// 这里先采用一个兜底的URL承接任意地址
const entry = `/fin/base.html?entry=${encodeURIComponent(data.url)}`;
// 地址不一样才需要更新
if (location.pathname + location.search !== entry) {
window.history.replaceState(null, '', entry);
}
// 此时页面并没有立即跳转,需要再稍微等待一下再显示loading
setTimeout(() => this.setState({loading: true}), 100);
// iframe 加载完毕
if (type === 'iframeDOMContentLoaded') {
this.setState({loading: false});
}
}
});
// history.replaceState拦截
var tempName = '_rawReplaceState';
if (!window.history[tempName]) {
window.history[tempName] = window.history.replaceState;
window.history.replaceState = function(state, title, url) {
url = beforeRedirect(url);
if (url) {
window.history[tempName](state, title, url);
}
}
}
// A标签拦截
document.addEventListener('click', function (e) {
var target = e.target || {};
// A标签可能包含子元素,点击目标可能不是A标签本身,这里只简单判断2层
if (target.tagName === 'A' ||
(target.parentNode && target.parentNode.tagName === 'A')
) {
target = target.tagName === 'A' ? target : target.parentNode;
var href = target.href;
// 不处理没有配置href或者指向JS代码的A标签
if (!href || href.indexOf('javascript') === 0) {
return;
}
var newHref = beforeRedirect(href, target.target === '_blank');
// 没有返回值一般是已经处理了跳转,需要禁用当前A标签的跳转
if (!newHref) {
target.target = '_self';
target.href = 'javascript:;';
} else if (newHref !== href) {
target.href = newHref;
}
}
}, true);
// location.href拦截
// 由于 location.href 无法重写,只能实现一个 location2.href = ''
if (Object.defineProperty) {
window.location2 = {};
Object.defineProperty(window.location2, 'href', {
get: function() {
return location.href;
},
set: function(href) {
var newHref = beforeRedirect(href);
if (newHref) {
location.href = newHref;
}
},
});
}
全局loading
- iframe跳转前
beforeHistoryChange打开loading。子应用监听document的DOMContentLoaded事件,向主应用通信关闭loading组件。iframe元素自身的onload事件也关闭loading组件作兜底。
弹窗居中问题
- iframe占据了全部的内容区域,个人觉得并不需要处理
- 需要处理的情况,弹窗这种全局组件挂载在iframe应用内的document上,覆盖一下原来页面弹窗的样式,弹窗和遮罩层的位置向左向上移动。
通信
- window对象的
postMessage。 MessageChannel消息通道两个端口的postMessage双向通信。- 两个web Worker中的postMessage也能相互传输MessageChannel的两个端口相互通信
- Vue的nextTick的使用的宏任务,对于宏任务来说,优先度是
setImmediate -> MessageChannel -> setTimeout 0
//index.html
const { port1, port2 } = new MessageChannel();
let iframe = document.querySelector('iframe');
iframe.addEventListener("load", () => {
port1.onmessage = (e) => {
console.log(e)
}
iframe.contentWindow.postMessage('来自index.html的信息', '*', [port2]);
});
//index2.html
window.addEventListener('message', e=>{
e.port[0].postMessage('来自index2.html的信息')
});
//甚至可以这么写
//window.addEventListener('message', e => {
// const port2 = e.ports[0];
// port2.addEventListener('message', () => {
// port2.postMessage('来自index2.html的信息')
// });
// port2.start();
//});
//或者这么写,会隐式调用start()方法
//window.addEventListener('message', e => {
// const port2 = e.ports[0];
// port2.onmessage = () => {
// port2.postMessage('来自index2.html的信息')
// };
//});
node
Node.js 基于V8引擎构建的服务器端平台,可将 Javascript 代码编译为机器码。Node.js 基于事件驱动、非阻塞I/O模型,充分利用操作系统提供的异步 I/O 进行多任务的执行,适合于 I/O 密集型的应用场景。可以应用于高并发场景,避免了线程创建、线程之间上下文切换所产生的资源开销。
- 使用异步代码,主线程不会在 I/O 操作中阻塞。服务器将继续参加请求。(磁盘读取和网络请求I/O 操作让线程阻塞,并且浪费资源)
- 事件循环是 Node.js 背后的魔力,Libuv 是 的实现此模式的库,事件循环有六个阶段,所有阶段的执行称为 tick。
- 所以只有一个 Event Loop 线程,当 Event Loop 需要执行 I/O 操作时。 libuv库在线程池中的空闲线程(OS线程)处理I/O操作,在工作完成后,回调被排在「poll」阶段Tick事件循环执行。
- timers:本阶段执行已经被 setTimeout() 和 setInterval() 的调度回调函数。(技术上来说,poll 阶段控制 timers 什么时候延迟执行。)
- pending callbacks:待处理的回掉。执行延迟到下一个循环迭代的 I/O 回调。执行一些系统操作的回调。比如TCP错误。
- idle, prepare:仅在系统内部使用。
- poll:检索新的I/O事件;执行与 I/O相关的回调。有时候 Node 会在此处阻塞。
- 如果 poll 队列不空,event loop会遍历队列并同步执行回调,直到队列清空或执行的回调数到达系统上限;
- 如果 poll 队列为空,则发生以下两件事之一:
- 如果代码已经被setImmediate()设定了回调, event loop将结束 poll 阶段进入 check 阶段来执行 check 队列(里面的回调 callback)。
- 如果代码没有被setImmediate()设定回调,event loop将阻塞在该阶段等待回调被加入 poll 队列,并立即执行。
- 当event loop进入 poll 阶段,并且 有设定的timers, 一旦 poll 队列为空(poll 阶段空闲状态): event loop将检查timers,如果有1个或多个timers的下限时间已经到达,event loop将绕回 timers 阶段,并执行 timer 队列。
- check:
setImmediate()回调在这里执行。- 这个阶段允许在 poll 阶段结束后立即执行回调。如果 poll 阶段空闲,并且有被setImmediate()设定的回调,event loop会转到 check 阶段而不是继续等待。
- close callbacks:一些准备关闭的回掉,如
socket.on('close', ...)。- 如果一个 socket 或 handle 被突然关掉(比如 socket.destroy()),close事件将在这个阶段被触发。
- 如果一个 socket 或 handle 被突然关掉(比如 socket.destroy()),close事件将在这个阶段被触发。
-
I/O 密集型的工作问题,服务器处理每个请求都需要消耗时间和资源(内存、CPU等等)。服务器收到新的请求,会交给线程处理。线程是 CPU 为执行一小段指令提供的时间和资源。服务器一次处理多个请求,每个线程只负责一个。(升级服务器资源(内存,CPU 内核等))
-
CPU 密集型任务的问题(数字计算的情况,比如加密、解密),工作线程Worker Threads对于执行 CPU 密集型的 JavaScript 操作非常有用。 它们在 I/O 密集型的工作中用途不大,Node.js 的内置的异步 I/O 操作比工作线程效率更高。(先创建线程池,再创建工作线程)
-
Timers: settimeout 、setInterval、setImmediate
- 同步代码 - process.nextTick() - 微任务 - setTimeout(() => {}, 0) - setImmediate
- setTimeout,setImmediate的执行顺序受poll阶段影响,不确定谁快。执行计时器的顺序将根据调用它们的上下文而异。如果二者都从主模块内调用,则时序将受进程性能的约束
- 在Node.js事件循环的每一轮中,process.nextTick()队列总是在微任务队列之前处理 。当把项目package.json的type设置成module打包成ESM时,整体代码运行在async/await的微任务中,在ESM运行时,代码其实是在微任务阶段中,必须清空完微任务队列,才会轮到nextTick。出现微任务比process.nextTick()先执行的情况。
- process.nextTick 的执行是优先于 setImmediate 的。process.nextTick将回调函数保存在数组中,每次循环会将数组中的回调函数全部执行,setImmediate 会将回调函数保存在链表中,每次执行链表中第一个回调函数。
-
File System: 文件系统,操作文件/目录
-
Stream: 流格式的数据的处理,读写大文件时降低内存压力
const fs = require('fs');
// 可读流,pause()静止态paused和resume()流动态flowing
// 支持data, end, error,close,readable事件监听
const stream1 = fs.createReadStream('./big_file.txt');
// 可写流
// 调用 `stream.write(chunk)` 的时候,可能会得到 false,写太快了,积压数据。
// 等 drain 事件触发了,我们才能继续 write。
// 在调用 `stream.end()` 之后,而且缓冲区数据都已经传给底层系统之后,触发 finish 事件。
const stream2 = fs.createWriteStream();
// 只要 stream1 有数据,就会流到 stream2。
stream1.pipe(stream2);
//pipe约等于两个事件封装
stream1.on('data', (chunk) => {
stream2.write(chunk)
})
stream1.on('end', () => {
stream2.end()
})
-
Http
- HTTP 请求都是无状态的,服务端可以通过响应头(set-cookie) 将少量数据响应给客户端,浏览器会遵循协议将数据保存,并在下次请求同一个服务的时候带上。
- Cookie 在 Web 应用中经常承担标识请求方身份的功能,所以 Web 应用在 Cookie 的基础上封装了 Session 的概念,专门用做用户身份识别。egg框架内置了Session插件,给我们提供了
ctx.session来访问或者修改当前用户 Session 。Session 的实现是基于 Cookie 的,默认配置下,用户 Session 的内容加密后直接存储在 Cookie 中的一个字段中,用户每次请求我们网站的时候都会带上这个 Cookie,我们在服务端解密后使用。 - Session 默认存放在 Cookie 中,扩展存储存储到 redis 中
- Session实践修改用户Session失效时间maxAge
- 页面鉴权,使用egg中间件拦截所有请求,用
ctx.session.expredAt和服务器当前时间判断是否登陆过期,如果是type是html则ctx.redirect('/login')重定向路由组件,如果type是json则返回401状态码。
- Cookie 在 Web 应用中经常承担标识请求方身份的功能,所以 Web 应用在 Cookie 的基础上封装了 Session 的概念,专门用做用户身份识别。egg框架内置了Session插件,给我们提供了
- View模板渲染,
egg-view-nunjucks注入CDN静态资源 - 异常处理
try catch;当代码抛出了异常没有被捕获到时,进程将会退出,此时 Node.js 提供了process.on('uncaughtException', handler)接口来捕获它;egg框架层统一异常处理onerror配置。 - 日志级别,分为
NONE,DEBUG,INFO,WARN和ERROR5 个级别。默认只会输出INFO及以上(WARN和ERROR)的日志到文件中。
- HTTP 请求都是无状态的,服务端可以通过响应头(set-cookie) 将少量数据响应给客户端,浏览器会遵循协议将数据保存,并在下次请求同一个服务的时候带上。
-
Cluster: 集群。开启多进程不是为了解决高并发,主要是解决了单进程模式下 Node.js CPU 利用率不足的情况,充分利用多核 CPU 的性能。
- JavaScript 代码是运行在单线程上的,换句话说一个 Node.js 进程只能运行在一个 CPU 上。
- cluster模块调用fork方法来创建子进程,该方法与child_process中的fork是同一个方法。cluster模块采用的是经典的主从模型,Cluster会创建一个master,然后根据你指定的数量(服务器的 CPU 核数)复制出多个子进程,可以使用
cluster.isMaster属性判断当前进程是master还是worker(工作进程)。由master进程来管理所有的子进程,主进程不负责具体的任务处理,主要工作是负责调度和管理。 - master进程内部启动了一个TCP服务器,而真正监听端口的只有这个服务器,当来自前端的请求触发服务器的connection事件后,master会将对应的socket具柄发送给子进程。master accepts()所有传入的连接请求,然后将相应的TCP请求处理发送给选中的worker工作进程(该方式仍然通过IPC来进行通信)。
- IPC的全称是Inter-Process Communication,即进程间通信。Node中实现IPC通道是依赖于libuv。表现在应用层上的进程间通信只有简单的message事件和send()方法,接口十分简洁和消息化。父进程在实际创建子进程之前,会创建
IPC通道并监听它,然后才真正的创建出子进程,这个过程中也会通过环境变量(NODECHANNELFD)告诉子进程这个IPC通道的文件描述符。子进程在启动的过程中,根据文件描述符去连接这个已存在的IPC通道,从而完成父子进程之间的连接。
-
egg.js的多进程模型
- Worker Threads 给 Node 提供真正的多线程能力。
- Node 是单线程的指的是 JavaScript 的执行是单线程的,v8是一个执行 JS 的引擎,创建的实例是多线程的,如主线程,编译/优化线程,分析器线程,垃圾回收线程。
// worker.js
const { parentPort, workerData } = require("worker_threads");
parentPort.postMessage(getFibonacciNumber(workerData.num))
function getFibonacciNumber(num) {
if (num === 0) {
return 0;
}
else if (num === 1) {
return 1;
}
else {
return getFibonacciNumber(num - 1) + getFibonacciNumber(num - 2);
}
}
// index.js
const { Worker } = require("worker_threads");
const path = require("path");
let number = 30;
const worker = new Worker(path.resolve(__dirname, './worker.js'),
{workerData: {num: number}});
worker.once("message", result => {
console.log(`${number}th Fibonacci Result: ${result}`);
});
worker.on("error", error => {
console.log(error);
});
worker.on("exit", exitCode => {
console.log(`It exited with code ${exitCode}`);
})
console.log("Execution in main thread");
- Buffer: 一小段缓存(大文件一点一点上传)
- Node.js 的 Buffer 是一个用于处理二进制数据的重要工具,它提供了高效的内存操作机制,特别适用于处理网络流、图片音视频文件操作等 I/O 相关的任务。
- 操作 Buffer,注意字符编码,默认按照 UTF-8 格式转换
Buffer.alloc(10);创建一个大小为 10 个字节的缓冲区Buffer.from("Hello, Node.js");从字符串创建 Bufferbuffer.length:获取 Buffer 的长度(字节数)。buffer.write(string[, offset[, length]][, encoding]):将字符串写入 Buffer。buffer.toString([encoding[, start[, end]]]):将 Buffer 转换为字符串。buffer.slice([start[, end]]):截取 Buffer 的一部分。Buffer.concat(list[, totalLength]):将多个 Buffer 拼接成一个。
- Buffer 内存分配
- Node.js 采用了 slab 机制进行预先申请、事后分配,是一种动态的管理机制。
- 在初次加载时就会初始化 1 个 8KB 的内存空间slab(
Buffer 在创建时大小已经被确定且是无法调整的) - 根据申请的内存大小8kb分为 小 Buffer 对象 和 大 Buffer 对象
- 由于同一个
slab可能分配给多个Buffer对象使用,只有这些小Buffer对象都在作用域释放时并都可以回收时,slab的8KB空间才会被回收,尽管创建一个字节的Buffer对象,但是如果不释放它,实际可能是8KB内存没有被释放。 - 小 Buffer 情况,会继续判断这个 slab 空间是否足够 - 如果空间足够就去使用剩余空间同时更新 slab 分配状态,偏移量会增加 - 如果空间不足,slab 空间不足,就会去创建一个新的 slab 空间用来分配
- 大 Buffer 情况,则会直接走 createUnsafeBuffer(size) 函数,内存分配是在 C++ 层面完成。
- 不论是小 Buffer 对象还是大 Buffer 对象,内存分配是在 C++ 层面完成称之为堆外内存,内存管理在 JavaScript 层面,最终还是可以被 V8 的垃圾回收标记所回收。
-
Child Processes: 子进程(Node.js的分身)
-
Events: 对应的就是EventHub,发布订阅模式是用于多个模块之间进行通信的(一个对象提供了on、off、emit)
export class EventHub {
private cache: { [key: string]: Array<(data: unknown) => void> } = {}
on(eventName: string, fn: (data: any) => void) {
this.cache[eventName] = this.cache[eventName] || []
this.cache[eventName].push(fn)
}
emit(eventName: string, data?: unknown) {
(this.cache[eventName] || []).forEach(fn => fn(data))
}
off(eventName: string, fn: (data: unknown) => void) {
let index = indexOf(this.cache[eventName], fn)
if (index === -1) return
this.cache[eventName].splice(index, 1)
}
}
function indexOf(array, item) {
if (array === undefined) return -1
let index = -1
for (let i = 0; i < array.length; i++) {
if (array[i] === item) {
index = i
}
}
return index
}
- koa的中间件,洋葱圈模型
function Koa () {
this.middleares = [];
}
Koa.prototype.use = function (middleare) {
this.middleares.push(middleare);
return this;
}
Koa.prototype.listen = function () {
const fn = compose(this.middleares);
}
function compose(middleares) {
let index = -1;
const dispatch = (i) => {
if(i <= index) throw new Error('next() 不能调用多次');
index = i;
if(i >= middleares.length) return;
// 每一个中间件都有一个`next`参数,(ctx, next) => ()
// `next`参数可以控制进入下一个中间件的时机。
const middleare = middleares[i];
// 每次调用next,都用调用一次dispatch方法,并且i+1
return middleare('ctx', dispatch.bind(null, i + 1));
}
return dispatch(0);
}
const app = new Koa();
app.use(async (ctx, next) => {
console.log('1');
next();
console.log('2');
});
app.use(async (ctx, next) => {
console.log('3');
next();
console.log('4');
});
app.use(async (ctx, next) => {
console.log('5');
next();
console.log('6');
});
app.listen();
docker
-
docker镜像(Image)一个特殊的文件系统。除了提供容器运行时所需的程序、库、资源、配置等文件外,还包含了一些为运行时准备的一些配置参数(如匿名卷、环境变量、用户等)。 镜像不包含任何动态数据,其内容在构建之后也不会被改变。 -
docker 镜像相关操作有: 搜索镜像
docker search [REPOSITORY[:TAG]]、拉取镜像docker pull [REPOSITORY[:TAG]]、查看镜像列表docker image ls、删除镜像:docker image rm [REPOSITORY[:TAG]] / docker rmi [REPOSITORY[:TAG]]等等。 -
在前端项目根目录下创建
nginx文件夹,该文件夹下新建文件default.conf
server {
listen 80;
server_name localhost;
#charset koi8-r;
access_log /var/log/nginx/host.access.log main;
error_log /var/log/nginx/error.log error;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
}
upstream backend {
server 172.17.0.2:8080;
server 172.17.0.3:8080;
}
location /api/ {
rewrite /api/(.*) /$1 break;
proxy_pass backend;
}
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
- 先docker pull nginx。
- 在前端项目根目录下创建Dockerfile文件
FROM nginx
COPY dist/ /usr/share/nginx/html/
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
- 基于该Dockerfile构建前端应用镜像。
-t是给镜像命名.是基于当前目录的Dockerfile来构建镜像
// 构建镜像
docker build -t vuenginxcontainer .
// 查看本地镜像
docker image ls | grep vuenginxcontainer
- Docker 容器Container: 镜像运行时的实体。
// 启动容器,端口映射,将宿主的3000端口映射到容器的80端口
docker run -itd -p 3000:80 --name [containerId] [image]
//查看容器进程
docker ps -a
// 删除容器
docker rm [containerID]
// 进入容器内部查看ip
docker exect -it [containerId] bash
- 编写 Dockerfile 将
egg应用docker化。
FROM node
USER root
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 8080
CMD [ "npm", "start" ]