微前端与node

233 阅读17分钟

Qiankun

JS沙箱

  • SanpshotSandbox
    • 微应用mount时先把上一次记录的变更 modifyPropsMap 应用到微应用的全局 window,没有则跳过;浅复制主应用的 window key-value 快照,用于下次恢复全局环境。
    • 微应用 unmount 时将当前微应用 windowkey-value快照key-value 进行 Diff,Diff 出来的结果modifyPropsMap用于下次恢复微应用环境的依据。将上次快照的 key-value 拷贝到主应用的 window 上,以此恢复环境
    • 有一个问题:每次微应用 unmount 时都要对每个属性值做一次 Diff,大量属性更新时耗费性能
  • LegacySandbox
    • Proxy监听对 window 的修改来直接记录 Diff 内容。如果是新增属性,那么存到 addedMap 里,如果是更新属性,那么把原来的键值存到 prevMap,把新的键值存到 newMap,通过这三个变量就能反应出微应用和原环境的变化,qiankun也能以此恢复环境。
  • 前面两种沙箱都是 单例模式 下使用的沙箱。也即一个页面中只能同时展示一个微应用,而且无论是 set 还是 get 依然是直接操作 window 对象。 因此,如果在同一个路由页面下展示多个微应用,会有全局变量污染的问题。
  • ProxySandbox
    • 把当前 window 的一些原生属性(如documentlocation, history,setTimeout等)拷贝出来,单独放在一个对象上,这个对象也称为 fakeWindow, 之后对每个微应用分配一个 fakeWindow
    • Proxy监听fakeWindow的改变。当微应用set``get全局变量时,如果是原生属性则会操作全局window ,如果不是原生属性,则优先操作 fakeWindow
    • 每个微应用都有自己一个环境,当在 active 时就给这个微应用分配一个 fakeWindow,当 inactive 时就把这个 fakeWindow 存起来,以便之后再利用。
  • 隔离原理
    • 把要执行 JS 代码放在一个立即执行函数中,且函数入参有 windowselfglobalThis
    • 给这个函数 绑定this上下文 window.proxy
    • eval执行这个函数,并把上面提到的沙箱对象 window.proxy 作为入参分别传入
    • 假如代码里有隐式声明的属性和方法如a = 1; add = () => {} 或调用全局对象的代码。上下文 this 则为刚刚绑定的 window.proxy。由于隐式声明 add 不会自动挂载到 window.proxy 上,所以当执行 addeval 就会报 add is undefined 如网关层自动注入的JS,老旧的第三方JSSDK,Webpack 插件引入的JS。解决的方法,把代码 a = 1 通过文本替换脚本 window.a,添加全局声明 window a
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追加到渲染容器。
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
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 有可能在一些公司代理、网关层中被拦截,自动注入一些脚本。
/**
 * 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隔离了
  • 执行时机

    • 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 时自动居中
    • 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。

image.png

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事件将在这个阶段被触发。
        image.png

image.png

  • 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状态码。
    • View模板渲染egg-view-nunjucks注入CDN静态资源
    • 异常处理try catch;当代码抛出了异常没有被捕获到时,进程将会退出,此时 Node.js 提供了 process.on('uncaughtException', handler) 接口来捕获它;egg框架层统一异常处理onerror配置。
    • 日志级别,分为 NONEDEBUGINFOWARN 和 ERROR 5 个级别。默认只会输出 INFO 及以上(WARN 和 ERROR)的日志到文件中。
  • 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通道,从而完成父子进程之间的连接。 image.png
  • egg.js的多进程模型

image.png

  • 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: {numnumber}});

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"); 从字符串创建 Buffer
      • buffer.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 对象都在作用域释放时并都可以回收时,slab8KB 空间才会被回收,尽管创建一个字节的 Buffer 对象,但是如果不释放它,实际可能是 8KB 内存没有被释放。
      • 小 Buffer 情况,会继续判断这个 slab 空间是否足够 - 如果空间足够就去使用剩余空间同时更新 slab 分配状态,偏移量会增加 - 如果空间不足,slab 空间不足,就会去创建一个新的 slab 空间用来分配
      • 大 Buffer 情况,则会直接走 createUnsafeBuffer(size) 函数,内存分配是在 C++ 层面完成。
      • 不论是小 Buffer 对象还是大 Buffer 对象,内存分配是在 C++ 层面完成称之为堆外内存,内存管理在 JavaScript 层面,最终还是可以被 V8 的垃圾回收标记所回收。

image.png

  • 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的中间件,洋葱圈模型

image.png

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" ]